Compare commits

..

1 Commits

Author SHA1 Message Date
Thierry Goettelmann
539056efd1 feat(lite/tests): First implementation of tests (Vitest + Playwright) 2022-12-12 12:03:15 +01:00
304 changed files with 4485 additions and 7966 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,7 @@
/@vates/*/node_modules/
/@xen-orchestra/*/dist/
/@xen-orchestra/*/node_modules/
/@xen-orchestra/*/.tests-output/
/packages/*/dist/
/packages/*/node_modules/
@@ -34,4 +35,3 @@ yarn-error.log.*
# code coverage
.nyc_output/
coverage/
.turbo/

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/async-each):
```sh
npm install --save @vates/async-each
```
> npm install --save @vates/async-each
```
## Usage

View File

@@ -33,7 +33,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^15.0.1",
"sinon": "^14.0.1",
"tap": "^16.3.0",
"test": "^3.2.1"
}

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/cached-dns.lookup):
```sh
npm install --save @vates/cached-dns.lookup
```
> npm install --save @vates/cached-dns.lookup
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/coalesce-calls):
```sh
npm install --save @vates/coalesce-calls
```
> npm install --save @vates/coalesce-calls
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/compose):
```sh
npm install --save @vates/compose
```
> npm install --save @vates/compose
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/decorate-with):
```sh
npm install --save @vates/decorate-with
```
> npm install --save @vates/decorate-with
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/disposable):
```sh
npm install --save @vates/disposable
```
> npm install --save @vates/disposable
```
## Usage

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.4",
"version": "0.1.3",
"engines": {
"node": ">=8.10"
},
@@ -25,11 +25,11 @@
"dependencies": {
"@vates/multi-key-map": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/log": "^0.5.0",
"ensure-array": "^1.0.0"
},
"devDependencies": {
"sinon": "^15.0.1",
"sinon": "^14.0.1",
"test": "^3.2.1"
}
}

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/event-listeners-manager):
```sh
npm install --save @vates/event-listeners-manager
```
> npm install --save @vates/event-listeners-manager
```
## Usage

View File

@@ -21,7 +21,7 @@
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.2.1"
"vhd-lib": "^4.2.0"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/multi-key-map):
```sh
npm install --save @vates/multi-key-map
```
> npm install --save @vates/multi-key-map
```
## Usage

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/nbd-client):
```sh
npm install --save @vates/nbd-client
```
> npm install --save @vates/nbd-client
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/otp):
```sh
npm install --save @vates/otp
```
> npm install --save @vates/otp
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/parse-duration):
```sh
npm install --save @vates/parse-duration
```
> npm install --save @vates/parse-duration
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/predicates):
```sh
npm install --save @vates/predicates
```
> npm install --save @vates/predicates
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/read-chunk):
```sh
npm install --save @vates/read-chunk
```
> npm install --save @vates/read-chunk
```
## Usage

View File

@@ -1,54 +0,0 @@
```js
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// if defined, a new detached task is created
//
// if not defined and created inside an existing task, the new task is considered a subtask
onProgress(event) {
// this function is called each time this task or one of it's subtasks change state
const { id, timestamp, type } = event
if (type === 'start') {
const { name, parentId } = event
} else if (type === 'end') {
const { result, status } = event
} else if (type === 'info' || type === 'warning') {
const { data, message } = event
} else if (type === 'property') {
const { name, value } = event
}
},
})
// this field is settable once before being observed
task.id
task.status
await task.abort()
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
// sends an info on the current task if any, otherwise does nothing
Task.info(message, data)
// sends an info on the current task if any, otherwise does nothing
Task.warning(message, data)
// attaches a property to the current task if any, otherwise does nothing
//
// the latest value takes precedence
//
// examples:
// - progress
Task.set(property, value)
```

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,85 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/task
[![Package Version](https://badgen.net/npm/v/@vates/task)](https://npmjs.org/package/@vates/task) ![License](https://badgen.net/npm/license/@vates/task) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/task)](https://bundlephobia.com/result?p=@vates/task) [![Node compatibility](https://badgen.net/npm/node/@vates/task)](https://npmjs.org/package/@vates/task)
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/task):
```sh
npm install --save @vates/task
```
## Usage
```js
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// if defined, a new detached task is created
//
// if not defined and created inside an existing task, the new task is considered a subtask
onProgress(event) {
// this function is called each time this task or one of it's subtasks change state
const { id, timestamp, type } = event
if (type === 'start') {
const { name, parentId } = event
} else if (type === 'end') {
const { result, status } = event
} else if (type === 'info' || type === 'warning') {
const { data, message } = event
} else if (type === 'property') {
const { name, value } = event
}
},
})
// this field is settable once before being observed
task.id
task.status
await task.abort()
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
// sends an info on the current task if any, otherwise does nothing
Task.info(message, data)
// sends an info on the current task if any, otherwise does nothing
Task.warning(message, data)
// attaches a property to the current task if any, otherwise does nothing
//
// the latest value takes precedence
//
// examples:
// - progress
Task.set(property, value)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,184 +0,0 @@
'use strict'
const assert = require('node:assert').strict
const { AsyncLocalStorage } = require('node:async_hooks')
// define a read-only, non-enumerable, non-configurable property
function define(object, property, value) {
Object.defineProperty(object, property, { value })
}
const noop = Function.prototype
const ABORTED = 'aborted'
const ABORTING = 'aborting'
const FAILURE = 'failure'
const PENDING = 'pending'
const SUCCESS = 'success'
exports.STATUS = { ABORTED, ABORTING, FAILURE, PENDING, SUCCESS }
const asyncStorage = new AsyncLocalStorage()
const getTask = () => asyncStorage.getStore()
exports.Task = class Task {
static get abortSignal() {
const task = getTask()
if (task !== undefined) {
return task.#abortController.signal
}
}
static info(message, data) {
const task = getTask()
if (task !== undefined) {
task.#emit('info', { data, message })
}
}
static run(opts, fn) {
return new this(opts).run(fn)
}
static set(name, value) {
const task = getTask()
if (task !== undefined) {
task.#emit('property', { name, value })
}
}
static warning(message, data) {
const task = getTask()
if (task !== undefined) {
task.#emit('warning', { data, message })
}
}
static wrap(opts, fn) {
// compatibility with @decorateWith
if (typeof fn !== 'function') {
;[fn, opts] = [opts, fn]
}
return function taskRun() {
return Task.run(typeof opts === 'function' ? opts.apply(this, arguments) : opts, () => fn.apply(this, arguments))
}
}
#abortController = new AbortController()
#onProgress
#parent
get id() {
return (this.id = Math.random().toString(36).slice(2))
}
set id(value) {
define(this, 'id', value)
}
#startData
#status = PENDING
get status() {
return this.#status
}
constructor({ name, onProgress }) {
this.#startData = { name }
if (onProgress !== undefined) {
this.#onProgress = onProgress
} else {
const parent = getTask()
if (parent !== undefined) {
this.#parent = parent
const { signal } = parent.#abortController
signal.addEventListener('abort', () => {
this.#abortController.abort(signal.reason)
})
this.#onProgress = parent.#onProgress
this.#startData.parentId = parent.id
} else {
this.#onProgress = noop
}
}
const { signal } = this.#abortController
signal.addEventListener('abort', () => {
if (this.status === PENDING) {
this.#status = this.#running ? ABORTING : ABORTED
}
})
}
abort(reason) {
this.#abortController.abort(reason)
}
#emit(type, data) {
data.id = this.id
data.timestamp = Date.now()
data.type = type
this.#onProgress(data)
}
#handleMaybeAbortion(result) {
if (this.status === ABORTING) {
this.#status = ABORTED
this.#emit('end', { status: ABORTED, result })
return true
}
return false;
}
async run(fn) {
const result = await this.runInside(fn)
if (this.status === PENDING) {
this.#status = SUCCESS
this.#emit('end', { status: SUCCESS, result })
}
return result
}
#running = false
async runInside(fn) {
assert.equal(this.status, PENDING)
assert.equal(this.#running, false)
this.#running = true
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
}
try {
const result = await asyncStorage.run(this, fn)
this.#handleMaybeAbortion(result)
this.#running = false
return result
} catch (result) {
if (!this.#handleMaybeAbortion(result)) {
this.#status = FAILURE
this.#emit('end', { status: FAILURE, result })
}
throw result
}
}
wrap(fn) {
const task = this
return function taskRun() {
return task.run(() => fn.apply(this, arguments))
}
}
wrapInside(fn) {
const task = this
return function taskRunInside() {
return task.runInside(() => fn.apply(this, arguments))
}
}
}

