Compare commits

..

1 Commits

Author SHA1 Message Date
Pierre Donias
bd04345569 feat(xo-server/self): ignore snapshots CPU and RAM usage 2023-04-26 15:52:04 +02:00
91 changed files with 308 additions and 6493 deletions

View File

@@ -28,7 +28,7 @@ module.exports = {
}, },
}, },
{ {
files: ['*.{integ,spec,test}.{,c,m}js'], files: ['*.{spec,test}.{,c,m}js'],
rules: { rules: {
'n/no-unpublished-require': 'off', 'n/no-unpublished-require': 'off',
'n/no-unpublished-import': 'off', 'n/no-unpublished-import': 'off',

View File

@@ -23,7 +23,7 @@
"@xen-orchestra/async-map": "^0.1.2", "@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0", "@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0", "promise-toolbox": "^0.21.0",
"xen-api": "^1.3.1" "xen-api": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"tap": "^16.3.0", "tap": "^16.3.0",
@@ -31,6 +31,6 @@
}, },
"scripts": { "scripts": {
"postversion": "npm publish --access public", "postversion": "npm publish --access public",
"test-integration": "tap --lines 70 --functions 36 --branches 54 --statements 69 *.integ.js" "test-integration": "tap *.spec.js"
} }
} }

View File

@@ -48,7 +48,7 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
assert.notEqual(parent, undefined) assert.notEqual(parent, undefined)
// inject a (non-enumerable) reference to the parent and the root task // inject a (non-enumerable) reference to the parent and the root task
Object.defineProperties(taskLog, { $parent: { value: parent }, $root: { value: parent.$root } }) Object.defineProperty(taskLog, { $parent: { value: parent }, $root: { value: parent.$root } })
;(parent.tasks ?? (parent.tasks = [])).push(taskLog) ;(parent.tasks ?? (parent.tasks = [])).push(taskLog)
} }
} else { } else {

View File

@@ -1,67 +0,0 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { makeOnProgress } = require('./combineEvents.js')
const { Task } = require('./index.js')
describe('makeOnProgress()', function () {
it('works', async function () {
const events = []
let log
const task = new Task({
data: { name: 'task' },
onProgress: makeOnProgress({
onRootTaskStart(log_) {
assert.equal(log, undefined)
log = log_
events.push('onRootTaskStart')
},
onRootTaskEnd(log_) {
assert.equal(log_, log)
events.push('onRootTaskEnd')
},
onTaskUpdate(log_) {
assert.equal(log_.$root, log)
events.push('onTaskUpdate')
},
}),
})
assert.equal(events.length, 0)
await task.run(async () => {
assert.equal(events[0], 'onRootTaskStart')
assert.equal(events[1], 'onTaskUpdate')
assert.equal(log.name, 'task')
Task.set('progress', 0)
assert.equal(events[2], 'onTaskUpdate')
assert.equal(log.properties.progress, 0)
Task.info('foo', {})
assert.equal(events[3], 'onTaskUpdate')
assert.deepEqual(log.infos, [{ data: {}, message: 'foo' }])
await Task.run({ data: { name: 'subtask' } }, () => {
assert.equal(events[4], 'onTaskUpdate')
assert.equal(log.tasks[0].name, 'subtask')
Task.warning('bar', {})
assert.equal(events[5], 'onTaskUpdate')
assert.deepEqual(log.tasks[0].warnings, [{ data: {}, message: 'bar' }])
})
assert.equal(events[6], 'onTaskUpdate')
assert.equal(log.tasks[0].status, 'success')
Task.set('progress', 100)
assert.equal(events[7], 'onTaskUpdate')
assert.equal(log.properties.progress, 100)
})
assert.equal(events[8], 'onRootTaskEnd')
assert.equal(events[9], 'onTaskUpdate')
assert.equal(log.status, 'success')
})
})

View File

@@ -83,7 +83,7 @@ exports.Task = class Task {
return this.#status return this.#status
} }
constructor({ data = {}, onProgress } = {}) { constructor({ data = {}, onProgress }) {
this.#startData = data this.#startData = data
if (onProgress !== undefined) { if (onProgress !== undefined) {
@@ -106,8 +106,6 @@ exports.Task = class Task {
const { signal } = this.#abortController const { signal } = this.#abortController
signal.addEventListener('abort', () => { signal.addEventListener('abort', () => {
if (this.status === PENDING && !this.#running) { if (this.status === PENDING && !this.#running) {
this.#maybeStart()
const status = ABORTED const status = ABORTED
this.#status = status this.#status = status
this.#emit('end', { result: signal.reason, status }) this.#emit('end', { result: signal.reason, status })
@@ -120,18 +118,16 @@ exports.Task = class Task {
} }
#emit(type, data) { #emit(type, data) {
data.id = this.id
data.timestamp = Date.now()
data.type = type
this.#onProgress(data)
}
#maybeStart() {
const startData = this.#startData const startData = this.#startData
if (startData !== undefined) { if (startData !== undefined) {
this.#startData = undefined this.#startData = undefined
this.#emit('start', startData) this.#emit('start', startData)
} }
data.id = this.id
data.timestamp = Date.now()
data.type = type
this.#onProgress(data)
} }
async run(fn) { async run(fn) {
@@ -149,8 +145,6 @@ exports.Task = class Task {
assert.equal(this.#running, false) assert.equal(this.#running, false)
this.#running = true this.#running = true
this.#maybeStart()
try { try {
const result = await asyncStorage.run(this, fn) const result = await asyncStorage.run(this, fn)
this.#running = false this.#running = false

View File

@@ -1,341 +0,0 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { Task } = require('./index.js')
const noop = Function.prototype
function assertEvent(task, expected, eventIndex = -1) {
const logs = task.$events
const actual = logs[eventIndex < 0 ? logs.length + eventIndex : eventIndex]
assert.equal(typeof actual, 'object')
assert.equal(typeof actual.id, 'string')
assert.equal(typeof actual.timestamp, 'number')
for (const keys of Object.keys(expected)) {
assert.equal(actual[keys], expected[keys])
}
}
// like new Task() but with a custom onProgress which adds event to task.$events
function createTask(opts) {
const events = []
const task = new Task({ ...opts, onProgress: events.push.bind(events) })
task.$events = events
return task
}
describe('Task', function () {
describe('contructor', function () {
it('data properties are passed to the start event', async function () {
const data = { foo: 0, bar: 1 }
const task = createTask({ data })
await task.run(noop)
assertEvent(task, { ...data, type: 'start' }, 0)
})
})
it('subtasks events are passed to root task', async function () {
const task = createTask()
const result = {}
await task.run(async () => {
await new Task().run(() => result)
})
assert.equal(task.$events.length, 4)
assertEvent(task, { type: 'start', parentId: task.id }, 1)
assertEvent(task, { type: 'end', status: 'success', result }, 2)
})
describe('.abortSignal', function () {
it('is undefined when run outside a task', function () {
assert.equal(Task.abortSignal, undefined)
})
it('is the current abort signal when run inside a task', async function () {
const task = createTask()
await task.run(() => {
const { abortSignal } = Task
assert.equal(abortSignal.aborted, false)
task.abort()
assert.equal(abortSignal.aborted, true)
})
})
})
describe('.abort()', function () {
it('aborts if the task throws fails with the abort reason', async function () {
const task = createTask()
const reason = {}
await task
.run(() => {
task.abort(reason)
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
it('does not abort if the task fails without the abort reason', async function () {
const task = createTask()
const result = new Error()
await task
.run(() => {
task.abort({})
throw result
})
.catch(noop)
assert.equal(task.status, 'failure')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'failure', result }, 1)
})
it('does not abort if the task succeed', async function () {
const task = createTask()
const result = {}
await task
.run(() => {
task.abort({})
return result
})
.catch(noop)
assert.equal(task.status, 'success')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'success', result }, 1)
})
it('aborts before task is running', function () {
const task = createTask()
const reason = {}
task.abort(reason)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
})
describe('.info()', function () {
it('does nothing when run outside a task', function () {
Task.info('foo')
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.info('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'info',
})
})
})
})
describe('.set()', function () {
it('does nothing when run outside a task', function () {
Task.set('progress', 10)
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.set('progress', 10)
assertEvent(task, {
name: 'progress',
type: 'property',
value: 10,
})
})
})
})
describe('.warning()', function () {
it('does nothing when run outside a task', function () {
Task.warning('foo')
})
it('emits an warning message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.warning('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'warning',
})
})
})
})
describe('#id', function () {
it('can be set', function () {
const task = createTask()
task.id = 'foo'
assert.equal(task.id, 'foo')
})
it('cannot be set more than once', function () {
const task = createTask()
task.id = 'foo'
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
it('is randomly generated if not set', function () {
assert.notEqual(createTask().id, createTask().id)
})
it('cannot be set after being observed', function () {
const task = createTask()
noop(task.id)
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
})
describe('#status', function () {
it('starts as pending', function () {
assert.equal(createTask().status, 'pending')
})
it('changes to success when finish without error', async function () {
const task = createTask()
await task.run(noop)
assert.equal(task.status, 'success')
})
it('changes to failure when finish with error', async function () {
const task = createTask()
await task
.run(() => {
throw Error()
})
.catch(noop)
assert.equal(task.status, 'failure')
})
it('changes to aborted after run is complete', async function () {
const task = createTask()
await task
.run(() => {
task.abort()
assert.equal(task.status, 'pending')
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
})
it('changes to aborted if aborted when not running', async function () {
const task = createTask()
task.abort()
assert.equal(task.status, 'aborted')
})
})
function makeRunTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('finishes the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'success')
assertEvent(task, {
status: 'success',
result: 'foo',
type: 'end',
})
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.run', function () {
makeRunTests((task, fn) => task.run(fn))
})
describe('.wrap', function () {
makeRunTests((task, fn) => task.wrap(fn)())
})
function makeRunInsideTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('does not finish the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'pending')
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.runInside', function () {
makeRunInsideTests((task, fn) => task.runInside(fn))
})
describe('.wrapInside', function () {
makeRunInsideTests((task, fn) => task.wrapInside(fn)())
})
})

View File

@@ -13,16 +13,12 @@
"url": "https://vates.fr" "url": "https://vates.fr"
}, },
"license": "ISC", "license": "ISC",
"version": "0.1.2", "version": "0.1.0",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
}, },
"devDependencies": {
"test": "^3.3.0"
},
"scripts": { "scripts": {
"postversion": "npm publish --access public", "postversion": "npm publish --access public"
"test": "node--test"
}, },
"exports": { "exports": {
".": "./index.js", ".": "./index.js",

View File

@@ -7,7 +7,7 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues", "bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": { "dependencies": {
"@xen-orchestra/async-map": "^0.1.2", "@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.36.1", "@xen-orchestra/backups": "^0.35.0",
"@xen-orchestra/fs": "^3.3.4", "@xen-orchestra/fs": "^3.3.4",
"filenamify": "^4.1.0", "filenamify": "^4.1.0",
"getopts": "^2.2.5", "getopts": "^2.2.5",
@@ -27,7 +27,7 @@
"scripts": { "scripts": {
"postversion": "npm publish --access public" "postversion": "npm publish --access public"
}, },
"version": "1.0.6", "version": "1.0.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"author": { "author": {
"name": "Vates SAS", "name": "Vates SAS",

View File

@@ -3,14 +3,12 @@
const { Task } = require('./Task') const { Task } = require('./Task')
exports.HealthCheckVmBackup = class HealthCheckVmBackup { exports.HealthCheckVmBackup = class HealthCheckVmBackup {
#restoredVm
#timeout
#xapi #xapi
#restoredVm
constructor({ restoredVm, timeout = 10 * 60 * 1000, xapi }) { constructor({ restoredVm, xapi }) {
this.#restoredVm = restoredVm this.#restoredVm = restoredVm
this.#xapi = xapi this.#xapi = xapi
this.#timeout = timeout
} }
async run() { async run() {
@@ -25,12 +23,7 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
// remove vifs // remove vifs
await Promise.all(restoredVm.$VIFs.map(vif => xapi.callAsync('VIF.destroy', vif.$ref))) await Promise.all(restoredVm.$VIFs.map(vif => xapi.callAsync('VIF.destroy', vif.$ref)))
const waitForScript = restoredVm.tags.includes('xo-backup-health-check-xenstore')
if (waitForScript) {
await restoredVm.set_xenstore_data({
'vm-data/xo-backup-health-check': 'planned',
})
}
const start = new Date() const start = new Date()
// start Vm // start Vm
@@ -41,7 +34,7 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
false // Skip pre-boot checks? false // Skip pre-boot checks?
) )
const started = new Date() const started = new Date()
const timeout = this.#timeout const timeout = 10 * 60 * 1000
const startDuration = started - start const startDuration = started - start
let remainingTimeout = timeout - startDuration let remainingTimeout = timeout - startDuration
@@ -59,52 +52,12 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
remainingTimeout -= running - started remainingTimeout -= running - started
if (remainingTimeout < 0) { if (remainingTimeout < 0) {
throw new Error(`local xapi did not get Running state for VM ${restoredId} after ${timeout / 1000} second`) throw new Error(`local xapi did not get Runnig state for VM ${restoredId} after ${timeout / 1000} second`)
} }
// wait for the guest tool version to be defined // wait for the guest tool version to be defined
await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, { await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, {
timeout: remainingTimeout, timeout: remainingTimeout,
}) })
const guestToolsReady = new Date()
remainingTimeout -= guestToolsReady - running
if (remainingTimeout < 0) {
throw new Error(`local xapi did not get he guest tools check ${restoredId} after ${timeout / 1000} second`)
}
if (waitForScript) {
const startedRestoredVm = await xapi.waitObjectState(
restoredVm.$ref,
vm =>
vm?.xenstore_data !== undefined &&
(vm.xenstore_data['vm-data/xo-backup-health-check'] === 'success' ||
vm.xenstore_data['vm-data/xo-backup-health-check'] === 'failure'),
{
timeout: remainingTimeout,
}
)
const scriptOk = new Date()
remainingTimeout -= scriptOk - guestToolsReady
if (remainingTimeout < 0) {
throw new Error(
`Backup health check script did not update vm-data/xo-backup-health-check of ${restoredId} after ${
timeout / 1000
} second, got ${
startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check']
} instead of 'success' or 'failure'`
)
}
if (startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check'] !== 'success') {
const message = startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check-error']
if (message) {
throw new Error(`Backup health check script failed with message ${message} for VM ${restoredId} `)
} else {
throw new Error(`Backup health check script failed for VM ${restoredId} `)
}
}
Task.info('Backup health check script successfully executed')
}
} }
) )
} }

