Compare commits
128 Commits
lite/tests
...
better-xen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6895e7288 | ||
|
|
e279ea01a2 | ||
|
|
8df308aa01 | ||
|
|
a3e37eca62 | ||
|
|
817911a41e | ||
|
|
9f4fce9daa | ||
|
|
9ff305d5db | ||
|
|
055c3e098f | ||
|
|
bc61dd85c6 | ||
|
|
db6f1405e9 | ||
|
|
3dc3376aec | ||
|
|
55920a58a3 | ||
|
|
2a70ebf667 | ||
|
|
2f65a86aa0 | ||
|
|
4bf81ac33b | ||
|
|
263c23ae8f | ||
|
|
bf51b945c5 | ||
|
|
9d7a461550 | ||
|
|
bbf60818eb | ||
|
|
103b22ebb2 | ||
|
|
cf4a1d7d40 | ||
|
|
e94f036aca | ||
|
|
675405f7ac | ||
|
|
f8a3536a88 | ||
|
|
e527a13b50 | ||
|
|
3be03451f8 | ||
|
|
9fa15d9c84 | ||
|
|
9c3d39b4a7 | ||
|
|
28800f43ee | ||
|
|
5c0b29c51f | ||
|
|
62d9d0208b | ||
|
|
4bf871e52f | ||
|
|
103972808c | ||
|
|
dc65bb87b5 | ||
|
|
bfa0282ecc | ||
|
|
aa66ec0ccd | ||
|
|
18fe19c680 | ||
|
|
ab0e411ac0 | ||
|
|
79671e6d61 | ||
|
|
71ad9773da | ||
|
|
34ecc2bcbb | ||
|
|
53f4f265dc | ||
|
|
97624ef836 | ||
|
|
fb8d0ed924 | ||
|
|
fedbdba13d | ||
|
|
a281682f7a | ||
|
|
07e9f09692 | ||
|
|
29d6e590de | ||
|
|
3e351f8529 | ||
|
|
bfbfb9379a | ||
|
|
4f31b7007a | ||
|
|
fe0cc2ebb9 | ||
|
|
2fd6f521f8 | ||
|
|
ec00728112 | ||
|
|
7174c1edeb | ||
|
|
7bd27e7437 | ||
|
|
0a28e30003 | ||
|
|
246c793c28 | ||
|
|
5f0ea4d586 | ||
|
|
3c7d316b3c | ||
|
|
645c8f32e3 | ||
|
|
adc5e7d0c0 | ||
|
|
b9b74ab1ac | ||
|
|
64298c04f2 | ||
|
|
3dfb7db039 | ||
|
|
b64d8f5cbf | ||
|
|
c2e5225728 | ||
|
|
6c44a94bf4 | ||
|
|
a2d9310d0a | ||
|
|
05197b93ee | ||
|
|
448d115d49 | ||
|
|
ae993dff45 | ||
|
|
1bc4805f3d | ||
|
|
98fe8f3955 | ||
|
|
e902bcef67 | ||
|
|
cb2a6e43a8 | ||
|
|
b73a0992f8 | ||
|
|
d0b3d78639 | ||
|
|
e6b8939772 | ||
|
|
bc372a982c | ||
|
|
3ff8064f1b | ||
|
|
834459186d | ||
|
|
12220ad4cf | ||
|
|
f6fd1db1ef | ||
|
|
a1050882ae | ||
|
|
687df5ead4 | ||
|
|
b057881ad0 | ||
|
|
2b23550996 | ||
|
|
afeb20e589 | ||
|
|
d7794518a2 | ||
|
|
fee61a43e3 | ||
|
|
b201afd192 | ||
|
|
feef1f8b0a | ||
|
|
1a5e2fde4f | ||
|
|
609e957a55 | ||
|
|
5c18404174 | ||
|
|
866a1dd8ae | ||
|
|
3bfd6c6979 | ||
|
|
06564e9091 | ||
|
|
1702783cfb | ||
|
|
4ea0cbaa37 | ||
|
|
2246e065f7 | ||
|
|
29a38cdf1a | ||
|
|
960c569e86 | ||
|
|
fa183fc97e | ||
|
|
a1d63118c0 | ||
|
|
f95a20173c | ||
|
|
b82d0fadc3 | ||
|
|
0635b3316e | ||
|
|
113235aec3 | ||
|
|
3921401e96 | ||
|
|
2e514478a4 | ||
|
|
b3d53b230e | ||
|
|
45dcb914ba | ||
|
|
711087b686 | ||
|
|
b100a59d1d | ||
|
|
109b2b0055 | ||
|
|
9dda99eb20 | ||
|
|
fa0f75b474 | ||
|
|
2d93e0d4be | ||
|
|
fe6406336d | ||
|
|
1037d44089 | ||
|
|
a8c3669f43 | ||
|
|
d91753aa82 | ||
|
|
b548514d44 | ||
|
|
ba782d2698 | ||
|
|
0552dc23a5 | ||
|
|
574bbbf5ff |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ yarn-error.log.*
|
||||
# code coverage
|
||||
.nyc_output/
|
||||
coverage/
|
||||
.turbo/
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/async-each):
|
||||
|
||||
```
|
||||
> npm install --save @vates/async-each
|
||||
```sh
|
||||
npm install --save @vates/async-each
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"sinon": "^15.0.1",
|
||||
"tap": "^16.3.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/cached-dns.lookup):
|
||||
|
||||
```
|
||||
> npm install --save @vates/cached-dns.lookup
|
||||
```sh
|
||||
npm install --save @vates/cached-dns.lookup
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/coalesce-calls):
|
||||
|
||||
```
|
||||
> npm install --save @vates/coalesce-calls
|
||||
```sh
|
||||
npm install --save @vates/coalesce-calls
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/compose):
|
||||
|
||||
```
|
||||
> npm install --save @vates/compose
|
||||
```sh
|
||||
npm install --save @vates/compose
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/decorate-with):
|
||||
|
||||
```
|
||||
> npm install --save @vates/decorate-with
|
||||
```sh
|
||||
npm install --save @vates/decorate-with
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/disposable):
|
||||
|
||||
```
|
||||
> npm install --save @vates/disposable
|
||||
```sh
|
||||
npm install --save @vates/disposable
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"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.5.0",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"sinon": "^15.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/event-listeners-manager):
|
||||
|
||||
```
|
||||
> npm install --save @vates/event-listeners-manager
|
||||
```sh
|
||||
npm install --save @vates/event-listeners-manager
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.2.0"
|
||||
"vhd-lib": "^4.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/multi-key-map):
|
||||
|
||||
```
|
||||
> npm install --save @vates/multi-key-map
|
||||
```sh
|
||||
npm install --save @vates/multi-key-map
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/nbd-client):
|
||||
|
||||
```
|
||||
> npm install --save @vates/nbd-client
|
||||
```sh
|
||||
npm install --save @vates/nbd-client
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/otp):
|
||||
|
||||
```
|
||||
> npm install --save @vates/otp
|
||||
```sh
|
||||
npm install --save @vates/otp
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/parse-duration):
|
||||
|
||||
```
|
||||
> npm install --save @vates/parse-duration
|
||||
```sh
|
||||
npm install --save @vates/parse-duration
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/predicates):
|
||||
|
||||
```
|
||||
> npm install --save @vates/predicates
|
||||
```sh
|
||||
npm install --save @vates/predicates
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/read-chunk):
|
||||
|
||||
```
|
||||
> npm install --save @vates/read-chunk
|
||||
```sh
|
||||
npm install --save @vates/read-chunk
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
54
@vates/task/.USAGE.md
Normal file
54
@vates/task/.USAGE.md
Normal file
@@ -0,0 +1,54 @@
|
||||
```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)
|
||||
```
|
||||
1
@vates/task/.npmignore
Symbolic link
1
@vates/task/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
85
@vates/task/README.md
Normal file
85
@vates/task/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/task
|
||||
|
||||
[](https://npmjs.org/package/@vates/task)  [](https://bundlephobia.com/result?p=@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)
|
||||
184
@vates/task/index.js
Normal file
184
@vates/task/index.js
Normal file
@@ -0,0 +1,184 @@
|
||||
'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))
|
||||
}
|
||||
}
|
||||
}
|
||||
23
@vates/task/package.json
Normal file
23
@vates/task/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/toggle-scripts):
|
||||
|
||||
```
|
||||
> npm install --save @vates/toggle-scripts
|
||||
```sh
|
||||
npm install --save @vates/toggle-scripts
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/async-map):
|
||||
|
||||
```
|
||||
> npm install --save @xen-orchestra/async-map
|
||||
```sh
|
||||
npm install --save @xen-orchestra/async-map
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"sinon": "^15.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/audit-core):
|
||||
|
||||
```
|
||||
> npm install --save @xen-orchestra/audit-core
|
||||
```sh
|
||||
npm install --save @xen-orchestra/audit-core
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/backups-cli):
|
||||
|
||||
```
|
||||
> npm install --global @xen-orchestra/backups-cli
|
||||
```sh
|
||||
npm install --global @xen-orchestra/backups-cli
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -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.2",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"@xen-orchestra/backups": "^0.29.5",
|
||||
"@xen-orchestra/fs": "^3.3.1",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
|
||||
@@ -38,7 +38,7 @@ const DEFAULT_VM_SETTINGS = {
|
||||
fullInterval: 0,
|
||||
healthCheckSr: undefined,
|
||||
healthCheckVmsWithTags: [],
|
||||
maxMergedDeltasPerRun: 2,
|
||||
maxMergedDeltasPerRun: Infinity,
|
||||
offlineBackup: false,
|
||||
offlineSnapshot: false,
|
||||
snapshotRetention: 0,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/backups):
|
||||
|
||||
```
|
||||
> npm install --save @xen-orchestra/backups
|
||||
```sh
|
||||
npm install --save @xen-orchestra/backups
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -28,6 +28,7 @@ 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')
|
||||
@@ -232,21 +233,23 @@ class RemoteAdapter {
|
||||
return promise
|
||||
}
|
||||
|
||||
#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 #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]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async deleteDeltaVmBackups(backups) {
|
||||
@@ -255,7 +258,7 @@ class RemoteAdapter {
|
||||
// this will delete the json, unused VHDs will be detected by `cleanVm`
|
||||
await asyncMapSettled(backups, ({ _filename }) => handler.unlink(_filename))
|
||||
|
||||
this.#removeVmBackupsFromCache(backups)
|
||||
await this.#removeVmBackupsFromCache(backups)
|
||||
}
|
||||
|
||||
async deleteMetadataBackup(backupId) {
|
||||
@@ -284,7 +287,7 @@ class RemoteAdapter {
|
||||
Promise.all([handler.unlink(_filename), handler.unlink(resolveRelativeFromFile(_filename, xva))])
|
||||
)
|
||||
|
||||
this.#removeVmBackupsFromCache(backups)
|
||||
await this.#removeVmBackupsFromCache(backups)
|
||||
}
|
||||
|
||||
deleteVmBackup(file) {
|
||||
@@ -641,7 +644,7 @@ class RemoteAdapter {
|
||||
})
|
||||
|
||||
// will not throw
|
||||
this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
|
||||
await this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
|
||||
debug('adding cache entry', { entry: path })
|
||||
backups[path] = {
|
||||
...metadata,
|
||||
@@ -659,7 +662,7 @@ class RemoteAdapter {
|
||||
const handler = this._handler
|
||||
if (this.#useVhdDirectory()) {
|
||||
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
||||
await createVhdDirectoryFromStream(handler, dataPath, input, {
|
||||
const size = await createVhdDirectoryFromStream(handler, dataPath, input, {
|
||||
concurrency: writeBlockConcurrency,
|
||||
compression: this.#getCompressionType(),
|
||||
async validator() {
|
||||
@@ -669,12 +672,14 @@ class RemoteAdapter {
|
||||
nbdClient,
|
||||
})
|
||||
await VhdAbstract.createAlias(handler, path, dataPath)
|
||||
return size
|
||||
} else {
|
||||
await this.outputStream(path, input, { checksum, validator })
|
||||
return 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,
|
||||
@@ -683,6 +688,7 @@ class RemoteAdapter {
|
||||
return validator.apply(this, arguments)
|
||||
},
|
||||
})
|
||||
return container.size
|
||||
}
|
||||
|
||||
// open the hierarchy of ancestors until we find a full one
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
require('@xen-orchestra/log/configure.js').catchGlobalErrors(
|
||||
require('@xen-orchestra/log/configure').catchGlobalErrors(
|
||||
require('@xen-orchestra/log').createLogger('xo:backups:worker')
|
||||
)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await pFromCallback(cb => rimraf(tempDir, cb))
|
||||
await rimraf(tempDir)
|
||||
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], `incorrect backup size in metadata`)
|
||||
assert.equal(loggued[0], `unexpected number of entries in backup cache`)
|
||||
|
||||
loggued = []
|
||||
await adapter.cleanVm(rootPath, { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
|
||||
@@ -378,7 +378,19 @@ 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`))
|
||||
|
||||
@@ -258,6 +258,9 @@ 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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
'use strict'
|
||||
|
||||
const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
|
||||
const { catchGlobalErrors } = require('@xen-orchestra/log/configure')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { getSyncedHandler } = require('@xen-orchestra/fs')
|
||||
const { join } = require('path')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.29.2",
|
||||
"version": "0.29.5",
|
||||
"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.3",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@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.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/fs": "^3.3.1",
|
||||
"@xen-orchestra/log": "^0.6.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": "^10.0.0",
|
||||
"fs-extra": "^11.1.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash": "^4.17.20",
|
||||
"node-zone": "^0.4.0",
|
||||
"parse-pairs": "^1.1.0",
|
||||
"parse-pairs": "^2.0.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.2.0",
|
||||
"vhd-lib": "^4.2.1",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^3.0.2",
|
||||
"sinon": "^14.0.1",
|
||||
"rimraf": "^4.1.1",
|
||||
"sinon": "^15.0.1",
|
||||
"test": "^3.2.1",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^1.5.3"
|
||||
"@xen-orchestra/xapi": "^1.6.1"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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')
|
||||
@@ -21,7 +20,7 @@ const { packUuid } = require('./_packUuid.js')
|
||||
const { Disposable } = require('promise-toolbox')
|
||||
const NbdClient = require('@vates/nbd-client')
|
||||
|
||||
const { debug, warn } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
const { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
|
||||
exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
@@ -29,8 +28,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
const backup = this._backup
|
||||
const adapter = this._adapter
|
||||
|
||||
const backupDir = getVmBackupDir(backup.vm.uuid)
|
||||
const vdisDir = `${backupDir}/vdis/${backup.job.id}`
|
||||
const vdisDir = `${this._vmBackupDir}/vdis/${backup.job.id}`
|
||||
|
||||
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
|
||||
let found = false
|
||||
@@ -135,7 +133,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
}
|
||||
}
|
||||
|
||||
async _transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
async _transfer({ timestamp, deltaExport }) {
|
||||
const adapter = this._adapter
|
||||
const backup = this._backup
|
||||
|
||||
@@ -143,7 +141,6 @@ 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
|
||||
|
||||
@@ -175,9 +172,10 @@ 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 = `${backupDir}/${vhds[id]}`
|
||||
const path = `${this._vmBackupDir}/${vhds[id]}`
|
||||
|
||||
const isDelta = vdi.other_config['xo:base_delta'] !== undefined
|
||||
let parentPath
|
||||
@@ -203,21 +201,25 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
|
||||
|
||||
let nbdClient
|
||||
if (!this._backup.config.useNbd) {
|
||||
if (this._backup.config.useNbd) {
|
||||
debug('useNbd is enabled', { vdi: id, path })
|
||||
// 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()
|
||||
debug(`got nbd connection `, { vdi: vdi.uuid })
|
||||
info('NBD client ready', { vdi: id, path })
|
||||
} catch (error) {
|
||||
nbdClient = undefined
|
||||
debug(`can't connect to nbd server or no server available`, { error })
|
||||
warn('error connecting to NBD server', { error, vdi: id, path })
|
||||
}
|
||||
} else {
|
||||
debug('useNbd is disabled', { vdi: id, path })
|
||||
}
|
||||
|
||||
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
|
||||
transferSize += await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
|
||||
// no checksum for VHDs, because they will be invalidated by
|
||||
// merges and chainings
|
||||
checksum: false,
|
||||
@@ -238,9 +240,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
})
|
||||
})
|
||||
)
|
||||
return {
|
||||
size: Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0),
|
||||
}
|
||||
return { size: transferSize }
|
||||
})
|
||||
metadataContent.size = size
|
||||
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadataContent)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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')
|
||||
@@ -34,7 +33,6 @@ 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
|
||||
|
||||
@@ -47,7 +45,7 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
|
||||
const basename = formatFilenameDate(timestamp)
|
||||
|
||||
const dataBasename = basename + '.xva'
|
||||
const dataFilename = backupDir + '/' + dataBasename
|
||||
const dataFilename = this._vmBackupDir + '/' + dataBasename
|
||||
|
||||
const metadata = {
|
||||
jobId: job.id,
|
||||
|
||||
@@ -16,7 +16,6 @@ const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
|
||||
exports.MixinBackupWriter = (BaseClass = Object) =>
|
||||
class MixinBackupWriter extends BaseClass {
|
||||
#lock
|
||||
#vmBackupDir
|
||||
|
||||
constructor({ remoteId, ...rest }) {
|
||||
super(rest)
|
||||
@@ -24,13 +23,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,
|
||||
@@ -50,7 +49,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)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cr-seed-cli):
|
||||
|
||||
```
|
||||
> npm install --global @xen-orchestra/cr-seed-cli
|
||||
```sh
|
||||
npm install --global @xen-orchestra/cr-seed-cli
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cron):
|
||||
|
||||
```
|
||||
> npm install --save @xen-orchestra/cron
|
||||
```sh
|
||||
npm install --save @xen-orchestra/cron
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"sinon": "^15.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/defined):
|
||||
|
||||
```
|
||||
> npm install --save @xen-orchestra/defined
|
||||
```sh
|
||||
npm install --save @xen-orchestra/defined
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/emit-async):
|
||||
|
||||
```
|
||||
> npm install --save @xen-orchestra/emit-async
|
||||
```sh
|
||||
npm install --save @xen-orchestra/emit-async
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/fs):
|
||||
|
||||
```
|
||||
> npm install --global @xen-orchestra/fs
|
||||
```sh
|
||||
npm install --global @xen-orchestra/fs
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"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.5.0",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"bind-property-descriptor": "^2.0.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"execa": "^5.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"fs-extra": "^11.1.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": "^3.0.0",
|
||||
"rimraf": "^4.1.1",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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('@xen-orchestra:fs')
|
||||
const { info, warn } = createLogger('xo:fs:abstract')
|
||||
|
||||
const checksumFile = file => file + '.checksum'
|
||||
const computeRate = (hrtime, size) => {
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('encryption', () => {
|
||||
dir = await pFromCallback(cb => tmp.dir(cb))
|
||||
})
|
||||
afterAll(async () => {
|
||||
await pFromCallback(cb => rimraf(dir, cb))
|
||||
await rimraf(dir)
|
||||
})
|
||||
|
||||
it('sync should NOT create metadata if missing (not encrypted)', async () => {
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
- 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**
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"eslint-plugin-vue": "^9.0.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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-nested": {},
|
||||
"postcss-custom-media": {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<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 />
|
||||
@@ -20,9 +26,9 @@
|
||||
<div v-else>
|
||||
<AppHeader />
|
||||
<div style="display: flex">
|
||||
<nav class="nav">
|
||||
<InfraPoolList />
|
||||
</nav>
|
||||
<transition name="slide">
|
||||
<AppNavigation />
|
||||
</transition>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
@@ -32,6 +38,7 @@
|
||||
</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";
|
||||
@@ -42,7 +49,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 InfraPoolList from "@/components/infra/InfraPoolList.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import { useChartTheme } from "@/composables/chart-theme.composable";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
@@ -105,19 +112,20 @@ watch(
|
||||
);
|
||||
|
||||
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
|
||||
const reload = () => window.location.reload();
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
@import "@/assets/base.css";
|
||||
|
||||
.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);
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(-37rem);
|
||||
}
|
||||
|
||||
.main {
|
||||
|
||||
2
@xen-orchestra/lite/src/assets/_responsive.pcss
Normal file
2
@xen-orchestra/lite/src/assets/_responsive.pcss
Normal file
@@ -0,0 +1,2 @@
|
||||
@custom-media --mobile (max-width: 1023px);
|
||||
@custom-media --desktop (min-width: 1024px);
|
||||
1
@xen-orchestra/lite/src/assets/object-not-found.svg
Normal file
1
@xen-orchestra/lite/src/assets/object-not-found.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
1
@xen-orchestra/lite/src/assets/page-not-found.svg
Normal file
1
@xen-orchestra/lite/src/assets/page-not-found.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -1,5 +1,11 @@
|
||||
<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>
|
||||
@@ -12,6 +18,17 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -2,15 +2,24 @@
|
||||
<div class="app-login form-container">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<img alt="XO Lite" src="../assets/logo-title.svg" />
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
{{ $t("login") }}
|
||||
</UiButton>
|
||||
</form>
|
||||
@@ -19,21 +28,47 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { onMounted, ref } from "vue";
|
||||
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 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() {
|
||||
await xenApiStore.connect(login.value, password.value);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,6 +85,7 @@ async function handleSubmit() {
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
font-size: 2rem;
|
||||
min-width: 30em;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
@@ -72,12 +108,6 @@ img {
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 120%;
|
||||
font-weight: bold;
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 45rem;
|
||||
max-width: 100%;
|
||||
@@ -89,6 +119,6 @@ input {
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 3rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
57
@xen-orchestra/lite/src/components/AppNavigation.vue
Normal file
57
@xen-orchestra/lite/src/components/AppNavigation.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<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>
|
||||
@@ -66,7 +66,7 @@ import useModal from "@/composables/modal.composable";
|
||||
|
||||
defineProps<{
|
||||
availableSorts: Sorts;
|
||||
activeSorts: ActiveSorts;
|
||||
activeSorts: ActiveSorts<Record<string, any>>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -66,9 +66,11 @@ const emit = defineEmits<{
|
||||
|
||||
const isSelectable = computed(() => props.modelValue !== undefined);
|
||||
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter({
|
||||
queryStringParam: "filter",
|
||||
});
|
||||
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } =
|
||||
useCollectionSorter();
|
||||
useCollectionSorter<Record<string, any>>({ queryStringParam: "sort" });
|
||||
|
||||
const filteredCollection = useFilteredCollection(
|
||||
toRef(props, "collection"),
|
||||
|
||||
47
@xen-orchestra/lite/src/components/ObjectNotFoundWrapper.vue
Normal file
47
@xen-orchestra/lite/src/components/ObjectNotFoundWrapper.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
23
@xen-orchestra/lite/src/components/RelativeTime.vue
Normal file
23
@xen-orchestra/lite/src/components/RelativeTime.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
@@ -11,5 +11,7 @@
|
||||
height: 6.5rem;
|
||||
background-color: var(--background-color-primary);
|
||||
border-bottom: 1px solid var(--color-blue-scale-400);
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,10 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-if="data !== undefined">
|
||||
<template 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">
|
||||
@@ -18,10 +19,8 @@
|
||||
}}</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
|
||||
</template>
|
||||
<UiSpinner v-else class="spinner" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,6 +44,9 @@ interface Props {
|
||||
nItems?: number;
|
||||
}
|
||||
|
||||
const MIN_WARNING_VALUE = 80;
|
||||
const MIN_DANGEROUS_VALUE = 90;
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const computedData = computed(() => {
|
||||
@@ -67,24 +69,7 @@ const computedData = computed(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.spinner {
|
||||
color: var(--color-extra-blue-base);
|
||||
display: flex;
|
||||
@@ -106,12 +91,6 @@ 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);
|
||||
}
|
||||
@@ -124,6 +103,18 @@ 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;
|
||||
|
||||
@@ -18,15 +18,15 @@ const data: LinearChartData = [
|
||||
{
|
||||
label: "First series",
|
||||
data: [
|
||||
{ date: "...", value: 1234 },
|
||||
{ date: "...", value: 1234 },
|
||||
{ timestamp: 1670478371123, value: 1234 },
|
||||
{ timestamp: 1670478519751, value: 1234 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Second series",
|
||||
data: [
|
||||
{ date: "...", value: 1234 },
|
||||
{ date: "...", value: 1234 },
|
||||
{ timestamp: 1670478519751, value: 1234 },
|
||||
{ timestamp: 167047555000, value: 1234 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</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";
|
||||
@@ -22,20 +23,18 @@ 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[]) => {
|
||||
if (props.valueFormatter) {
|
||||
return props.valueFormatter(value as number);
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
};
|
||||
const valueFormatter = (value: OptionDataValue | OptionDataValue[]) =>
|
||||
props.valueFormatter?.(value as number) ?? `${value}`;
|
||||
|
||||
provide("valueFormatter", valueFormatter);
|
||||
|
||||
@@ -62,8 +61,10 @@ const option = computed<EChartsOption>(() => ({
|
||||
xAxis: {
|
||||
type: "time",
|
||||
axisLabel: {
|
||||
showMinLabel: true,
|
||||
showMaxLabel: true,
|
||||
formatter: (timestamp: number) =>
|
||||
utcFormat("%a\n%I:%M\n%p")(new Date(timestamp)),
|
||||
showMaxLabel: false,
|
||||
showMinLabel: false,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
@@ -71,19 +72,20 @@ 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.date, item.value]),
|
||||
data: series.data.map((item) => [item.timestamp, item.value]),
|
||||
})),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.chart {
|
||||
width: 50rem;
|
||||
width: 100%;
|
||||
height: 30rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:class="inputClass"
|
||||
:disabled="disabled || isLabelDisabled"
|
||||
class="input"
|
||||
ref="inputElement"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<template v-else>
|
||||
@@ -14,6 +15,7 @@
|
||||
:class="inputClass"
|
||||
:disabled="disabled || isLabelDisabled"
|
||||
class="select"
|
||||
ref="inputElement"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
@@ -70,6 +72,8 @@ interface Props extends Omit<InputHTMLAttributes, ""> {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { color: "info" });
|
||||
|
||||
const inputElement = ref();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: any): void;
|
||||
}>();
|
||||
@@ -78,6 +82,10 @@ 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",
|
||||
@@ -88,13 +96,19 @@ const wrapperClass = computed(() => [
|
||||
]);
|
||||
|
||||
const inputClass = computed(() => [
|
||||
props.color,
|
||||
color.value ?? 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>
|
||||
|
||||
96
@xen-orchestra/lite/src/components/form/FormInputWrapper.vue
Normal file
96
@xen-orchestra/lite/src/components/form/FormInputWrapper.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<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>
|
||||
@@ -1,33 +0,0 @@
|
||||
<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>
|
||||
@@ -12,7 +12,13 @@
|
||||
</MenuTrigger>
|
||||
<AppMenu v-else shadow :disabled="isDisabled">
|
||||
<template #trigger="{ open, isOpen }">
|
||||
<MenuTrigger :active="isOpen" :icon="icon" @click="open">
|
||||
<MenuTrigger
|
||||
:active="isOpen"
|
||||
:busy="isBusy"
|
||||
:disabled="isDisabled"
|
||||
:icon="icon"
|
||||
@click="open"
|
||||
>
|
||||
<slot />
|
||||
<UiIcon
|
||||
:fixed-width="false"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">{{ $t("cpu-usage") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("cpu-usage") }}</UiCardTitle>
|
||||
<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 UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">{{ $t("ram-usage") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("ram-usage") }}</UiCardTitle>
|
||||
<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 UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">{{ $t("status") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("status") }}</UiCardTitle>
|
||||
<template v-if="isReady">
|
||||
<PoolDashboardStatusItem
|
||||
:active="activeHostsCount"
|
||||
:total="totalHostsCount"
|
||||
:label="$t('hosts')"
|
||||
:total="totalHostsCount"
|
||||
/>
|
||||
<UiSeparator />
|
||||
<PoolDashboardStatusItem
|
||||
:active="activeVmsCount"
|
||||
:total="totalVmsCount"
|
||||
:label="$t('vms')"
|
||||
:total="totalVmsCount"
|
||||
/>
|
||||
</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();
|
||||
|
||||
@@ -1,56 +1,31 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<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>
|
||||
<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" />
|
||||
</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;
|
||||
@@ -85,21 +60,3 @@ 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>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("hosts") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.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",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<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>
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("vms") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</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";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("hosts") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</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";
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<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>
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("vms") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</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";
|
||||
|
||||
63
@xen-orchestra/lite/src/components/ui/SizeStatsSummary.vue
Normal file
63
@xen-orchestra/lite/src/components/ui/SizeStatsSummary.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<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>
|
||||
65
@xen-orchestra/lite/src/components/ui/UiCardTitle.vue
Normal file
65
@xen-orchestra/lite/src/components/ui/UiCardTitle.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<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>
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<div class="ui-key-value-list"><slot /></div>
|
||||
<table class="ui-key-value-list">
|
||||
<tbody>
|
||||
<slot />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-key-value-list {
|
||||
margin-top: 2rem;
|
||||
font-size: 1.4rem;
|
||||
/* UiKeyValueRow: 15em (key) + 15em (value) + 1rem (gap) */
|
||||
min-width: calc(30em + 1rem);
|
||||
border-spacing: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
<template>
|
||||
<div class="ui-key-value-row">
|
||||
<span class="key" v-if="$slots.key">
|
||||
<tr class="ui-key-value-row">
|
||||
<th v-if="$slots.key" class="key">
|
||||
<slot name="key" />
|
||||
</span>
|
||||
<span class="value">
|
||||
</th>
|
||||
<td :colspan="$slots.key ? 1 : 2" class="value">
|
||||
<slot name="value" />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.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;
|
||||
}
|
||||
@import "@/assets/_responsive.pcss";
|
||||
|
||||
.key,
|
||||
.value {
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
min-width: 15em;
|
||||
max-width: 30em;
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMagicKeys, whenever } from "@vueuse/core";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -64,11 +64,13 @@ 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;
|
||||
@@ -142,6 +144,9 @@ const className = computed(() => {
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: auto;
|
||||
min-height: 23rem;
|
||||
max-height: calc(100vh - 40rem);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
|
||||
181
@xen-orchestra/lite/src/components/vm/VmHeader.vue
Normal file
181
@xen-orchestra/lite/src/components/vm/VmHeader.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<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>
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<AppMenu
|
||||
:disabled="selectedRefs.length === 0"
|
||||
:horizontal="!isMobile"
|
||||
:shadow="isMobile"
|
||||
class="vms-actions-bar"
|
||||
horizontal
|
||||
placement="bottom-end"
|
||||
>
|
||||
<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>
|
||||
@@ -27,6 +33,10 @@
|
||||
</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,
|
||||
@@ -34,19 +44,21 @@ import {
|
||||
faCopy,
|
||||
faDisplay,
|
||||
faEdit,
|
||||
faEllipsis,
|
||||
faFileCsv,
|
||||
faFileExport,
|
||||
faPowerOff,
|
||||
faRoute,
|
||||
faTrashCan,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import AppMenu from "@/components/menu/AppMenu.vue";
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
selectedRefs: string[];
|
||||
}>();
|
||||
|
||||
const { isMobile } = storeToRefs(useUiStore());
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# useArrayRemovedItemsHistory composable
|
||||
|
||||
This composable allows you to keep a history of each removed item of an array.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
const myArray = ref([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray)
|
||||
|
||||
myArray.push('A'); // myArray = ['A']; history = []
|
||||
myArray.push('B'); // myArray = ['A', 'B']; history = []
|
||||
myArray.shift(); // myArray = ['B']; history = ['A']
|
||||
```
|
||||
|
||||
You can limit the number of items to keep in history:
|
||||
|
||||
```typescript
|
||||
const myArray = ref([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray, 30);
|
||||
```
|
||||
|
||||
Be careful when using an array of objects which is likely to be replaced (instead of being altered):
|
||||
|
||||
```typescript
|
||||
const myArray = ref([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray);
|
||||
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
|
||||
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }, { id: 'bar' }]
|
||||
```
|
||||
|
||||
In this case, `{ id: 'bar' }` is detected as removed since in JavaScript `{ id: 'bar' } !== { id: 'bar' }`.
|
||||
|
||||
You must therefore use an identity function as third parameter to return the value to be used to detect deletion:
|
||||
|
||||
```typescript
|
||||
const myArray = ref<{ id: string }[]>([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray, undefined, (item) => item.id);
|
||||
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
|
||||
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }]
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
import { differenceBy } from "lodash-es";
|
||||
import { type Ref, ref, unref, watch } from "vue";
|
||||
|
||||
export default function useArrayRemovedItemsHistory<T>(
|
||||
list: Ref<T[]>,
|
||||
limit = Infinity,
|
||||
iteratee: (item: T) => unknown = (item) => item
|
||||
) {
|
||||
const currentList: Ref<T[]> = ref([]);
|
||||
const history: Ref<T[]> = ref([]);
|
||||
|
||||
watch(
|
||||
list,
|
||||
(updatedList) => {
|
||||
currentList.value = [...updatedList];
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(currentList, (nextList, previousList) => {
|
||||
const removedItems = differenceBy(previousList, nextList, iteratee);
|
||||
history.value.push(...removedItems);
|
||||
const currentLimit = unref(limit);
|
||||
if (history.value.length > currentLimit) {
|
||||
history.value.slice(-currentLimit);
|
||||
}
|
||||
});
|
||||
|
||||
return history;
|
||||
}
|
||||
@@ -1,382 +1,411 @@
|
||||
import { provide } from "vue";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, provide, ref, watch } from "vue";
|
||||
import { THEME_KEY } from "vue-echarts";
|
||||
|
||||
export const useChartTheme = () => {
|
||||
provide(THEME_KEY, {
|
||||
color: ["#8F84FF", "#EF7F18"],
|
||||
backgroundColor: "#ffffff",
|
||||
textStyle: {},
|
||||
grid: {
|
||||
top: 80,
|
||||
left: 80,
|
||||
right: 20,
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: "#1A1B38",
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
const { colorMode } = storeToRefs(useUiStore());
|
||||
|
||||
const style = window.getComputedStyle(window.document.documentElement);
|
||||
|
||||
const getColors = () => ({
|
||||
background: style.getPropertyValue("--background-color-primary"),
|
||||
title: style.getPropertyValue("--color-blue-scale-100"),
|
||||
subtitle: style.getPropertyValue("--color-blue-scale-300"),
|
||||
splitLine: style.getPropertyValue("--color-blue-scale-400"),
|
||||
primary: style.getPropertyValue("--color-extra-blue-base"),
|
||||
secondary: style.getPropertyValue("--color-orange-world-base"),
|
||||
});
|
||||
|
||||
const colors = ref(getColors());
|
||||
|
||||
watch(colorMode, () => (colors.value = getColors()), { flush: "post" });
|
||||
|
||||
provide(
|
||||
THEME_KEY,
|
||||
computed(() => ({
|
||||
color: [colors.value.primary, colors.value.secondary],
|
||||
backgroundColor: colors.value.background,
|
||||
textStyle: {},
|
||||
grid: {
|
||||
top: 80,
|
||||
left: 80,
|
||||
right: 20,
|
||||
},
|
||||
subtextStyle: {
|
||||
color: "#9899A5",
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
title: {
|
||||
textStyle: {
|
||||
color: colors.value.title,
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
},
|
||||
subtextStyle: {
|
||||
color: colors.value.subtitle,
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
line: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
showSymbol: false,
|
||||
symbolSize: 10,
|
||||
symbol: "circle",
|
||||
smooth: false,
|
||||
},
|
||||
radar: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
symbolSize: 10,
|
||||
symbol: "circle",
|
||||
smooth: false,
|
||||
},
|
||||
bar: {
|
||||
itemStyle: {
|
||||
barBorderWidth: 0,
|
||||
barBorderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
pie: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
scatter: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
boxplot: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
parallel: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
sankey: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
funnel: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
gauge: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: "#eb8146",
|
||||
color0: "transparent",
|
||||
borderColor: "#d95850",
|
||||
borderColor0: "#58c470",
|
||||
borderWidth: "2",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
symbolSize: "10",
|
||||
symbol: "emptyArrow",
|
||||
smooth: true,
|
||||
color: ["#893448", "#d95850", "#eb8146", "#ffb248", "#f2d643", "#ebdba4"],
|
||||
label: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
},
|
||||
map: {
|
||||
itemStyle: {
|
||||
areaColor: "#f3f3f3",
|
||||
borderColor: "#999999",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
emphasis: {
|
||||
line: {
|
||||
itemStyle: {
|
||||
areaColor: "#ffb248",
|
||||
borderColor: "#eb8146",
|
||||
borderWidth: 1,
|
||||
borderWidth: 2,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
showSymbol: false,
|
||||
symbolSize: 10,
|
||||
symbol: "circle",
|
||||
smooth: false,
|
||||
},
|
||||
radar: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
symbolSize: 10,
|
||||
symbol: "circle",
|
||||
smooth: false,
|
||||
},
|
||||
bar: {
|
||||
itemStyle: {
|
||||
barBorderWidth: 0,
|
||||
barBorderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
pie: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
scatter: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
boxplot: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
parallel: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
sankey: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
funnel: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
gauge: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
},
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: "#eb8146",
|
||||
color0: "transparent",
|
||||
borderColor: "#d95850",
|
||||
borderColor0: "#58c470",
|
||||
borderWidth: "2",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
symbolSize: "10",
|
||||
symbol: "emptyArrow",
|
||||
smooth: true,
|
||||
color: [
|
||||
"#893448",
|
||||
"#d95850",
|
||||
"#eb8146",
|
||||
"#ffb248",
|
||||
"#f2d643",
|
||||
"#ebdba4",
|
||||
],
|
||||
label: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
},
|
||||
map: {
|
||||
itemStyle: {
|
||||
areaColor: "#f3f3f3",
|
||||
borderColor: "#999999",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: "#ffb248",
|
||||
borderColor: "#eb8146",
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
geo: {
|
||||
itemStyle: {
|
||||
areaColor: "#f3f3f3",
|
||||
borderColor: "#999999",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
emphasis: {
|
||||
geo: {
|
||||
itemStyle: {
|
||||
areaColor: "#ffb248",
|
||||
borderColor: "#eb8146",
|
||||
borderWidth: 1,
|
||||
areaColor: "#f3f3f3",
|
||||
borderColor: "#999999",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
},
|
||||
},
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: "#aaaaaa",
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: "#ffb248",
|
||||
borderColor: "#eb8146",
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
lineStyle: {
|
||||
color: "#333",
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
lineStyle: {
|
||||
color: "#333",
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: "#999999",
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: [colors.value.splitLine],
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: "#999999",
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: ["#e6e6e6"],
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#aaaaaa",
|
||||
// },
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#333",
|
||||
// },
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: colors.value.subtitle,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: [colors.value.splitLine],
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
logAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
lineStyle: {
|
||||
color: "#333",
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: "#999999",
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: [colors.value.splitLine],
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#aaaaaa",
|
||||
// },
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#333",
|
||||
// },
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: "#9899A5",
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: ["#E5E5E7"],
|
||||
timeAxis: {
|
||||
axisLine: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#aaaaaa",
|
||||
// },
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#333",
|
||||
// },
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: colors.value.subtitle,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
type: "dashed",
|
||||
color: [colors.value.splitLine],
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
logAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
lineStyle: {
|
||||
color: "#333",
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: "#999999",
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: ["#e6e6e6"],
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
timeAxis: {
|
||||
axisLine: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#aaaaaa",
|
||||
// },
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
// lineStyle: {
|
||||
// color: "#333",
|
||||
// },
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: "#9899A5",
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
type: "dashed",
|
||||
color: ["#E5E5E7"],
|
||||
},
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
areaStyle: {
|
||||
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: "#999999",
|
||||
},
|
||||
emphasis: {
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: "#666666",
|
||||
borderColor: "#999999",
|
||||
},
|
||||
emphasis: {
|
||||
iconStyle: {
|
||||
borderColor: "#666666",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
left: "right",
|
||||
top: "bottom",
|
||||
textStyle: {
|
||||
color: "#9899A5",
|
||||
legend: {
|
||||
left: "right",
|
||||
top: "bottom",
|
||||
textStyle: {
|
||||
color: colors.value.subtitle,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: "#8F84FF",
|
||||
width: 1,
|
||||
},
|
||||
crossStyle: {
|
||||
color: "#8F84FF",
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: "#8F84FF",
|
||||
color: "#893448",
|
||||
width: 1,
|
||||
},
|
||||
crossStyle: {
|
||||
color: "#8F84FF",
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: "#893448",
|
||||
width: 1,
|
||||
},
|
||||
itemStyle: {
|
||||
color: "#893448",
|
||||
borderWidth: 1,
|
||||
},
|
||||
controlStyle: {
|
||||
color: "#893448",
|
||||
borderColor: "#893448",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
checkpointStyle: {
|
||||
color: "#eb8146",
|
||||
borderColor: "#ffb248",
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: "#ffb248",
|
||||
color: "#893448",
|
||||
borderWidth: 1,
|
||||
},
|
||||
controlStyle: {
|
||||
color: "#893448",
|
||||
borderColor: "#893448",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
checkpointStyle: {
|
||||
color: "#eb8146",
|
||||
borderColor: "#ffb248",
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: "#ffb248",
|
||||
},
|
||||
controlStyle: {
|
||||
color: "#893448",
|
||||
borderColor: "#893448",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
label: {
|
||||
color: "#893448",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
color: [
|
||||
"#893448",
|
||||
"#d95850",
|
||||
"#eb8146",
|
||||
"#ffb248",
|
||||
"#f2d643",
|
||||
"rgb(247,238,173)",
|
||||
],
|
||||
},
|
||||
dataZoom: {
|
||||
backgroundColor: "rgba(255,255,255,0)",
|
||||
dataBackgroundColor: "rgba(255,178,72,0.5)",
|
||||
fillerColor: "rgba(255,178,72,0.15)",
|
||||
handleColor: "#ffb248",
|
||||
handleSize: "100%",
|
||||
textStyle: {
|
||||
color: "#333",
|
||||
visualMap: {
|
||||
color: [
|
||||
"#893448",
|
||||
"#d95850",
|
||||
"#eb8146",
|
||||
"#ffb248",
|
||||
"#f2d643",
|
||||
"rgb(247,238,173)",
|
||||
],
|
||||
},
|
||||
},
|
||||
markPoint: {
|
||||
label: {
|
||||
color: "#ffffff",
|
||||
dataZoom: {
|
||||
backgroundColor: "rgba(255,255,255,0)",
|
||||
dataBackgroundColor: "rgba(255,178,72,0.5)",
|
||||
fillerColor: "rgba(255,178,72,0.15)",
|
||||
handleColor: "#ffb248",
|
||||
handleSize: "100%",
|
||||
textStyle: {
|
||||
color: "#333",
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
markPoint: {
|
||||
label: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,34 +3,37 @@
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
const { filters, addFilter, removeFilter, predicate } =
|
||||
useCollectionFilter(options);
|
||||
|
||||
const filteredCollection = myCollection.filter(predicate);
|
||||
const filteredCollection = computed(() => myCollection.filter(predicate));
|
||||
addFilter("name:/^Foo/");
|
||||
addFilter("count:>3");
|
||||
```
|
||||
|
||||
## URL Query String
|
||||
## Options
|
||||
|
||||
By default, when adding/removing filters, the URL will update automatically.
|
||||
### `queryStringParam`
|
||||
|
||||
This option allows to activate the URL Query String support.
|
||||
|
||||
```typescript
|
||||
const { addFilter } = useCollectionFilter({ queryStringParam: "filter" });
|
||||
addFilter("name:/^foo/i"); // Will update the URL with ?filter=name:/^foo/i
|
||||
```
|
||||
|
||||
### Change the URL query string parameter name
|
||||
### Initial filters
|
||||
|
||||
This option allows to set some initial filters.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
/* ... */
|
||||
} = useCollectionFilter({ queryStringParam: "f" }); // ?f=name:/^foo/i
|
||||
} = useCollectionFilter({ initialFilters: ["!name_label:foobar"] });
|
||||
```
|
||||
|
||||
### Disable the usage of URL query string
|
||||
|
||||
```typescript
|
||||
const {
|
||||
/* ... */
|
||||
} = useCollectionFilter({ queryStringParam: undefined });
|
||||
```
|
||||
When using the `initialFilters` option with the `queryStringParam` option,
|
||||
`initialFilters` will only be applied if no query string parameter is defined in the URL.
|
||||
|
||||
## Example of using the composable with the `CollectionFilter` component
|
||||
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { getFirst } from "@/libs/utils";
|
||||
import * as CM from "complex-matcher";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { type LocationQueryValue, useRoute, useRouter } from "vue-router";
|
||||
|
||||
interface Config {
|
||||
queryStringParam?: string;
|
||||
initialFilters?: string[];
|
||||
}
|
||||
|
||||
export default function useCollectionFilter(
|
||||
config: Config = { queryStringParam: "filter" }
|
||||
) {
|
||||
export default function useCollectionFilter<T>(config: Config = {}) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const filtersSet = ref(
|
||||
config.queryStringParam
|
||||
? queryToSet(route.query[config.queryStringParam] as LocationQueryValue)
|
||||
: new Set<string>()
|
||||
);
|
||||
const { queryStringParam, initialFilters = [] } = config;
|
||||
const filtersSet = ref<Set<string>>(new Set(initialFilters));
|
||||
const filters = computed(() => Array.from(filtersSet.value.values()));
|
||||
|
||||
if (config.queryStringParam) {
|
||||
const queryStringParam = config.queryStringParam;
|
||||
if (queryStringParam !== undefined) {
|
||||
const queryString = route.query[queryStringParam];
|
||||
|
||||
if (queryString !== undefined) {
|
||||
filtersSet.value = queryToSet(getFirst(queryString));
|
||||
}
|
||||
|
||||
watch(filters, (value) =>
|
||||
router.replace({
|
||||
query: { ...route.query, [queryStringParam]: value.join(" ") },
|
||||
@@ -35,7 +37,7 @@ export default function useCollectionFilter(
|
||||
filtersSet.value.delete(filter);
|
||||
};
|
||||
|
||||
const predicate = computed(() => {
|
||||
const predicate = computed<(value: T) => boolean>(() => {
|
||||
return CM.parse(
|
||||
Array.from(filters.value.values()).join(" ")
|
||||
).createPredicate();
|
||||
@@ -49,7 +51,7 @@ export default function useCollectionFilter(
|
||||
};
|
||||
}
|
||||
|
||||
function queryToSet(query: LocationQueryValue): Set<string> {
|
||||
function queryToSet(query?: LocationQueryValue): Set<string> {
|
||||
if (!query) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# useCollectionSorter composable
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
const { sorts, addSort, removeSort, compareFn, toggleSortDirection } =
|
||||
useCollectionSorter(options);
|
||||
|
||||
const sortedCollection = computed(() => myCollection.sort(compareFn));
|
||||
addSort("name", true);
|
||||
addSort("age", false);
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### `queryStringParam`
|
||||
|
||||
This option allows to activate the URL Query String support.
|
||||
|
||||
```typescript
|
||||
const { addSort } = useCollectionSorter({ queryStringParam: "sort" });
|
||||
addSort("name", true); // Will update the URL with ?sort=name:1
|
||||
```
|
||||
|
||||
### Initial sorts
|
||||
|
||||
This option allows to set some initial sorts.
|
||||
|
||||
Use `key` for ascending sort and `-key` for descending sort.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
/* ... */
|
||||
} = useCollectionSorter({
|
||||
initialSorts: ["name", "-age"],
|
||||
});
|
||||
```
|
||||
|
||||
When using the `initialSorts` option with the `queryStringParam` option,
|
||||
`initialSorts` will only be applied if no query string parameter is defined in the URL.
|
||||
@@ -1,52 +1,43 @@
|
||||
import { getFirst } from "@/libs/utils";
|
||||
import type { ActiveSorts, InitialSorts, SortConfig } from "@/types/sort";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { type LocationQueryValue, useRoute, useRouter } from "vue-router";
|
||||
import type { ActiveSorts } from "@/types/sort";
|
||||
|
||||
interface Config {
|
||||
queryStringParam?: string;
|
||||
}
|
||||
|
||||
export default function useCollectionSorter(
|
||||
config: Config = { queryStringParam: "sort" }
|
||||
) {
|
||||
export default function useCollectionSorter<T>(config: SortConfig<T> = {}) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { queryStringParam, initialSorts = [] } = config;
|
||||
const sorts = ref<ActiveSorts<T>>(parseInitialSorts(initialSorts));
|
||||
|
||||
const sorts = ref<ActiveSorts>(
|
||||
config.queryStringParam
|
||||
? queryToMap(route.query[config.queryStringParam] as LocationQueryValue)
|
||||
: new Map()
|
||||
const sortsAsString = computed(() =>
|
||||
Array.from(sorts.value)
|
||||
.map(([property, isAsc]) => `${String(property)}:${isAsc ? "1" : "0"}`)
|
||||
.join(",")
|
||||
);
|
||||
|
||||
if (config.queryStringParam) {
|
||||
const queryStringParam = config.queryStringParam;
|
||||
watch(
|
||||
sorts,
|
||||
(value) =>
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
[queryStringParam]: Array.from(value)
|
||||
.map(
|
||||
([property, isAscending]) =>
|
||||
`${property}:${isAscending ? "1" : "0"}`
|
||||
)
|
||||
.join(","),
|
||||
},
|
||||
}),
|
||||
{ deep: true }
|
||||
if (queryStringParam !== undefined) {
|
||||
const queryString = route.query[queryStringParam];
|
||||
|
||||
if (queryString !== undefined) {
|
||||
sorts.value = queryToMap(getFirst(queryString));
|
||||
}
|
||||
|
||||
watch(sortsAsString, (value) =>
|
||||
router.replace({
|
||||
query: { ...route.query, [queryStringParam]: value },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const addSort = (property: string, isAscending: boolean) => {
|
||||
const addSort = (property: keyof T, isAscending: boolean) => {
|
||||
sorts.value.set(property, isAscending);
|
||||
};
|
||||
|
||||
const removeSort = (property: string) => {
|
||||
const removeSort = (property: keyof T) => {
|
||||
sorts.value.delete(property);
|
||||
};
|
||||
|
||||
const toggleSortDirection = (property: string) => {
|
||||
const toggleSortDirection = (property: keyof T) => {
|
||||
if (!sorts.value.has(property)) {
|
||||
return;
|
||||
}
|
||||
@@ -55,7 +46,7 @@ export default function useCollectionSorter(
|
||||
};
|
||||
|
||||
const compareFn = computed(() => {
|
||||
return (record1: any, record2: any) => {
|
||||
return (record1: T, record2: T) => {
|
||||
for (const [property, isAscending] of sorts.value) {
|
||||
const value1 = record1[property];
|
||||
const value2 = record2[property];
|
||||
@@ -82,7 +73,7 @@ export default function useCollectionSorter(
|
||||
};
|
||||
}
|
||||
|
||||
function queryToMap(query: LocationQueryValue) {
|
||||
function queryToMap(query?: LocationQueryValue) {
|
||||
if (!query) {
|
||||
return new Map();
|
||||
}
|
||||
@@ -94,3 +85,14 @@ function queryToMap(query: LocationQueryValue) {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function parseInitialSorts<T>(sorts: InitialSorts<T>): ActiveSorts<T> {
|
||||
return new Map(
|
||||
sorts.map((sort) => {
|
||||
const isDescending = sort.startsWith("-");
|
||||
const property = (isDescending ? sort.substring(1) : sort) as keyof T;
|
||||
|
||||
return [property, !isDescending];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { computed, onUnmounted, ref, type ComputedRef } from "vue";
|
||||
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
|
||||
import type { GRANULARITY, XapiStatsResponse } from "@/libs/xapi-stats";
|
||||
import {
|
||||
type GRANULARITY,
|
||||
type HostStats,
|
||||
RRD_STEP_FROM_STRING,
|
||||
type VmStats,
|
||||
type XapiStatsResponse,
|
||||
} from "@/libs/xapi-stats";
|
||||
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
@@ -17,21 +23,34 @@ export type Stat<T> = {
|
||||
pausable: Pausable;
|
||||
};
|
||||
|
||||
export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
|
||||
type: "host" | "vm",
|
||||
granularity: GRANULARITY
|
||||
) {
|
||||
export type FetchedStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
S extends HostStats | VmStats
|
||||
> = {
|
||||
register: (object: T) => void;
|
||||
unregister: (object: T) => void;
|
||||
stats?: ComputedRef<Stat<S>[]>;
|
||||
timestampStart?: ComputedRef<number>;
|
||||
timestampEnd?: ComputedRef<number>;
|
||||
};
|
||||
|
||||
export default function useFetchStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
S extends HostStats | VmStats
|
||||
>(type: "host" | "vm", granularity: GRANULARITY) {
|
||||
const stats = ref<Map<string, Stat<S>>>(new Map());
|
||||
const timestamp = ref<number[]>([0, 0]);
|
||||
|
||||
const register = (object: T) => {
|
||||
if (stats.value.has(object.uuid)) {
|
||||
stats.value.get(object.uuid)!.pausable.resume();
|
||||
const mapKey = `${object.uuid}-${granularity}`;
|
||||
if (stats.value.has(mapKey)) {
|
||||
stats.value.get(mapKey)!.pausable.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
const pausable = useTimeoutPoll(
|
||||
async () => {
|
||||
if (!stats.value.has(object.uuid)) {
|
||||
if (!stats.value.has(mapKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,15 +59,21 @@ export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
|
||||
granularity
|
||||
)) as XapiStatsResponse<S>;
|
||||
|
||||
stats.value.get(object.uuid)!.stats = newStats.stats;
|
||||
timestamp.value = [
|
||||
newStats.endTimestamp -
|
||||
RRD_STEP_FROM_STRING[granularity] *
|
||||
(newStats.stats.memory.length - 1),
|
||||
newStats.endTimestamp,
|
||||
];
|
||||
|
||||
stats.value.get(mapKey)!.stats = newStats.stats;
|
||||
await promiseTimeout(newStats.interval * 1000);
|
||||
},
|
||||
0,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
stats.value.set(object.uuid, {
|
||||
stats.value.set(mapKey, {
|
||||
id: object.uuid,
|
||||
name: object.name_label,
|
||||
stats: undefined,
|
||||
@@ -57,8 +82,9 @@ export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
|
||||
};
|
||||
|
||||
const unregister = (object: T) => {
|
||||
stats.value.get(object.uuid)?.pausable.pause();
|
||||
stats.value.delete(object.uuid);
|
||||
const mapKey = `${object.uuid}-${granularity}`;
|
||||
stats.value.get(mapKey)?.pausable.pause();
|
||||
stats.value.delete(mapKey);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -69,5 +95,7 @@ export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
|
||||
register,
|
||||
unregister,
|
||||
stats: computed<Stat<S>[]>(() => Array.from(stats.value.values())),
|
||||
timestampStart: computed(() => timestamp.value[0]),
|
||||
timestampEnd: computed(() => timestamp.value[1]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# useRelativeTime composable
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
const relativeTime = useRelativeTime(fromDate, toDate);
|
||||
|
||||
console.log(relativeTime.value); // 3 days 27 minutes 10 seconds ago
|
||||
```
|
||||
|
||||
# Reactivity
|
||||
|
||||
Both arguments can be `Ref`
|
||||
|
||||
```ts
|
||||
const now = useNow();
|
||||
const relativeTime = useRelativeTime(fromDate, now); // Value will be updated each time `now` changes
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user