View File

@@ -1,23 +0,0 @@
{
"private": false,
"name": "@vates/task",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/task",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/task",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.0.1",
"engines": {
"node": ">=14"
},
"scripts": {
"postversion": "npm publish --access public"
}
}

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/toggle-scripts):
```sh
npm install --save @vates/toggle-scripts
```
> npm install --save @vates/toggle-scripts
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/async-map):
```sh
npm install --save @xen-orchestra/async-map
```
> npm install --save @xen-orchestra/async-map
```
## Usage

View File

@@ -35,7 +35,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^15.0.1",
"sinon": "^14.0.1",
"test": "^3.2.1"
}
}

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/audit-core):
```sh
npm install --save @xen-orchestra/audit-core
```
> npm install --save @xen-orchestra/audit-core
```
## Contributions

View File

@@ -7,7 +7,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.2.3",
"version": "0.2.2",
"engines": {
"node": ">=14"
},
@@ -17,7 +17,7 @@
},
"dependencies": {
"@vates/decorate-with": "^2.0.0",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/log": "^0.5.0",
"golike-defer": "^0.5.1",
"object-hash": "^2.0.1"
},

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/backups-cli):
```sh
npm install --global @xen-orchestra/backups-cli
```
> npm install --global @xen-orchestra/backups-cli
```
## Usage

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.29.5",
"@xen-orchestra/fs": "^3.3.1",
"@xen-orchestra/backups": "^0.29.2",
"@xen-orchestra/fs": "^3.3.0",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",

View File

@@ -38,7 +38,7 @@ const DEFAULT_VM_SETTINGS = {
fullInterval: 0,
healthCheckSr: undefined,
healthCheckVmsWithTags: [],
maxMergedDeltasPerRun: Infinity,
maxMergedDeltasPerRun: 2,
offlineBackup: false,
offlineSnapshot: false,
snapshotRetention: 0,

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/backups):
```sh
npm install --save @xen-orchestra/backups
```
> npm install --save @xen-orchestra/backups
```
## Contributions

View File