View File

@@ -1,35 +0,0 @@
#!/bin/sh
# This script must be executed at the start of the machine.
#
# It must run as root to be able to use xenstore-read and xenstore-write
# fail in case of error or undefined variable
set -eu
# stop there if a health check is not in progress
if [ "$(xenstore-read vm-data/xo-backup-health-check 2>&1)" != planned ]
then
exit
fi
# not necessary, but informs XO that this script has started which helps diagnose issues
xenstore-write vm-data/xo-backup-health-check running
# put your test here
#
# in this example, the command `sqlite3` is used to validate the health of a database
# and its output is captured and passed to XO via the XenStore in case of error
if output=$(sqlite3 ~/my-database.sqlite3 .table 2>&1)
then
# inform XO everything is ok
xenstore-write vm-data/xo-backup-health-check success
else
# inform XO there is an issue
xenstore-write vm-data/xo-backup-health-check failure
# more info about the issue can be written to `vm-data/health-check-error`
#
# it will be shown in XO
xenstore-write vm-data/xo-backup-health-check-error "$output"
fi

View File

@@ -8,13 +8,13 @@
"type": "git", "type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git" "url": "https://github.com/vatesfr/xen-orchestra.git"
}, },
"version": "0.36.1", "version": "0.35.0",
"engines": { "engines": {
"node": ">=14.6" "node": ">=14.6"
}, },
"scripts": { "scripts": {
"postversion": "npm publish --access public", "postversion": "npm publish --access public",
"test-integration": "node--test *.integ.js" "test": "node--test"
}, },
"dependencies": { "dependencies": {
"@kldzj/stream-throttle": "^1.1.1", "@kldzj/stream-throttle": "^1.1.1",

View File

@@ -50,8 +50,7 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
}, },
}) })
this.transfer = task.wrapFn(this.transfer) this.transfer = task.wrapFn(this.transfer)
this.cleanup = task.wrapFn(this.cleanup) this.cleanup = task.wrapFn(this.cleanup, true)
this.healthCheck = task.wrapFn(this.healthCheck, true)
return task.run(() => this._prepare()) return task.run(() => this._prepare())
} }

View File

