mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
5 Commits
8025af3cc8
...
feat/rest-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d08cdcd8b | ||
|
|
7f6ea616ca | ||
|
|
5601a64703 | ||
|
|
93ce1d783a | ||
|
|
f7fb88cf80 |
@@ -11,6 +11,10 @@
|
||||
"./xo": "./dist/xo.d.mts",
|
||||
"./xo-app": "./dist/xo-app.d.mts",
|
||||
"./xen-api": "./dist/xen-api.d.mts",
|
||||
"./backup-expression-context": {
|
||||
"default": "./dist/backup-expression-context.mjs",
|
||||
"types": "./dist/backup-expression-context.d.mts"
|
||||
},
|
||||
"./common": {
|
||||
"default": "./dist/common.mjs",
|
||||
"types": "./dist/common.d.mts"
|
||||
|
||||
38
@vates/types/src/backup-expression-context.mts
Normal file
38
@vates/types/src/backup-expression-context.mts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const BACKUP_EXPRESSION_CONTEXT = {
|
||||
run: {
|
||||
dayOfWeek: { type: 'number' },
|
||||
dayOfMonth: { type: 'number' },
|
||||
hour: { type: 'number' },
|
||||
month: { type: 'number' },
|
||||
year: { type: 'number' },
|
||||
},
|
||||
sr: {
|
||||
name_label: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
tags: { type: 'string[]' },
|
||||
},
|
||||
backupRepository: {
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
tags: { type: 'string[]' },
|
||||
},
|
||||
vm: {
|
||||
name_label: { type: 'string' },
|
||||
tags: { type: 'string[]' },
|
||||
power_state: { type: 'string' },
|
||||
},
|
||||
chainLength: { type: 'number' },
|
||||
} as const
|
||||
|
||||
export type BACKUP_EXPRESSION_CONTEXT = (typeof BACKUP_EXPRESSION_CONTEXT)[keyof typeof BACKUP_EXPRESSION_CONTEXT]
|
||||
|
||||
export const BACKUP_SETTING_CONTEXT = {
|
||||
healthCheckVmsWithTags: ['vm', 'run'],
|
||||
fullInterval: ['vm', 'run', 'chainLength'],
|
||||
vmFilter: ['vm', 'run'],
|
||||
srs: ['sr', 'run'],
|
||||
remotes: ['backupRepository', 'run'],
|
||||
} as const
|
||||
|
||||
export type BACKUP_SETTING_CONTEXT = (typeof BACKUP_SETTING_CONTEXT)[keyof typeof BACKUP_SETTING_CONTEXT]
|
||||
@@ -214,6 +214,7 @@ export interface Xapi {
|
||||
joinPool(masterAddress: string, masterUsername: string, masterPassword: string, force?: boolean): Promise<void>
|
||||
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
|
||||
pool_emergencyShutdown(): Promise<void>
|
||||
ejectCdFromVm(vmId: XoVm['id']): Promise<void>
|
||||
resumeVm(id: XoVm['id']): Promise<void>
|
||||
revertVm(snapshotId: XoVmSnapshot['id']): Promise<void>
|
||||
unpauseVm(id: XoVm['id']): Promise<void>
|
||||
|
||||
@@ -552,8 +552,8 @@ export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
}
|
||||
export type XoVmBackupJobScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
fullInterval?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags'] | string
|
||||
fullInterval?: number | string
|
||||
copyRetention?: number
|
||||
snapshotRetention?: number
|
||||
cbtDestroySnapshotData?: boolean
|
||||
@@ -564,9 +564,9 @@ export type XoVmBackupJob = BaseXoJob & {
|
||||
compression?: 'native' | 'zstd' | ''
|
||||
proxy?: XoProxy['id']
|
||||
mode: 'full' | 'delta'
|
||||
remotes?: CMType.IdOr<XoBackupRepository['id']>
|
||||
remotes?: CMType.IdOr<XoBackupRepository['id']> | string
|
||||
vms: CMType.IdOr<XoVm['id']> | Record<string, unknown>
|
||||
srs?: CMType.IdOr<XoSr['id']>
|
||||
srs?: CMType.IdOr<XoSr['id']> | string
|
||||
type: 'backup'
|
||||
settings: {
|
||||
'': XoVmBackupJobGeneralSettings
|
||||
@@ -598,13 +598,15 @@ export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
nRetriesVmBackupFailures?: number
|
||||
mergeBackupsSynchronously?: boolean
|
||||
timeout?: number
|
||||
timezone?: string
|
||||
maxExportRate?: number
|
||||
backupReportTpl?: 'compactMjml'
|
||||
reportWhen: 'failure'
|
||||
}
|
||||
export type XoMirrorBackupScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
healthCheckVmsWithTags?: XoVm['tags'] | string
|
||||
vmFilter?: string
|
||||
healthCheckSr?: XoSr['id']
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export default {
|
||||
clone: true,
|
||||
delete: true,
|
||||
'eject-cd': true,
|
||||
export: true,
|
||||
'migrate-send': true,
|
||||
pause: true,
|
||||
|
||||
111
@xen-orchestra/backups/_buildContext.mjs
Normal file
111
@xen-orchestra/backups/_buildContext.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
// @ts-check
|
||||
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import moment from 'moment-timezone'
|
||||
import { parse } from 'xo-remote-parser'
|
||||
|
||||
const { warn } = createLogger('xo:backups:buildContext')
|
||||
|
||||
/**
|
||||
* @typedef {import('@vates/types/xen-api').XenApiVm} XenApiVm
|
||||
* @typedef {import('@vates/types/xo').XoSr} XoSr
|
||||
* @typedef {import('@vates/types/xo').XoBackupRepository} XoBackupRepository
|
||||
*
|
||||
* @typedef {object} RunContext
|
||||
* @property {number} dayOfWeek
|
||||
* @property {number} dayOfMonth
|
||||
* @property {number} hour
|
||||
* @property {number} month
|
||||
* @property {number} year
|
||||
*
|
||||
* @typedef {object} VmContext
|
||||
* @property {Record<string, unknown>} vm
|
||||
* @property {RunContext} run
|
||||
* @property {number} [chainLength]
|
||||
*
|
||||
* @typedef {object} SrContext
|
||||
* @property {{name_label: string, type: string, description: string, tags: string[]}} sr
|
||||
* @property {RunContext} run
|
||||
*
|
||||
* @typedef {object} BackupRepositoryContext
|
||||
* @property {{name: string, type: string | undefined, tags: string[]}} backupRepository
|
||||
* @property {RunContext} run
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @param {string | undefined} timezone
|
||||
* @returns {RunContext}
|
||||
*/
|
||||
export function buildRunContext(date, timezone) {
|
||||
const momentDate = timezone ? moment.tz(date, timezone) : moment(date)
|
||||
return {
|
||||
dayOfWeek: momentDate.day(),
|
||||
dayOfMonth: momentDate.date(),
|
||||
hour: momentDate.hour(),
|
||||
month: momentDate.month() + 1,
|
||||
year: momentDate.year(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {XenApiVm} vm
|
||||
* @param {RunContext} run
|
||||
* @param {number} [chainLength]
|
||||
* @returns {VmContext}
|
||||
*/
|
||||
export function buildVmContext(vm, run, chainLength) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const vmObject = {}
|
||||
for (const [key, value] of Object.entries(vm)) {
|
||||
if (!key.startsWith('$') && typeof value !== 'function') {
|
||||
vmObject[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(vmObject.tags)) {
|
||||
vmObject.tags = []
|
||||
}
|
||||
|
||||
return { vm: vmObject, run, chainLength }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {XoSr} sr
|
||||
* @param {RunContext} run
|
||||
* @returns {SrContext}
|
||||
*/
|
||||
export function buildSrContext(sr, run) {
|
||||
return {
|
||||
sr: {
|
||||
name_label: sr.name_label,
|
||||
type: sr.SR_type,
|
||||
description: sr.name_description,
|
||||
tags: Array.isArray(sr.tags) ? sr.tags : [],
|
||||
},
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {XoBackupRepository} backupRepository
|
||||
* @param {RunContext} run
|
||||
* @returns {BackupRepositoryContext}
|
||||
*/
|
||||
export function buildBackupRepositoryContext(backupRepository, run) {
|
||||
let type
|
||||
try {
|
||||
type = parse(backupRepository.url).type
|
||||
} catch (error) {
|
||||
warn('cannot parse backup repository URL', { backupRepositoryId: backupRepository.id, error })
|
||||
}
|
||||
|
||||
return {
|
||||
backupRepository: {
|
||||
name: backupRepository.name,
|
||||
type,
|
||||
tags: [], // Placeholder - Backup repositories don't have tags yet
|
||||
},
|
||||
run,
|
||||
}
|
||||
}
|
||||
147
@xen-orchestra/backups/_buildContext.test.mjs
Normal file
147
@xen-orchestra/backups/_buildContext.test.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { buildRunContext, buildVmContext, buildSrContext, buildBackupRepositoryContext } from './_buildContext.mjs'
|
||||
import { BACKUP_EXPRESSION_CONTEXT } from '@vates/types/backup-expression-context'
|
||||
|
||||
describe('buildContext', () => {
|
||||
const date = new Date('2025-01-05T03:00:00Z')
|
||||
const runContext = buildRunContext(date, 'America/New_York')
|
||||
const expectedRunContext = {
|
||||
dayOfWeek: 6,
|
||||
dayOfMonth: 4,
|
||||
hour: 22,
|
||||
month: 1,
|
||||
year: 2025,
|
||||
}
|
||||
|
||||
it('buildRunContext', () => {
|
||||
assert.deepEqual(runContext, expectedRunContext)
|
||||
})
|
||||
|
||||
it('buildVmContext', () => {
|
||||
const vm1 = { name: 'name', $value: '$value', func: () => {} }
|
||||
const vmContext1 = buildVmContext(vm1, runContext, 3)
|
||||
assert.deepEqual(vmContext1, {
|
||||
vm: {
|
||||
name: 'name',
|
||||
tags: [],
|
||||
},
|
||||
run: expectedRunContext,
|
||||
chainLength: 3,
|
||||
})
|
||||
|
||||
const vm2 = { name: 'name', $value: '$value', func: () => {}, tags: ['tag'] }
|
||||
const vmContext2 = buildVmContext(vm2, runContext, undefined)
|
||||
assert.deepEqual(vmContext2, {
|
||||
vm: {
|
||||
name: 'name',
|
||||
tags: ['tag'],
|
||||
},
|
||||
run: expectedRunContext,
|
||||
chainLength: undefined,
|
||||
})
|
||||
|
||||
const vm3 = { name: 'name', $value: '$value', func: () => {}, tags: 'tag' }
|
||||
const vmContext3 = buildVmContext(vm3, runContext, undefined)
|
||||
assert.deepEqual(vmContext3, {
|
||||
vm: {
|
||||
name: 'name',
|
||||
tags: [],
|
||||
},
|
||||
run: expectedRunContext,
|
||||
chainLength: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('buildVmContext exposes every field advertised by the schema', () => {
|
||||
// vm is an open context (buildVmContext copies through all real VM fields),
|
||||
// so we assert the schema's curated fields are a subset of the output, not equality.
|
||||
const vm = { name_label: 'name', power_state: 'Running', tags: ['tag'], $ref: 'ref', func: () => {} }
|
||||
const { vm: vmContext } = buildVmContext(vm, runContext)
|
||||
|
||||
for (const field of Object.keys(BACKUP_EXPRESSION_CONTEXT.vm)) {
|
||||
assert.ok(field in vmContext, `vm context is missing schema field: ${field}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('buildSrContext', () => {
|
||||
const sr1 = {
|
||||
name_label: 'name_label',
|
||||
SR_type: 'type',
|
||||
name_description: 'description',
|
||||
ignored: 'ignored',
|
||||
}
|
||||
const srContext1 = buildSrContext(sr1, runContext)
|
||||
assert.deepEqual(srContext1, {
|
||||
sr: {
|
||||
name_label: 'name_label',
|
||||
type: 'type',
|
||||
description: 'description',
|
||||
tags: [],
|
||||
},
|
||||
run: expectedRunContext,
|
||||
})
|
||||
assert.deepEqual(Object.keys(srContext1.sr).sort(), Object.keys(BACKUP_EXPRESSION_CONTEXT.sr).sort())
|
||||
|
||||
const sr2 = {
|
||||
name_label: 'name_label',
|
||||
SR_type: 'type',
|
||||
name_description: 'description',
|
||||
tags: ['tag'],
|
||||
ignored: 'ignored',
|
||||
}
|
||||
const srContext2 = buildSrContext(sr2, runContext)
|
||||
assert.deepEqual(srContext2, {
|
||||
sr: {
|
||||
name_label: 'name_label',
|
||||
type: 'type',
|
||||
description: 'description',
|
||||
tags: ['tag'],
|
||||
},
|
||||
run: expectedRunContext,
|
||||
})
|
||||
assert.deepEqual(Object.keys(srContext2.sr).sort(), Object.keys(BACKUP_EXPRESSION_CONTEXT.sr).sort())
|
||||
})
|
||||
|
||||
it('buildBackupRepositoryContext', () => {
|
||||
const backupRepository1 = {
|
||||
name: 'name',
|
||||
url: 'nfs://host/path',
|
||||
ignored: 'ignored',
|
||||
}
|
||||
const backupRepositoryContext1 = buildBackupRepositoryContext(backupRepository1, runContext)
|
||||
assert.deepEqual(backupRepositoryContext1, {
|
||||
backupRepository: {
|
||||
name: 'name',
|
||||
type: 'nfs',
|
||||
tags: [],
|
||||
},
|
||||
run: expectedRunContext,
|
||||
})
|
||||
assert.deepEqual(
|
||||
Object.keys(backupRepositoryContext1.backupRepository).sort(),
|
||||
Object.keys(BACKUP_EXPRESSION_CONTEXT.backupRepository).sort()
|
||||
)
|
||||
|
||||
const backupRepository2 = {
|
||||
name: 'name',
|
||||
url: 'nfs://host/path',
|
||||
tags: [],
|
||||
ignored: 'ignored',
|
||||
}
|
||||
const backupRepositoryContext2 = buildBackupRepositoryContext(backupRepository2, runContext)
|
||||
assert.deepEqual(backupRepositoryContext2, {
|
||||
backupRepository: {
|
||||
name: 'name',
|
||||
type: 'nfs',
|
||||
tags: [],
|
||||
},
|
||||
run: expectedRunContext,
|
||||
})
|
||||
assert.deepEqual(
|
||||
Object.keys(backupRepositoryContext2.backupRepository).sort(),
|
||||
Object.keys(BACKUP_EXPRESSION_CONTEXT.backupRepository).sort()
|
||||
)
|
||||
})
|
||||
})
|
||||
24
@xen-orchestra/backups/_expressionPredicate.mjs
Normal file
24
@xen-orchestra/backups/_expressionPredicate.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
// @ts-check
|
||||
|
||||
import * as CM from 'complex-matcher'
|
||||
|
||||
/**
|
||||
* Parse and compile a complex-matcher expression into a predicate function.
|
||||
* Throws synchronously if the expression is syntactically invalid.
|
||||
*
|
||||
* @param {string} expression
|
||||
* @returns {(context: object) => boolean}
|
||||
*/
|
||||
export function compileExpression(expression) {
|
||||
return CM.parse(expression).createPredicate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if value is a non-empty string — i.e. a complex-matcher expression.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {value is string}
|
||||
*/
|
||||
export function isExpression(value) {
|
||||
return typeof value === 'string' && value.length > 0 && !Number.isFinite(Number(value))
|
||||
}
|
||||
95
@xen-orchestra/backups/_expressionPredicate.test.mjs
Normal file
95
@xen-orchestra/backups/_expressionPredicate.test.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { compileExpression, isExpression } from './_expressionPredicate.mjs'
|
||||
|
||||
describe('compileExpression', () => {
|
||||
it('run:dayOfWeek:0', () => {
|
||||
const predicate = compileExpression('run:dayOfWeek:0')
|
||||
|
||||
assert(predicate({ run: { dayOfWeek: 0 } }))
|
||||
assert(!predicate({ run: { dayOfWeek: 1 } }))
|
||||
})
|
||||
|
||||
it('chainLength:>5', () => {
|
||||
const predicate = compileExpression('chainLength:>5')
|
||||
|
||||
assert(predicate({ chainLength: 6 }))
|
||||
assert(!predicate({ chainLength: 5 }))
|
||||
})
|
||||
|
||||
it('vm:tags:prod', () => {
|
||||
const predicate = compileExpression('vm:tags:prod')
|
||||
|
||||
assert(predicate({ vm: { tags: ['prod'] } }))
|
||||
assert(predicate({ vm: { tags: ['prod', 'test'] } }))
|
||||
assert(!predicate({ vm: { tags: ['test'] } }))
|
||||
assert(!predicate({ vm: { tags: [] } }))
|
||||
})
|
||||
|
||||
it('run:dayOfWeek:0 chainLength:>3', () => {
|
||||
const predicate = compileExpression('run:dayOfWeek:0 chainLength:>3')
|
||||
|
||||
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
|
||||
assert(!predicate({ run: { dayOfWeek: 0 }, chainLength: 2 }))
|
||||
assert(!predicate({ run: { dayOfWeek: 1 }, chainLength: 6 }))
|
||||
})
|
||||
|
||||
it('|(run:dayOfWeek:0 chainLength:>6)', () => {
|
||||
const predicate = compileExpression('|(run:dayOfWeek:0 chainLength:>6)')
|
||||
|
||||
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 7 }))
|
||||
assert(predicate({ run: { dayOfWeek: 1 }, chainLength: 7 }))
|
||||
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
|
||||
assert(!predicate({ run: { dayOfWeek: 1 }, chainLength: 6 }))
|
||||
})
|
||||
|
||||
it('missing field', () => {
|
||||
const predicate = compileExpression('run:dayOfWeek:0')
|
||||
|
||||
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
|
||||
assert(!predicate({ chainLength: 6 }))
|
||||
assert(!predicate({ run: {}, chainLength: 6 }))
|
||||
})
|
||||
|
||||
it('null-checking', () => {
|
||||
const predicate = compileExpression('!vm')
|
||||
|
||||
assert(predicate({}))
|
||||
assert(!predicate({ vm: [{ vm: 'vm' }] }))
|
||||
})
|
||||
|
||||
it('throws on invalid expression', () => {
|
||||
assert.throws(() => compileExpression('vm:('))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExpression', () => {
|
||||
it('non-empty string', () => {
|
||||
assert(isExpression('vm:tags:test'))
|
||||
})
|
||||
|
||||
it('number', () => {
|
||||
assert(!isExpression(0))
|
||||
})
|
||||
|
||||
it('string[]', () => {
|
||||
assert(!isExpression(['string']))
|
||||
})
|
||||
|
||||
it('undefined', () => {
|
||||
assert(!isExpression(undefined))
|
||||
})
|
||||
|
||||
it('empty string', () => {
|
||||
assert(!isExpression(''))
|
||||
})
|
||||
|
||||
it('number', () => {
|
||||
assert(!isExpression('3'))
|
||||
})
|
||||
|
||||
it('number with spaces', () => {
|
||||
assert(!isExpression(' 5 '))
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,8 @@ import { FullRemote } from './_vmRunners/FullRemote.mjs'
|
||||
import { IncrementalRemote } from './_vmRunners/IncrementalRemote.mjs'
|
||||
import { Throttle } from '@vates/generator-toolbox'
|
||||
import createStreamThrottle from './_createStreamThrottle.mjs'
|
||||
import { compileExpression, isExpression } from '../_expressionPredicate.mjs'
|
||||
import { buildRunContext, buildVmContext } from '../_buildContext.mjs'
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
@@ -60,21 +62,25 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
|
||||
return
|
||||
}
|
||||
|
||||
const runContext = job.runContext ?? buildRunContext(new Date(), settings.timezone)
|
||||
const predicate = isExpression(settings.vmFilter) ? compileExpression(settings.vmFilter) : null
|
||||
|
||||
const vmsUuids = await sourceRemoteAdapter.listAllVms()
|
||||
|
||||
const queue = new Set(vmsUuids)
|
||||
|
||||
Task.info('vms', { vms: vmsUuids })
|
||||
const nbVms = vmsUuids.length
|
||||
let nbVms = vmsUuids.length
|
||||
let nbVmsDone = 0
|
||||
|
||||
remoteAdapters = getAdaptersByRemote(remoteAdapters)
|
||||
const allSettings = this._job.settings
|
||||
const baseSettings = this._baseSettings
|
||||
|
||||
const queue = new Set(vmsUuids)
|
||||
const taskByVmId = {}
|
||||
const nTriesByVmId = {}
|
||||
|
||||
const handleVm = vmUuid => {
|
||||
const handleVm = async vmUuid => {
|
||||
if (nTriesByVmId[vmUuid] === undefined) {
|
||||
nTriesByVmId[vmUuid] = 0
|
||||
}
|
||||
@@ -84,12 +90,26 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
|
||||
const vmSettings = { ...settings, ...allSettings[vmUuid] }
|
||||
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
|
||||
|
||||
const vmBackups = await sourceRemoteAdapter.listVmBackups(vmUuid, ({ mode }) => mode === job.mode)
|
||||
|
||||
// avoiding to create tasks for empty directories
|
||||
if (vmBackups.length === 0) {
|
||||
nbVms--
|
||||
return
|
||||
}
|
||||
|
||||
if (predicate !== null && !predicate(buildVmContext(vmBackups.at(-1).vm, runContext))) {
|
||||
nbVms--
|
||||
return
|
||||
}
|
||||
|
||||
const opts = {
|
||||
baseSettings,
|
||||
config,
|
||||
job,
|
||||
healthCheckSr,
|
||||
remoteAdapters,
|
||||
runContext,
|
||||
schedule,
|
||||
settings: vmSettings,
|
||||
sourceRemoteAdapter,
|
||||
@@ -106,45 +126,39 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
|
||||
throw new Error(`Job mode ${job.mode} not implemented for mirror backup`)
|
||||
}
|
||||
|
||||
return sourceRemoteAdapter
|
||||
.listVmBackups(vmUuid, ({ mode }) => mode === job.mode)
|
||||
.then(vmBackups => {
|
||||
// avoiding to create tasks for empty directories
|
||||
if (vmBackups.length > 0) {
|
||||
if (taskByVmId[vmUuid] === undefined) {
|
||||
taskByVmId[vmUuid] = new Task(taskStart)
|
||||
}
|
||||
const task = taskByVmId[vmUuid]
|
||||
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
|
||||
let taskError
|
||||
return task
|
||||
.runInside(async () =>
|
||||
vmBackup.run().catch(error => {
|
||||
taskError = error
|
||||
})
|
||||
)
|
||||
.then(result => {
|
||||
if (taskError === undefined) {
|
||||
nbVmsDone++
|
||||
return task.success(result)
|
||||
}
|
||||
if (isLastRun) {
|
||||
nbVmsDone++
|
||||
return task.failure(taskError)
|
||||
}
|
||||
Task.set('progress', Math.round((nbVmsDone * 100) / nbVms))
|
||||
// don't end the task
|
||||
task.warning(`Retry the VM mirror backup due to an error`, {
|
||||
attempt: nTriesByVmId[vmUuid],
|
||||
error: taskError.message,
|
||||
isRetry: true,
|
||||
})
|
||||
queue.add(vmUuid)
|
||||
})
|
||||
.catch(noop)
|
||||
if (taskByVmId[vmUuid] === undefined) {
|
||||
taskByVmId[vmUuid] = new Task(taskStart)
|
||||
}
|
||||
const task = taskByVmId[vmUuid]
|
||||
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
|
||||
let taskError
|
||||
return task
|
||||
.runInside(async () =>
|
||||
vmBackup.run().catch(error => {
|
||||
taskError = error
|
||||
})
|
||||
)
|
||||
.then(result => {
|
||||
if (taskError === undefined) {
|
||||
nbVmsDone++
|
||||
return task.success(result)
|
||||
}
|
||||
if (isLastRun) {
|
||||
nbVmsDone++
|
||||
return task.failure(taskError)
|
||||
}
|
||||
Task.set('progress', Math.round((nbVmsDone * 100) / nbVms))
|
||||
// don't end the task
|
||||
task.warning(`Retry the VM mirror backup due to an error`, {
|
||||
attempt: nTriesByVmId[vmUuid],
|
||||
error: taskError.message,
|
||||
isRetry: true,
|
||||
})
|
||||
queue.add(vmUuid)
|
||||
})
|
||||
.catch(noop)
|
||||
}
|
||||
|
||||
const { concurrency } = settings
|
||||
const _handleVm = !concurrency ? handleVm : limitConcurrency(concurrency)(handleVm)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Throttle } from '@vates/generator-toolbox'
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import createStreamThrottle from './_createStreamThrottle.mjs'
|
||||
import { selectSynchronizedSnapshotVms } from './_selectSynchronizedSnapshotVms.mjs'
|
||||
import { buildRunContext } from '../_buildContext.mjs'
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
@@ -68,6 +69,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
const getSnapshotNameLabel = this._getSnapshotNameLabel
|
||||
const schedule = this._schedule
|
||||
const settings = this._settings
|
||||
const runContext = job.runContext ?? buildRunContext(new Date(), settings.timezone)
|
||||
|
||||
const throttleGenerator = new Throttle(settings.maxExportRate)
|
||||
const throttleStream = createStreamThrottle(settings.maxExportRate)
|
||||
@@ -151,6 +153,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
healthCheckSr,
|
||||
job,
|
||||
remoteAdapters,
|
||||
runContext,
|
||||
schedule,
|
||||
settings: vmSettings,
|
||||
srs,
|
||||
@@ -249,6 +252,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
throttleGenerator,
|
||||
throttleStream,
|
||||
vm,
|
||||
runContext,
|
||||
}
|
||||
|
||||
const vmBackup = this._getVmBackup(job.mode, opts)
|
||||
|
||||
@@ -53,7 +53,7 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
|
||||
'writer.run()'
|
||||
)
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
this._vm = metadata.vm
|
||||
nbTransferredVms++
|
||||
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
|
||||
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
this._vm = metadata.vm
|
||||
nbTransferredVms++
|
||||
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
|
||||
}
|
||||
|
||||
@@ -18,10 +18,19 @@ import { ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
|
||||
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
|
||||
import { AggregatedIncrementalXapiWriter } from '../_writers/AggregatedIncrementalXapiWriter.mjs'
|
||||
import { TaskProgressHandler } from './_TaskProgressHandler.mjs'
|
||||
import { compileExpression, isExpression } from '../../_expressionPredicate.mjs'
|
||||
import { buildVmContext } from '../../_buildContext.mjs'
|
||||
|
||||
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
|
||||
|
||||
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
const fullInterval = this._settings.fullInterval
|
||||
this._fullIntervalPredicate = isExpression(fullInterval) ? compileExpression(fullInterval) : null
|
||||
}
|
||||
|
||||
_getWriters() {
|
||||
return [
|
||||
IncrementalRemoteWriter,
|
||||
@@ -229,15 +238,17 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
}
|
||||
|
||||
// we do tafter checkbasevdis because we want the writer to know the target VM
|
||||
// especially on replication when we alwayr update the same VM
|
||||
// especially on replication when we always update the same VM
|
||||
const fullInterval = this._settings.fullInterval
|
||||
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
|
||||
debug('not using base VM because fullInterval reached', {
|
||||
fullInterval,
|
||||
deltaChainLength,
|
||||
eq: fullInterval < deltaChainLength + 1,
|
||||
dc1: deltaChainLength + 1,
|
||||
})
|
||||
const chainLength = (deltaChainLength ?? 0) + 1
|
||||
if (isExpression(fullInterval)) {
|
||||
const context = buildVmContext(this._vm, this._runContext, chainLength)
|
||||
if (this._fullIntervalPredicate(context)) {
|
||||
debug('forcing full backup: fullInterval expression matched', { fullInterval })
|
||||
return
|
||||
}
|
||||
} else if (fullInterval !== 0 && fullInterval <= chainLength) {
|
||||
debug('not using base VM because fullInterval reached', { fullInterval, chainLength })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { Task } from '@vates/task'
|
||||
import { compileExpression, isExpression } from '../../_expressionPredicate.mjs'
|
||||
import { buildVmContext } from '../../_buildContext.mjs'
|
||||
|
||||
const { debug, warn } = createLogger('xo:backups:AbstractVmRunner')
|
||||
|
||||
@@ -73,24 +75,58 @@ export const Abstract = class AbstractVmBackupRunner {
|
||||
}
|
||||
|
||||
// check if current VM has tags
|
||||
const tags = this._tags
|
||||
const tags = this._vm.tags
|
||||
|
||||
// accept both 'xo:no-health-check' and 'xo:no-health-check=reason'
|
||||
const vmAlwaysIgnored = tags.some(t => t.startsWith('xo:no-health-check'))
|
||||
const intersect = !vmAlwaysIgnored && settings.healthCheckVmsWithTags.some(t => tags.includes(t))
|
||||
|
||||
if (vmAlwaysIgnored || (settings.healthCheckVmsWithTags.length !== 0 && !intersect)) {
|
||||
// create a task to have an info in the logs and reports
|
||||
if (tags.some(t => t.startsWith('xo:no-health-check'))) {
|
||||
return Task.run(
|
||||
{
|
||||
properties: { name: 'health check' },
|
||||
properties: {
|
||||
name: 'health check',
|
||||
},
|
||||
},
|
||||
() => {
|
||||
Task.info(`This VM doesn't match the health check's tags for this schedule`)
|
||||
Task.info(`This VM is excluded from health checks via tag`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpression(settings.healthCheckVmsWithTags)) {
|
||||
const context = buildVmContext(this._vm, this._runContext)
|
||||
if (!this._healthCheckPredicate(context)) {
|
||||
return Task.run(
|
||||
{
|
||||
properties: { name: 'health check' },
|
||||
},
|
||||
() => {
|
||||
Task.info(`VM did not match healthCheckVmsWithTags expression`)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
|
||||
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
|
||||
// create a task to have an info in the logs and reports
|
||||
return Task.run(
|
||||
{
|
||||
properties: { name: 'health check' },
|
||||
},
|
||||
() => {
|
||||
Task.info(`This VM doesn't match the health check's tags for this schedule`)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await this._callWriters(writer => writer.healthCheck(), 'writer.healthCheck()')
|
||||
}
|
||||
|
||||
_compileHealthCheckPredicate() {
|
||||
const { healthCheckVmsWithTags } = this._settings
|
||||
if (isExpression(healthCheckVmsWithTags)) {
|
||||
this._healthCheckPredicate = compileExpression(healthCheckVmsWithTags)
|
||||
} else {
|
||||
this._healthCheckPredicate = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
|
||||
job,
|
||||
healthCheckSr,
|
||||
remoteAdapters,
|
||||
runContext,
|
||||
schedule,
|
||||
settings,
|
||||
sourceRemoteAdapter,
|
||||
@@ -40,11 +41,14 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
|
||||
this._throttleGenerator = throttleGenerator
|
||||
this._throttleStream = throttleStream
|
||||
this._vmUuid = vmUuid
|
||||
this._runContext = runContext
|
||||
|
||||
const allSettings = job.settings
|
||||
const writers = new Set()
|
||||
this._writers = writers
|
||||
|
||||
this._compileHealthCheckPredicate()
|
||||
|
||||
const [BackupWriter, AggregratedBackupWriter] = this._getRemoteWriters()
|
||||
if (settings.distributeBackups) {
|
||||
writers.add(
|
||||
|
||||
@@ -36,6 +36,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
job,
|
||||
remoteAdapters,
|
||||
remotes,
|
||||
runContext,
|
||||
schedule,
|
||||
settings,
|
||||
srs,
|
||||
@@ -63,12 +64,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
this.timestamp = undefined
|
||||
|
||||
// VM currently backed up
|
||||
const tags = (this._tags = vm.tags)
|
||||
const tags = vm.tags
|
||||
|
||||
// VM (snapshot) that is really exported
|
||||
this._exportedVm = undefined
|
||||
this._synchronizedSnapshotTimestamp = synchronizedSnapshotTimestamp
|
||||
this._vm = vm
|
||||
this._runContext = runContext
|
||||
|
||||
this._getSnapshotNameLabel = getSnapshotNameLabel
|
||||
this._isIncremental = job.mode === 'delta'
|
||||
@@ -90,6 +92,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
settings.offlineSnapshot = true
|
||||
}
|
||||
this._settings = settings
|
||||
|
||||
this._compileHealthCheckPredicate()
|
||||
|
||||
// Create writers
|
||||
{
|
||||
const writers = new Set()
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@xen-orchestra/backup-archive": "^1.2.0",
|
||||
"app-conf": "^3.0.0",
|
||||
"compare-versions": "^6.0.0",
|
||||
"complex-matcher": "^1.1.1",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
@@ -57,6 +58,7 @@
|
||||
"value-matcher": "^0.2.0",
|
||||
"vhd-lib": "^4.17.0",
|
||||
"xen-api": "^5.1.0",
|
||||
"xo-remote-parser": "^2.0.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -280,7 +280,8 @@ export class CleanupClient {
|
||||
if (config.deleteBackupFiles) {
|
||||
try {
|
||||
const jobDetails = await this.dispatchClient.backup.details(job.id)
|
||||
const jobRemotes = extractIdsFromSimplePattern(jobDetails.remotes)
|
||||
const jobRemotes =
|
||||
typeof jobDetails.remotes === 'string' ? [] : extractIdsFromSimplePattern(jobDetails.remotes)
|
||||
|
||||
if (jobRemotes.length > 0) {
|
||||
const backupsByRemote = await this.dispatchClient.backup.listVmBackups(jobRemotes)
|
||||
|
||||
@@ -13,9 +13,9 @@ import { Unbrand } from '../open-api/common/response.common.mjs'
|
||||
import type * as CMType from '@vates/types/lib/complex-matcher'
|
||||
|
||||
type UnbrandedXoVmBackupJob = Omit<XoVmBackupJob, 'settings' | 'remotes' | 'vms' | 'srs'> & {
|
||||
remotes?: CMType.IdOr<string>
|
||||
remotes?: CMType.IdOr<string> | string
|
||||
vms: CMType.IdOr<string> | Record<string, unknown>
|
||||
srs?: CMType.IdOr<string>
|
||||
srs?: CMType.IdOr<string> | string
|
||||
settings: {
|
||||
'': XoVmBackupJobGeneralSettings
|
||||
[scheduleId: string]: Unbrand<XoVmBackupJobScheduleSettings> | undefined
|
||||
|
||||
@@ -8,18 +8,21 @@ export class BackupRepositoryService {
|
||||
constructor(@inject(RestApi) restApi: RestApi) {
|
||||
this.#restApi = restApi
|
||||
}
|
||||
isBackupRepositoryReferenced(
|
||||
idsToCheck: AnyXoBackupJob['remotes'],
|
||||
|
||||
repositoryId: XoBackupRepository['id']
|
||||
): boolean {
|
||||
isBackupRepositoryReferenced(idsToCheck: AnyXoBackupJob['remotes'], repositoryId: XoBackupRepository['id']): boolean {
|
||||
if (idsToCheck === undefined) {
|
||||
return false
|
||||
}
|
||||
const { id } = idsToCheck
|
||||
const ids = typeof id === 'string' ? [id] : id.__or
|
||||
return ids.includes(repositoryId)
|
||||
|
||||
if (typeof idsToCheck === 'string') {
|
||||
return true
|
||||
} else {
|
||||
const { id } = idsToCheck
|
||||
const ids = typeof id === 'string' ? [id] : id.__or
|
||||
return ids.includes(repositoryId)
|
||||
}
|
||||
}
|
||||
|
||||
public async getReferencingJobs(repositoryId: XoBackupRepository['id']): Promise<AnyXoBackupJob['id'][]> {
|
||||
const allJobs = await this.#restApi.xoApp.getAllJobs()
|
||||
const referencingJobs: AnyXoBackupJob['id'][] = []
|
||||
|
||||
@@ -637,6 +637,47 @@ export class VmController extends XapiXoController<XoVm> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Eject the CD currently inserted in the VM's CD drive.
|
||||
*
|
||||
* Does nothing if the VM has no CD drive or if the drive is already empty.
|
||||
*
|
||||
* Required privilege:
|
||||
* - resource: vm, action: eject-cd
|
||||
*
|
||||
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
|
||||
*/
|
||||
@Example(taskLocation)
|
||||
@Extension('x-mcp-exposure', 'confirm')
|
||||
@Post('{id}/actions/eject_cd')
|
||||
@Middlewares(acl({ resource: 'vm', action: 'eject-cd', objectId: 'params.id' }))
|
||||
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(noContentResp.status, noContentResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
|
||||
async ejectCdVm(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
|
||||
const vmId = id as XoVm['id']
|
||||
const action = async () => {
|
||||
await this.getXapi(vmId)
|
||||
.ejectCdFromVm(vmId)
|
||||
.catch(error => {
|
||||
if (error.code !== 'VBD_IS_EMPTY') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return this.createAction<void>(action, {
|
||||
sync,
|
||||
statusCode: noContentResp.status,
|
||||
taskProperties: {
|
||||
name: 'eject CD from VM',
|
||||
objectId: vmId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege:
|
||||
* - resource: vm, action: revert-snapshot
|
||||
|
||||
@@ -23,7 +23,7 @@ import BackupJobSchedulesCard from '@/modules/backup-job/components/list/panel/c
|
||||
import BackupJobSettingsCard from '@/modules/backup-job/components/list/panel/cards/BackupJobSettingsCard.vue'
|
||||
import BackupJobTargetsCard from '@/modules/backup-job/components/list/panel/cards/BackupJobTargetsCard.vue'
|
||||
import type { FrontAnyXoBackupJob } from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
|
||||
import { BACKUP_JOB_LAST_RUNS_COUNT } from '@/modules/backup-job/utils/xo-backup-job.util.ts'
|
||||
import { BACKUP_JOB_LAST_RUNS_COUNT, getTargetIds } from '@/modules/backup-job/utils/xo-backup-job.util.ts'
|
||||
import { useXoBackupLogCollection } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
|
||||
import {
|
||||
useXoBackupRepositoryCollection,
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
|
||||
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
|
||||
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -66,7 +65,7 @@ const lastBackupLogs = computed(() =>
|
||||
|
||||
const backupRepositoryTargets = computed(() =>
|
||||
backupJob !== undefined
|
||||
? getBackupRepositoriesByIds(extractIdsFromSimplePattern<FrontXoBackupRepository['id']>(backupJob.remotes))
|
||||
? getBackupRepositoriesByIds(getTargetIds<FrontXoBackupRepository['id']>(backupJob.remotes))
|
||||
: []
|
||||
)
|
||||
|
||||
@@ -75,6 +74,6 @@ const storageRepositoryTargets = computed(() => {
|
||||
return []
|
||||
}
|
||||
|
||||
return getSrsByIds(extractIdsFromSimplePattern<FrontXoSr['id']>(backupJob.srs))
|
||||
return getSrsByIds(getTargetIds<FrontXoSr['id']>(backupJob.srs))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,10 @@ export function useXoBackupJobTypeLabels(rawBackupJob: MaybeRefOrGetter<FrontAny
|
||||
const { t } = useI18n()
|
||||
|
||||
function hasIds(pattern: unknown) {
|
||||
if (typeof pattern === 'string') {
|
||||
return true
|
||||
}
|
||||
|
||||
return extractIdsFromSimplePattern(pattern).length > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
|
||||
import type { Status } from '@core/components/status/VtsStatus.vue'
|
||||
import type { VM_POWER_STATE, XoSr } from '@vates/types'
|
||||
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
|
||||
|
||||
export const BACKUP_JOB_LAST_RUNS_COUNT = 3
|
||||
|
||||
@@ -54,6 +55,14 @@ export function hasBackupJobSnapshotRetention(backupJob: FrontXoVmBackupJob): bo
|
||||
)
|
||||
}
|
||||
|
||||
export function getTargetIds<TId extends string>(pattern: string | { id: TId | { __or: TId[] } } | undefined): TId[] {
|
||||
// Expression targets are only resolved when the job runs, so there are no fixed IDs to show
|
||||
if (typeof pattern === 'string') {
|
||||
return []
|
||||
}
|
||||
return extractIdsFromSimplePattern<TId>(pattern)
|
||||
}
|
||||
|
||||
export function isBackupJobVmsSmartMode(
|
||||
vmsConfig: FrontXoVmBackupJob['vms'] | undefined
|
||||
): vmsConfig is VmsSmartModeEnabled {
|
||||
|
||||
@@ -6,9 +6,15 @@ export function getBackupJobsUsingBackupRepository(
|
||||
brId: FrontXoBackupRepository['id'],
|
||||
backupJobs: FrontAnyXoBackupJob[]
|
||||
): FrontAnyXoBackupJob[] {
|
||||
return backupJobs.filter(
|
||||
backupJob =>
|
||||
extractIdsFromSimplePattern(backupJob.remotes).includes(brId) ||
|
||||
('sourceRemote' in backupJob && backupJob.sourceRemote === brId)
|
||||
)
|
||||
return backupJobs.filter(backupJob => {
|
||||
if ('sourceRemote' in backupJob && backupJob.sourceRemote === brId) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof backupJob.remotes === 'string') {
|
||||
return true
|
||||
}
|
||||
|
||||
return extractIdsFromSimplePattern(backupJob.remotes).includes(brId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,4 +43,13 @@ describe('getBackupJobsUsingBackupRepository', () => {
|
||||
|
||||
expect(getBackupJobsUsingBackupRepository(br.id, [otherTarget, otherSource])).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a job using an expression as using every repository', () => {
|
||||
const job = createBackupJob({
|
||||
id: 'expression',
|
||||
remotes: 'backupRepository:type:nfs',
|
||||
})
|
||||
|
||||
expect(getBackupJobsUsingBackupRepository(br.id, [job])).toEqual([job])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
> Users must be able to say: "Nice enhancement, I'm eager to test it"
|
||||
|
||||
- [REST API] Add `POST /rest/v0/vms/{id}/actions/eject_cd` to eject the CD from a VM (PR [#10660](https://github.com/vatesfr/xen-orchestra/pull/10660))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: "I had this issue, happy to know it's fixed"
|
||||
@@ -31,6 +33,14 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @vates/types minor
|
||||
- @xen-orchestra/acl minor
|
||||
- @xen-orchestra/backups minor
|
||||
- @xen-orchestra/qa-test patch
|
||||
- @xen-orchestra/rest-api minor
|
||||
- @xen-orchestra/web patch
|
||||
- @xen-orchestra/web-core minor
|
||||
- xo-server minor
|
||||
- xo-web minor
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -39,3 +39,28 @@ $ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
|
||||
### Feedback follow-up
|
||||
|
||||
The page feedback widget accepts an optional email address after a negative vote.
|
||||
It sends the address as `data.email` in the same Formbricks response as the comment,
|
||||
only when the visitor selects **Send**. An empty address is omitted; **Skip** saves
|
||||
only the vote. Email addresses are not included in Matomo events.
|
||||
|
||||
Before deploying this widget, add an optional **Open Text** question to the
|
||||
Formbricks survey configured in `customFields.formbricks`:
|
||||
|
||||
- Question ID: `email` (the API key, not just the displayed label).
|
||||
- Input type: `email`.
|
||||
- Required: off.
|
||||
- Label: `Want a follow-up? (optional)`.
|
||||
- Description: `We'll only use this to ask about your feedback or let you know when it's addressed.`
|
||||
|
||||
Keep the existing question IDs and reason choices unchanged. Apply the same field
|
||||
to the XCP-ng, Xen Orchestra, and Product docs surveys before deploying their
|
||||
respective widgets. Verify that a response with an email is visible in Formbricks
|
||||
and that anonymous feedback still works.
|
||||
|
||||
Follow-ups are manual. Use the address only to clarify the report or reply once
|
||||
the correction is live, and record the reply in a private triage note. Do not copy
|
||||
the address into public issues or pull requests.
|
||||
|
||||
@@ -151,7 +151,7 @@ Actions are written using the exact string you pass in a privilege. A parent act
|
||||
|
||||
| Resource | Available actions |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `vm` | `read`, `clone`, `delete`, `migrate-send`, `export`, `pause`, `start`, `resume`, `revert-snapshot`, `snapshot`, `suspend`, `unpause`, `reboot:clean`, `reboot:hard`, `shutdown:clean`, `shutdown:hard`, `update:affinityHost`, `update:autoPoweron`, `update:blockedOperations`, `update:coresPerSocket`, `update:cpuCap`, `update:cpuMask`, `update:cpuWeight`, `update:cpus`, `update:cpusStaticMax`, `update:creation`, `update:datasources`, `update:expNestedHvm`, `update:hasVendorDevice`, `update:highAvailability`, `update:hvmBootFirmware`, `update:memory`, `update:memoryMax`, `update:memoryMin`, `update:memoryStaticMax`, `update:nameDescription`, `update:nameLabel`, `update:nestedVirt`, `update:nicType`, `update:notes`, `update:PV_args`, `update:resourceSet`, `update:secureBoot`, `update:share`, `update:startDelay`, `update:suspendSr`, `update:tags`, `update:uefiMode`, `update:vga`, `update:videoram`, `update:viridian`, `update:virtualizationMode`, `update:xenStoreData` |
|
||||
| `vm` | `read`, `clone`, `delete`, `eject-cd`, `migrate-send`, `export`, `pause`, `start`, `resume`, `revert-snapshot`, `snapshot`, `suspend`, `unpause`, `reboot:clean`, `reboot:hard`, `shutdown:clean`, `shutdown:hard`, `update:affinityHost`, `update:autoPoweron`, `update:blockedOperations`, `update:coresPerSocket`, `update:cpuCap`, `update:cpuMask`, `update:cpuWeight`, `update:cpus`, `update:cpusStaticMax`, `update:creation`, `update:datasources`, `update:expNestedHvm`, `update:hasVendorDevice`, `update:highAvailability`, `update:hvmBootFirmware`, `update:memory`, `update:memoryMax`, `update:memoryMin`, `update:memoryStaticMax`, `update:nameDescription`, `update:nameLabel`, `update:nestedVirt`, `update:nicType`, `update:notes`, `update:PV_args`, `update:resourceSet`, `update:secureBoot`, `update:share`, `update:startDelay`, `update:suspendSr`, `update:tags`, `update:uefiMode`, `update:vga`, `update:videoram`, `update:viridian`, `update:virtualizationMode`, `update:xenStoreData` |
|
||||
| `vm-snapshot` | `read`, `delete`, `export`, `update:tags` |
|
||||
| `vm-template` | `read`, `delete`, `export`, `instantiate`, `update:tags` |
|
||||
| `vm-controller` | `read`, `update:tags` |
|
||||
|
||||
@@ -1,202 +1,247 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import styles from './styles.module.css';
|
||||
import React, { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useLocation } from '@docusaurus/router'
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'
|
||||
import styles from './styles.module.css'
|
||||
|
||||
type FormbricksConfig = {
|
||||
apiHost: string;
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
};
|
||||
apiHost: string
|
||||
environmentId: string
|
||||
surveyId: string
|
||||
}
|
||||
|
||||
type Vote = 'Yes' | 'No'
|
||||
type Step = 'vote' | 'why' | 'done'
|
||||
|
||||
// Labels must match the choice labels of the "XO Docs page feedback" survey in Formbricks.
|
||||
const REASONS = [
|
||||
'Outdated',
|
||||
'Unclear or confusing',
|
||||
'Missing information',
|
||||
'Inaccurate',
|
||||
];
|
||||
|
||||
type Step = 'vote' | 'why' | 'done';
|
||||
const REASONS = ['Outdated', 'Unclear or confusing', 'Missing information', 'Inaccurate']
|
||||
|
||||
// Mirror feedback into Matomo (when tracking is enabled) so quality
|
||||
// signals can be crossed with traffic data, e.g. thumbs-down per view.
|
||||
const trackFeedbackEvent = (action: string, page: string) => {
|
||||
window._paq?.push(['trackEvent', 'doc_feedback', action, page]);
|
||||
};
|
||||
window._paq?.push(['trackEvent', 'doc_feedback', action, page])
|
||||
}
|
||||
|
||||
export default function PageFeedback(): React.ReactElement | null {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
const formbricks = siteConfig.customFields?.formbricks as FormbricksConfig;
|
||||
const {pathname} = useLocation();
|
||||
|
||||
const [step, setStep] = useState<Step>('vote');
|
||||
const [reasons, setReasons] = useState<string[]>([]);
|
||||
const [details, setDetails] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const responseId = useRef<string | null>(null);
|
||||
const votePending = useRef<Promise<void> | null>(null);
|
||||
|
||||
// A client-side navigation lands on a new page: reset the widget.
|
||||
useEffect(() => {
|
||||
setStep('vote');
|
||||
setReasons([]);
|
||||
setDetails('');
|
||||
responseId.current = null;
|
||||
votePending.current = null;
|
||||
}, [pathname]);
|
||||
|
||||
const responseData = (vote: 'Yes' | 'No') => ({
|
||||
vote,
|
||||
page: pathname,
|
||||
...(reasons.length > 0 ? {reason: reasons} : {}),
|
||||
...(details.trim() ? {details: details.trim()} : {}),
|
||||
});
|
||||
|
||||
const send = async (
|
||||
method: 'POST' | 'PUT',
|
||||
vote: 'Yes' | 'No',
|
||||
finished: boolean,
|
||||
) => {
|
||||
const base = `${formbricks.apiHost}/api/v1/client/${formbricks.environmentId}/responses`;
|
||||
const url = method === 'PUT' ? `${base}/${responseId.current}` : base;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
...(method === 'POST'
|
||||
? {surveyId: formbricks.surveyId, meta: {url: window.location.href}}
|
||||
: {}),
|
||||
finished,
|
||||
data: responseData(vote),
|
||||
}),
|
||||
});
|
||||
if (method === 'POST' && res.ok) {
|
||||
const json = await res.json();
|
||||
responseId.current = json?.data?.id ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Feedback must never break the docs. Votes lost to network
|
||||
// errors or blockers are acceptable.
|
||||
}
|
||||
};
|
||||
|
||||
const vote = (value: 'Yes' | 'No') => {
|
||||
setStep(value === 'Yes' ? 'done' : 'why');
|
||||
trackFeedbackEvent(value === 'Yes' ? 'up' : 'down', pathname);
|
||||
// Record the vote right away so it is kept even if the visitor
|
||||
// leaves without answering the follow-up.
|
||||
votePending.current = send('POST', value, value === 'Yes');
|
||||
};
|
||||
|
||||
const submitWhy = async () => {
|
||||
setSending(true);
|
||||
reasons.forEach((reason) => trackFeedbackEvent(`reason:${reason}`, pathname));
|
||||
// Wait for the initial vote POST so we update its response instead
|
||||
// of creating a duplicate when the visitor answers quickly.
|
||||
await votePending.current;
|
||||
await send(responseId.current ? 'PUT' : 'POST', 'No', true);
|
||||
setSending(false);
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
const toggleReason = (reason: string) =>
|
||||
setReasons((prev) =>
|
||||
prev.includes(reason)
|
||||
? prev.filter((r) => r !== reason)
|
||||
: [...prev, reason],
|
||||
);
|
||||
const { siteConfig } = useDocusaurusContext()
|
||||
const formbricks = siteConfig.customFields?.formbricks as FormbricksConfig
|
||||
const { pathname } = useLocation()
|
||||
|
||||
if (!formbricks?.environmentId) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return <FeedbackForm key={pathname} formbricks={formbricks} pathname={pathname} />
|
||||
}
|
||||
|
||||
function FeedbackForm({ formbricks, pathname }: { formbricks: FormbricksConfig; pathname: string }) {
|
||||
const [step, setStep] = useState<Step>('vote')
|
||||
const [reasons, setReasons] = useState<string[]>([])
|
||||
const [details, setDetails] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const emailId = useId()
|
||||
const active = useRef(true)
|
||||
const responseId = useRef<string | null>(null)
|
||||
const votePending = useRef<Promise<boolean> | null>(null)
|
||||
const request = useRef<AbortController | null>(null)
|
||||
const trackedVote = useRef<Vote | null>(null)
|
||||
|
||||
// Each page gets its own form and response. Abort any request when leaving it.
|
||||
useEffect(() => {
|
||||
active.current = true
|
||||
return () => {
|
||||
active.current = false
|
||||
request.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const send = async (value: Vote, finished: boolean, includeDetails = false) => {
|
||||
const base = `${formbricks.apiHost}/api/v1/client/${formbricks.environmentId}/responses`
|
||||
const id = responseId.current
|
||||
const controller = new AbortController()
|
||||
request.current = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 15000)
|
||||
try {
|
||||
const res = await fetch(id ? `${base}/${id}` : base, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(!id ? { surveyId: formbricks.surveyId, meta: { url: window.location.href } } : {}),
|
||||
finished,
|
||||
data: {
|
||||
vote: value,
|
||||
page: pathname,
|
||||
...(includeDetails
|
||||
? {
|
||||
...(reasons.length > 0 ? { reason: reasons } : {}),
|
||||
...(details.trim() ? { details: details.trim() } : {}),
|
||||
...(email.trim() ? { email: email.trim() } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
if (!id) {
|
||||
const json = await res.json()
|
||||
if (!json?.data?.id) return false
|
||||
responseId.current = json.data.id
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
const vote = async (value: Vote) => {
|
||||
setError(false)
|
||||
if (trackedVote.current !== value) {
|
||||
trackFeedbackEvent(value === 'Yes' ? 'up' : 'down', pathname)
|
||||
trackedVote.current = value
|
||||
}
|
||||
// Save the vote even if the visitor leaves without completing the follow-up.
|
||||
votePending.current = send(value, value === 'Yes')
|
||||
if (value === 'No') {
|
||||
setStep('why')
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
const saved = await votePending.current
|
||||
if (!active.current) return
|
||||
setSending(false)
|
||||
if (saved) setStep('done')
|
||||
else setError(true)
|
||||
}
|
||||
|
||||
const finish = async (includeDetails: boolean) => {
|
||||
setSending(true)
|
||||
setError(false)
|
||||
// Wait for the vote so that follow-up details update the same response.
|
||||
await votePending.current
|
||||
if (!active.current) return
|
||||
const saved = await send('No', true, includeDetails)
|
||||
if (!active.current) return
|
||||
setSending(false)
|
||||
if (saved) {
|
||||
if (includeDetails) {
|
||||
reasons.forEach(reason => trackFeedbackEvent(`reason:${reason}`, pathname))
|
||||
}
|
||||
setEmail('')
|
||||
setStep('done')
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
const submitWhy = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
void finish(true)
|
||||
}
|
||||
|
||||
const toggleReason = (reason: string) =>
|
||||
setReasons(prev => (prev.includes(reason) ? prev.filter(r => r !== reason) : [...prev, reason]))
|
||||
|
||||
return (
|
||||
<div className={styles.feedback}>
|
||||
{step === 'vote' && (
|
||||
<div className={styles.voteRow}>
|
||||
<span className={styles.question}>Was this page helpful?</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.voteButton}
|
||||
onClick={() => vote('Yes')}>
|
||||
<button type='button' className={styles.voteButton} disabled={sending} onClick={() => vote('Yes')}>
|
||||
<ThumbIcon /> Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.voteButton}
|
||||
onClick={() => vote('No')}>
|
||||
<button type='button' className={styles.voteButton} disabled={sending} onClick={() => vote('No')}>
|
||||
<ThumbIcon down /> No
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'why' && (
|
||||
<div className={styles.why}>
|
||||
<span className={styles.question}>
|
||||
Sorry about that. What was the problem?
|
||||
</span>
|
||||
<form className={styles.why} onSubmit={submitWhy}>
|
||||
<span className={styles.question}>Sorry about that. What was the problem?</span>
|
||||
<div className={styles.reasons}>
|
||||
{REASONS.map((reason) => (
|
||||
{REASONS.map(reason => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
type='button'
|
||||
className={styles.reason}
|
||||
aria-pressed={reasons.includes(reason)}
|
||||
onClick={() => toggleReason(reason)}>
|
||||
disabled={sending}
|
||||
onClick={() => toggleReason(reason)}
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.details}
|
||||
aria-label="Additional details"
|
||||
disabled={sending}
|
||||
aria-label='Additional details'
|
||||
rows={3}
|
||||
placeholder="Tell us what's wrong or missing... (optional)"
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
onChange={e => setDetails(e.target.value)}
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submit}
|
||||
<div className={styles.followUp}>
|
||||
<label htmlFor={emailId}>Want a follow-up? (optional)</label>
|
||||
<input
|
||||
id={emailId}
|
||||
className={styles.email}
|
||||
type='email'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
aria-describedby={`${emailId}-help`}
|
||||
placeholder='Email address'
|
||||
maxLength={254}
|
||||
value={email}
|
||||
disabled={sending}
|
||||
onClick={submitWhy}>
|
||||
Send
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
<small id={`${emailId}-help`}>
|
||||
We'll only use this to ask about your feedback or let you know when it's addressed.
|
||||
</small>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button type='submit' className={styles.submit} disabled={sending}>
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.skip}
|
||||
onClick={() => setStep('done')}>
|
||||
<button type='button' className={styles.skip} disabled={sending} onClick={() => void finish(false)}>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<span className={styles.thanks}>Thanks for your feedback!</span>
|
||||
{error && (
|
||||
<p className={styles.error} role='alert'>
|
||||
We couldn't send your feedback. Please try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step === 'done' && <span className={styles.thanks}>Thanks for your feedback!</span>}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ThumbIcon({down = false}: {down?: boolean}) {
|
||||
function ThumbIcon({ down = false }: { down?: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={down ? styles.thumbDown : undefined}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M7 10v12" />
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<path d='M7 10v12' />
|
||||
<path d='M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
color: var(--ifm-color-primary-contrast-foreground, #fff);
|
||||
}
|
||||
|
||||
.details {
|
||||
.details,
|
||||
.email {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
@@ -80,7 +81,6 @@
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-font-color-base);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -116,3 +116,33 @@
|
||||
.thanks {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.details {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.followUp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.email {
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.followUp small {
|
||||
color: var(--ifm-font-color-secondary);
|
||||
}
|
||||
|
||||
.voteButton:disabled,
|
||||
.reason:disabled,
|
||||
.skip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--ifm-color-danger);
|
||||
}
|
||||
|
||||
26
packages/xo-server/src/api/_backupExpressions.mjs
Normal file
26
packages/xo-server/src/api/_backupExpressions.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isExpression, compileExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
|
||||
import { invalidParameters } from 'xo-common/api-errors.js'
|
||||
|
||||
export function assertValidSettingsExpression(settings, keys) {
|
||||
for (const [scheduleId, scheduleSettings] of Object.entries(settings ?? {})) {
|
||||
for (const key of keys) {
|
||||
assertValidExpression(`settings/${scheduleId === '' ? 'general' : scheduleId}/${key}`, scheduleSettings?.[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidExpression(name, value) {
|
||||
if (isExpression(value)) {
|
||||
try {
|
||||
compileExpression(value)
|
||||
} catch (error) {
|
||||
throw invalidParameters(`Invalid ${name} expression: ${error.message}`, [
|
||||
{ instancePath: `/${name}`, message: error.message },
|
||||
])
|
||||
}
|
||||
} else if (typeof value === 'string' && value.trim() === '') {
|
||||
throw invalidParameters(`Invalid ${name} expression: must be a non-empty expression`, [
|
||||
{ instancePath: `/${name}`, message: 'must be a non-empty expression' },
|
||||
])
|
||||
}
|
||||
}
|
||||
77
packages/xo-server/src/api/_backupExpressions.test.mjs
Normal file
77
packages/xo-server/src/api/_backupExpressions.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { invalidParameters } from 'xo-common/api-errors.js'
|
||||
|
||||
import { assertValidExpression, assertValidSettingsExpression } from './_backupExpressions.mjs'
|
||||
|
||||
const VALID = 'vm:tags:prod'
|
||||
const INVALID = 'vm:('
|
||||
|
||||
// checks that the error is `invalidParameters` and points to the expected field
|
||||
const isInvalidAt = instancePath => error =>
|
||||
invalidParameters.is(error) && error.data.errors[0].instancePath === instancePath
|
||||
|
||||
describe('assertValidExpression', function () {
|
||||
it('accepts a valid expression', function () {
|
||||
assert.doesNotThrow(() => assertValidExpression('srs', VALID))
|
||||
})
|
||||
|
||||
it('rejects an invalid expression with invalidParameters', function () {
|
||||
assert.throws(() => assertValidExpression('srs', INVALID), isInvalidAt('/srs'))
|
||||
})
|
||||
|
||||
it('ignores values which are not expressions', function () {
|
||||
for (const value of [undefined, '3', { id: 'sr-1' }, { id: { __or: ['sr-1', 'sr-2'] } }]) {
|
||||
assert.doesNotThrow(() => assertValidExpression('srs', value))
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects blank strings', function () {
|
||||
for (const value of ['', ' ']) {
|
||||
assert.throws(() => assertValidExpression('srs', value), isInvalidAt('/srs'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertValidSettingsExpression', function () {
|
||||
const KEYS = ['healthCheckVmsWithTags', 'fullInterval']
|
||||
|
||||
it('accepts valid expressions in the general and the schedule settings', function () {
|
||||
assert.doesNotThrow(() =>
|
||||
assertValidSettingsExpression(
|
||||
{ '': { fullInterval: VALID }, 'schedule-1': { healthCheckVmsWithTags: VALID } },
|
||||
KEYS
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an invalid expression in the schedule settings and points to it', function () {
|
||||
assert.throws(
|
||||
() => assertValidSettingsExpression({ '': {}, 'schedule-1': { fullInterval: INVALID } }, KEYS),
|
||||
isInvalidAt('/settings/schedule-1/fullInterval')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an invalid expression in the general settings', function () {
|
||||
// the general settings are keyed by '', named "general" in the path.
|
||||
assert.throws(
|
||||
() => assertValidSettingsExpression({ '': { healthCheckVmsWithTags: INVALID } }, KEYS),
|
||||
isInvalidAt('/settings/general/healthCheckVmsWithTags')
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the legacy array and number values', function () {
|
||||
assert.doesNotThrow(() =>
|
||||
assertValidSettingsExpression({ 'schedule-1': { healthCheckVmsWithTags: ['tag'], fullInterval: 3 } }, KEYS)
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the keys it has not been asked to check', function () {
|
||||
assert.doesNotThrow(() => assertValidSettingsExpression({ 'schedule-1': { vmFilter: INVALID } }, KEYS))
|
||||
})
|
||||
|
||||
it('handles missing settings and undefined entries', function () {
|
||||
assert.doesNotThrow(() => assertValidSettingsExpression(undefined, KEYS))
|
||||
assert.doesNotThrow(() => assertValidSettingsExpression({ 'schedule-1': undefined }, KEYS))
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { pipeline } from 'readable-stream'
|
||||
import createNdJsonStream from '../_createNdJsonStream.mjs'
|
||||
import { REMOVE_CACHE_ENTRY } from '../_pDebounceWithKey.mjs'
|
||||
import { safeDateFormat } from '../utils.mjs'
|
||||
import { assertValidSettingsExpression, assertValidExpression } from './_backupExpressions.mjs'
|
||||
|
||||
const SCHEMA_SETTINGS = {
|
||||
type: 'object',
|
||||
@@ -64,6 +65,10 @@ const SCHEMA_SETTINGS = {
|
||||
}
|
||||
|
||||
export function createJob({ schedules, ...job }) {
|
||||
assertValidExpression('srs', job.srs)
|
||||
assertValidExpression('remotes', job.remotes)
|
||||
assertValidSettingsExpression(job.settings, ['healthCheckVmsWithTags', 'fullInterval'])
|
||||
|
||||
return this.createBackupNgJob('backup', job, schedules).then(({ id }) => id)
|
||||
}
|
||||
|
||||
@@ -85,7 +90,7 @@ createJob.params = {
|
||||
optional: true,
|
||||
},
|
||||
remotes: {
|
||||
type: 'object',
|
||||
type: ['object', 'string'],
|
||||
optional: true,
|
||||
},
|
||||
schedules: {
|
||||
@@ -94,7 +99,7 @@ createJob.params = {
|
||||
},
|
||||
settings: SCHEMA_SETTINGS,
|
||||
srs: {
|
||||
type: 'object',
|
||||
type: ['object', 'string'],
|
||||
optional: true,
|
||||
},
|
||||
vms: {
|
||||
@@ -118,6 +123,10 @@ deleteJob.params = {
|
||||
}
|
||||
|
||||
export function editJob(props) {
|
||||
assertValidExpression('srs', props.srs)
|
||||
assertValidExpression('remotes', props.remotes)
|
||||
assertValidSettingsExpression(props.settings, ['healthCheckVmsWithTags', 'fullInterval'])
|
||||
|
||||
return this.updateJob(props)
|
||||
}
|
||||
|
||||
@@ -143,12 +152,12 @@ editJob.params = {
|
||||
optional: true,
|
||||
},
|
||||
remotes: {
|
||||
type: 'object',
|
||||
type: ['object', 'string'],
|
||||
optional: true,
|
||||
},
|
||||
settings: SCHEMA_SETTINGS,
|
||||
srs: {
|
||||
type: 'object',
|
||||
type: ['object', 'string'],
|
||||
optional: true,
|
||||
},
|
||||
vms: {
|
||||
@@ -176,6 +185,8 @@ getJob.params = {
|
||||
}
|
||||
|
||||
export async function runJob({ id, schedule, settings, vm, vms = vm !== undefined ? [vm] : undefined }) {
|
||||
assertValidSettingsExpression(settings, ['healthCheckVmsWithTags', 'fullInterval'])
|
||||
|
||||
return this.runJobSequence([id], await this.getSchedule(schedule), {
|
||||
settings,
|
||||
vms,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertValidSettingsExpression } from './_backupExpressions.mjs'
|
||||
|
||||
const SCHEMA_SETTINGS = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -47,6 +49,8 @@ const MIRROR_BACKUP_FILTER = {
|
||||
}
|
||||
|
||||
export function createJob({ schedules, ...job }) {
|
||||
assertValidSettingsExpression(job.settings, ['healthCheckVmsWithTags', 'vmFilter'])
|
||||
|
||||
return this.createBackupNgJob('mirrorBackup', job, schedules).then(({ id }) => id)
|
||||
}
|
||||
|
||||
@@ -88,6 +92,8 @@ deleteJob.params = {
|
||||
}
|
||||
|
||||
export function editJob(props) {
|
||||
assertValidSettingsExpression(props.settings, ['healthCheckVmsWithTags', 'vmFilter'])
|
||||
|
||||
return this.updateJob(props)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isSrWritable } from '../../utils.mjs'
|
||||
import { buildSrContext, buildBackupRepositoryContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
import { compileExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
|
||||
|
||||
export function resolveBackupSrs(job, srs, runContext) {
|
||||
const predicate = compileExpression(job.srs)
|
||||
|
||||
const srUuids = []
|
||||
srs.forEach(sr => {
|
||||
if (isSrWritable(sr) && predicate(buildSrContext(sr, runContext))) {
|
||||
srUuids.push(sr.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
return srUuids
|
||||
}
|
||||
|
||||
export function resolveBackupRepositories(job, backupRepositories, runContext) {
|
||||
const predicate = compileExpression(job.remotes)
|
||||
|
||||
const backupRepositoryIds = []
|
||||
const proxySkippedIds = []
|
||||
const invalidUrlIds = []
|
||||
backupRepositories.forEach(backupRepository => {
|
||||
if (!backupRepository.enabled) return
|
||||
|
||||
const context = buildBackupRepositoryContext(backupRepository, runContext)
|
||||
if (predicate(context)) {
|
||||
if (context.backupRepository.type !== undefined) {
|
||||
if (backupRepository.proxy === job.proxy) {
|
||||
backupRepositoryIds.push(backupRepository.id)
|
||||
} else {
|
||||
proxySkippedIds.push(backupRepository.id)
|
||||
}
|
||||
} else {
|
||||
invalidUrlIds.push(backupRepository.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { backupRepositoryIds, proxySkippedIds, invalidUrlIds }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { buildRunContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
|
||||
import { resolveBackupRepositories, resolveBackupSrs } from './_resolveBackupTargets.mjs'
|
||||
|
||||
// a Monday, at 22:00 UTC
|
||||
const RUN_CONTEXT = buildRunContext(new Date('2026-11-02T22:00:00Z'), 'UTC')
|
||||
|
||||
const sr = ({ uuid, name_label = uuid, SR_type = 'nfs', content_type = 'user', size = 100, tags = [] }) => ({
|
||||
uuid,
|
||||
name_label,
|
||||
name_description: '',
|
||||
SR_type,
|
||||
content_type,
|
||||
size,
|
||||
tags,
|
||||
})
|
||||
|
||||
const backupRepository = ({ id, name = id, url = 'nfs://nas:/backups', enabled = true, proxy }) => ({
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
enabled,
|
||||
proxy,
|
||||
})
|
||||
|
||||
describe('resolveBackupSrs()', () => {
|
||||
it('returns the SRs matching the expression', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' }), sr({ uuid: 'local-sr', SR_type: 'ext' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['nfs-sr'])
|
||||
})
|
||||
|
||||
it('never returns an ISO or empty SR', () => {
|
||||
const srs = [sr({ uuid: 'writable' }), sr({ uuid: 'iso', content_type: 'iso' }), sr({ uuid: 'empty', size: 0 })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['writable'])
|
||||
})
|
||||
|
||||
it('evaluates the run context', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:22' }, srs, RUN_CONTEXT), ['nfs-sr'])
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:23' }, srs, RUN_CONTEXT), [])
|
||||
})
|
||||
|
||||
it('returns an empty array when nothing matches', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:name_label:missing' }, srs, RUN_CONTEXT), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBackupRepositories()', () => {
|
||||
it('returns the backup repositories matching the expression', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'local-br', url: 'file:///media/backups' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['nfs-br'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a disabled backup repository silently', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'enabled' }),
|
||||
backupRepository({ id: 'disabled', enabled: false }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['enabled'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a matching backup repository linked to another proxy', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'same-proxy', proxy: 'proxy-1' }),
|
||||
backupRepository({ id: 'other-proxy', proxy: 'proxy-2' }),
|
||||
backupRepository({ id: 'no-proxy' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories(
|
||||
{ remotes: 'backupRepository:type:nfs', proxy: 'proxy-1' },
|
||||
backupRepositories,
|
||||
RUN_CONTEXT
|
||||
),
|
||||
{ backupRepositoryIds: ['same-proxy'], proxySkippedIds: ['other-proxy', 'no-proxy'], invalidUrlIds: [] }
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a matching backup repository whose URL cannot be parsed', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'invalid', url: 'foo://bar' }),
|
||||
]
|
||||
|
||||
// without the URL check, the negation would pick the invalid repository, its type being undefined
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: '!backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: [],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: ['invalid'],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report an invalid backup repository the expression does not match', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'invalid', url: 'foo://bar' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:name:nfs-br' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['nfs-br'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty arrays when nothing matches', () => {
|
||||
const backupRepositories = [backupRepository({ id: 'nfs-br' })]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:s3' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: [],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,9 @@ import { serializeError, unboxIdsFromPattern } from '../../utils.mjs'
|
||||
import { serveVmBackups, VmBackupsCache } from './_vmBackupsCache.mjs'
|
||||
import { VmBackupsSource } from './_vmBackupsSource.mjs'
|
||||
import { waitAll } from '../../_waitAll.mjs'
|
||||
import { isExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
|
||||
import { buildRunContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
import { resolveBackupSrs, resolveBackupRepositories } from './_resolveBackupTargets.mjs'
|
||||
|
||||
const logger = createLogger('xo:xo-mixins:backups-ng')
|
||||
|
||||
@@ -190,7 +193,68 @@ export default class BackupNg {
|
||||
|
||||
let vmIds
|
||||
|
||||
const timezone = job.settings[schedule.id]?.timezone ?? job.settings['']?.timezone ?? schedule.timezone
|
||||
const runContext = buildRunContext(new Date(), timezone)
|
||||
|
||||
job = { ...job, runContext }
|
||||
|
||||
if (job.type === 'backup') {
|
||||
const srsExpression = isExpression(job.srs) ? job.srs : undefined
|
||||
const remotesExpression = isExpression(job.remotes) ? job.remotes : undefined
|
||||
|
||||
if (isExpression(job.srs)) {
|
||||
const srUuids = resolveBackupSrs(job, Object.values(app.getObjectsByType('SR')), runContext)
|
||||
|
||||
job.srs = { id: { __or: srUuids } }
|
||||
}
|
||||
|
||||
if (isExpression(job.remotes)) {
|
||||
const {
|
||||
backupRepositoryIds: remoteIds,
|
||||
proxySkippedIds,
|
||||
invalidUrlIds,
|
||||
} = resolveBackupRepositories(job, await app.getAllRemotes(), runContext)
|
||||
|
||||
if (proxySkippedIds.length > 0) {
|
||||
jobsLogger.warning(
|
||||
'some backup repositories matched the expression but are not linked to the proxy of this job, they are skipped',
|
||||
{
|
||||
event: 'task.warning',
|
||||
taskId: runJobId,
|
||||
data: { backupRepositories: proxySkippedIds, proxy: job.proxy },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (invalidUrlIds.length > 0) {
|
||||
jobsLogger.warning(
|
||||
'some backup repositories matched the expression but have an invalid URL, they are skipped',
|
||||
{
|
||||
event: 'task.warning',
|
||||
taskId: runJobId,
|
||||
data: { backupRepositories: invalidUrlIds },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
job.remotes = { id: { __or: remoteIds } }
|
||||
}
|
||||
|
||||
if (
|
||||
(srsExpression !== undefined || remotesExpression !== undefined) &&
|
||||
unboxIdsFromPattern(job.srs).length === 0 &&
|
||||
unboxIdsFromPattern(job.remotes).length === 0
|
||||
) {
|
||||
jobsLogger.warning(
|
||||
'no SR or backup repository matched the expressions, nothing will be backed up or replicated',
|
||||
{
|
||||
event: 'task.warning',
|
||||
taskId: runJobId,
|
||||
data: { srs: srsExpression, remotes: remotesExpression },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const vmsPattern = job.vms
|
||||
|
||||
// Make sure we are passing only the VM to run which can be
|
||||
@@ -255,7 +319,6 @@ export default class BackupNg {
|
||||
|
||||
job = {
|
||||
...job,
|
||||
|
||||
vms: { id: { __or: vmIds } },
|
||||
settings: merge(job.settings, data?.settings),
|
||||
}
|
||||
|
||||
@@ -241,9 +241,13 @@ export default class {
|
||||
if (idsToCheck === undefined) {
|
||||
return false
|
||||
}
|
||||
const { id } = idsToCheck
|
||||
const ids = typeof id === 'string' ? [id] : id.__or
|
||||
return ids.includes(remoteId)
|
||||
if (typeof idsToCheck === 'string') {
|
||||
return true
|
||||
} else {
|
||||
const { id } = idsToCheck
|
||||
const ids = typeof id === 'string' ? [id] : id.__or
|
||||
return ids.includes(remoteId)
|
||||
}
|
||||
}
|
||||
|
||||
// The caller of this function have to create a parent Task
|
||||
|
||||
@@ -637,6 +637,7 @@ const messages = {
|
||||
'Delete old backups before backing up the VMs. If the new backup fails, you will lose your old backups.',
|
||||
customTag: 'Custom tag',
|
||||
editJobNotFound: "The job you're trying to edit wasn't found",
|
||||
editJobUsesExpressions: 'This job uses expressions which are not supported in XO 5, edit it in XO 6',
|
||||
preferNbd: 'Use NBD to transfer disk if available',
|
||||
preferNbdInformation: 'A network accessible by XO or the proxy must have NBD enabled.',
|
||||
distributeBackups: 'Distribute backups across backup repositories',
|
||||
|
||||
@@ -18,6 +18,7 @@ import Metadata from './new/metadata'
|
||||
import New from './new'
|
||||
import NewMirrorBackup from './new/mirror'
|
||||
import NewSequence from './new/sequence'
|
||||
import { usesExpressions } from './utils'
|
||||
|
||||
export default decorate([
|
||||
addSubscriptions({
|
||||
@@ -56,6 +57,10 @@ export default decorate([
|
||||
<span className='text-danger'>
|
||||
<Icon icon='error' /> {_('editJobNotFound')}
|
||||
</span>
|
||||
) : usesExpressions(job) ? (
|
||||
<span className='text-danger'>
|
||||
<Icon icon='error' /> {_('editJobUsesExpressions')}
|
||||
</span>
|
||||
) : job.type === 'backup' ? (
|
||||
<New job={job} schedules={schedules} />
|
||||
) : job.type === 'mirrorBackup' ? (
|
||||
|
||||
@@ -21,6 +21,13 @@ export const constructPattern = values =>
|
||||
},
|
||||
}
|
||||
|
||||
export const usesExpressions = job =>
|
||||
typeof job.srs === 'string' ||
|
||||
typeof job.remotes === 'string' ||
|
||||
Object.values(job.settings ?? {}).some(settings =>
|
||||
['healthCheckVmsWithTags', 'fullInterval', 'vmFilter'].some(key => typeof settings?.[key] === 'string')
|
||||
)
|
||||
|
||||
export const FormFeedback = ({ component: Component, error, message, ...props }) => (
|
||||
<div>
|
||||
<Component
|
||||
|
||||
Reference in New Issue
Block a user