@@ -28,7 +28,6 @@ const { isMetadataFile } = require('./_backupType.js')
const { isValidXva } = require('./_isValidXva.js')
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
const { lvs, pvs } = require('./_lvm.js')
const { watchStreamSize } = require('./_watchStreamSize')
// @todo : this import is marked extraneous , sould be fixed when lib is published
const { mount } = require('@vates/fuse-vhd')
const { asyncEach } = require('@vates/async-each')
@@ -233,23 +232,21 @@ class RemoteAdapter {
return promise
}
async #removeVmBackupsFromCache(backups) {
await asyncEach(
Object.entries(
groupBy(
backups.map(_ => _._filename),
dirname
)
),
([dir, filenames]) =>
// will not reject
this._updateCache(dir + '/cache.json.gz', backups => {
for (const filename of filenames) {
debug('removing cache entry', { entry: filename })
delete backups[filename]
}
})
)
#removeVmBackupsFromCache(backups) {
for (const [dir, filenames] of Object.entries(
groupBy(
backups.map(_ => _._filename),
dirname
)
)) {
// detached async action, will not reject
this._updateCache(dir + '/cache.json.gz', backups => {
for (const filename of filenames) {
debug('removing cache entry', { entry: filename })
delete backups[filename]
}
})
}
}
async deleteDeltaVmBackups(backups) {
@@ -258,7 +255,7 @@ class RemoteAdapter {
// this will delete the json, unused VHDs will be detected by `cleanVm`
await asyncMapSettled(backups, ({ _filename }) => handler.unlink(_filename))
await this.#removeVmBackupsFromCache(backups)
this.#removeVmBackupsFromCache(backups)
}
async deleteMetadataBackup(backupId) {
@@ -287,7 +284,7 @@ class RemoteAdapter {
Promise.all([handler.unlink(_filename), handler.unlink(resolveRelativeFromFile(_filename, xva))])
)
await this.#removeVmBackupsFromCache(backups)
this.#removeVmBackupsFromCache(backups)
}
deleteVmBackup(file) {
@@ -644,7 +641,7 @@ class RemoteAdapter {
})
// will not throw
await this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
debug('adding cache entry', { entry: path })
backups[path] = {
...metadata,
@@ -662,7 +659,7 @@ class RemoteAdapter {
const handler = this._handler
if (this.#useVhdDirectory()) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
const size = await createVhdDirectoryFromStream(handler, dataPath, input, {
await createVhdDirectoryFromStream(handler, dataPath, input, {
concurrency: writeBlockConcurrency,
compression: this.#getCompressionType(),
async validator() {
@@ -672,14 +669,12 @@ class RemoteAdapter {
nbdClient,
})
await VhdAbstract.createAlias(handler, path, dataPath)
return size
} else {
return this.outputStream(path, input, { checksum, validator })
await this.outputStream(path, input, { checksum, validator })
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
@@ -688,7 +683,6 @@ class RemoteAdapter {
return validator.apply(this, arguments)
},
})
return container.size
}
// open the hierarchy of ancestors until we find a full one

View File

@@ -100,7 +100,7 @@ class Task {
* In case of error, the task will be failed.
*
* @typedef Result
* @param {() => Result} fn
* @param {() => Result)} fn
* @param {boolean} last - Whether the task should succeed if there is no error
* @returns Result
*/

View File

@@ -1,6 +1,6 @@
'use strict'
require('@xen-orchestra/log/configure').catchGlobalErrors(
require('@xen-orchestra/log/configure.js').catchGlobalErrors(
require('@xen-orchestra/log').createLogger('xo:backups:worker')
)

View File

@@ -31,7 +31,7 @@ beforeEach(async () => {
})
afterEach(async () => {
await rimraf(tempDir)
await pFromCallback(cb => rimraf(tempDir, cb))
await handler.forget()
})
@@ -221,7 +221,7 @@ test('it merges delta of non destroyed chain', async () => {
loggued.push(message)
}
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
assert.equal(loggued[0], `unexpected number of entries in backup cache`)
assert.equal(loggued[0], `incorrect backup size in metadata`)
loggued = []
await adapter.cleanVm(rootPath, { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
@@ -378,19 +378,7 @@ describe('tests multiple combination ', () => {
],
})
)
if (!useAlias && vhdMode === 'directory') {
try {
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
} catch (err) {
assert.strictEqual(
err.code,
'NOT_SUPPORTED',
'Merging directory without alias should raise a not supported error'
)
return
}
assert.strictEqual(true, false, 'Merging directory without alias should raise an error')
}
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))

View File

@@ -258,9 +258,6 @@ exports.importDeltaVm = defer(async function importDeltaVm(
$defer.onFailure(() => newVdi.$destroy())
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
if (vdi.virtual_size > newVdi.virtual_size) {
await newVdi.$callAsync('resize', vdi.virtual_size)
}
} else if (vdiRef === vmRecord.suspend_VDI) {
// suspendVDI has already created
newVdi = suspendVdi

View File

@@ -4,7 +4,7 @@
'use strict'
const { catchGlobalErrors } = require('@xen-orchestra/log/configure')
const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
const { createLogger } = require('@xen-orchestra/log')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { join } = require('path')

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.29.5",
"version": "0.29.2",
"engines": {
"node": ">=14.6"
},
@@ -21,38 +21,38 @@
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/disposable": "^0.1.3",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "*",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.3.1",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/fs": "^3.3.0",
"@xen-orchestra/log": "^0.5.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1",
"d3-time-format": "^3.0.0",
"decorator-synchronized": "^0.6.0",
"end-of-stream": "^1.4.4",
"fs-extra": "^11.1.0",
"fs-extra": "^10.0.0",
"golike-defer": "^0.5.1",
"limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.20",
"node-zone": "^0.4.0",
"parse-pairs": "^2.0.0",
"parse-pairs": "^1.1.0",
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"uuid": "^9.0.0",
"vhd-lib": "^4.2.1",
"vhd-lib": "^4.2.0",
"yazl": "^2.5.1"
},
"devDependencies": {
"rimraf": "^4.1.1",
"sinon": "^15.0.1",
"rimraf": "^3.0.2",
"sinon": "^14.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^1.6.1"
"@xen-orchestra/xapi": "^1.5.3"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -11,6 +11,7 @@ const { dirname } = require('path')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { Task } = require('../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
@@ -20,7 +21,7 @@ const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const NbdClient = require('@vates/nbd-client')
const { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
const { debug, warn } = createLogger('xo:backups:DeltaBackupWriter')
exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
@@ -28,7 +29,8 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
const backup = this._backup
const adapter = this._adapter
const vdisDir = `${this._vmBackupDir}/vdis/${backup.job.id}`
const backupDir = getVmBackupDir(backup.vm.uuid)
const vdisDir = `${backupDir}/vdis/${backup.job.id}`
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
let found = false
@@ -133,7 +135,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
}
async _transfer({ timestamp, deltaExport }) {
async _transfer({ timestamp, deltaExport, sizeContainers }) {
const adapter = this._adapter
const backup = this._backup
@@ -141,6 +143,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
const jobId = job.id
const handler = adapter.handler
const backupDir = getVmBackupDir(vm.uuid)
// TODO: clean VM backup directory
@@ -172,10 +175,9 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
const { size } = await Task.run({ name: 'transfer' }, async () => {
let transferSize = 0
await Promise.all(
map(deltaExport.vdis, async (vdi, id) => {
const path = `${this._vmBackupDir}/${vhds[id]}`
const path = `${backupDir}/${vhds[id]}`
const isDelta = vdi.other_config['xo:base_delta'] !== undefined
let parentPath
@@ -201,25 +203,21 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
let nbdClient
if (this._backup.config.useNbd) {
debug('useNbd is enabled', { vdi: id, path })
if (!this._backup.config.useNbd) {
// get nbd if possible
try {
// this will always take the first host in the list
const [nbdInfo] = await vm.$xapi.call('VDI.get_nbd_info', vdiRef)
debug('got NBD info', { nbdInfo, vdi: id, path })
nbdClient = new NbdClient(nbdInfo)
await nbdClient.connect()
info('NBD client ready', { vdi: id, path })
debug(`got nbd connection `, { vdi: vdi.uuid })
} catch (error) {
nbdClient = undefined
warn('error connecting to NBD server', { error, vdi: id, path })
debug(`can't connect to nbd server or no server available`, { error })
}
} else {
debug('useNbd is disabled', { vdi: id, path })
}
transferSize += await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
// no checksum for VHDs, because they will be invalidated by
// merges and chainings
checksum: false,
@@ -240,7 +238,9 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
})
})
)
return { size: transferSize }
return {
size: Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0),
}
})
metadataContent.size = size
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadataContent)

View File

@@ -2,6 +2,7 @@
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { Task } = require('../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
@@ -33,6 +34,7 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
const { job, scheduleId, vm } = backup
const adapter = this._adapter
const backupDir = getVmBackupDir(vm.uuid)
// TODO: clean VM backup directory
@@ -45,7 +47,7 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
const basename = formatFilenameDate(timestamp)
const dataBasename = basename + '.xva'
const dataFilename = this._vmBackupDir + '/' + dataBasename
const dataFilename = backupDir + '/' + dataBasename
const metadata = {
jobId: job.id,

View File

@@ -16,6 +16,7 @@ const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
exports.MixinBackupWriter = (BaseClass = Object) =>
class MixinBackupWriter extends BaseClass {
#lock
#vmBackupDir
constructor({ remoteId, ...rest }) {
super(rest)
@@ -23,13 +24,13 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
this._adapter = rest.backup.remoteAdapters[remoteId]
this._remoteId = remoteId
this._vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
}
async _cleanVm(options) {
try {
return await Task.run({ name: 'clean-vm' }, () => {
return this._adapter.cleanVm(this._vmBackupDir, {
return this._adapter.cleanVm(this.#vmBackupDir, {
...options,
fixMetadata: true,
logInfo: info,
@@ -49,7 +50,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
async beforeBackup() {
const { handler } = this._adapter
const vmBackupDir = this._vmBackupDir
const vmBackupDir = this.#vmBackupDir
await handler.mktree(vmBackupDir)
this.#lock = await handler.lock(vmBackupDir)
}

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cr-seed-cli):
```sh
npm install --global @xen-orchestra/cr-seed-cli
```
> npm install --global @xen-orchestra/cr-seed-cli
```
## Contributions

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cron):
```sh
npm install --save @xen-orchestra/cron
```
> npm install --save @xen-orchestra/cron
```
## Usage

View File

@@ -42,7 +42,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^15.0.1",
"sinon": "^14.0.1",
"test": "^3.2.1"
}
}

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/defined):
```sh
npm install --save @xen-orchestra/defined
```
> npm install --save @xen-orchestra/defined
```
## Contributions

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/emit-async):
```sh
npm install --save @xen-orchestra/emit-async
```
> npm install --save @xen-orchestra/emit-async
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/fs):
```sh
npm install --global @xen-orchestra/fs
```
> npm install --global @xen-orchestra/fs
```
## Contributions

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "3.3.1",
"version": "3.3.0",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -30,11 +30,11 @@
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/log": "^0.5.0",
"bind-property-descriptor": "^2.0.0",
"decorator-synchronized": "^0.6.0",
"execa": "^5.0.0",
"fs-extra": "^11.1.0",
"fs-extra": "^10.0.0",
"get-stream": "^6.0.0",
"limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.4",
@@ -53,7 +53,7 @@
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^4.1.1",
"rimraf": "^3.0.0",
"tmp": "^0.2.1"
},
"scripts": {

View File

@@ -14,7 +14,7 @@ import { basename, dirname, normalize as normalizePath } from './path'
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
const { info, warn } = createLogger('xo:fs:abstract')
const { info, warn } = createLogger('@xen-orchestra:fs')
const checksumFile = file => file + '.checksum'
const computeRate = (hrtime, size) => {

View File

@@ -116,7 +116,7 @@ describe('encryption', () => {
dir = await pFromCallback(cb => tmp.dir(cb))
})
afterAll(async () => {
await rimraf(dir)
await pFromCallback(cb => rimraf(dir, cb))
})
it('sync should NOT create metadata if missing (not encrypted)', async () => {

View File

@@ -22,7 +22,7 @@ module.exports = {
"@typescript-eslint/no-explicit-any": "off",
"@limegrass/import-alias/import-alias": [
"error",
{ aliasConfigPath: require("path").join(__dirname, "tsconfig.json") },
{ aliasConfigPath: require("path").join(__dirname, "tsconfig.app.json") },
],
},
};

View File

@@ -6,11 +6,6 @@
- Settings page (PR [#6418](https://github.com/vatesfr/xen-orchestra/pull/6418))
- Uncollapse hosts in the tree by default (PR [#6428](https://github.com/vatesfr/xen-orchestra/pull/6428))
- Display RAM usage in pool dashboard (PR [#6419](https://github.com/vatesfr/xen-orchestra/pull/6419))
- Implement not found page (PR [#6410](https://github.com/vatesfr/xen-orchestra/pull/6410))
- Display CPU usage chart in pool dashboard (PR [#6577](https://github.com/vatesfr/xen-orchestra/pull/6577))
- Display network throughput chart in pool dashboard (PR [#6610](https://github.com/vatesfr/xen-orchestra/pull/6610))
- Display RAM usage chart in pool dashboard (PR [#6604](https://github.com/vatesfr/xen-orchestra/pull/6604))
- Ability to change the state of a VM (PRs [#6571](https://github.com/vatesfr/xen-orchestra/pull/6571) [#6608](https://github.com/vatesfr/xen-orchestra/pull/6608))
## **0.1.0**

View File

@@ -0,0 +1,4 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["./**/*"]
}

View File

@@ -0,0 +1,8 @@
import { test, expect } from "@playwright/test";
// See here how to get started:
// https://playwright.dev/docs/intro
test("visits the app root url", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("login-form")).toBeTruthy();
});

View File

@@ -3,12 +3,16 @@
"version": "0.1.0",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
"build": "run-p test:type-check build-only",
"preview": "vite preview --port 4173",
"build-only": "GIT_HEAD=$(git rev-parse HEAD) vite build",
"deploy": "./scripts/deploy.sh",
"test": "yarn run type-check",
"type-check": "vue-tsc --noEmit"
"test": "run-p test:once test:type-check",
"test:once": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage",
"test:type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
@@ -40,19 +44,23 @@
"devDependencies": {
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@limegrass/eslint-plugin-import-alias": "^1.0.5",
"@playwright/test": "^1.28.1",
"@rushstack/eslint-patch": "^1.1.0",
"@testing-library/vue": "^6.6.1",
"@types/node": "^16.11.41",
"@vitejs/plugin-vue": "^3.2.0",
"@vitest/coverage-c8": "^0.25.3",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/tsconfig": "^0.1.3",
"eslint-plugin-vue": "^9.0.0",
"happy-dom": "^7.7.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.19",
"postcss-custom-media": "^9.0.1",
"postcss-nested": "^6.0.0",
"typescript": "^4.9.3",
"vite": "^3.2.4",
"vitest": "^0.25.3",
"vue-tsc": "^1.0.9"
},
"private": true,

View File

@@ -0,0 +1,114 @@
import type { PlaywrightTestConfig } from "@playwright/test";
import { devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: "./e2e",
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [["html", { outputFolder: ".tests-output/e2e-report" }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
screenshot: "only-on-failure",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: ".tests-output/e2e-result",
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? "vite preview --port 3000" : "vite dev",
port: 5173,
reuseExistingServer: !process.env.CI,
},
};
export default config;

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
"postcss-nested": {},
"postcss-custom-media": {},
},
};

View File

@@ -13,12 +13,6 @@
<a :href="url.href" target="_blank" rel="noopener">{{ 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="!xenApiStore.isConnected">
<AppLogin />
@@ -26,9 +20,9 @@
<div v-else>
<AppHeader />
<div style="display: flex">
<transition name="slide">
<AppNavigation />
</transition>
<nav class="nav">
<InfraPoolList />
</nav>
<main class="main">
<RouterView />
</main>
@@ -38,7 +32,6 @@
</template>
<script lang="ts" setup>
import AppNavigation from "@/components/AppNavigation.vue";
import { useUiStore } from "@/stores/ui.store";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
import { logicAnd } from "@vueuse/math";
@@ -49,7 +42,7 @@ import { faServer } from "@fortawesome/free-solid-svg-icons";
import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UiButton from "@/components/ui/UiButton.vue";
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
import UiModal from "@/components/ui/UiModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useHostStore } from "@/stores/host.store";
@@ -112,20 +105,19 @@ watch(
);
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
const reload = () => window.location.reload();
</script>
<style lang="postcss">
@import "@/assets/base.css";
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(-37rem);
.nav {
overflow: auto;
width: 37rem;
max-width: 37rem;
height: calc(100vh - 9rem);
padding: 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
}
.main {

View File

@@ -1,2 +0,0 @@
@custom-media --mobile (max-width: 1023px);
@custom-media --desktop (min-width: 1024px);

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,11 +1,5 @@
<template>
<header class="app-header">
<UiIcon
v-if="isMobile"
ref="navigationTrigger"
:icon="faBars"
class="toggle-navigation"
/>
<RouterLink :to="{ name: 'home' }">
<img alt="XO Lite" src="../assets/logo.svg" />
</RouterLink>
@@ -18,17 +12,6 @@
<script lang="ts" setup>
import AccountButton from "@/components/AccountButton.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
import { faBars } from "@fortawesome/free-solid-svg-icons";
import { storeToRefs } from "pinia";
const uiStore = useUiStore();
const { isMobile } = storeToRefs(uiStore);
const navigationStore = useNavigationStore();
const { trigger: navigationTrigger } = storeToRefs(navigationStore);
</script>
<style lang="postcss" scoped>

View File

@@ -1,25 +1,16 @@
<template>
<div class="app-login form-container">
<form @submit.prevent="handleSubmit">
<form @submit.prevent="handleSubmit" data-testid="login-form">
<img alt="XO Lite" src="../assets/logo-title.svg" />
<FormInputWrapper>
<FormInput v-model="login" name="login" readonly type="text" />
</FormInputWrapper>
<FormInputWrapper :error="error">
<FormInput
name="password"
ref="passwordRef"
type="password"
v-model="password"
:placeholder="$t('password')"
:readonly="isConnecting"
/>
</FormInputWrapper>
<UiButton
type="submit"
:busy="isConnecting"
:disabled="password.trim().length < 1"
>
<input v-model="login" name="login" readonly type="text" />
<input
v-model="password"
:readonly="isConnecting"
name="password"
:placeholder="$t('password')"
type="password"
/>
<UiButton :busy="isConnecting" type="submit">
{{ $t("login") }}
</UiButton>
</form>
@@ -28,47 +19,21 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import { onMounted, ref } from "vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const { t } = useI18n();
const xenApiStore = useXenApiStore();
const { isConnecting } = storeToRefs(xenApiStore);
const login = ref("root");
const password = ref("");
const error = ref<string>();
const passwordRef = ref<InstanceType<typeof FormInput>>();
const isInvalidPassword = ref(false);
const focusPasswordInput = () => passwordRef.value?.focus();
onMounted(() => {
xenApiStore.reconnect();
focusPasswordInput();
});
watch(password, () => {
isInvalidPassword.value = false;
error.value = undefined;
});
async function handleSubmit() {
try {
await xenApiStore.connect(login.value, password.value);
} catch (err) {
if ((err as Error).message === "SESSION_AUTHENTICATION_FAILED") {
focusPasswordInput();
isInvalidPassword.value = true;
error.value = t("password-invalid");
} else {
error.value = t("error-occured");
console.error(err);
}
}
await xenApiStore.connect(login.value, password.value);
}
</script>
@@ -85,7 +50,6 @@ async function handleSubmit() {
form {
display: flex;
font-size: 2rem;
min-width: 30em;
max-width: 100%;
align-items: center;
@@ -108,6 +72,12 @@ img {
margin-bottom: 5rem;
}
label {
font-size: 120%;
font-weight: bold;
margin: 1.5rem 0 0.5rem 0;
}
input {
width: 45rem;
max-width: 100%;
@@ -119,6 +89,6 @@ input {
}
button {
margin-top: 2rem;
margin-top: 3rem;
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<nav
v-if="isDesktop || isOpen"
ref="navElement"
:class="{ collapsible: isMobile }"
class="app-navigation"
>
<InfraPoolList />
</nav>
</template>
<script lang="ts" setup>
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
import { onClickOutside, whenever } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { ref } from "vue";
const uiStore = useUiStore();
const { isMobile, isDesktop } = storeToRefs(uiStore);
const navigationStore = useNavigationStore();
const { isOpen, trigger } = storeToRefs(navigationStore);
const navElement = ref();
whenever(isOpen, () => {
const unregisterEvent = onClickOutside(
navElement,
() => {
isOpen.value = false;
unregisterEvent?.();
},
{
ignore: [trigger],
}
);
});
</script>
<style lang="postcss" scoped>
.app-navigation {
overflow: auto;
width: 37rem;
max-width: 37rem;
height: calc(100vh - 9rem);
padding: 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
&.collapsible {
position: fixed;
z-index: 1;
}
}
</style>

View File

@@ -66,7 +66,7 @@ import useModal from "@/composables/modal.composable";
defineProps<{
availableSorts: Sorts;
activeSorts: ActiveSorts<Record<string, any>>;
activeSorts: ActiveSorts;
}>();
const emit = defineEmits<{

View File

@@ -66,11 +66,9 @@ const emit = defineEmits<{
const isSelectable = computed(() => props.modelValue !== undefined);
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter({
queryStringParam: "filter",
});
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } =
useCollectionSorter<Record<string, any>>({ queryStringParam: "sort" });
useCollectionSorter();
const filteredCollection = useFilteredCollection(
toRef(props, "collection"),

View File

@@ -1,47 +0,0 @@
<template>
<div class="wrapper-spinner" v-if="!store.isReady">
<UiSpinner class="spinner" />
</div>
<ObjectNotFoundView :id="id" v-else-if="isRecordNotFound" />
<slot v-else />
</template>
<script lang="ts" setup>
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { useRouter } from "vue-router";
const storeByType = {
vm: useVmStore,
host: useHostStore,
};
const props = defineProps<{ objectType: "vm" | "host"; id?: string }>();
const store = storeByType[props.objectType]();
const { currentRoute } = useRouter();
const id = computed(
() => props.id ?? (currentRoute.value.params.uuid as string)
);
const isRecordNotFound = computed(
() => store.isReady && !store.hasRecordByUuid(id.value)
);
</script>
<style scoped>
.wrapper-spinner {
display: flex;
height: 100%;
}
.spinner {
color: var(--color-extra-blue-base);
display: flex;
margin: auto;
width: 10rem;
height: 10rem;
}
</style>

View File

@@ -0,0 +1,27 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
describe("PowerStateIcon.vue", () => {
it("should render correctly", async () => {
const wrapper = mount(PowerStateIcon, {
props: {
state: "Running",
},
});
expect(wrapper.element.classList.contains("state-running")).toBeTruthy();
await wrapper.setProps({
state: "Paused",
});
expect(wrapper.element.classList.contains("state-paused")).toBeTruthy();
await wrapper.setProps({
state: "not-exists",
});
expect(wrapper.element.classList.contains("state-not-exists")).toBeTruthy();
});
});

View File

@@ -3,6 +3,7 @@
</template>
<script lang="ts" setup>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed } from "vue";
import {
faMoon,

View File

@@ -1,23 +0,0 @@
<template>
<span :title="date.toLocaleString()">{{ relativeTime }}</span>
</template>
<script lang="ts" setup>
import useRelativeTime from "@/composables/relative-time.composable";
import { useNow } from "@vueuse/core";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
date: Date | number | string;
interval?: number;
}>(),
{ interval: 1000 }
);
const date = computed(() => new Date(props.date));
const now = useNow({ interval: props.interval });
const relativeTime = useRelativeTime(date, now);
</script>
<style lang="postcss" scoped></style>

View File

@@ -11,7 +11,5 @@
height: 6.5rem;
background-color: var(--background-color-primary);
border-bottom: 1px solid var(--color-blue-scale-400);
max-width: 100%;
overflow: auto;
}
</style>

View File

@@ -19,10 +19,6 @@ defineProps<{
</script>
<style lang="postcss" scoped>
.actions {
margin-left: auto;
}
.title-bar {
display: flex;
align-items: center;

View File

@@ -1,14 +1,13 @@
<template>
<div>
<template v-if="data !== undefined">
<div class="header">
<slot name="header" />
</div>
<div v-if="data !== undefined">
<div
v-for="item in computedData.sortedArray"
:key="item.id"
class="progress-item"
:class="{
warning: item.value > MIN_WARNING_VALUE,
error: item.value > MIN_DANGEROUS_VALUE,
}"
>
<UiProgressBar :value="item.value" color="custom" />
<div class="legend">
@@ -19,8 +18,10 @@
}}</UiBadge>
</div>
</div>
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
</template>
<div class="footer">
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
</div>
</div>
<UiSpinner v-else class="spinner" />
</div>
</template>
@@ -44,9 +45,6 @@ interface Props {
nItems?: number;
}
const MIN_WARNING_VALUE = 80;
const MIN_DANGEROUS_VALUE = 90;
const props = defineProps<Props>();
const computedData = computed(() => {
@@ -69,7 +67,24 @@ const computedData = computed(() => {
});
</script>
<style lang="postcss" scoped>
<style scoped>
.header {
color: var(--color-extra-blue-base);
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--color-extra-blue-base);
margin-bottom: 2rem;
font-size: 16px;
font-weight: 700;
}
.footer {
display: flex;
justify-content: space-between;
font-weight: 700;
font-size: 14px;
color: var(--color-blue-scale-300);
}
.spinner {
color: var(--color-extra-blue-base);
display: flex;
@@ -91,6 +106,12 @@ const computedData = computed(() => {
font-weight: 700;
}
.progress-item {
--progress-bar-height: 1.2rem;
--progress-bar-color: var(--color-extra-blue-l20);
--progress-bar-background-color: var(--color-blue-scale-400);
}
.progress-item:nth-child(1) {
--progress-bar-color: var(--color-extra-blue-d60);
}
@@ -103,18 +124,6 @@ const computedData = computed(() => {
--progress-bar-color: var(--color-extra-blue-d20);
}
.progress-item {
--progress-bar-height: 1.2rem;
--progress-bar-color: var(--color-extra-blue-l20);
--progress-bar-background-color: var(--color-blue-scale-400);
&.warning {
--progress-bar-color: var(--color-orange-world-base);
}
&.error {
--progress-bar-color: var(--color-red-vates-base);
}
}
.circle {
display: inline-block;
width: 1rem;

View File

@@ -18,15 +18,15 @@ const data: LinearChartData = [
{
label: "First series",
data: [
{ timestamp: 1670478371123, value: 1234 },
{ timestamp: 1670478519751, value: 1234 },
{ date: "...", value: 1234 },
{ date: "...", value: 1234 },
],
},
{
label: "Second series",
data: [
{ timestamp: 1670478519751, value: 1234 },
{ timestamp: 167047555000, value: 1234 },
{ date: "...", value: 1234 },
{ date: "...", value: 1234 },
],
},
];

View File

@@ -6,7 +6,6 @@
</template>
<script lang="ts" setup>
import { utcFormat } from "d3-time-format";
import type { EChartsOption } from "echarts";
import { computed, provide } from "vue";
import VueCharts from "vue-echarts";
@@ -23,18 +22,20 @@ import { CanvasRenderer } from "echarts/renderers";
import type { OptionDataValue } from "echarts/types/src/util/types";
import UiCard from "@/components/ui/UiCard.vue";
const Y_AXIS_MAX_VALUE = 200;
const props = defineProps<{
title?: string;
subtitle?: string;
data: LinearChartData;
valueFormatter?: (value: number) => string;
maxValue?: number;
}>();
const valueFormatter = (value: OptionDataValue | OptionDataValue[]) =>
props.valueFormatter?.(value as number) ?? `${value}`;
const valueFormatter = (value: OptionDataValue | OptionDataValue[]) => {
if (props.valueFormatter) {
return props.valueFormatter(value as number);
}
return value.toString();
};
provide("valueFormatter", valueFormatter);
@@ -61,10 +62,8 @@ const option = computed<EChartsOption>(() => ({
xAxis: {
type: "time",
axisLabel: {
formatter: (timestamp: number) =>
utcFormat("%a\n%I:%M\n%p")(new Date(timestamp)),
showMaxLabel: false,
showMinLabel: false,
showMinLabel: true,
showMaxLabel: true,
},
},
yAxis: {
@@ -72,20 +71,19 @@ const option = computed<EChartsOption>(() => ({
axisLabel: {
formatter: valueFormatter,
},
max: () => props.maxValue ?? Y_AXIS_MAX_VALUE,
},
series: props.data.map((series, index) => ({
type: "line",
name: series.label,
zlevel: index + 1,
data: series.data.map((item) => [item.timestamp, item.value]),
data: series.data.map((item) => [item.date, item.value]),
})),
}));
</script>
<style lang="postcss" scoped>
.chart {
width: 100%;
width: 50rem;
height: 30rem;
}
</style>

View File

@@ -6,7 +6,6 @@
:class="inputClass"
:disabled="disabled || isLabelDisabled"
class="input"
ref="inputElement"
v-bind="$attrs"
/>
<template v-else>
@@ -15,7 +14,6 @@
:class="inputClass"
:disabled="disabled || isLabelDisabled"
class="select"
ref="inputElement"
v-bind="$attrs"
>
<slot />
@@ -72,8 +70,6 @@ interface Props extends Omit<InputHTMLAttributes, ""> {
const props = withDefaults(defineProps<Props>(), { color: "info" });
const inputElement = ref();
const emit = defineEmits<{
(event: "update:modelValue", value: any): void;
}>();
@@ -82,10 +78,6 @@ const value = useVModel(props, "modelValue", emit);
const empty = computed(() => isEmpty(props.modelValue));
const isSelect = inject("isSelect", false);
const isLabelDisabled = inject("isLabelDisabled", ref(false));
const color = inject(
"color",
computed(() => undefined)
);
const wrapperClass = computed(() => [
isSelect ? "form-select" : "form-input",
@@ -96,19 +88,13 @@ const wrapperClass = computed(() => [
]);
const inputClass = computed(() => [
color.value ?? props.color,
props.color,
{
right: props.right,
"has-before": props.before !== undefined,
"has-after": props.after !== undefined,
},
]);
const focus = () => inputElement.value.focus();
defineExpose({
focus,
});
</script>
<style lang="postcss" scoped>

View File

@@ -1,96 +0,0 @@
<template>
<div class="wrapper">
<label
v-if="$slots.label"
class="form-label"
:class="{ disabled, ...formInputWrapperClass }"
>
<slot />
</label>
<slot />
<p v-if="hasError || hasWarning" :class="formInputWrapperClass">
<UiIcon :icon="faCircleExclamation" v-if="hasError" />{{
error ?? warning
}}
</p>
</div>
</template>
<script lang="ts" setup>
import { computed, provide, useSlots } from "vue";
import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons";
import UiIcon from "@/components/ui/UiIcon.vue";
const slots = useSlots();
const props = defineProps<{
disabled?: boolean;
error?: string;
warning?: string;
}>();
provide("hasLabel", slots.label !== undefined);
provide(
"isLabelDisabled",
computed(() => props.disabled)
);
const hasError = computed(
() => props.error !== undefined && props.error.trim() !== ""
);
const hasWarning = computed(
() => props.warning !== undefined && props.warning.trim() !== ""
);
provide(
"color",
computed(() =>
hasError.value ? "error" : hasWarning.value ? "warning" : undefined
)
);
const formInputWrapperClass = computed(() => ({
error: hasError.value,
warning: !hasError.value && hasWarning.value,
}));
</script>
<style lang="postcss" scoped>
.wrapper {
display: flex;
flex-direction: column;
}
.wrapper :deep(.input) {
margin-bottom: 1rem;
}
.form-label {
font-size: 1.6rem;
display: inline-flex;
align-items: center;
gap: 0.625em;
&.disabled {
cursor: not-allowed;
color: var(--color-blue-scale-300);
}
}
p.error,
p.warning {
font-size: 0.65em;
margin-bottom: 1rem;
}
.error {
color: var(--color-red-vates-base);
}
.warning {
color: var(--color-orange-world-base);
}
p svg {
margin-right: 0.4em;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<label :class="{ disabled }" class="form-label">
<slot />
</label>
</template>
<script lang="ts" setup>
import { computed, provide } from "vue";
const props = defineProps<{
disabled?: boolean;
}>();
provide("hasLabel", true);
provide(
"isLabelDisabled",
computed(() => props.disabled)
);
</script>
<style lang="postcss" scoped>
.form-label {
font-size: 1.6rem;
display: inline-flex;
align-items: center;
gap: 0.625em;
&.disabled {
cursor: not-allowed;
color: var(--color-blue-scale-300);
}
}
</style>

View File

@@ -12,13 +12,7 @@
</MenuTrigger>
<AppMenu v-else shadow :disabled="isDisabled">
<template #trigger="{ open, isOpen }">
<MenuTrigger
:active="isOpen"
:busy="isBusy"
:disabled="isDisabled"
:icon="icon"
@click="open"
>
<MenuTrigger :active="isOpen" :icon="icon" @click="open">
<slot />
<UiIcon
:fixed-width="false"

View File

@@ -1,6 +1,6 @@
<template>
<UiCard>
<UiCardTitle>{{ $t("cpu-usage") }}</UiCardTitle>
<UiTitle type="h4">{{ $t("cpu-usage") }}</UiTitle>
<HostsCpuUsage />
<VmsCpuUsage />
</UiCard>
@@ -9,5 +9,5 @@
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
</script>

View File

@@ -1,93 +0,0 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('network-throughput')"
:value-formatter="customValueFormatter"
/>
</template>
<script lang="ts" setup>
import { computed, inject } from "vue";
import { map } from "lodash-es";
import { useI18n } from "vue-i18n";
import LinearChart from "@/components/charts/LinearChart.vue";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import { formatSize } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import type { LinearChartData } from "@/types/chart";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const data = computed<LinearChartData>(() => {
const stats = hostLastWeekStats?.stats?.value;
const timestampStart = hostLastWeekStats?.timestampStart?.value;
if (timestampStart === undefined || stats === undefined) {
return [];
}
const results = {
tx: new Map<number, { timestamp: number; value: number }>(),
rx: new Map<number, { timestamp: number; value: number }>(),
};
const addResult = (stats: HostStats, type: "tx" | "rx") => {
const networkStats = Object.values(stats.pifs[type]);
for (let hourIndex = 0; hourIndex < networkStats[0].length; hourIndex++) {
const timestamp =
(timestampStart + hourIndex * RRD_STEP_FROM_STRING.hours) * 1000;
const networkThroughput = networkStats.reduce(
(total, throughput) => total + throughput[hourIndex],
0
);
results[type].set(timestamp, {
timestamp,
value: (results[type].get(timestamp)?.value ?? 0) + networkThroughput,
});
}
};
stats.forEach((host) => {
if (!host.stats) {
return;
}
addResult(host.stats, "rx");
addResult(host.stats, "tx");
});
return [
{
label: t("network-upload"),
data: Array.from(results["tx"].values()),
},
{
label: t("network-download"),
data: Array.from(results["rx"].values()),
},
];
});
// TODO: improve the way to get the max value of graph
// See: https://github.com/vatesfr/xen-orchestra/pull/6610/files#r1072237279
const customMaxValue = computed(
() =>
Math.max(
...map(data.value[0].data, "value"),
...map(data.value[1].data, "value")
) * 1.5
);
const customValueFormatter = (value: number) => String(formatSize(value));
</script>

View File

@@ -1,6 +1,6 @@
<template>
<UiCard>
<UiCardTitle>{{ $t("ram-usage") }}</UiCardTitle>
<UiTitle type="h4">{{ $t("ram-usage") }}</UiTitle>
<HostsRamUsage />
<VmsRamUsage />
</UiCard>
@@ -10,5 +10,5 @@
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
</script>

View File

@@ -1,17 +1,17 @@
<template>
<UiCard>
<UiCardTitle>{{ $t("status") }}</UiCardTitle>
<UiTitle type="h4">{{ $t("status") }}</UiTitle>
<template v-if="isReady">
<PoolDashboardStatusItem
:active="activeHostsCount"
:label="$t('hosts')"
:total="totalHostsCount"
:label="$t('hosts')"
/>
<UiSeparator />
<PoolDashboardStatusItem
:active="activeVmsCount"
:label="$t('vms')"
:total="totalVmsCount"
:label="$t('vms')"
/>
</template>
<UiSpinner v-else class="spinner" />
@@ -19,14 +19,14 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import PoolDashboardStatusItem from "@/components/pool/dashboard/PoolDashboardStatusItem.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiSeparator from "@/components/ui/UiSeparator.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
const vmStore = useVmStore();
const hostMetricsStore = useHostMetricsStore();

View File

@@ -1,31 +1,56 @@
<template>
<UiCard>
<UiCardTitle
:left="$t('storage-usage')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar
:data="srStore.isReady ? data.result : undefined"
:nItems="N_ITEMS"
>
<template #footer>
<SizeStatsSummary :size="data.maxSize" :usage="data.usedSize" />
<UiTitle type="h4">{{ $t("storage-usage") }}</UiTitle>
<UsageBar :data="srStore.isReady ? data.result : undefined" :nItems="N_ITEMS">
<template #header>
<span>{{ $t("storage") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
</template>
<template #footer v-if="showFooter">
<div class="footer-card">
<p>{{ $t("total-used") }}:</p>
<div class="footer-value">
<p>{{ percentUsed }}%</p>
<p>
{{ formatSize(data.usedSize) }}
</p>
</div>
</div>
<div class="footer-card">
<p>{{ $t("total-free") }}:</p>
<div class="footer-value">
<p>{{ percentFree }}%</p>
<p>
{{ formatSize(data.maxSize) }}
</p>
</div>
</div>
</template>
</UsageBar>
</UiCard>
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { computed } from "vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import UsageBar from "@/components/UsageBar.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { formatSize, percent } from "@/libs/utils";
import { useSrStore } from "@/stores/storage.store";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
const srStore = useSrStore();
const percentUsed = computed(() =>
percent(data.value.usedSize, data.value.maxSize, 1)
);
const percentFree = computed(() =>
percent(data.value.maxSize - data.value.usedSize, data.value.maxSize, 1)
);
const showFooter = computed(() => !isNaN(percentUsed.value));
const data = computed<{
result: { id: string; label: string; value: number }[];
maxSize: number;
@@ -60,3 +85,21 @@ const data = computed<{
return { result, maxSize, usedSize };
});
</script>
<style lang="postcss" scoped>
.footer-card {
color: var(--color-blue-scale-200);
display: flex;
text-transform: uppercase;
}
.footer-card p {
font-weight: 700;
}
.footer-value {
display: flex;
flex-direction: column;
text-align: right;
}
</style>

View File

@@ -1,20 +1,19 @@
<template>
<UiCardTitle
subtitle
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("hosts") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
</template>
</UsageBar>
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import { getAvgCpuUsage } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",

View File

@@ -1,82 +0,0 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('pool-cpu-usage')"
:value-formatter="customValueFormatter"
/>
</template>
<script lang="ts" setup>
import LinearChart from "@/components/charts/LinearChart.vue";
import type { HostStats } from "@/libs/xapi-stats";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData } from "@/types/chart";
import { sumBy } from "lodash-es";
import { storeToRefs } from "pinia";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const { allRecords: hosts } = storeToRefs(useHostStore());
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
);
const data = computed<LinearChartData>(() => {
const timestampStart = hostLastWeekStats?.timestampStart?.value;
const stats = hostLastWeekStats?.stats?.value;
if (timestampStart === undefined || stats === undefined) {
return [];
}
const result = new Map<number, { timestamp: number; value: number }>();
const addResult = (stats: HostStats) => {
const cpus = Object.values(stats.cpus);
for (let hourIndex = 0; hourIndex < cpus[0].length; hourIndex++) {
const timestamp =
(timestampStart + hourIndex * RRD_STEP_FROM_STRING.hours) * 1000;
const cpuUsageSum = cpus.reduce(
(total, cpu) => total + cpu[hourIndex],
0
);
result.set(timestamp, {
timestamp: timestamp,
value: Math.round((result.get(timestamp)?.value ?? 0) + cpuUsageSum),
});
}
};
stats.forEach((host) => {
if (!host.stats) {
return;
}
addResult(host.stats);
});
return [
{
label: t("stacked-cpu-usage"),
data: Array.from(result.values()),
},
];
});
const customValueFormatter = (value: number) => `${value}%`;
</script>

View File

@@ -1,14 +1,13 @@
<template>
<UiCardTitle
subtitle
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("vms") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
</template>
</UsageBar>
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";

View File

@@ -1,14 +1,13 @@
<template>
<UiCardTitle
subtitle
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("hosts") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
</template>
</UsageBar>
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";

View File

@@ -1,93 +0,0 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('pool-ram-usage')"
:value-formatter="customValueFormatter"
>
<template #summary>
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
</template>
</LinearChart>
</template>
<script lang="ts" setup>
import LinearChart from "@/components/charts/LinearChart.vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData } from "@/types/chart";
import { sumBy } from "lodash-es";
import { storeToRefs } from "pinia";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
import { formatSize, getHostMemory, isHostRunning } from "@/libs/utils";
import type { XenApiHost } from "@/libs/xen-api";
const { allRecords: hosts } = storeToRefs(useHostStore());
const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const runningHosts = computed(() => hosts.value.filter(isHostRunning));
const customMaxValue = computed(() =>
sumBy(runningHosts.value, (host) => getHostMemory(host)?.size ?? 0)
);
const currentData = computed(() => {
let size = 0,
usage = 0;
runningHosts.value.forEach((host) => {
const hostMemory = getHostMemory(host);
size += hostMemory?.size ?? 0;
usage += hostMemory?.usage ?? 0;
});
return { size, usage };
});
const data = computed<LinearChartData>(() => {
const timestampStart = hostLastWeekStats?.timestampStart?.value;
const stats = hostLastWeekStats?.stats?.value;
if (timestampStart === undefined || stats === undefined) {
return [];
}
const result = new Map<number, { timestamp: number; value: number }>();
stats.forEach(({ stats }) => {
if (stats?.memory === undefined) {
return;
}
const memoryFree = stats.memoryFree;
const memoryUsage = stats.memory.map(
(memory, index) => memory - memoryFree[index]
);
memoryUsage.forEach((value, hourIndex) => {
const timestamp =
(timestampStart + hourIndex * RRD_STEP_FROM_STRING.hours) * 1000;
result.set(timestamp, {
timestamp,
value: (result.get(timestamp)?.value ?? 0) + memoryUsage[hourIndex],
});
});
});
return [
{
label: t("stacked-ram-usage"),
data: Array.from(result.values()),
},
];
});
const customValueFormatter = (value: number) => String(formatSize(value));
</script>

View File

@@ -1,14 +1,13 @@
<template>
<UiCardTitle
subtitle
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("vms") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
</template>
</UsageBar>
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";

View File

@@ -1,63 +0,0 @@
<template>
<div class="summary" v-if="isDisplayed">
<div class="summary-card">
<p>{{ $t("total-used") }}:</p>
<div class="summary-value">
<p>{{ percentUsed }}%</p>
<p>
{{ formatSize(usage) }}
</p>
</div>
</div>
<div class="summary-card">
<p>{{ $t("total-free") }}:</p>
<div class="summary-value">
<p>{{ percentFree }}%</p>
<p>
{{ formatSize(free) }}
</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { formatSize, percent } from "@/libs/utils";
import { computed } from "vue";
const props = defineProps<{
size: number;
usage: number;
}>();
const free = computed(() => props.size - props.usage);
const percentFree = computed(() => percent(free.value, props.size));
const percentUsed = computed(() => percent(props.usage, props.size));
const isDisplayed = computed(
() => !isNaN(percentUsed.value) && !isNaN(percentFree.value)
);
</script>
<style lang="postcss" scoped>
.summary {
display: flex;
justify-content: space-between;
font-weight: 700;
font-size: 14px;
color: var(--color-blue-scale-300);
}
.summary-card {
color: var(--color-blue-scale-200);
display: flex;
text-transform: uppercase;
}
.summary-card p {
font-weight: 700;
}
.summary-value {
display: flex;
flex-direction: column;
text-align: right;
}
</style>

View File

@@ -0,0 +1,27 @@
import UiBadge from "@/components/ui/UiBadge.vue";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { render } from "@testing-library/vue";
describe("UiBadge", () => {
it("should render with no icon", () => {
const { getByText, queryByTestId } = render(UiBadge, {
slots: {
default: "3456",
},
});
getByText("3456");
expect(queryByTestId("ui-icon")).toBeNull();
});
it("should render with icon", () => {
const { getByTestId } = render(UiBadge, {
props: {
icon: faDisplay,
},
});
getByTestId("ui-icon");
});
});

View File

@@ -1,65 +0,0 @@
<template>
<div :class="{ subtitle }" class="ui-section-title">
<component
:is="subtitle ? 'h5' : 'h4'"
v-if="$slots.default || left"
class="left"
>
<slot>{{ left }}</slot>
</component>
<component
:is="subtitle ? 'h6' : 'h5'"
v-if="$slots.right || right"
class="right"
>
<slot name="right">{{ right }}</slot>
</component>
</div>
</template>
<script lang="ts" setup>
defineProps<{
subtitle?: boolean;
left?: string;
right?: string;
}>();
</script>
<style lang="postcss" scoped>
.ui-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
--section-title-left-size: 2rem;
--section-title-left-color: var(--color-blue-scale-100);
--section-title-left-weight: 500;
--section-title-right-size: 1.6rem;
--section-title-right-color: var(--color-extra-blue-base);
--section-title-right-weight: 700;
&.subtitle {
border-bottom: 1px solid var(--color-extra-blue-base);
--section-title-left-size: 1.6rem;
--section-title-left-color: var(--color-extra-blue-base);
--section-title-left-weight: 700;
--section-title-right-size: 1.4rem;
--section-title-right-color: var(--color-extra-blue-base);
--section-title-right-weight: 400;
}
}
.left {
font-size: var(--section-title-left-size);
font-weight: var(--section-title-left-weight);
color: var(--section-title-left-color);
}
.right {
font-size: var(--section-title-right-size);
font-weight: var(--section-title-right-weight);
color: var(--section-title-right-color);
}
</style>

View File

@@ -5,6 +5,7 @@
:spin="busy"
class="ui-icon"
:fixed-width="fixedWidth"
data-testid="ui-icon"
/>
</template>
@@ -12,6 +13,7 @@
import { computed } from "vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
const props = withDefaults(
defineProps<{

View File

@@ -1,15 +1,14 @@
<template>
<table class="ui-key-value-list">
<tbody>
<slot />
</tbody>
</table>
<div class="ui-key-value-list"><slot /></div>
</template>
<script lang="ts" setup></script>
<style lang="postcss" scoped>
.ui-key-value-list {
border-spacing: 0;
margin-top: 2rem;
font-size: 1.4rem;
/* UiKeyValueRow: 15em (key) + 15em (value) + 1rem (gap) */
min-width: calc(30em + 1rem);
}
</style>

View File

@@ -1,33 +1,37 @@
<template>
<tr class="ui-key-value-row">
<th v-if="$slots.key" class="key">
<div class="ui-key-value-row">
<span class="key" v-if="$slots.key">
<slot name="key" />
</th>
<td :colspan="$slots.key ? 1 : 2" class="value">
</span>
<span class="value">
<slot name="value" />
</td>
</tr>
</span>
</div>
</template>
<script lang="ts" setup></script>
<style lang="postcss" scoped>
@import "@/assets/_responsive.pcss";
.ui-key-value-row {
display: flex;
gap: 1rem;
align-items: baseline;
margin: 0.5em 0;
}
.key {
color: var(--color-blue-scale-300);
width: 15%;
max-width: 15em;
max-width: 30em;
}
.value {
flex-grow: 1;
}
.key,
.value {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
font-weight: 400;
}
.key {
padding-right: 2rem;
text-align: left;
color: var(--color-blue-scale-300);
@media (--desktop) {
min-width: 20rem;
}
text-overflow: ellipsis;
width: 100%;
min-width: 15em;
max-width: 30em;
}
</style>

View File

@@ -33,12 +33,12 @@
</template>
<script lang="ts" setup>
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { computed } from "vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMagicKeys, whenever } from "@vueuse/core";
import { computed } from "vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
const props = withDefaults(
defineProps<{
@@ -64,13 +64,11 @@ const className = computed(() => {
<style lang="postcss" scoped>
.ui-modal {
position: fixed;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
overflow: auto;
align-items: center;
justify-content: center;
background-color: #00000080;
@@ -144,9 +142,6 @@ const className = computed(() => {
}
.content {
overflow: auto;
min-height: 23rem;
max-height: calc(100vh - 40rem);
margin-top: 2rem;
}

View File

@@ -1,181 +0,0 @@
<template>
<TitleBar :icon="faDisplay">
{{ name }}
<template #actions>
<AppMenu shadow placement="bottom-end">
<template #trigger="{ open, isOpen }">
<UiButton :active="isOpen" :icon="faPowerOff" @click="open">
{{ $t("change-state") }}
<UiIcon :icon="faAngleDown" />
</UiButton>
</template>
<MenuItem
@click="xenApi.vm.start({ vmRef: vm.$ref })"
:busy="isOperationsPending('start')"
:disabled="!isHalted"
:icon="faPlay"
>
{{ $t("start") }}
</MenuItem>
<MenuItem
:busy="isOperationsPending('start_on')"
:disabled="!isHalted"
:icon="faServer"
>
{{ $t("start-on-host") }}
<template #submenu>
<MenuItem
v-for="host in hostStore.allRecords"
@click="xenApi.vm.startOn({ vmRef: vm.$ref, hostRef: host.$ref })"
v-bind:key="host.$ref"
:icon="faServer"
>
<div class="wrapper">
{{ host.name_label }}
<div>
<UiIcon
:icon="
host.$ref === poolStore.pool?.master ? faStar : undefined
"
class="star"
/>
<PowerStateIcon
:state="isHostRunning(host) ? 'Running' : 'Halted'"
/>
</div>
</div>
</MenuItem>
</template>
</MenuItem>
<MenuItem
@click="xenApi.vm.pause({ vmRef: vm.$ref })"
:busy="isOperationsPending('pause')"
:disabled="!isRunning"
:icon="faPause"
>
{{ $t("pause") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.suspend({ vmRef: vm.$ref })"
:busy="isOperationsPending('suspend')"
:disabled="!isRunning"
:icon="faMoon"
>
{{ $t("suspend") }}
</MenuItem>
<!-- TODO: update the icon once Clémence has integrated the action into figma -->
<MenuItem
@click="
xenApi.vm.resume({
vmRef: vm.$ref,
})
"
:busy="isOperationsPending(['unpause', 'resume'])"
:disabled="!isSuspended && !isPaused"
:icon="faCirclePlay"
>
{{ $t("resume") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref })"
:busy="isOperationsPending('clean_reboot')"
:disabled="!isRunning"
:icon="faRotateLeft"
>
{{ $t("reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending('hard_reboot')"
:disabled="!isRunning && !isPaused"
:icon="faRepeat"
>
{{ $t("force-reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref })"
:busy="isOperationsPending('clean_shutdown')"
:disabled="!isRunning"
:icon="faPowerOff"
>
{{ $t("shutdown") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending('hard_shutdown')"
:disabled="!isRunning && !isSuspended && !isPaused"
:icon="faPlug"
>
{{ $t("force-shutdown") }}
</MenuItem>
</AppMenu>
</template>
</TitleBar>
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import TitleBar from "@/components/TitleBar.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
import { isHostRunning } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useVmStore } from "@/stores/vm.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import {
faAngleDown,
faCirclePlay,
faDisplay,
faMoon,
faPause,
faPlay,
faPlug,
faPowerOff,
faRepeat,
faRotateLeft,
faServer,
faStar,
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { computedAsync } from "@vueuse/core";
import { difference } from "lodash";
const vmStore = useVmStore();
const hostStore = useHostStore();
const poolStore = usePoolStore();
const { currentRoute } = useRouter();
const isOperationsPending = (operations: string[] | string) => {
const _operations = Array.isArray(operations) ? operations : [operations];
return (
difference(_operations, vmOperations.value).length < _operations.length
);
};
const vmOperations = computed(() => Object.values(vm.value.current_operations));
const vm = computed(
() => vmStore.getRecordByUuid(currentRoute.value.params.uuid as string)!
);
const xenApi = computedAsync(() => useXenApiStore().getXapi());
const name = computed(() => vm.value.name_label);
const isRunning = computed(() => vm.value.power_state === "Running");
const isHalted = computed(() => vm.value.power_state === "Halted");
const isSuspended = computed(() => vm.value.power_state === "Suspended");
const isPaused = computed(() => vm.value.power_state === "Paused");
</script>
<style lang="postcss" scoped>
.star {
margin: 0 1rem;
color: var(--color-orange-world-base);
}
.wrapper {
display: flex;
justify-content: space-between;
width: 100%;
}
</style>

View File

@@ -1,15 +1,9 @@
<template>
<AppMenu
:disabled="selectedRefs.length === 0"
:horizontal="!isMobile"
:shadow="isMobile"
class="vms-actions-bar"
placement="bottom-end"
horizontal
>
<template v-if="isMobile" #trigger="{ isOpen, open }">
<UiButton :active="isOpen" :icon="faEllipsis" transparent @click="open" />
</template>
<MenuItem :icon="faPowerOff">{{ $t("change-power-state") }}</MenuItem>
<MenuItem :icon="faRoute">{{ $t("migrate") }}</MenuItem>
<MenuItem :icon="faCopy">{{ $t("copy") }}</MenuItem>
@@ -33,10 +27,6 @@
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useUiStore } from "@/stores/ui.store";
import {
faBox,
faCamera,
@@ -44,21 +34,19 @@ import {
faCopy,
faDisplay,
faEdit,
faEllipsis,
faFileCsv,
faFileExport,
faPowerOff,
faRoute,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import { storeToRefs } from "pinia";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
defineProps<{
disabled?: boolean;
selectedRefs: string[];
}>();
const { isMobile } = storeToRefs(useUiStore());
</script>
<style lang="postcss" scoped>

Some files were not shown because too many files have changed in this diff Show More