@@ -80,7 +80,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
assert.notStrictEqual( assert.notStrictEqual(
this._metadataFileName, this._metadataFileName,
undefined, undefined,
'Metadata file name should be defined before making a health check' 'Metadata file name should be defined before making a healthcheck'
) )
return Task.run( return Task.run(
{ {

View File

@@ -18,7 +18,7 @@
"preferGlobal": true, "preferGlobal": true,
"dependencies": { "dependencies": {
"golike-defer": "^0.5.1", "golike-defer": "^0.5.1",
"xen-api": "^1.3.1" "xen-api": "^1.3.0"
}, },
"scripts": { "scripts": {
"postversion": "npm publish" "postversion": "npm publish"

View File

@@ -1,5 +1,25 @@
<template> <template>
<UnreachableHostsModal /> <UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #subtitle>{{ $t("following-hosts-unreachable") }}</template>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url.hostname">
<a :href="url.href" rel="noopener" target="_blank">{{ url.href }}</a>
</li>
</ul>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected"> <div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin /> <AppLogin />
</div> </div>
@@ -21,14 +41,21 @@ import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue"; import AppLogin from "@/components/AppLogin.vue";
import AppNavigation from "@/components/AppNavigation.vue"; import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue"; import AppTooltips from "@/components/AppTooltips.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue"; import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable"; import { useChartTheme } from "@/composables/chart-theme.composable";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store"; import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store"; import { useUiStore } from "@/stores/ui.store";
import { useXenApiStore } from "@/stores/xen-api.store"; import { useXenApiStore } from "@/stores/xen-api.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core"; import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
import { logicAnd } from "@vueuse/math"; import { logicAnd } from "@vueuse/math";
import { computed } from "vue"; import { difference } from "lodash-es";
import { computed, ref, watch } from "vue";
const unreachableHostsUrls = ref<URL[]>([]);
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
let link = document.querySelector( let link = document.querySelector(
"link[rel~='icon']" "link[rel~='icon']"
@@ -43,6 +70,7 @@ link.href = favicon;
document.title = "XO Lite"; document.title = "XO Lite";
const xenApiStore = useXenApiStore(); const xenApiStore = useXenApiStore();
const { records: hosts } = useHostStore().subscribe();
const { pool } = usePoolStore().subscribe(); const { pool } = usePoolStore().subscribe();
useChartTheme(); useChartTheme();
const uiStore = useUiStore(); const uiStore = useUiStore();
@@ -65,6 +93,17 @@ if (import.meta.env.DEV) {
); );
} }
watch(hosts, (hosts, previousHosts) => {
difference(hosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.push(url)
);
});
});
whenever( whenever(
() => pool.value?.$ref, () => pool.value?.$ref,
async (poolRef) => { async (poolRef) => {
@@ -73,6 +112,9 @@ whenever(
await xenApi.startWatch(); await xenApi.startWatch();
} }
); );
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
const reload = () => window.location.reload();
</script> </script>
<style lang="postcss"> <style lang="postcss">

View File

@@ -1,15 +1,15 @@
<template> <template>
<div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip"> <div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip">
<span class="triangle" /> <span class="triangle" />
<span class="label">{{ options.content }}</span> <span class="label">{{ content }}</span>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { TooltipOptions } from "@/stores/tooltip.store"; import { isEmpty, isFunction, isString } from "lodash-es";
import { isString } from "lodash-es";
import place from "placement.js"; import place from "placement.js";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import type { TooltipOptions } from "@/stores/tooltip.store";
const props = defineProps<{ const props = defineProps<{
target: HTMLElement; target: HTMLElement;
@@ -18,13 +18,29 @@ const props = defineProps<{
const tooltipElement = ref<HTMLElement>(); const tooltipElement = ref<HTMLElement>();
const isDisabled = computed(() => const content = computed(() =>
isString(props.options.content) isString(props.options) ? props.options : props.options.content
? props.options.content.trim() === ""
: props.options.content === false
); );
const placement = computed(() => props.options.placement ?? "top"); const isDisabled = computed(() => {
if (isEmpty(content.value)) {
return true;
}
if (isString(props.options)) {
return false;
}
if (isFunction(props.options.disabled)) {
return props.options.disabled(props.target);
}
return props.options.disabled ?? false;
});
const placement = computed(() =>
isString(props.options) ? "top" : props.options.placement ?? "top"
);
watchEffect(() => { watchEffect(() => {
if (tooltipElement.value) { if (tooltipElement.value) {

View File

@@ -14,12 +14,7 @@
</UiActionButton> </UiActionButton>
</UiFilterGroup> </UiFilterGroup>
<UiModal <UiModal v-if="isOpen" :icon="faFilter" @submit.prevent="handleSubmit">
v-if="isOpen"
:icon="faFilter"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="rows"> <div class="rows">
<CollectionFilterRow <CollectionFilterRow
v-for="(newFilter, index) in newFilters" v-for="(newFilter, index) in newFilters"

View File

@@ -17,12 +17,7 @@
</UiActionButton> </UiActionButton>
</UiFilterGroup> </UiFilterGroup>
<UiModal <UiModal v-if="isOpen" :icon="faSort" @submit.prevent="handleSubmit">
v-if="isOpen"
:icon="faSort"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="form-widgets"> <div class="form-widgets">
<FormWidget :label="$t('sort-by')"> <FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty"> <select v-model="newSortProperty">

View File

@@ -1,59 +0,0 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import { faServer } from "@fortawesome/free-solid-svg-icons";
import UiModal from "@/components/ui/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { computed, ref, watch } from "vue";
import { difference } from "lodash";
import { useHostStore } from "@/stores/host.store";
const { records: hosts } = useHostStore().subscribe();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
const reload = () => window.location.reload();
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
</script>
<style lang="postcss" scoped>
.description p {
margin: 1rem 0;
}
</style>

View File

@@ -1,5 +1,12 @@
<template> <template>
<li v-if="host !== undefined" class="infra-host-item"> <li
v-if="host !== undefined"
v-tooltip="{
content: host.name_label,
disabled: isTooltipDisabled,
}"
class="infra-host-item"
>
<InfraItemLabel <InfraItemLabel
:active="isCurrentHost" :active="isCurrentHost"
:icon="faServer" :icon="faServer"
@@ -29,6 +36,7 @@ import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue"; import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue"; import InfraVmList from "@/components/infra/InfraVmList.vue";
import { vTooltip } from "@/directives/tooltip.directive"; import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store"; import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store"; import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store"; import { useUiStore } from "@/stores/ui.store";
@@ -58,6 +66,9 @@ const isCurrentHost = computed(
() => props.hostOpaqueRef === uiStore.currentHostOpaqueRef () => props.hostOpaqueRef === uiStore.currentHostOpaqueRef
); );
const [isExpanded, toggle] = useToggle(true); const [isExpanded, toggle] = useToggle(true);
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>

View File

@@ -7,9 +7,9 @@
class="infra-item-label" class="infra-item-label"
v-bind="$attrs" v-bind="$attrs"
> >
<a :href="href" class="link" @click="navigate" v-tooltip="hasTooltip"> <a :href="href" class="link" @click="navigate">
<UiIcon :icon="icon" class="icon" /> <UiIcon :icon="icon" class="icon" />
<div ref="textElement" class="text"> <div class="text">
<slot /> <slot />
</div> </div>
</a> </a>
@@ -22,10 +22,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue"; import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types"; import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { computed, ref } from "vue";
import type { RouteLocationRaw } from "vue-router"; import type { RouteLocationRaw } from "vue-router";
defineProps<{ defineProps<{
@@ -33,9 +30,6 @@ defineProps<{
route: RouteLocationRaw; route: RouteLocationRaw;
active?: boolean; active?: boolean;
}>(); }>();
const textElement = ref<HTMLElement>();
const hasTooltip = computed(() => hasEllipsis(textElement.value));
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>

View File

@@ -1,5 +1,13 @@
<template> <template>
<li v-if="vm !== undefined" ref="rootElement" class="infra-vm-item"> <li
v-if="vm !== undefined"
ref="rootElement"
v-tooltip="{
content: vm.name_label,
disabled: isTooltipDisabled,
}"
class="infra-vm-item"
>
<InfraItemLabel <InfraItemLabel
v-if="isVisible" v-if="isVisible"
:icon="faDisplay" :icon="faDisplay"
@@ -19,6 +27,8 @@
import InfraAction from "@/components/infra/InfraAction.vue"; import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue"; import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue"; import PowerStateIcon from "@/components/PowerStateIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store"; import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons"; import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
@@ -39,6 +49,9 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
stop(); stop();
} }
}); });
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>

View File

@@ -16,7 +16,6 @@ defineProps<{
<style lang="postcss" scoped> <style lang="postcss" scoped>
.ui-badge { .ui-badge {
white-space: nowrap;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;

View File

@@ -1,13 +1,7 @@
<template> <template>
<div class="legend"> <div class="legend">
<template v-if="$slots.label || label"> <span class="circle" />
<span class="circle" /> <slot name="label">{{ label }}</slot>
<div class="label-container">
<div ref="labelElement" v-tooltip="isTooltipEnabled" class="label">
<slot name="label">{{ label }}</slot>
</div>
</div>
</template>
<UiBadge class="badge"> <UiBadge class="badge">
<slot name="value">{{ value }}</slot> <slot name="value">{{ value }}</slot>
</UiBadge> </UiBadge>
@@ -16,23 +10,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import UiBadge from "@/components/ui/UiBadge.vue"; import UiBadge from "@/components/ui/UiBadge.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { computed, ref } from "vue";
defineProps<{ defineProps<{
label?: string; label?: string;
value?: string; value?: string;
}>(); }>();
const labelElement = ref<HTMLElement>();
const isTooltipEnabled = computed(() =>
hasEllipsis(labelElement.value, { vertical: true })
);
</script> </script>
<style lang="postcss" scoped> <style scoped lang="postcss">
.badge { .badge {
font-size: 0.9em; font-size: 0.9em;
font-weight: 700; font-weight: 700;
@@ -40,8 +25,8 @@ const isTooltipEnabled = computed(() =>
.circle { .circle {
display: inline-block; display: inline-block;
min-width: 1rem; width: 1rem;
min-height: 1rem; height: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background-color: var(--progress-bar-color); background-color: var(--progress-bar-color);
} }
@@ -53,14 +38,4 @@ const isTooltipEnabled = computed(() =>
gap: 0.5rem; gap: 0.5rem;
margin: 1.6em 0; margin: 1.6em 0;
} }
.label-container {
overflow: hidden;
}
.label {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style> </style>

View File

@@ -1,37 +0,0 @@
<template>
<UiTabBar>
<RouterTab :to="{ name: 'vm.dashboard', params: { uuid } }">
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.console', params: { uuid } }">
{{ $t("console") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.alarms', params: { uuid } }">
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.stats', params: { uuid } }">
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.system', params: { uuid } }">
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.network', params: { uuid } }">
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.storage', params: { uuid } }">
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.tasks', params: { uuid } }">
{{ $t("tasks") }}
</RouterTab>
</UiTabBar>
</template>
<script lang="ts" setup>
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
defineProps<{
uuid: string;
}>();
</script>

View File

@@ -1,71 +1,36 @@
# Tooltip Directive # Tooltip Directive
By default, the tooltip will appear centered above the target element. By default, tooltip will appear centered above the target element.
## Directive argument
The directive argument can be either:
- The tooltip content
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
## Tooltip content
The tooltip content can be either:
- `false` or an empty-string to disable the tooltip
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
- Non-empty string to enable the tooltip and use the string as content.
## Tooltip placement
Tooltip can be placed on the following positions:
- `top`
- `top-start`
- `top-end`
- `bottom`
- `bottom-start`
- `bottom-end`
- `left`
- `left-start`
- `left-end`
- `right`
- `right-start`
- `right-end`
## Usage ## Usage
```vue ```vue
<template> <template>
<!-- Boolean / Undefined --> <!-- Static -->
<span v-tooltip="true"
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<span v-tooltip
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<!-- String -->
<span v-tooltip="'Tooltip content'">Item</span> <span v-tooltip="'Tooltip content'">Item</span>
<!-- Object --> <!-- Dynamic -->
<span v-tooltip="myTooltipContent">Item</span>
<!-- Placement -->
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span> <span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
<!-- Dynamic --> <!-- Disabling (variable) -->
<span v-tooltip="myTooltip">Item</span> <span v-tooltip="{ content: 'Foobar', disabled: isDisabled }">Item</span>
<!-- Conditional --> <!-- Disabling (function) -->
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span> <span v-tooltip="{ content: 'Foobar', disabled: isDisabledFn }">Item</span>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";
import { vTooltip } from "@/directives/tooltip.directive"; import { vTooltip } from "@/directives/tooltip.directive";
const myTooltip = ref("Content"); // or ref({ content: "Content", placement: "left-end" }) const myTooltipContent = ref("Content");
const isTooltipEnabled = ref(true); const isDisabled = ref(true);
const isDisabledFn = (target: Element) => {
// return boolean;
};
</script> </script>
``` ```

View File

@@ -1,36 +1,8 @@
import type { Directive } from "vue";
import type { TooltipEvents, TooltipOptions } from "@/stores/tooltip.store"; import type { TooltipEvents, TooltipOptions } from "@/stores/tooltip.store";
import { useTooltipStore } from "@/stores/tooltip.store"; import { useTooltipStore } from "@/stores/tooltip.store";
import { isObject } from "lodash-es";
import type { Options } from "placement.js";
import type { Directive } from "vue";
type TooltipDirectiveContent = undefined | boolean | string; export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
type TooltipDirectiveOptions =
| TooltipDirectiveContent
| {
content?: TooltipDirectiveContent;
placement?: Options["placement"];
};
const parseOptions = (
options: TooltipDirectiveOptions,
target: HTMLElement
): TooltipOptions => {
const { placement, content } = isObject(options)
? options
: { placement: undefined, content: options };
return {
placement,
content:
content === true || content === undefined
? target.innerText.trim()
: content,
};
};
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
mounted(target, binding) { mounted(target, binding) {
const store = useTooltipStore(); const store = useTooltipStore();
@@ -38,11 +10,11 @@ export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
? { on: "focusin", off: "focusout" } ? { on: "focusin", off: "focusout" }
: { on: "mouseenter", off: "mouseleave" }; : { on: "mouseenter", off: "mouseleave" };
store.register(target, parseOptions(binding.value, target), events); store.register(target, binding.value, events);
}, },
updated(target, binding) { updated(target, binding) {
const store = useTooltipStore(); const store = useTooltipStore();
store.updateOptions(target, parseOptions(binding.value, target)); store.updateOptions(target, binding.value);
}, },
beforeUnmount(target) { beforeUnmount(target) {
const store = useTooltipStore(); const store = useTooltipStore();

View File

@@ -71,20 +71,8 @@ export function parseDateTime(dateTime: string) {
return date.getTime(); return date.getTime();
} }
export const hasEllipsis = ( export const hasEllipsis = (target: Element | undefined | null) =>
target: Element | undefined | null, target != undefined && target.clientWidth < target.scrollWidth;
{ vertical = false }: { vertical?: boolean } = {}
) => {
if (target == null) {
return false;
}
if (vertical) {
return target.clientHeight < target.scrollHeight;
}
return target.clientWidth < target.scrollWidth;
};
export function percent(currentValue: number, maxValue: number, precision = 2) { export function percent(currentValue: number, maxValue: number, precision = 2) {
return round((currentValue / maxValue) * 100, precision); return round((currentValue / maxValue) * 100, precision);

View File

@@ -17,7 +17,6 @@
"coming-soon": "Coming soon!", "coming-soon": "Coming soon!",
"community": "Community", "community": "Community",
"community-name": "{name} community", "community-name": "{name} community",
"console": "Console",
"copy": "Copy", "copy": "Copy",
"cpu-provisioning": "CPU provisioning", "cpu-provisioning": "CPU provisioning",
"cpu-usage": "CPU usage", "cpu-usage": "CPU usage",

View File

@@ -17,7 +17,6 @@
"coming-soon": "Bientôt disponible !", "coming-soon": "Bientôt disponible !",
"community": "Communauté", "community": "Communauté",
"community-name": "Communauté {name}", "community-name": "Communauté {name}",
"console": "Console",
"copy": "Copier", "copy": "Copier",
"cpu-provisioning": "Provisionnement CPU", "cpu-provisioning": "Provisionnement CPU",
"cpu-usage": "Utilisation CPU", "cpu-usage": "Utilisation CPU",

View File

@@ -1,11 +1,12 @@
import pool from "@/router/pool"; import pool from "@/router/pool";
import vm from "@/router/vm";
import HomeView from "@/views/HomeView.vue"; import HomeView from "@/views/HomeView.vue";
import HostDashboardView from "@/views/host/HostDashboardView.vue"; import HostDashboardView from "@/views/host/HostDashboardView.vue";
import HostRootView from "@/views/host/HostRootView.vue"; import HostRootView from "@/views/host/HostRootView.vue";
import PageNotFoundView from "@/views/PageNotFoundView.vue"; import PageNotFoundView from "@/views/PageNotFoundView.vue";
import SettingsView from "@/views/settings/SettingsView.vue"; import SettingsView from "@/views/settings/SettingsView.vue";
import StoryView from "@/views/StoryView.vue"; import StoryView from "@/views/StoryView.vue";
import VmConsoleView from "@/views/vm/VmConsoleView.vue";
import VmRootView from "@/views/vm/VmRootView.vue";
import storiesRoutes from "virtual:stories"; import storiesRoutes from "virtual:stories";
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter, createWebHashHistory } from "vue-router";
@@ -30,7 +31,6 @@ const router = createRouter({
component: SettingsView, component: SettingsView,
}, },
pool, pool,
vm,
{ {
path: "/host/:uuid", path: "/host/:uuid",
component: HostRootView, component: HostRootView,
@@ -42,6 +42,17 @@ const router = createRouter({
}, },
], ],
}, },
{
path: "/vm/:uuid",
component: VmRootView,
children: [
{
path: "console",
name: "vm.console",
component: VmConsoleView,
},
],
},
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
name: "notFound", name: "notFound",

View File

@@ -1,47 +0,0 @@
export default {
path: "/vm/:uuid",
component: () => import("@/views/vm/VmRootView.vue"),
redirect: { name: "vm.console" },
children: [
{
path: "dashboard",
name: "vm.dashboard",
component: () => import("@/views/vm/VmDashboardView.vue"),
},
{
path: "console",
name: "vm.console",
component: () => import("@/views/vm/VmConsoleView.vue"),
},
{
path: "alarms",
name: "vm.alarms",
component: () => import("@/views/vm/VmAlarmsView.vue"),
},
{
path: "stats",
name: "vm.stats",
component: () => import("@/views/vm/VmStatsView.vue"),
},
{
path: "system",
name: "vm.system",
component: () => import("@/views/vm/VmSystemView.vue"),
},
{
path: "network",
name: "vm.network",
component: () => import("@/views/vm/VmNetworkView.vue"),
},
{
path: "storage",
name: "vm.storage",
component: () => import("@/views/vm/VmStorageView.vue"),
},
{
path: "tasks",
name: "vm.tasks",
component: () => import("@/views/vm/VmTasksView.vue"),
},
],
};

View File

@@ -4,10 +4,13 @@ import type { Options } from "placement.js";
import { type EffectScope, computed, effectScope, ref } from "vue"; import { type EffectScope, computed, effectScope, ref } from "vue";
import { type WindowEventName, useEventListener } from "@vueuse/core"; import { type WindowEventName, useEventListener } from "@vueuse/core";
export type TooltipOptions = { export type TooltipOptions =
content: string | false; | string
placement: Options["placement"]; | {
}; content: string;
placement?: Options["placement"];
disabled?: boolean | ((target: HTMLElement) => boolean);
};
export type TooltipEvents = { on: WindowEventName; off: WindowEventName }; export type TooltipEvents = { on: WindowEventName; off: WindowEventName };

View File

@@ -13,7 +13,7 @@
<PoolDashboardRamUsage /> <PoolDashboardRamUsage />
</div> </div>
<div class="item"> <div class="item">
<PoolDashboardCpuProvisioning /> <PoolDashboardCpuProvisionning />
</div> </div>
<div class="item"> <div class="item">
<PoolDashboardNetworkChart /> <PoolDashboardNetworkChart />
@@ -38,7 +38,7 @@ import { provide, watch } from "vue";
import PoolCpuUsageChart from "@/components/pool/dashboard/cpuUsage/PoolCpuUsageChart.vue"; import PoolCpuUsageChart from "@/components/pool/dashboard/cpuUsage/PoolCpuUsageChart.vue";
import PoolDashboardCpuUsage from "@/components/pool/dashboard/PoolDashboardCpuUsage.vue"; import PoolDashboardCpuUsage from "@/components/pool/dashboard/PoolDashboardCpuUsage.vue";
import PoolDashboardNetworkChart from "@/components/pool/dashboard/PoolDashboardNetworkChart.vue"; import PoolDashboardNetworkChart from "@/components/pool/dashboard/PoolDashboardNetworkChart.vue";
import PoolDashboardCpuProvisioning from "@/components/pool/dashboard/PoolDashboardCpuProvisioning.vue"; import PoolDashboardCpuProvisionning from "@/components/pool/dashboard/PoolDashboardCpuProvisionning.vue";
import PoolDashboardRamUsage from "@/components/pool/dashboard/PoolDashboardRamUsage.vue"; import PoolDashboardRamUsage from "@/components/pool/dashboard/PoolDashboardRamUsage.vue";
import PoolDashboardRamUsageChart from "@/components/pool/dashboard/ramUsage/PoolRamUsage.vue"; import PoolDashboardRamUsageChart from "@/components/pool/dashboard/ramUsage/PoolRamUsage.vue";
import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue"; import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";

View File

@@ -1,49 +1,32 @@
<template> <template>
<UiCard class="home-view"> <div class="home-view">
<UiCardTitle>Component Story skeleton generator</UiCardTitle> <UiTitle type="h4">
This helper will generate a basic story component
<div class="row"> </UiTitle>
Choose a component <div>
<FormSelect v-model="componentPath"> Choose a component:
<select v-model="componentPath">
<option value="" /> <option value="" />
<option v-for="path in componentPaths" :key="path"> <option v-for="(component, path) in componentsWithProps" :key="path">
{{ path }} {{ path }}
</option> </option>
</FormSelect> </select>
<div class="slots">
<label>
Slots names, separated by a comma
<input v-model="slots" />
</label>
<button @click="slots = 'default'">Default</button>
<button @click="slots = ''">Clear</button>
</div>
</div> </div>
<CodeHighlight v-if="componentPath" :code="template" />
<div class="row"> </div>
Slot names, separated by comma
<span class="slots">
<FormInput v-model="slots" />
<UiButton @click="slots = 'default'">Default</UiButton>
<UiButton outlined @click="slots = ''">Clear</UiButton>
</span>
</div>
<p v-for="warning in warnings" :key="warning" class="row warning">
<UiIcon :icon="faWarning" />
{{ warning }}
</p>
<CodeHighlight
class="code-highlight"
v-if="componentPath"
:code="template"
/>
</UiCard>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import CodeHighlight from "@/components/CodeHighlight.vue"; import CodeHighlight from "@/components/CodeHighlight.vue";
import FormInput from "@/components/form/FormInput.vue"; import UiTitle from "@/components/ui/UiTitle.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { castArray } from "lodash-es";
import { type ComponentOptions, computed, ref, watch } from "vue"; import { type ComponentOptions, computed, ref, watch } from "vue";
const componentPath = ref(""); const componentPath = ref("");
@@ -61,14 +44,10 @@ const componentsWithProps = Object.fromEntries(
) )
); );
const componentPaths = Object.keys(componentsWithProps);
const lines = ref<string[]>([]); const lines = ref<string[]>([]);
const slots = ref(""); const slots = ref("");
const quote = (str: string) => `'${str}'`; const quote = (str: string) => `'${str}'`;
const camel = (str: string) =>
str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
const paramsToImport = ref(new Set<string>()); const paramsToImport = ref(new Set<string>());
const widgetsToImport = ref(new Set<string>()); const widgetsToImport = ref(new Set<string>());
@@ -82,15 +61,13 @@ const template = computed(() => {
.filter((name) => name !== ""); .filter((name) => name !== "");
for (const slotName of slotsNames) { for (const slotName of slotsNames) {
paramsLines.push( paramsLines.push(`slot(${slotName === "default" ? "" : quote(slotName)})`);
`slot(${slotName === "default" ? "" : quote(camel(slotName))})`
);
} }
for (const slotName of slotsNames) { for (const slotName of slotsNames) {
paramsLines.push( paramsLines.push(
`setting(${quote( `setting(${quote(
`${camel(slotName)}SlotContent` `${slotName}SlotContent`
)}).preset('Example content for ${slotName} slot').widget(text()).help('Content for ${slotName} slot')` )}).preset('Example content for ${slotName} slot').widget(text()).help('Content for ${slotName} slot')`
); );
} }
@@ -101,7 +78,7 @@ const template = computed(() => {
} }
const paramsStr = paramsLines.join(",\n "); const paramsStr = paramsLines.join(",\n ");
const scriptEndTag = "</" + "script>";
return `<template> return `<template>
<ComponentStory <ComponentStory
v-slot="{ properties, settings }" v-slot="{ properties, settings }"
@@ -114,10 +91,8 @@ const template = computed(() => {
? `>\n ${slotsNames ? `>\n ${slotsNames
.map((name) => .map((name) =>
name === "default" name === "default"
? `{{ settings.${camel(name)}SlotContent }}` ? `{{ settings.${name}SlotContent }}`
: `<template #${name}>{{ settings.${camel( : `<template #${name}>{{ settings.${name}SlotContent }}</template>`
name
)}SlotContent }}</template>`
) )
.join("\n ")} .join("\n ")}
</${componentName}>` </${componentName}>`
@@ -143,30 +118,10 @@ ${
)} } from "@/libs/story/story-widget"` )} } from "@/libs/story/story-widget"`
: "" : ""
} }
${scriptEndTag} ${"<"}/script>
`; `;
}); });
const warnings = ref(new Set<string>());
const extractTypeFromConstructor = (
ctor: null | (new () => unknown),
propName: string
) => {
if (ctor == null) {
warnings.value.add(
`An unknown type has been detected for prop "${propName}"`
);
return "unknown";
}
if (ctor === Date) {
return "Date";
}
return ctor.name.toLocaleLowerCase();
};
watch( watch(
componentPath, componentPath,
(path: string) => { (path: string) => {
@@ -178,7 +133,6 @@ watch(
slots.value = ""; slots.value = "";
widgetsToImport.value = new Set(); widgetsToImport.value = new Set();
paramsToImport.value = new Set(); paramsToImport.value = new Set();
warnings.value = new Set();
lines.value = []; lines.value = [];
for (const propName in component.props) { for (const propName in component.props) {
@@ -193,14 +147,12 @@ watch(
current.push(`default(${quote(prop.default)})`); current.push(`default(${quote(prop.default)})`);
} }
if (prop.type !== undefined) { if (prop.type) {
const type = castArray(prop.type) const type = prop.type();
.map((ctor) => extractTypeFromConstructor(ctor, propName))
.join(" | ");
if (type !== "unknown") { current.push(
current.push(`type(${quote(type)})`); `type(${quote(Array.isArray(type) ? "array" : typeof type)})`
} );
} }
const isModel = component.emits?.includes(`update:${propName}`); const isModel = component.emits?.includes(`update:${propName}`);
@@ -212,28 +164,16 @@ watch(
})` })`
); );
if (!isModel) { current.push("widget()");
current.push("widget()");
}
lines.value.push(current.join(".")); lines.value.push(current.join("."));
} }
let shouldImportEvent = false;
if (component.emits) { if (component.emits) {
for (const eventName of component.emits) {
if (eventName.startsWith("update:")) {
continue;
}
shouldImportEvent = true;
lines.value.push(`event(${quote(eventName)})`);
}
}
if (shouldImportEvent) {
paramsToImport.value.add("event"); paramsToImport.value.add("event");
for (const eventName of component.emits) {
lines.value.push(`event("${eventName}")`);
}
} }
}, },
{ immediate: true } { immediate: true }
@@ -245,28 +185,11 @@ watch(
margin: 1rem; margin: 1rem;
} }
.ui-title {
margin-bottom: 1rem;
}
.slots { .slots {
display: inline-flex;
align-items: stretch;
gap: 1rem;
:deep(input) {
height: 100%;
}
}
.row {
margin-bottom: 2rem;
font-size: 1.6rem;
}
.warning {
font-size: 1.6rem;
font-weight: 600;
color: var(--color-orange-world-base);
}
.code-highlight {
margin-top: 1rem; margin-top: 1rem;
} }
</style> </style>

View File

@@ -1,7 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,7 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,7 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,7 +1,6 @@
<template> <template>
<ObjectNotFoundWrapper :is-ready="isReady" :uuid-checker="hasUuid"> <ObjectNotFoundWrapper :is-ready="isReady" :uuid-checker="hasUuid">
<VmHeader /> <VmHeader />
<VmTabBar :uuid="vm!.uuid" />
<RouterView /> <RouterView />
</ObjectNotFoundWrapper> </ObjectNotFoundWrapper>
</template> </template>
@@ -9,16 +8,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue"; import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import VmHeader from "@/components/vm/VmHeader.vue"; import VmHeader from "@/components/vm/VmHeader.vue";
import VmTabBar from "@/components/vm/VmTabBar.vue";
import { useUiStore } from "@/stores/ui.store"; import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store"; import { useVmStore } from "@/stores/vm.store";
import { whenever } from "@vueuse/core"; import { watchEffect } from "vue";
import { computed } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();
const { getByUuid, hasUuid, isReady } = useVmStore().subscribe(); const { getByUuid, hasUuid, isReady } = useVmStore().subscribe();
const uiStore = useUiStore(); const uiStore = useUiStore();
const vm = computed(() => getByUuid(route.params.uuid as string));
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on)); watchEffect(() => {
uiStore.currentHostOpaqueRef = getByUuid(
route.params.uuid as string
)?.resident_on;
});
</script> </script>

View File

@@ -1,7 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,7 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,7 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,7 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -21,7 +21,7 @@
"dependencies": { "dependencies": {
"@vates/event-listeners-manager": "^1.0.1", "@vates/event-listeners-manager": "^1.0.1",
"@vates/parse-duration": "^0.1.1", "@vates/parse-duration": "^0.1.1",
"@vates/task": "^0.1.2", "@vates/task": "^0.1.0",
"@xen-orchestra/log": "^0.6.0", "@xen-orchestra/log": "^0.6.0",
"acme-client": "^5.0.0", "acme-client": "^5.0.0",
"app-conf": "^2.3.0", "app-conf": "^2.3.0",

View File

@@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "@xen-orchestra/proxy", "name": "@xen-orchestra/proxy",
"version": "0.26.23", "version": "0.26.20",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs", "description": "XO Proxy used to remotely execute backup jobs",
"keywords": [ "keywords": [
@@ -32,7 +32,7 @@
"@vates/decorate-with": "^2.0.0", "@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4", "@vates/disposable": "^0.1.4",
"@xen-orchestra/async-map": "^0.1.2", "@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.36.1", "@xen-orchestra/backups": "^0.35.0",
"@xen-orchestra/fs": "^3.3.4", "@xen-orchestra/fs": "^3.3.4",
"@xen-orchestra/log": "^0.6.0", "@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0", "@xen-orchestra/mixin": "^0.1.0",
@@ -60,7 +60,7 @@
"source-map-support": "^0.5.16", "source-map-support": "^0.5.16",
"stoppable": "^1.0.6", "stoppable": "^1.0.6",
"xdg-basedir": "^5.1.0", "xdg-basedir": "^5.1.0",
"xen-api": "^1.3.1", "xen-api": "^1.3.0",
"xo-common": "^0.8.0" "xo-common": "^0.8.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -43,7 +43,7 @@
"pw": "^0.0.4", "pw": "^0.0.4",
"xdg-basedir": "^4.0.0", "xdg-basedir": "^4.0.0",
"xo-lib": "^0.11.1", "xo-lib": "^0.11.1",
"xo-vmdk-to-vhd": "^2.5.4" "xo-vmdk-to-vhd": "^2.5.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.0.0", "@babel/cli": "^7.0.0",

View File

@@ -4,7 +4,7 @@
"version": "0.2.2", "version": "0.2.2",
"name": "@xen-orchestra/vmware-explorer", "name": "@xen-orchestra/vmware-explorer",
"dependencies": { "dependencies": {
"@vates/task": "^0.1.2", "@vates/task": "^0.1.0",
"@vates/read-chunk": "^1.1.1", "@vates/read-chunk": "^1.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-fetch": "^3.3.0", "node-fetch": "^3.3.0",

View File

@@ -15,7 +15,7 @@
"node": ">=14" "node": ">=14"
}, },
"peerDependencies": { "peerDependencies": {
"xen-api": "^1.3.1" "xen-api": "^1.3.0"
}, },
"scripts": { "scripts": {
"postversion": "npm publish --access public", "postversion": "npm publish --access public",

View File

@@ -2,57 +2,13 @@
## **next** ## **next**
### Bug fixes
- [New/VM] Fix stuck Cloud Config import ([GitHub comment](https://github.com/vatesfr/xen-orchestra/issues/5896#issuecomment-1465253774))
### Released packages
- xen-api 1.3.1
- @xen-orchestra/proxy 0.26.23
- xo-server 5.114.2
## **5.82.1** (2023-05-12)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Enhancements
- [Plugins] Clicking on a plugin name now filters out other plugins
### Bug fixes
- [Host/Network] Fix IP configuration not working with empty fields
- [Import/VM/From VMware] Fix `Property description must be an object: undefined` [Forum#61834](https://xcp-ng.org/forum/post/61834) [Forum#61900](https://xcp-ng.org/forum/post/61900)
- [Import/VM/From VMware] Fix `Cannot read properties of undefined (reading 'stream')` [Forum#59879](https://xcp-ng.org/forum/post/59879) (PR [#6825](https://github.com/vatesfr/xen-orchestra/pull/6825))
- [OVA export] Fix major memory leak which may lead to xo-server crash [Forum#56051](https://xcp-ng.org/forum/post/56051) (PR [#6800](https://github.com/vatesfr/xen-orchestra/pull/6800))
- [VM] Fix `VBD_IS_EMPTY` error when converting to template [Forum#61653](https://xcp-ng.org/forum/post/61653) (PR [#6808](https://github.com/vatesfr/xen-orchestra/pull/6808))
- [New/Network] Fix `invalid parameter error` when not providing a VLAN [Forum#62090](https://xcp-ng.org/forum/post/62090) (PR [#6829](https://github.com/vatesfr/xen-orchestra/pull/6829))
- [Backup/Health check] Fix `task has already ended` error during a healthcheck in continous replication [Forum#62073](https://xcp-ng.org/forum/post/62073) (PR [#6830](https://github.com/vatesfr/xen-orchestra/pull/6830))
### Released packages
- @vates/task 0.1.2
- xo-vmdk-to-vhd 2.5.4
- @xen-orchestra/backups 0.36.1
- @xen-orchestra/proxy 0.26.22
- xo-server 5.114.1
- xo-web 5.117.1
## **5.82.0** (2023-04-28)
### Highlights
- [Host] Smart reboot: suspend resident VMs, restart host and resume VMs [#6750](https://github.com/vatesfr/xen-orchestra/issues/6750) (PR [#6795](https://github.com/vatesfr/xen-orchestra/pull/6795))
- [Backup/exports] Retry when failing to read a data block during Delta Backup, Continuous Replication, disk and OVA export when NBD is enabled [PR #6763](https://github.com/vatesfr/xen-orchestra/pull/6763)
- [Backup/Health check] [Opt-in XenStore API](https://xen-orchestra.com/docs/backups.html#backup-health-check) to execute custom checks inside the VM (PR [#6784](https://github.com/vatesfr/xen-orchestra/pull/6784))
### Enhancements ### Enhancements
- [VM/Advanced] Automatically eject removable medias when converting a VM to a template [#6752](https://github.com/vatesfr/xen-orchestra/issues/6752) (PR [#6769](https://github.com/vatesfr/xen-orchestra/pull/6769)) - [VM/Advanced] Automatically eject removable medias when converting a VM to a template [#6752](https://github.com/vatesfr/xen-orchestra/issues/6752) (PR [#6769](https://github.com/vatesfr/xen-orchestra/pull/6769))
- [Dashboard/Health] Add free space column for storage state table (PR [#6778](https://github.com/vatesfr/xen-orchestra/pull/6778)) - [Dashboard/Health] Add free space column for storage state table (PR [#6778](https://github.com/vatesfr/xen-orchestra/pull/6778))
- [VM/General] Displays the template name used to create the VM, as well as the email address of the VM creator for admin users (PR [#6771](https://github.com/vatesfr/xen-orchestra/pull/6771)) - [VM/General] Displays the template name used to create the VM, as well as the email address of the VM creator for admin users (PR [#6771](https://github.com/vatesfr/xen-orchestra/pull/6771))
- [Kubernetes] Give the possibility to create an high availability cluster (PR [#6794](https://github.com/vatesfr/xen-orchestra/pull/6794)) - [Backup/exports] Retry when failing to read a data block during Delta Backup, Continuous Replication, disk and OVA export when NBD is enabled [PR #6763](https://github.com/vatesfr/xen-orchestra/pull/6763)
- [Host] Smart reboot: suspend resident VMs, restart host and resume VMs [#6750](https://github.com/vatesfr/xen-orchestra/issues/6750) (PR [#6795](https://github.com/vatesfr/xen-orchestra/pull/6795))
### Bug fixes ### Bug fixes
@@ -73,21 +29,21 @@
- vhd-lib 4.4.0 - vhd-lib 4.4.0
- xen-api 1.3.0 - xen-api 1.3.0
- @vates/nbd-client 1.2.0 - @vates/nbd-client 1.2.0
- @vates/task 0.1.0
- @xen-orchestra/xapi 2.2.0 - @xen-orchestra/xapi 2.2.0
- @xen-orchestra/backups 0.35.0
- @xen-orchestra/backups-cli 1.0.5
- @xen-orchestra/mixins 0.10.0 - @xen-orchestra/mixins 0.10.0
- @xen-orchestra/proxy 0.26.20
- @xen-orchestra/vmware-explorer 0.2.2 - @xen-orchestra/vmware-explorer 0.2.2
- xo-cli 0.18.0 - xo-cli 0.18.0
- xo-server 5.112.0
- xo-server-usage-report 0.10.4 - xo-server-usage-report 0.10.4
- @vates/task 0.1.1 - xo-web 5.115.0
- @xen-orchestra/backups 0.36.0
- @xen-orchestra/backups-cli 1.0.6
- @xen-orchestra/proxy 0.26.21
- xo-server 5.113.0
- xo-web 5.116.1
## **5.81** (2023-03-31) ## **5.81** (2023-03-31)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" /> <img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Highlights ### Highlights
@@ -126,6 +82,8 @@
## **5.80.2** (2023-03-16) ## **5.80.2** (2023-03-16)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Enhancements ### Enhancements
- [Plugin/auth-oidc] Support `email` for _username field_ setting [Forum#59587](https://xcp-ng.org/forum/post/59587) - [Plugin/auth-oidc] Support `email` for _username field_ setting [Forum#59587](https://xcp-ng.org/forum/post/59587)

View File

@@ -7,15 +7,10 @@
> Users must be able to say: “Nice enhancement, I'm eager to test it” > Users must be able to say: “Nice enhancement, I'm eager to test it”
- [Proxy] Make proxy address editable (PR [#6816](https://github.com/vatesfr/xen-orchestra/pull/6816))
- [Home/Host] Displays a warning for hosts with HVM disabled [#6823](https://github.com/vatesfr/xen-orchestra/issues/6823) (PR [#6834](https://github.com/vatesfr/xen-orchestra/pull/6834))
### Bug fixes ### Bug fixes
> Users must be able to say: “I had this issue, happy to know it's fixed” > Users must be able to say: “I had this issue, happy to know it's fixed”
- [Sorted table] In collapsed actions, a spinner is displayed during the action time (PR [#6831](https://github.com/vatesfr/xen-orchestra/pull/6831))
### Packages to release ### Packages to release
> When modifying a package, add it here with its release type. > When modifying a package, add it here with its release type.
@@ -32,6 +27,4 @@
<!--packages-start--> <!--packages-start-->
- xo-web minor
<!--packages-end--> <!--packages-end-->

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -296,9 +296,8 @@ When it's done exporting, we'll remove the snapshot. Note: this operation will t
Concurrency is a parameter that let you define how many VMs your backup job will manage simultaneously. Concurrency is a parameter that let you define how many VMs your backup job will manage simultaneously.
:::tip :::tip
- Default concurrency value is 2 if left empty. - Default concurrency value is 2 if left empty.
::: :::
Let's say you want to backup 50 VMs (each with 1x disk) at 3:00 AM. There are **2 different strategies**: Let's say you want to backup 50 VMs (each with 1x disk) at 3:00 AM. There are **2 different strategies**:
@@ -336,7 +335,6 @@ When a backup job is configured using Normal snapshot mode, it's possible to use
- **xo-offline-backup** to apply offline snapshotting mode (VM with be shut down prior to snapshot) - **xo-offline-backup** to apply offline snapshotting mode (VM with be shut down prior to snapshot)
- **xo-memory-backup** to apply RAM-enabled snapshotting - **xo-memory-backup** to apply RAM-enabled snapshotting
- **xo-backup-healthcheck-xenstore** to use a script during [backup healthcheck](#backup-health-check)
For example, you could have a regular backup job with 10 VMs configured with Normal snapshotting, including two which are database servers. Since database servers are generally more sensitive to being restored from snapshots, you could apply the **xo-memory-backup** tag to those two VMs and only those will be backed up in RAM-enabled mode. This will avoid the need to manage a separate backup job and schedule. For example, you could have a regular backup job with 10 VMs configured with Normal snapshotting, including two which are database servers. Since database servers are generally more sensitive to being restored from snapshots, you could apply the **xo-memory-backup** tag to those two VMs and only those will be backed up in RAM-enabled mode. This will avoid the need to manage a separate backup job and schedule.
@@ -344,10 +342,10 @@ For example, you could have a regular backup job with 10 VMs configured with Nor
Just a refresher/summary: You can select multiple backup methods for the same job: Just a refresher/summary: You can select multiple backup methods for the same job:
- Full: _Backup_ and _Disaster Recovery_ (DR) - Full: *Backup* and *Disaster Recovery* (DR)
- Deltas: _Delta Backup_ and _Continuous Replication_ (CR) - Deltas: *Delta Backup* and *Continuous Replication* (CR)
The Full and Delta options are mutually exclusive; Rolling Snapshots are compatible with both. The Backup and Delta Backup go to a remote Target (e.g, NFS); DR and CR back up to another XCP-ng storage repository (i.e., not the one on which the VM's being backed up reside). In the Schedule configuration, you will have the option to select the number of "Backup Retention" if your backup includes a _Backup_ (or _Delta Backup_); you will have the option to select the number "Replication Retention" if you have selected _DR_ or _CR_ in the backup configuration. The Full and Delta options are mutually exclusive; Rolling Snapshots are compatible with both. The Backup and Delta Backup go to a remote Target (e.g, NFS); DR and CR back up to another XCP-ng storage repository (i.e., not the one on which the VM's being backed up reside). In the Schedule configuration, you will have the option to select the number of "Backup Retention" if your backup includes a *Backup* (or *Delta Backup*); you will have the option to select the number "Replication Retention" if you have selected *DR* or *CR* in the backup configuration.
### Rolling Snapshots ### Rolling Snapshots
@@ -368,56 +366,3 @@ It is often a good idea to configure retention of older backups with decreasing
- a monthly backup (retaining 12) - a monthly backup (retaining 12)
Again, all of these can be assigned to the same backup job. Note that if you do a weekly and a monthly backup, at some point, these will fall on the same day. Xen Orchestra is designed to fail gracefully (with an error message) if a backup job for a VM is already running. For this reason, you will want to set the time on the monthly job to run before the weekly job so that if one fails, it will be the weekly rather than the monthly one; if the weekly one fails, the monthly will be there for that spot in the retention plan; if the monthly one fails, the weekly one will only be retained for 4 weeks, and then there will be a gap in the monthly retention. Again, all of these can be assigned to the same backup job. Note that if you do a weekly and a monthly backup, at some point, these will fall on the same day. Xen Orchestra is designed to fail gracefully (with an error message) if a backup job for a VM is already running. For this reason, you will want to set the time on the monthly job to run before the weekly job so that if one fails, it will be the weekly rather than the monthly one; if the weekly one fails, the monthly will be there for that spot in the retention plan; if the monthly one fails, the weekly one will only be retained for 4 weeks, and then there will be a gap in the monthly retention.
## Backup Health Check
Backup health check ensures the backups are ready to be restored.
### Different level of checking
#### Check for boot
XO will restore the VM, either by downloading it for a delta/full backup or by cloning it for a disaster recovery of continous replication and then wait for the guest tools to be loaded before the end of a timeout of 10 minutes (boot + guest tools).
A VM without guest tools will fail its health check.
The restored VM is then deleted.
#### Execute a script
If a VM has the tag **xo-backup-healthcheck-xenstore** during a backup health check, then XO will wait for a script to change the value of the xenstore `vm-data/xo-backup-health-check` key to be either `success` or `failure`.
In case of `failure`, it will mark the health check as failed, and will show the (optional) message contained in `vm-data/xo-backup-health-check-error`
The script needs to be planned on boot. It can check if the record `vm-data/xo-backup-health-check` of the local xenstore contains `planned`
to differenciate a normal boot and a boot during health check.
On success it must write `success`in `vm-data/xo-backup-health-check`.
On failure it must write `failure` in `vm-data/xo-backup-health-check`, and may optionally add details in `vm-data/xo-backup-health-check-error` .
The total timeout of a backup health check (boot + guest tools + scripts) is 10min.
The restored VM is then deleted.
An example in bash is shown in `@xen-orchestra/backups/docs/healtcheck example/wait30seconds.sh`
### Running Health checks
#### Checking a backup
Go to backup > restore and click on the tick to launch a health check.
![](./assets/restorehealthcheck.png)
Then, you will select the backup to be checked and a destination SR, which must have enough space for the full restore.
#### Scheduling health check after backups
Go to Backup > overview > edit.
Then edit the schedule and check the healthcheck box.
![](./assets/scheduled_healthcheck.png)
You will then need to select the SR used, which must have enough space to restore the VMs. Healthcheck will be done after each VM backup, before starting the next one.
You can filter the VMs list by providing tags, only the VMs with these tags will be checked.

View File

@@ -336,5 +336,5 @@ useSudo = true
You need to configure `sudo` to allow the user of your choice to run mount/umount commands without asking for a password. Depending on your operating system / sudo version, the location of this configuration may change. Regardless, you can use: You need to configure `sudo` to allow the user of your choice to run mount/umount commands without asking for a password. Depending on your operating system / sudo version, the location of this configuration may change. Regardless, you can use:
``` ```
username ALL=(root)NOPASSWD: /bin/mount, /bin/umount, /bin/findmnt username ALL=(root)NOPASSWD: /bin/mount, /bin/umount
``` ```

View File

@@ -96,7 +96,7 @@
"prepare": "husky install", "prepare": "husky install",
"prettify": "prettier --ignore-path .gitignore --ignore-unknown --write .", "prettify": "prettier --ignore-path .gitignore --ignore-unknown --write .",
"test": "npm run test-lint && npm run test-unit", "test": "npm run test-lint && npm run test-unit",
"test-integration": "jest \".integ\\.spec\\.js$\" && scripts/run-script.js --parallel test-integration", "test-integration": "jest \".integ\\.spec\\.js$\"",
"test-lint": "eslint --ignore-path .gitignore --ignore-pattern packages/xo-web .", "test-lint": "eslint --ignore-path .gitignore --ignore-pattern packages/xo-web .",
"test-unit": "jest \"^(?!.*\\.integ\\.spec\\.js$)\" && scripts/run-script.js --bail test" "test-unit": "jest \"^(?!.*\\.integ\\.spec\\.js$)\" && scripts/run-script.js --bail test"
}, },

View File

@@ -35,7 +35,7 @@
}, },
"scripts": { "scripts": {
"postversion": "npm publish", "postversion": "npm publish",
"test-integration": "node--test *.integ.js" "test": "node--test"
}, },
"devDependencies": { "devDependencies": {
"execa": "^4.0.0", "execa": "^4.0.0",

View File

@@ -40,7 +40,7 @@
"human-format": "^1.0.0", "human-format": "^1.0.0",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"pw": "^0.0.4", "pw": "^0.0.4",
"xen-api": "^1.3.1" "xen-api": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.1.5", "@babel/cli": "^7.1.5",

View File

@@ -1,7 +1,7 @@
{ {
"private": false, "private": false,
"name": "xen-api", "name": "xen-api",
"version": "1.3.1", "version": "1.3.0",
"license": "ISC", "license": "ISC",
"description": "Connector to the Xen API", "description": "Connector to the Xen API",
"keywords": [ "keywords": [
@@ -35,7 +35,7 @@
"bind-property-descriptor": "^2.0.0", "bind-property-descriptor": "^2.0.0",
"blocked": "^1.2.1", "blocked": "^1.2.1",
"debug": "^4.0.1", "debug": "^4.0.1",
"http-request-plus": "^1.0.2", "http-request-plus": "^1.0.0",
"jest-diff": "^29.0.3", "jest-diff": "^29.0.3",
"json-rpc-protocol": "^0.13.1", "json-rpc-protocol": "^0.13.1",
"kindof": "^2.0.0", "kindof": "^2.0.0",

View File

@@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "xo-server", "name": "xo-server",
"version": "5.114.2", "version": "5.112.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"description": "Server part of Xen-Orchestra", "description": "Server part of Xen-Orchestra",
"keywords": [ "keywords": [
@@ -41,7 +41,7 @@
"@vates/predicates": "^1.1.0", "@vates/predicates": "^1.1.0",
"@vates/read-chunk": "^1.1.1", "@vates/read-chunk": "^1.1.1",
"@xen-orchestra/async-map": "^0.1.2", "@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.36.1", "@xen-orchestra/backups": "^0.35.0",
"@xen-orchestra/cron": "^1.0.6", "@xen-orchestra/cron": "^1.0.6",
"@xen-orchestra/defined": "^0.0.1", "@xen-orchestra/defined": "^0.0.1",
"@xen-orchestra/emit-async": "^1.0.0", "@xen-orchestra/emit-async": "^1.0.0",
@@ -131,12 +131,12 @@
"vhd-lib": "^4.4.0", "vhd-lib": "^4.4.0",
"ws": "^8.2.3", "ws": "^8.2.3",
"xdg-basedir": "^5.1.0", "xdg-basedir": "^5.1.0",
"xen-api": "^1.3.1", "xen-api": "^1.3.0",
"xo-acl-resolver": "^0.4.1", "xo-acl-resolver": "^0.4.1",
"xo-collection": "^0.5.0", "xo-collection": "^0.5.0",
"xo-common": "^0.8.0", "xo-common": "^0.8.0",
"xo-remote-parser": "^0.9.2", "xo-remote-parser": "^0.9.2",
"xo-vmdk-to-vhd": "^2.5.4" "xo-vmdk-to-vhd": "^2.5.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.0.0", "@babel/cli": "^7.0.0",

View File

@@ -10,7 +10,7 @@ export async function create({ pool, name, description, pif, mtu = 1500, vlan =
description, description,
pifId: pif && this.getObject(pif, 'PIF')._xapiId, pifId: pif && this.getObject(pif, 'PIF')._xapiId,
mtu: +mtu, mtu: +mtu,
vlan, vlan: +vlan,
}) })
if (nbd) { if (nbd) {
@@ -27,7 +27,7 @@ create.params = {
description: { type: 'string', minLength: 0, optional: true }, description: { type: 'string', minLength: 0, optional: true },
pif: { type: 'string', optional: true }, pif: { type: 'string', optional: true },
mtu: { type: 'integer', optional: true }, mtu: { type: 'integer', optional: true },
vlan: { type: 'integer', optional: true }, vlan: { type: ['integer', 'string'], optional: true },
} }
create.resolve = { create.resolve = {

View File

@@ -84,10 +84,10 @@ export async function reconfigureIp({ pif, mode = 'DHCP', ip = '', netmask = '',
reconfigureIp.params = { reconfigureIp.params = {
id: { type: 'string', optional: true }, id: { type: 'string', optional: true },
mode: { type: 'string', optional: true }, mode: { type: 'string', optional: true },
ip: { type: 'string', minLength: 0, optional: true }, ip: { type: 'string', optional: true },
netmask: { type: 'string', minLength: 0, optional: true }, netmask: { type: 'string', optional: true },
gateway: { type: 'string', minLength: 0, optional: true }, gateway: { type: 'string', optional: true },
dns: { type: 'string', minLength: 0, optional: true }, dns: { type: 'string', optional: true },
} }
reconfigureIp.resolve = { reconfigureIp.resolve = {

View File

@@ -186,12 +186,6 @@ export const create = defer(async function ($defer, params) {
} }
} }
const resourceSetTags = resourceSet !== undefined ? (await this.getResourceSet(resourceSet)).tags : undefined
const paramsTags = params.tags
if (resourceSetTags !== undefined) {
params.tags = paramsTags !== undefined ? paramsTags.concat(resourceSetTags) : resourceSetTags
}
const xapiVm = await xapi.createVm(template._xapiId, params, checkLimits, user.id) const xapiVm = await xapi.createVm(template._xapiId, params, checkLimits, user.id)
$defer.onFailure(() => xapi.VM_destroy(xapiVm.$ref, { deleteDisks: true, force: true })) $defer.onFailure(() => xapi.VM_destroy(xapiVm.$ref, { deleteDisks: true, force: true }))
@@ -883,8 +877,7 @@ export async function convertToTemplate({ vm }) {
// Attempts to eject all removable media // Attempts to eject all removable media
const ignoreNotRemovable = error => { const ignoreNotRemovable = error => {
const { code } = error if (error.code !== 'VBD_NOT_REMOVABLE_MEDIA') {
if (code !== 'VBD_IS_EMPTY' && code !== 'VBD_NOT_REMOVABLE_MEDIA') {
throw error throw error
} }
} }
@@ -1376,7 +1369,7 @@ export async function importMultipleFromEsxi({
await asyncEach( await asyncEach(
vms, vms,
async vm => { async vm => {
await Task.run({ data: { name: `importing vm ${vm}` } }, async () => { await new Task({ name: `importing vm ${vm}` }).run(async () => {
try { try {
const vmUuid = await this.migrationfromEsxi({ const vmUuid = await this.migrationfromEsxi({
host, host,

View File

@@ -191,7 +191,7 @@ export default class BackupNg {
vmIds.forEach(handleRecord) vmIds.forEach(handleRecord)
unboxIdsFromPattern(job.srs).forEach(handleRecord) unboxIdsFromPattern(job.srs).forEach(handleRecord)
// add xapi specific to the health check SR if needed // add xapi specific to the healthcheck SR if needed
if (job.settings[schedule.id].healthCheckSr !== undefined) { if (job.settings[schedule.id].healthCheckSr !== undefined) {
handleRecord(job.settings[schedule.id].healthCheckSr) handleRecord(job.settings[schedule.id].healthCheckSr)
} }

View File

@@ -154,7 +154,7 @@ export default class MigrateVm {
} }
#connectToEsxi(host, user, password, sslVerify) { #connectToEsxi(host, user, password, sslVerify) {
return Task.run({ data: { name: `connecting to ${host}` } }, async () => { return new Task({ name: `connecting to ${host}` }).run(async () => {
const esxi = new Esxi(host, user, password, sslVerify) const esxi = new Esxi(host, user, password, sslVerify)
await fromEvent(esxi, 'ready') await fromEvent(esxi, 'ready')
return esxi return esxi
@@ -174,24 +174,21 @@ export default class MigrateVm {
const app = this._app const app = this._app
const esxi = await this.#connectToEsxi(host, user, password, sslVerify) const esxi = await this.#connectToEsxi(host, user, password, sslVerify)
const esxiVmMetadata = await Task.run({ data: { name: `get metadata of ${vmId}` } }, async () => { const esxiVmMetadata = await new Task({ name: `get metadata of ${vmId}` }).run(async () => {
return esxi.getTransferableVmMetadata(vmId) return esxi.getTransferableVmMetadata(vmId)
}) })
const { disks, firmware, memory, name_label, networks, nCpus, powerState, snapshots } = esxiVmMetadata const { disks, firmware, memory, name_label, networks, nCpus, powerState, snapshots } = esxiVmMetadata
const isRunning = powerState !== 'poweredOff' const isRunning = powerState !== 'poweredOff'
const chainsByNodes = await Task.run( const chainsByNodes = await new Task({ name: `build disks and snapshots chains for ${vmId}` }).run(async () => {
{ data: { name: `build disks and snapshots chains for ${vmId}` } }, return this.#buildDiskChainByNode(disks, snapshots)
async () => { })
return this.#buildDiskChainByNode(disks, snapshots)
}
)
const sr = app.getXapiObject(srId) const sr = app.getXapiObject(srId)
const xapi = sr.$xapi const xapi = sr.$xapi
const vm = await Task.run({ data: { name: 'creating MV on XCP side' } }, async () => { const vm = await new Task({ name: 'creating MV on XCP side ' }).run(async () => {
// got data, ready to start creating // got data, ready to start creating
const vm = await xapi._getOrWaitObject( const vm = await xapi._getOrWaitObject(
await xapi.VM_create({ await xapi.VM_create({
@@ -236,7 +233,7 @@ export default class MigrateVm {
const vhds = await Promise.all( const vhds = await Promise.all(
Object.keys(chainsByNodes).map(async (node, userdevice) => Object.keys(chainsByNodes).map(async (node, userdevice) =>
Task.run({ data: { name: `Cold import of disks ${node}` } }, async () => { new Task({ name: `Cold import of disks ${node} ` }).run(async () => {
const chainByNode = chainsByNodes[node] const chainByNode = chainsByNodes[node]
const vdi = await xapi._getOrWaitObject( const vdi = await xapi._getOrWaitObject(
await xapi.VDI_create({ await xapi.VDI_create({
@@ -271,11 +268,9 @@ export default class MigrateVm {
} }
parentVhd = vhd parentVhd = vhd
} }
if (vhd !== undefined) {
// it can be empty if the VM don't have a snapshot and is running const stream = vhd.stream()
const stream = vhd.stream() await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
}
return { vdi, vhd } return { vdi, vhd }
}) })
) )
@@ -283,26 +278,20 @@ export default class MigrateVm {
if (isRunning && stopSource) { if (isRunning && stopSource) {
// it the vm was running, we stop it and transfer the data in the active disk // it the vm was running, we stop it and transfer the data in the active disk
await Task.run({ data: { name: 'powering down source VM' } }, () => esxi.powerOff(vmId)) await new Task({ name: 'powering down source VM' }).run(() => esxi.powerOff(vmId))
await Promise.all( await Promise.all(
Object.keys(chainsByNodes).map(async (node, userdevice) => { Object.keys(chainsByNodes).map(async (node, userdevice) => {
await Task.run({ data: { name: `Transfering deltas of ${userdevice}` } }, async () => { await new Task({ name: `Transfering deltas of ${userdevice}` }).run(async () => {
const chainByNode = chainsByNodes[node] const chainByNode = chainsByNodes[node]
const disk = chainByNode[chainByNode.length - 1] const disk = chainByNode[chainByNode.length - 1]
const { fileName, path, datastore, isFull } = disk const { fileName, path, datastore, isFull } = disk
const { vdi, vhd: parentVhd } = vhds[userdevice] const { vdi, vhd: parentVhd } = vhds[userdevice]
let vhd let vhd
if (vdi === undefined) {
throw new Error(`Can't import delta of a running VM without its parent vdi`)
}
if (isFull) { if (isFull) {
vhd = await VhdEsxiRaw.open(esxi, datastore, path + '/' + fileName, { thin }) vhd = await VhdEsxiRaw.open(esxi, datastore, path + '/' + fileName, { thin })
await vhd.readBlockAllocationTable() await vhd.readBlockAllocationTable()
} else { } else {
if (parentVhd === undefined) {
throw new Error(`Can't import delta of a running VM without its parent VHD`)
}
// we only want to transfer blocks present in the delta vhd, not the full vhd chain // we only want to transfer blocks present in the delta vhd, not the full vhd chain
vhd = await openDeltaVmdkasVhd(esxi, datastore, path + '/' + fileName, parentVhd, { vhd = await openDeltaVmdkasVhd(esxi, datastore, path + '/' + fileName, parentVhd, {
lookMissingBlockInParent: false, lookMissingBlockInParent: false,
@@ -316,7 +305,7 @@ export default class MigrateVm {
) )
} }
await Task.run({ data: { name: 'Finishing transfer' } }, async () => { await new Task({ name: 'Finishing transfer' }).run(async () => {
// remove the importing in label // remove the importing in label
await vm.set_name_label(esxiVmMetadata.name_label) await vm.set_name_label(esxiVmMetadata.name_label)

View File

@@ -31,6 +31,8 @@ const computeVmXapiResourcesUsage = vm => {
let disks = 0 let disks = 0
let disk = 0 let disk = 0
const canUseLiveResources = !(vm.is_a_snapshot || vm.is_a_template)
forEach(vm.$VBDs, vbd => { forEach(vm.$VBDs, vbd => {
let vdi, vdiId let vdi, vdiId
if (vbd.type === 'Disk' && !processed[(vdiId = vbd.VDI)] && (vdi = vbd.$VDI)) { if (vbd.type === 'Disk' && !processed[(vdiId = vbd.VDI)] && (vdi = vbd.$VDI)) {
@@ -41,11 +43,11 @@ const computeVmXapiResourcesUsage = vm => {
}) })
return { return {
cpus: vm.VCPUs_at_startup, cpus: canUseLiveResources ? vm.VCPUs_at_startup : 0,
disk, disk,
disks, disks,
memory: vm.memory_dynamic_max, memory: canUseLiveResources ? vm.memory_dynamic_max : 0,
vms: 1, vms: canUseLiveResources ? 1 : 0,
} }
} }

View File

@@ -149,7 +149,6 @@ export default class RestApi {
collections.backups = { id: 'backups' } collections.backups = { id: 'backups' }
collections.restore = { id: 'restore' } collections.restore = { id: 'restore' }
collections.tasks = { id: 'tasks' }
collections.vms.actions = { collections.vms.actions = {
__proto__: null, __proto__: null,

View File

@@ -1,7 +1,7 @@
{ {
"private": false, "private": false,
"name": "xo-vmdk-to-vhd", "name": "xo-vmdk-to-vhd",
"version": "2.5.4", "version": "2.5.3",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"description": "JS lib reading and writing .vmdk and .ova files", "description": "JS lib reading and writing .vmdk and .ova files",
"keywords": [ "keywords": [

View File

@@ -26,7 +26,7 @@ export async function writeOvaOn(
async function writeDisk(entry, blockIterator) { async function writeDisk(entry, blockIterator) {
for await (const block of blockIterator) { for await (const block of blockIterator) {
await fromCallback.call(entry, entry.write, block) entry.write(block)
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "xo-web", "name": "xo-web",
"version": "5.117.1", "version": "5.115.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"description": "Web interface client for Xen-Orchestra", "description": "Web interface client for Xen-Orchestra",
"keywords": [ "keywords": [
@@ -137,7 +137,7 @@
"xo-common": "^0.8.0", "xo-common": "^0.8.0",
"xo-lib": "^0.11.1", "xo-lib": "^0.11.1",
"xo-remote-parser": "^0.9.2", "xo-remote-parser": "^0.9.2",
"xo-vmdk-to-vhd": "^2.5.4" "xo-vmdk-to-vhd": "^2.5.3"
}, },
"scripts": { "scripts": {
"build": "GIT_HEAD=$(git rev-parse HEAD) NODE_ENV=production gulp build", "build": "GIT_HEAD=$(git rev-parse HEAD) NODE_ENV=production gulp build",

File diff suppressed because it is too large Load Diff

View File

@@ -917,7 +917,6 @@ const messages = {
// ----- Host item ------ // ----- Host item ------
host: 'Host', host: 'Host',
hostHvmDisabled: 'Hardware-assisted virtualization is not enabled on this host',
hostNoLicensePartialProSupport: hostNoLicensePartialProSupport:
'This host does not have an active license, even though it is in a pool with licensed hosts. In order for XCP-ng Pro Support to be enabled on a pool, all hosts within the pool must have an active license', 'This host does not have an active license, even though it is in a pool with licensed hosts. In order for XCP-ng Pro Support to be enabled on a pool, all hosts within the pool must have an active license',
hostNoSupport: 'No XCP-ng Pro Support enabled on this host', hostNoSupport: 'No XCP-ng Pro Support enabled on this host',
@@ -2434,9 +2433,6 @@ const messages = {
recipeNumberOfNodesLabel: 'Number of worker nodes', recipeNumberOfNodesLabel: 'Number of worker nodes',
recipeSshKeyLabel: 'SSH key', recipeSshKeyLabel: 'SSH key',
recipeStaticIpAddresses: 'Static IP addresses', recipeStaticIpAddresses: 'Static IP addresses',
recipeHighAvailability: 'High Availability Cluster',
recipeHaControPlaneIpAddress: 'Control plane { i, number } node IP address/subnet mask',
recipeVip: 'VIP address',
recipeControlPlaneIpAddress: 'Control plane node IP address/subnet mask', recipeControlPlaneIpAddress: 'Control plane node IP address/subnet mask',
recipeWorkerIpAddress: 'Worker node { i, number } IP address/subnet mask', recipeWorkerIpAddress: 'Worker node { i, number } IP address/subnet mask',
recipeGatewayIpAddress: 'Gateway IP address', recipeGatewayIpAddress: 'Gateway IP address',

View File

@@ -168,12 +168,8 @@ const handleFnProps = (prop, items, userData) => (typeof prop === 'function' ? p
const CollapsedActions = decorate([ const CollapsedActions = decorate([
withRouter, withRouter,
provideState({ provideState({
initialState: () => ({
runningActions: [],
}),
effects: { effects: {
async execute(state, { handler, label, redirectOnSuccess }) { async execute(state, { handler, label, redirectOnSuccess }) {
this.state.runningActions = [...this.state.runningActions, label]
try { try {
await handler() await handler()
ifDef(redirectOnSuccess, this.props.router.push) ifDef(redirectOnSuccess, this.props.router.push)
@@ -187,25 +183,18 @@ const CollapsedActions = decorate([
_error(label, defined(error.message, String(error))) _error(label, defined(error.message, String(error)))
} }
} }
} finally {
this.state.runningActions = this.state.runningActions.filter(action => action !== label)
} }
}, },
}, },
computed: { computed: {
wrappedActions: ({ runningActions }, { actions }) =>
actions.map(action => {
action.isRunning = runningActions.includes(action.label)
return action
}),
dropdownId: generateId, dropdownId: generateId,
actions: ({ wrappedActions: actions }, { items, userData }) => actions: (_, { actions, items, userData }) =>
actions.map(({ disabled, grouped, handler, icon, isRunning, label, level, redirectOnSuccess }) => { actions.map(({ disabled, grouped, handler, icon, label, level, redirectOnSuccess }) => {
const actionItems = Array.isArray(items) || !grouped ? items : [items] const actionItems = Array.isArray(items) || !grouped ? items : [items]
return { return {
disabled: isRunning || handleFnProps(disabled, actionItems, userData), disabled: handleFnProps(disabled, actionItems, userData),
handler: () => handler(actionItems, userData), handler: () => handler(actionItems, userData),
icon: isRunning ? 'loading' : handleFnProps(icon, actionItems, userData), icon: handleFnProps(icon, actionItems, userData),
label: handleFnProps(label, actionItems, userData), label: handleFnProps(label, actionItems, userData),
level: handleFnProps(level, actionItems, userData), level: handleFnProps(level, actionItems, userData),
redirectOnSuccess: handleFnProps(redirectOnSuccess, actionItems, userData), redirectOnSuccess: handleFnProps(redirectOnSuccess, actionItems, userData),

View File

@@ -136,17 +136,7 @@ const New = decorate([
)} )}
<FormGroup> <FormGroup>
<label> <label>
<strong> <strong>{_('healthCheck')}</strong>{' '}
<a
className='text-info'
rel='noreferrer'
href='https://xen-orchestra.com/docs/backups.html#backup-health-check'
target='_blank'
>
<Icon icon='info' />
</a>{' '}
{_('healthCheck')}
</strong>{' '}
{conditionalTooltip( {conditionalTooltip(
<input <input
checked={schedule.healthCheckVmsWithTags !== undefined} checked={schedule.healthCheckVmsWithTags !== undefined}
@@ -167,7 +157,7 @@ const New = decorate([
<p className='h2'> <p className='h2'>
<Tags labels={schedule.healthCheckVmsWithTags} onChange={effects.setHealthCheckTags} /> <Tags labels={schedule.healthCheckVmsWithTags} onChange={effects.setHealthCheckTags} />
</p> </p>
<strong>{_('healthCheckChooseSr')}</strong> <strong>{_('sr')}</strong>
<SelectSr <SelectSr
onChange={effects.setHealthCheckSr} onChange={effects.setHealthCheckSr}
placeholder={_('healthCheckChooseSr')} placeholder={_('healthCheckChooseSr')}

View File

@@ -201,14 +201,7 @@ export default class Restore extends Component {
_restoreHealthCheck = data => _restoreHealthCheck = data =>
confirm({ confirm({
title: _('checkVmBackupsTitle', { vm: data.last.vm.name_label }), title: _('checkVmBackupsTitle', { vm: data.last.vm.name_label }),
body: ( body: <RestoreBackupsModalBody data={data} showGenerateNewMacAddress={false} showStartAfterBackup={false} />,
<RestoreBackupsModalBody
data={data}
showGenerateNewMacAddress={false}
showStartAfterBackup={false}
backupHealthCheck
/>
),
icon: 'restore', icon: 'restore',
}) })
.then(({ backup, targetSrs: { mainSr, mapVdisSrs } }) => { .then(({ backup, targetSrs: { mainSr, mapVdisSrs } }) => {

View File

@@ -1,5 +1,4 @@
import _ from 'intl' import _ from 'intl'
import Icon from 'icon'
import React from 'react' import React from 'react'
import ChooseSrForEachVdisModal from 'xo/choose-sr-for-each-vdis-modal' import ChooseSrForEachVdisModal from 'xo/choose-sr-for-each-vdis-modal'
import Component from 'base-component' import Component from 'base-component'
@@ -34,16 +33,6 @@ export default class RestoreBackupsModalBody extends Component {
render() { render() {
return ( return (
<div> <div>
{this.props.backupHealthCheck && (
<a
className='text-info'
rel='noreferrer'
href='https://xen-orchestra.com/docs/backups.html#backup-health-check'
target='_blank'
>
<Icon icon='info' />
</a>
)}
<div className='mb-1'> <div className='mb-1'>
<Select <Select
optionRenderer={BACKUP_RENDERER} optionRenderer={BACKUP_RENDERER}
@@ -88,7 +77,6 @@ export default class RestoreBackupsModalBody extends Component {
RestoreBackupsModalBody.defaultProps = { RestoreBackupsModalBody.defaultProps = {
showGenerateNewMacAddress: true, showGenerateNewMacAddress: true,
showStartAfterBackup: true, showStartAfterBackup: true,
backupHealthCheck: false,
} }
export class RestoreBackupsBulkModalBody extends Component { export class RestoreBackupsBulkModalBody extends Component {

View File

@@ -177,17 +177,6 @@ export default class HostItem extends Component {
), ),
}) })
} }
if (!host.hvmCapable) {
alerts.push({
level: 'warning',
render: (
<span>
<Icon icon='alarm' /> {_('hostHvmDisabled')}
</span>
),
})
}
return alerts return alerts
} }
) )

View File

@@ -21,7 +21,6 @@ import { FormattedRelative, FormattedTime } from 'react-intl'
import { Sr } from 'render-xo-item' import { Sr } from 'render-xo-item'
import { Text } from 'editable' import { Text } from 'editable'
import { Toggle, Select, SizeInput } from 'form' import { Toggle, Select, SizeInput } from 'form'
import * as xoaPlans from 'xoa-plans'
import { import {
detachHost, detachHost,
disableHost, disableHost,
@@ -45,7 +44,6 @@ import {
import { installCertificate } from './install-certificate' import { installCertificate } from './install-certificate'
const ALLOW_INSTALL_SUPP_PACK = process.env.XOA_PLAN > 1 const ALLOW_INSTALL_SUPP_PACK = process.env.XOA_PLAN > 1
const ALLOW_SMART_REBOOT = xoaPlans.CURRENT.value >= xoaPlans.PREMIUM.value
const SCHED_GRAN_TYPE_OPTIONS = [ const SCHED_GRAN_TYPE_OPTIONS = [
{ {
@@ -64,9 +62,7 @@ const SCHED_GRAN_TYPE_OPTIONS = [
const forceReboot = host => restartHost(host, true) const forceReboot = host => restartHost(host, true)
const smartReboot = ALLOW_SMART_REBOOT const smartReboot = host => restartHost(host, false, true) // don't force, suspend resident VMs
? host => restartHost(host, false, true) // don't force, suspend resident VMs
: () => {}
const formatPack = ({ name, author, description, version }, key) => ( const formatPack = ({ name, author, description, version }, key) => (
<tr key={key}> <tr key={key}>
@@ -263,12 +259,11 @@ export default class extends Component {
<TabButton <TabButton
key='smart-reboot' key='smart-reboot'
btnStyle='warning' btnStyle='warning'
disabled={!ALLOW_SMART_REBOOT}
handler={smartReboot} handler={smartReboot}
handlerParam={host} handlerParam={host}
icon='freeze' icon='freeze'
labelId='smartRebootHostLabel' labelId='smartRebootHostLabel'
tooltip={ALLOW_SMART_REBOOT ? _('smartRebootHostTooltip') : _('availableXoaPremium')} tooltip={_('smartRebootHostTooltip')}
/>, />,
<TabButton <TabButton
key='force-reboot' key='force-reboot'

View File

@@ -53,17 +53,6 @@ export default decorate([
workerNodeIpAddresses, workerNodeIpAddresses,
}) })
}, },
onChangeCpIp(__, ev) {
const { name, value } = ev.target
const { onChange, value: prevValue } = this.props
const controlPlaneIpAddresses = prevValue.controlPlaneIpAddresses ?? []
controlPlaneIpAddresses[name.split('.')[1]] = value
onChange({
...prevValue,
controlPlaneIpAddresses,
})
},
onChangeNameserver(__, ev) { onChangeNameserver(__, ev) {
const { value } = ev.target const { value } = ev.target
const { onChange, value: prevValue } = this.props const { onChange, value: prevValue } = this.props
@@ -148,19 +137,6 @@ export default decorate([
value={value.nbNodes} value={value.nbNodes}
/> />
</FormGrid.Row> </FormGrid.Row>
<FormGrid.Row>
<label>
<input
className='mt-1'
name='highAvailability'
onChange={effects.toggleStaticIpAddress}
type='checkbox'
value={value.highAvailability}
/>
&nbsp;
{_('recipeHighAvailability')}
</label>
</FormGrid.Row>
<FormGrid.Row> <FormGrid.Row>
<label> <label>
<input <input
@@ -176,48 +152,18 @@ export default decorate([
</FormGrid.Row> </FormGrid.Row>
{value.nbNodes > 0 && {value.nbNodes > 0 &&
value.staticIpAddress && [ value.staticIpAddress && [
value.highAvailability && [ <FormGrid.Row key='controlPlaneIpAddrRow'>
Array.from({ length: 3 }).map((v, i) => ( <label>{_('recipeControlPlaneIpAddress')}</label>
<FormGrid.Row key={i}> <input
<label>{_('recipeHaControPlaneIpAddress', { i: i + 1 })}</label> className='form-control'
<input name='controlPlaneIpAddress'
className='form-control' onChange={effects.onChangeValue}
name={`controlPlaneIpAddress.${i}`} placeholder={formatMessage(messages.recipeControlPlaneIpAddress)}
onChange={effects.onChangeCpIp} required
placeholder={formatMessage(messages.recipeHaControPlaneIpAddress, { i: i + 1 })} type='text'
required value={value.controlPlaneIpAddress}
type='text' />
value={value[`controlPlaneIpAddress.${i}`]} </FormGrid.Row>,
/>
</FormGrid.Row>
)),
<FormGrid.Row key='vipAddrRow'>
<label>{_('recipeVip')}</label>
<input
className='form-control'
name='vipAddress'
onChange={effects.onChangeValue}
placeholder={formatMessage(messages.recipeVip)}
required
type='text'
value={value.vipAddress}
/>
</FormGrid.Row>,
],
!value.highAvailability && [
<FormGrid.Row key='controlPlaneIpAddrRow'>
<label>{_('recipeControlPlaneIpAddress')}</label>
<input
className='form-control'
name='controlPlaneIpAddress'
onChange={effects.onChangeValue}
placeholder={formatMessage(messages.recipeControlPlaneIpAddress)}
required
type='text'
value={value.controlPlaneIpAddress}
/>
</FormGrid.Row>,
],
<FormGrid.Row key='gatewayRow'> <FormGrid.Row key='gatewayRow'>
<label>{_('recipeGatewayIpAddress')}</label> <label>{_('recipeGatewayIpAddress')}</label>
<input <input

View File

@@ -53,34 +53,27 @@ export default decorate([
const { const {
clusterName, clusterName,
controlPlaneIpAddress, controlPlaneIpAddress,
controlPlaneIpAddresses,
gatewayIpAddress, gatewayIpAddress,
highAvailability,
nameservers, nameservers,
nbNodes, nbNodes,
network, network,
searches, searches,
sr, sr,
sshKey, sshKey,
vipAddress,
workerNodeIpAddresses, workerNodeIpAddresses,
} = recipeParams } = recipeParams
markRecipeAsCreating(RECIPE_INFO.id) markRecipeAsCreating(RECIPE_INFO.id)
const tag = await createKubernetesCluster({ const tag = await createKubernetesCluster({
clusterName,
clusterName, clusterName,
controlPlaneIpAddress, controlPlaneIpAddress,
controlPlaneIpAddresses,
gatewayIpAddress, gatewayIpAddress,
highAvailability,
nameservers, nameservers,
nbNodes: +nbNodes, nbNodes: +nbNodes,
network: network.id, network: network.id,
searches, searches,
sr: sr.id, sr: sr.id,
sshKey, sshKey,
vipAddress,
workerNodeIpAddresses, workerNodeIpAddresses,
}) })
markRecipeAsDone(RECIPE_INFO.id) markRecipeAsDone(RECIPE_INFO.id)

View File

@@ -319,7 +319,6 @@ const HealthCheckTask = ({ children, className, task }) => (
const HealthCheckVmStartTask = ({ children, className, task }) => ( const HealthCheckVmStartTask = ({ children, className, task }) => (
<li className={className}> <li className={className}>
<Icon icon='run' /> {task.message} <TaskStateInfos status={task.status} /> <Icon icon='run' /> {task.message} <TaskStateInfos status={task.status} />
<TaskInfos infos={task.infos} />
<TaskStart task={task} /> <TaskStart task={task} />
<TaskEnd task={task} /> <TaskEnd task={task} />
<TaskError task={task} /> <TaskError task={task} />

View File

@@ -197,11 +197,11 @@ const NewNetwork = decorate([
networks, networks,
pif, pif,
pifs, pifs,
vlan,
} = state } = state
let { mtu, vlan } = state let { mtu } = state
mtu = mtu === '' ? undefined : +mtu mtu = mtu === '' ? undefined : +mtu
vlan = vlan === '' ? undefined : +vlan
return bonded return bonded
? createBondedNetwork({ ? createBondedNetwork({

View File

@@ -37,13 +37,7 @@ import { updateApplianceSettings } from './update-appliance-settings'
import Tooltip from '../../common/tooltip' import Tooltip from '../../common/tooltip'
import { getXoaPlan, SOURCES } from '../../common/xoa-plans' import { getXoaPlan, SOURCES } from '../../common/xoa-plans'
const _editProxy = (value, { name, proxy }) => { const _editProxy = (value, { name, proxy }) => editProxyAppliance(proxy, { [name]: value })
if (typeof value === 'string') {
value = value.trim()
value = value === '' ? null : value
}
return editProxyAppliance(proxy, { [name]: value })
}
const HEADER = ( const HEADER = (
<h2> <h2>
@@ -149,12 +143,6 @@ const COLUMNS = [
itemRenderer: proxy => <Vm id={proxy.vmUuid} link />, itemRenderer: proxy => <Vm id={proxy.vmUuid} link />,
name: _('vm'), name: _('vm'),
}, },
{
itemRenderer: proxy => (
<Text data-name='address' data-proxy={proxy} value={proxy.address ?? ''} onChange={_editProxy} />
),
name: _('address'),
},
{ {
name: _('license'), name: _('license'),
itemRenderer: (proxy, { isAdmin, licensesByVmUuid }) => { itemRenderer: (proxy, { isAdmin, licensesByVmUuid }) => {

View File

@@ -5,11 +5,9 @@ import ActionToggle from 'action-toggle'
import Button from 'button' import Button from 'button'
import Component from 'base-component' import Component from 'base-component'
import decorate from 'apply-decorators' import decorate from 'apply-decorators'
import escapeRegExp from 'lodash/escapeRegExp'
import GenericInput from 'json-schema-input' import GenericInput from 'json-schema-input'
import Icon from 'icon' import Icon from 'icon'
import isEmpty from 'lodash/isEmpty' import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import map from 'lodash/map' import map from 'lodash/map'
import orderBy from 'lodash/orderBy' import orderBy from 'lodash/orderBy'
import pFinally from 'promise-toolbox/finally' import pFinally from 'promise-toolbox/finally'
@@ -41,17 +39,6 @@ class Plugin extends Component {
this.testFormId = `form-test-${props.id}` this.testFormId = `form-test-${props.id}`
} }
_getPluginLink = createSelector(
() => this.props.name,
name => {
const s = new ComplexMatcher.Property(
'name',
new ComplexMatcher.RegExp('^' + escapeRegExp(name) + '$', 'i')
).toString()
return location => ({ ...location, query: { ...location.query, s } })
}
)
_getUiSchema = createSelector(() => this.props.configurationSchema, generateUiSchema) _getUiSchema = createSelector(() => this.props.configurationSchema, generateUiSchema)
_updateExpanded = () => { _updateExpanded = () => {
@@ -138,8 +125,9 @@ class Plugin extends Component {
<Row> <Row>
<Col mediumSize={8}> <Col mediumSize={8}>
<h5 className='form-inline clearfix'> <h5 className='form-inline clearfix'>
<ActionToggle disabled={loaded && props.unloadable === false} handler={this._updateLoad} value={loaded} />{' '} <ActionToggle disabled={loaded && props.unloadable === false} handler={this._updateLoad} value={loaded} />
<Link to={this._getPluginLink()}>{props.name}</Link> <span>{`(v${props.version}) `}</span> <span className='text-primary'>{` ${props.name} `}</span>
<span>{`(v${props.version}) `}</span>
{description !== undefined && description !== '' && ( {description !== undefined && description !== '' && (
<span className='text-muted small'> - {description}</span> <span className='text-muted small'> - {description}</span>
)} )}

View File

@@ -485,7 +485,6 @@ export default class User extends Component {
<option value='tr'>Türkçe</option> <option value='tr'>Türkçe</option>
<option value='he'>עברי</option> <option value='he'>עברי</option>
<option value='zh'>简体中文</option> <option value='zh'>简体中文</option>
<option value='fa'>فارسی</option>
</select> </select>
</Col> </Col>
</Row> </Row>

View File

@@ -22,8 +22,6 @@ example.{,c,m}js.map
/test/ /test/
/tests/ /tests/
*.integ.{,c,m}js
*.integ.{,c,m}js.map
*.spec.{,c,m}js *.spec.{,c,m}js
*.spec.{,c,m}js.map *.spec.{,c,m}js.map
*.test.{,c,m}js *.test.{,c,m}js

View File

@@ -11236,13 +11236,6 @@ http-request-plus@^1.0.0:
dependencies: dependencies:
"@xen-orchestra/log" "^0.6.0" "@xen-orchestra/log" "^0.6.0"
http-request-plus@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/http-request-plus/-/http-request-plus-1.0.2.tgz#e0d6b4fa79c82f1f9df7dcd1ce62c26934fa20ca"
integrity sha512-no2XjPTwCGwzgF+abs3o76e+J2Fm2w9xnUu/nlMbOIPrqooHGPKfpM7uv+QsGMKilFrchJQpvc1NLBfb9VI14Q==
dependencies:
"@xen-orchestra/log" "^0.6.0"
http-server-plus@^1.0.0: http-server-plus@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/http-server-plus/-/http-server-plus-1.0.0.tgz#cb7adc1ca3e679e8728286a6c9b5b1bd012ccbfd" resolved "https://registry.yarnpkg.com/http-server-plus/-/http-server-plus-1.0.0.tgz#cb7adc1ca3e679e8728286a6c9b5b1bd012ccbfd"