Compare commits
55 Commits
gen-signed
...
xo-lite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae087a6539 | ||
|
|
4db93f8ced | ||
|
|
e5c737cba7 | ||
|
|
9f0f38ef94 | ||
|
|
d76996b1d5 | ||
|
|
3b77897692 | ||
|
|
d4ed555abd | ||
|
|
97d77c0aa5 | ||
|
|
a9ad0ec455 | ||
|
|
78ec008c26 | ||
|
|
2d71bef5d8 | ||
|
|
3ec7c61987 | ||
|
|
526c2001d3 | ||
|
|
f3d4e40c6d | ||
|
|
ac8f93fb0e | ||
|
|
d2fbc1b573 | ||
|
|
c5670a047f | ||
|
|
e9472889f2 | ||
|
|
9bec4b571c | ||
|
|
b56cc96e37 | ||
|
|
011164f16c | ||
|
|
b9a9471408 | ||
|
|
9abd1429a2 | ||
|
|
7f656973de | ||
|
|
5e0766fcb1 | ||
|
|
2dc5c0e161 | ||
|
|
d0730d05fd | ||
|
|
8fe3a439fc | ||
|
|
12c7113662 | ||
|
|
36be46b073 | ||
|
|
25ef579df5 | ||
|
|
cbbb07d389 | ||
|
|
96df84c9d8 | ||
|
|
17c4b5cbe7 | ||
|
|
cf642cd720 | ||
|
|
047f3a9b4c | ||
|
|
b0f85e0380 | ||
|
|
7aa518b43c | ||
|
|
d187d6aeeb | ||
|
|
289dce3876 | ||
|
|
930afea1a1 | ||
|
|
3801fa9134 | ||
|
|
ae211046b8 | ||
|
|
87ce9ff63a | ||
|
|
131c6321be | ||
|
|
6abcce498f | ||
|
|
9c38f5b327 | ||
|
|
14720d4cbf | ||
|
|
940ef2845d | ||
|
|
e3dbb7a6c2 | ||
|
|
8cba6ebb20 | ||
|
|
a1b322f5be | ||
|
|
07ff19c4b8 | ||
|
|
3a0af4e7e0 | ||
|
|
dbb3f74ab0 |
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'status: triaging :triangular_flag_on_post:, type: bug :bug:'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- Node: [e.g. 16.12.1]
|
||||
- xo-server: [e.g. 5.82.3]
|
||||
- xo-web: [e.g. 5.87.0]
|
||||
- hypervisor: [e.g. XCP-ng 8.2.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
@@ -1,68 +0,0 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/async-each
|
||||
|
||||
[](https://npmjs.org/package/@vates/async-each)  [](https://bundlephobia.com/result?p=@vates/async-each) [](https://npmjs.org/package/@vates/async-each)
|
||||
|
||||
> Run async fn for each item in (async) iterable
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/async-each):
|
||||
|
||||
```
|
||||
> npm install --save @vates/async-each
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### `asyncEach(iterable, iteratee, [opts])`
|
||||
|
||||
Executes `iteratee` in order for each value yielded by `iterable`.
|
||||
|
||||
Returns a promise wich rejects as soon as a call to `iteratee` throws or a promise returned by it rejects, and which resolves when all promises returned by `iteratee` have resolved.
|
||||
|
||||
`iterable` must be an iterable or async iterable.
|
||||
|
||||
`iteratee` is called with the same `this` value as `asyncEach`, and with the following arguments:
|
||||
|
||||
- `value`: the value yielded by `iterable`
|
||||
- `index`: the 0-based index for this value
|
||||
- `iterable`: the iterable itself
|
||||
|
||||
`opts` is an object that can contains the following options:
|
||||
|
||||
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `1`
|
||||
- `signal`: an abort signal to stop the iteration
|
||||
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
|
||||
|
||||
```js
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
|
||||
const contents = []
|
||||
await asyncEach(
|
||||
['foo.txt', 'bar.txt', 'baz.txt'],
|
||||
async function (filename, i) {
|
||||
contents[i] = await readFile(filename)
|
||||
},
|
||||
{
|
||||
// reads two files at a time
|
||||
concurrency: 2,
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -1,35 +0,0 @@
|
||||
### `asyncEach(iterable, iteratee, [opts])`
|
||||
|
||||
Executes `iteratee` in order for each value yielded by `iterable`.
|
||||
|
||||
Returns a promise wich rejects as soon as a call to `iteratee` throws or a promise returned by it rejects, and which resolves when all promises returned by `iteratee` have resolved.
|
||||
|
||||
`iterable` must be an iterable or async iterable.
|
||||
|
||||
`iteratee` is called with the same `this` value as `asyncEach`, and with the following arguments:
|
||||
|
||||
- `value`: the value yielded by `iterable`
|
||||
- `index`: the 0-based index for this value
|
||||
- `iterable`: the iterable itself
|
||||
|
||||
`opts` is an object that can contains the following options:
|
||||
|
||||
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `1`
|
||||
- `signal`: an abort signal to stop the iteration
|
||||
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
|
||||
|
||||
```js
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
|
||||
const contents = []
|
||||
await asyncEach(
|
||||
['foo.txt', 'bar.txt', 'baz.txt'],
|
||||
async function (filename, i) {
|
||||
contents[i] = await readFile(filename)
|
||||
},
|
||||
{
|
||||
// reads two files at a time
|
||||
concurrency: 2,
|
||||
}
|
||||
)
|
||||
```
|
||||
@@ -1,99 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
class AggregateError extends Error {
|
||||
constructor(errors, message) {
|
||||
super(message)
|
||||
this.errors = errors
|
||||
}
|
||||
}
|
||||
|
||||
exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 1, signal, stopOnError = true } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const it = (iterable[Symbol.iterator] || iterable[Symbol.asyncIterator]).call(iterable)
|
||||
const errors = []
|
||||
let running = 0
|
||||
let index = 0
|
||||
|
||||
let onAbort
|
||||
if (signal !== undefined) {
|
||||
onAbort = () => {
|
||||
onRejectedWrapper(new Error('asyncEach aborted'))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const clean = () => {
|
||||
onFulfilled = onRejected = noop
|
||||
if (onAbort !== undefined) {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
resolve = (resolve =>
|
||||
function resolveAndClean(value) {
|
||||
resolve(value)
|
||||
clean()
|
||||
})(resolve)
|
||||
reject = (reject =>
|
||||
function rejectAndClean(reason) {
|
||||
reject(reason)
|
||||
clean()
|
||||
})(reject)
|
||||
|
||||
let onFulfilled = value => {
|
||||
--running
|
||||
next()
|
||||
}
|
||||
const onFulfilledWrapper = value => onFulfilled(value)
|
||||
|
||||
let onRejected = stopOnError
|
||||
? reject
|
||||
: error => {
|
||||
--running
|
||||
errors.push(error)
|
||||
next()
|
||||
}
|
||||
const onRejectedWrapper = reason => onRejected(reason)
|
||||
|
||||
let nextIsRunning = false
|
||||
let next = async () => {
|
||||
if (nextIsRunning) {
|
||||
return
|
||||
}
|
||||
nextIsRunning = true
|
||||
if (running < concurrency) {
|
||||
const cursor = await it.next()
|
||||
if (cursor.done) {
|
||||
next = () => {
|
||||
if (running === 0) {
|
||||
if (errors.length === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new AggregateError(errors))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
++running
|
||||
try {
|
||||
const result = iteratee.call(this, cursor.value, index++, iterable)
|
||||
let then
|
||||
if (result != null && typeof result === 'object' && typeof (then = result.then) === 'function') {
|
||||
then.call(result, onFulfilledWrapper, onRejectedWrapper)
|
||||
} else {
|
||||
onFulfilled(result)
|
||||
}
|
||||
} catch (error) {
|
||||
onRejected(error)
|
||||
}
|
||||
}
|
||||
nextIsRunning = false
|
||||
return next()
|
||||
}
|
||||
nextIsRunning = false
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { asyncEach } = require('./')
|
||||
|
||||
const randomDelay = (max = 10) =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, Math.floor(Math.random() * max + 1))
|
||||
})
|
||||
|
||||
const rejectionOf = p =>
|
||||
new Promise((resolve, reject) => {
|
||||
p.then(reject, resolve)
|
||||
})
|
||||
|
||||
describe('asyncEach', () => {
|
||||
const thisArg = 'qux'
|
||||
const values = ['foo', 'bar', 'baz']
|
||||
|
||||
Object.entries({
|
||||
'sync iterable': () => values,
|
||||
'async iterable': async function* () {
|
||||
for (const value of values) {
|
||||
await randomDelay()
|
||||
yield value
|
||||
}
|
||||
},
|
||||
}).forEach(([what, getIterable]) =>
|
||||
describe('with ' + what, () => {
|
||||
let iterable
|
||||
beforeEach(() => {
|
||||
iterable = getIterable()
|
||||
})
|
||||
|
||||
it('works', async () => {
|
||||
const iteratee = jest.fn(async () => {})
|
||||
|
||||
await asyncEach.call(thisArg, iterable, iteratee)
|
||||
|
||||
expect(iteratee.mock.instances).toEqual(Array.from(values, () => thisArg))
|
||||
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
|
||||
})
|
||||
;[1, 2, 4].forEach(concurrency => {
|
||||
it('respects a concurrency of ' + concurrency, async () => {
|
||||
let running = 0
|
||||
|
||||
await asyncEach(
|
||||
values,
|
||||
async () => {
|
||||
++running
|
||||
expect(running).toBeLessThanOrEqual(concurrency)
|
||||
await randomDelay()
|
||||
--running
|
||||
},
|
||||
{ concurrency }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('stops on first error when stopOnError is true', async () => {
|
||||
const error = new Error()
|
||||
const iteratee = jest.fn((_, i) => {
|
||||
if (i === 1) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
expect(await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: true }))).toBe(error)
|
||||
expect(iteratee).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects AggregateError when stopOnError is false', async () => {
|
||||
const errors = []
|
||||
const iteratee = jest.fn(() => {
|
||||
const error = new Error()
|
||||
errors.push(error)
|
||||
throw error
|
||||
})
|
||||
|
||||
const error = await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: false }))
|
||||
expect(error.errors).toEqual(errors)
|
||||
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
|
||||
})
|
||||
|
||||
it('can be interrupted with an AbortSignal', async () => {
|
||||
const ac = new AbortController()
|
||||
const iteratee = jest.fn((_, i) => {
|
||||
if (i === 1) {
|
||||
ac.abort()
|
||||
}
|
||||
})
|
||||
|
||||
await expect(asyncEach(iterable, iteratee, { signal: ac.signal })).rejects.toThrow('asyncEach aborted')
|
||||
expect(iteratee).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/async-each",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/async-each",
|
||||
"description": "Run async fn for each item in (async) iterable",
|
||||
"keywords": [
|
||||
"array",
|
||||
"async",
|
||||
"collection",
|
||||
"each",
|
||||
"for",
|
||||
"foreach",
|
||||
"iterable",
|
||||
"iterator"
|
||||
],
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/async-each",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
}
|
||||
}
|
||||
@@ -65,23 +65,6 @@ const f = compose(
|
||||
)
|
||||
```
|
||||
|
||||
Functions can receive extra parameters:
|
||||
|
||||
```js
|
||||
const isIn = (value, min, max) => min <= value && value <= max
|
||||
|
||||
// Only compatible when `fns` is passed as an array!
|
||||
const f = compose([
|
||||
[add, 2],
|
||||
[isIn, 3, 10],
|
||||
])
|
||||
|
||||
console.log(f(1))
|
||||
// → true
|
||||
```
|
||||
|
||||
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -46,20 +46,3 @@ const f = compose(
|
||||
[add2, mul3]
|
||||
)
|
||||
```
|
||||
|
||||
Functions can receive extra parameters:
|
||||
|
||||
```js
|
||||
const isIn = (value, min, max) => min <= value && value <= max
|
||||
|
||||
// Only compatible when `fns` is passed as an array!
|
||||
const f = compose([
|
||||
[add, 2],
|
||||
[isIn, 3, 10],
|
||||
])
|
||||
|
||||
console.log(f(1))
|
||||
// → true
|
||||
```
|
||||
|
||||
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.
|
||||
|
||||
@@ -4,13 +4,11 @@ const defaultOpts = { async: false, right: false }
|
||||
|
||||
exports.compose = function compose(opts, fns) {
|
||||
if (Array.isArray(opts)) {
|
||||
fns = opts.slice() // don't mutate passed array
|
||||
fns = opts
|
||||
opts = defaultOpts
|
||||
} else if (typeof opts === 'object') {
|
||||
opts = Object.assign({}, defaultOpts, opts)
|
||||
if (Array.isArray(fns)) {
|
||||
fns = fns.slice() // don't mutate passed array
|
||||
} else {
|
||||
if (!Array.isArray(fns)) {
|
||||
fns = Array.prototype.slice.call(arguments, 1)
|
||||
}
|
||||
} else {
|
||||
@@ -22,24 +20,6 @@ exports.compose = function compose(opts, fns) {
|
||||
if (n === 0) {
|
||||
throw new TypeError('at least one function must be passed')
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const entry = fns[i]
|
||||
if (Array.isArray(entry)) {
|
||||
const fn = entry[0]
|
||||
const args = entry.slice()
|
||||
args[0] = undefined
|
||||
fns[i] = function composeWithArgs(value) {
|
||||
args[0] = value
|
||||
try {
|
||||
return fn.apply(this, args)
|
||||
} finally {
|
||||
args[0] = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (n === 1) {
|
||||
return fns[0]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "2.1.0",
|
||||
"version": "2.0.0",
|
||||
"engines": {
|
||||
"node": ">=7.6"
|
||||
},
|
||||
|
||||
@@ -59,17 +59,6 @@ decorateMethodsWith(Foo, {
|
||||
|
||||
The decorated class is returned, so you can export it directly.
|
||||
|
||||
To apply multiple transforms to a method, you can either call `decorateMethodsWith` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
|
||||
|
||||
```js
|
||||
decorateMethodsWith(Foo, {
|
||||
bar: compose([
|
||||
[lodash.debounce, 150]
|
||||
lodash.curry,
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -40,14 +40,3 @@ decorateMethodsWith(Foo, {
|
||||
```
|
||||
|
||||
The decorated class is returned, so you can export it directly.
|
||||
|
||||
To apply multiple transforms to a method, you can either call `decorateMethodsWith` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
|
||||
|
||||
```js
|
||||
decorateMethodsWith(Foo, {
|
||||
bar: compose([
|
||||
[lodash.debounce, 150]
|
||||
lodash.curry,
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@ module.exports = function (pkg, configs = {}) {
|
||||
|
||||
return {
|
||||
comments: !__PROD__,
|
||||
ignore: __PROD__ ? [/\btests?\//, /\.spec\.js$/] : undefined,
|
||||
ignore: __PROD__ ? [/\.spec\.js$/] : undefined,
|
||||
plugins: Object.keys(plugins)
|
||||
.map(plugin => [plugin, plugins[plugin]])
|
||||
.sort(([a], [b]) => {
|
||||
@@ -56,14 +56,22 @@ module.exports = function (pkg, configs = {}) {
|
||||
}),
|
||||
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
|
||||
targets: (() => {
|
||||
const targets = {}
|
||||
|
||||
if (pkg.browserslist !== undefined) {
|
||||
targets.browsers = pkg.browserslist
|
||||
}
|
||||
|
||||
let node = (pkg.engines || {}).node
|
||||
if (node !== undefined) {
|
||||
const trimChars = '^=>~'
|
||||
while (trimChars.includes(node[0])) {
|
||||
node = node.slice(1)
|
||||
}
|
||||
targets.node = node
|
||||
}
|
||||
return { browsers: pkg.browserslist, node }
|
||||
|
||||
return targets
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.16.2",
|
||||
"@xen-orchestra/fs": "^0.19.2",
|
||||
"@xen-orchestra/backups": "^0.13.0",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.20.0"
|
||||
"promise-toolbox": "^0.19.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.10.1"
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -3,19 +3,19 @@ const Disposable = require('promise-toolbox/Disposable.js')
|
||||
const fromCallback = require('promise-toolbox/fromCallback.js')
|
||||
const fromEvent = require('promise-toolbox/fromEvent.js')
|
||||
const pDefer = require('promise-toolbox/defer.js')
|
||||
const { dirname, join, normalize, resolve } = require('path')
|
||||
const pump = require('pump')
|
||||
const { basename, dirname, join, normalize, resolve } = require('path')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdSynthetic } = require('vhd-lib')
|
||||
const { createSyntheticStream, mergeVhd, default: Vhd } = require('vhd-lib')
|
||||
const { deduped } = require('@vates/disposable/deduped.js')
|
||||
const { execFile } = require('child_process')
|
||||
const { readdir, stat } = require('fs-extra')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const { ZipFile } = require('yazl')
|
||||
|
||||
const { BACKUP_DIR } = require('./_getVmBackupDir.js')
|
||||
const { cleanVm } = require('./_cleanVm.js')
|
||||
const { getTmpDir } = require('./_getTmpDir.js')
|
||||
const { isMetadataFile } = require('./_backupType.js')
|
||||
const { isMetadataFile, isVhdFile } = require('./_backupType.js')
|
||||
const { isValidXva } = require('./_isValidXva.js')
|
||||
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
|
||||
const { lvs, pvs } = require('./_lvm.js')
|
||||
@@ -77,6 +77,48 @@ class RemoteAdapter {
|
||||
return this._handler
|
||||
}
|
||||
|
||||
async _deleteVhd(path) {
|
||||
const handler = this._handler
|
||||
const vhds = await asyncMapSettled(
|
||||
await handler.list(dirname(path), {
|
||||
filter: isVhdFile,
|
||||
prependDir: true,
|
||||
}),
|
||||
async path => {
|
||||
try {
|
||||
const vhd = new Vhd(handler, path)
|
||||
await vhd.readHeaderAndFooter()
|
||||
return {
|
||||
footer: vhd.footer,
|
||||
header: vhd.header,
|
||||
path,
|
||||
}
|
||||
} catch (error) {
|
||||
// Do not fail on corrupted VHDs (usually uncleaned temporary files),
|
||||
// they are probably inconsequent to the backup process and should not
|
||||
// fail it.
|
||||
warn(`BackupNg#_deleteVhd ${path}`, { error })
|
||||
}
|
||||
}
|
||||
)
|
||||
const base = basename(path)
|
||||
const child = vhds.find(_ => _ !== undefined && _.header.parentUnicodeName === base)
|
||||
if (child === undefined) {
|
||||
await handler.unlink(path)
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const childPath = child.path
|
||||
const mergedDataSize = await mergeVhd(handler, path, handler, childPath)
|
||||
await handler.rename(path, childPath)
|
||||
return mergedDataSize
|
||||
} catch (error) {
|
||||
handler.unlink(path).catch(warn)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async _findPartition(devicePath, partitionId) {
|
||||
const partitions = await listPartitions(devicePath)
|
||||
const partition = partitions.find(_ => _.id === partitionId)
|
||||
@@ -211,9 +253,16 @@ class RemoteAdapter {
|
||||
|
||||
async deleteDeltaVmBackups(backups) {
|
||||
const handler = this._handler
|
||||
|
||||
// unused VHDs will be detected by `cleanVm`
|
||||
await asyncMapSettled(backups, ({ _filename }) => VhdAbstract.unlink(handler, _filename))
|
||||
let mergedDataSize = 0
|
||||
await asyncMapSettled(backups, ({ _filename, vhds }) =>
|
||||
Promise.all([
|
||||
handler.unlink(_filename),
|
||||
asyncMap(Object.values(vhds), async _ => {
|
||||
mergedDataSize += await this._deleteVhd(resolveRelativeFromFile(_filename, _))
|
||||
}),
|
||||
])
|
||||
)
|
||||
return mergedDataSize
|
||||
}
|
||||
|
||||
async deleteMetadataBackup(backupId) {
|
||||
@@ -312,17 +361,6 @@ class RemoteAdapter {
|
||||
return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
|
||||
}
|
||||
|
||||
// this function will be the one where we plug the logic of the storage format by fs type/user settings
|
||||
|
||||
// if the file is named .vhd => vhd
|
||||
// if the file is named alias.vhd => alias to a vhd
|
||||
getVhdFileName(baseName) {
|
||||
if (this._handler.type === 's3') {
|
||||
return `${baseName}.alias.vhd` // we want an alias to a vhddirectory
|
||||
}
|
||||
return `${baseName}.vhd`
|
||||
}
|
||||
|
||||
async listAllVmBackups() {
|
||||
const handler = this._handler
|
||||
|
||||
@@ -467,24 +505,6 @@ class RemoteAdapter {
|
||||
return backups.sort(compareTimestamp)
|
||||
}
|
||||
|
||||
async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
|
||||
const handler = this._handler
|
||||
|
||||
if (path.endsWith('.alias.vhd')) {
|
||||
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
||||
await createVhdDirectoryFromStream(handler, dataPath, input, {
|
||||
concurrency: 16,
|
||||
async validator() {
|
||||
await input.task
|
||||
return validator.apply(this, arguments)
|
||||
},
|
||||
})
|
||||
await VhdAbstract.createAlias(handler, path, dataPath)
|
||||
} else {
|
||||
await this.outputStream(path, input, { checksum, validator })
|
||||
}
|
||||
}
|
||||
|
||||
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
|
||||
await this._handler.outputStream(path, input, {
|
||||
checksum,
|
||||
@@ -496,52 +516,6 @@ class RemoteAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
async _createSyntheticStream(handler, paths) {
|
||||
let disposableVhds = []
|
||||
|
||||
// if it's a path : open all hierarchy of parent
|
||||
if (typeof paths === 'string') {
|
||||
let vhd,
|
||||
vhdPath = paths
|
||||
do {
|
||||
const disposable = await openVhd(handler, vhdPath)
|
||||
vhd = disposable.value
|
||||
disposableVhds.push(disposable)
|
||||
vhdPath = resolveRelativeFromFile(vhdPath, vhd.header.parentUnicodeName)
|
||||
} while (vhd.footer.diskType !== Constants.DISK_TYPES.DYNAMIC)
|
||||
} else {
|
||||
// only open the list of path given
|
||||
disposableVhds = paths.map(path => openVhd(handler, path))
|
||||
}
|
||||
|
||||
// I don't want the vhds to be disposed on return
|
||||
// but only when the stream is done ( or failed )
|
||||
const disposables = await Disposable.all(disposableVhds)
|
||||
const vhds = disposables.value
|
||||
|
||||
let disposed = false
|
||||
const disposeOnce = async () => {
|
||||
if (!disposed) {
|
||||
disposed = true
|
||||
|
||||
try {
|
||||
await disposables.dispose()
|
||||
} catch (error) {
|
||||
warn('_createSyntheticStream: failed to dispose VHDs', { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const synthetic = new VhdSynthetic(vhds)
|
||||
await synthetic.readHeaderAndFooter()
|
||||
await synthetic.readBlockAllocationTable()
|
||||
const stream = await synthetic.stream()
|
||||
stream.on('end', disposeOnce)
|
||||
stream.on('close', disposeOnce)
|
||||
stream.on('error', disposeOnce)
|
||||
return stream
|
||||
}
|
||||
|
||||
async readDeltaVmBackup(metadata) {
|
||||
const handler = this._handler
|
||||
const { vbds, vdis, vhds, vifs, vm } = metadata
|
||||
@@ -549,7 +523,7 @@ class RemoteAdapter {
|
||||
|
||||
const streams = {}
|
||||
await asyncMapSettled(Object.keys(vdis), async id => {
|
||||
streams[`${id}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[id]))
|
||||
streams[`${id}.vhd`] = await createSyntheticStream(handler, join(dir, vhds[id]))
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const assert = require('assert')
|
||||
const findLast = require('lodash/findLast.js')
|
||||
const groupBy = require('lodash/groupBy.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const keyBy = require('lodash/keyBy.js')
|
||||
const mapValues = require('lodash/mapValues.js')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { defer } = require('golike-defer')
|
||||
const { formatDateTime } = require('@xen-orchestra/xapi')
|
||||
@@ -36,11 +35,6 @@ const forkDeltaExport = deltaExport =>
|
||||
|
||||
exports.VmBackup = class VmBackup {
|
||||
constructor({ config, getSnapshotNameLabel, job, remoteAdapters, remotes, schedule, settings, srs, vm }) {
|
||||
if (vm.other_config['xo:backup:job'] === job.id) {
|
||||
// otherwise replicated VMs would be matched and replicated again and again
|
||||
throw new Error('cannot backup a VM created by this very job')
|
||||
}
|
||||
|
||||
this.config = config
|
||||
this.job = job
|
||||
this.remoteAdapters = remoteAdapters
|
||||
@@ -290,28 +284,17 @@ exports.VmBackup = class VmBackup {
|
||||
}
|
||||
|
||||
async _removeUnusedSnapshots() {
|
||||
const jobSettings = this.job.settings
|
||||
const baseVmRef = this._baseVm?.$ref
|
||||
const { config } = this
|
||||
const baseSettings = {
|
||||
...config.defaultSettings,
|
||||
...config.metadata.defaultSettings,
|
||||
...jobSettings[''],
|
||||
}
|
||||
// TODO: handle all schedules (no longer existing schedules default to 0 retention)
|
||||
|
||||
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
|
||||
const { scheduleId } = this
|
||||
const scheduleSnapshots = this._jobSnapshots.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
|
||||
|
||||
const baseVmRef = this._baseVm?.$ref
|
||||
const xapi = this._xapi
|
||||
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
|
||||
const settings = {
|
||||
...baseSettings,
|
||||
...jobSettings[scheduleId],
|
||||
...jobSettings[this.vm.uuid],
|
||||
await asyncMap(getOldEntries(this._settings.snapshotRetention, scheduleSnapshots), ({ $ref }) => {
|
||||
if ($ref !== baseVmRef) {
|
||||
return xapi.VM_destroy($ref)
|
||||
}
|
||||
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
|
||||
if ($ref !== baseVmRef) {
|
||||
return xapi.VM_destroy($ref)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,14 +303,12 @@ exports.VmBackup = class VmBackup {
|
||||
|
||||
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
|
||||
if (baseVm === undefined) {
|
||||
debug('no base VM found')
|
||||
return
|
||||
}
|
||||
|
||||
const fullInterval = this._settings.fullInterval
|
||||
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
|
||||
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
|
||||
debug('not using base VM becaust fullInterval reached')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -338,17 +319,10 @@ exports.VmBackup = class VmBackup {
|
||||
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
await asyncMap(await baseVm.$getDisks(), async baseRef => {
|
||||
const [baseUuid, snapshotOf] = await Promise.all([
|
||||
xapi.getField('VDI', baseRef, 'uuid'),
|
||||
xapi.getField('VDI', baseRef, 'snapshot_of'),
|
||||
])
|
||||
const snapshotOf = await xapi.getField('VDI', baseRef, 'snapshot_of')
|
||||
const srcVdi = srcVdis[snapshotOf]
|
||||
if (srcVdi !== undefined) {
|
||||
baseUuidToSrcVdi.set(baseUuid, srcVdi)
|
||||
} else {
|
||||
debug('ignore snapshot VDI because no longer present on VM', {
|
||||
vdi: baseUuid,
|
||||
})
|
||||
baseUuidToSrcVdi.set(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -359,23 +333,9 @@ exports.VmBackup = class VmBackup {
|
||||
false
|
||||
)
|
||||
|
||||
if (presentBaseVdis.size === 0) {
|
||||
debug('no base VM found')
|
||||
return
|
||||
}
|
||||
|
||||
const fullVdisRequired = new Set()
|
||||
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
|
||||
if (presentBaseVdis.has(baseUuid)) {
|
||||
debug('found base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdi.uuid,
|
||||
})
|
||||
} else {
|
||||
debug('missing base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdi.uuid,
|
||||
})
|
||||
if (!presentBaseVdis.has(baseUuid)) {
|
||||
fullVdisRequired.add(srcVdi.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const rimraf = require('rimraf')
|
||||
const tmp = require('tmp')
|
||||
const fs = require('fs-extra')
|
||||
const { getHandler } = require('@xen-orchestra/fs')
|
||||
const { pFromCallback } = require('promise-toolbox')
|
||||
const crypto = require('crypto')
|
||||
const { RemoteAdapter } = require('./RemoteAdapter')
|
||||
const { VHDFOOTER, VHDHEADER } = require('./tests.fixtures.js')
|
||||
const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
|
||||
|
||||
let tempDir, adapter, handler, jobId, vdiId, basePath
|
||||
|
||||
jest.setTimeout(60000)
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await pFromCallback(cb => tmp.dir(cb))
|
||||
handler = getHandler({ url: `file://${tempDir}` })
|
||||
await handler.sync()
|
||||
adapter = new RemoteAdapter(handler)
|
||||
jobId = uniqueId()
|
||||
vdiId = uniqueId()
|
||||
basePath = `vdis/${jobId}/${vdiId}`
|
||||
await fs.mkdirp(`${tempDir}/${basePath}`)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await pFromCallback(cb => rimraf(tempDir, cb))
|
||||
await handler.forget()
|
||||
})
|
||||
|
||||
const uniqueId = () => crypto.randomBytes(16).toString('hex')
|
||||
|
||||
async function generateVhd(path, opts = {}) {
|
||||
let vhd
|
||||
|
||||
const dataPath = opts.useAlias ? path + '.data' : path
|
||||
if (opts.mode === 'directory') {
|
||||
await handler.mkdir(dataPath)
|
||||
vhd = new VhdDirectory(handler, dataPath)
|
||||
} else {
|
||||
const fd = await handler.openFile(dataPath, 'wx')
|
||||
vhd = new VhdFile(handler, fd)
|
||||
}
|
||||
|
||||
vhd.header = { ...VHDHEADER, ...opts.header }
|
||||
vhd.footer = { ...VHDFOOTER, ...opts.footer }
|
||||
vhd.footer.uuid = Buffer.from(crypto.randomBytes(16))
|
||||
|
||||
if (vhd.header.parentUnicodeName) {
|
||||
vhd.footer.diskType = Constants.DISK_TYPES.DIFFERENCING
|
||||
} else {
|
||||
vhd.footer.diskType = Constants.DISK_TYPES.DYNAMIC
|
||||
}
|
||||
|
||||
if (opts.useAlias === true) {
|
||||
await VhdAbstract.createAlias(handler, path + '.alias.vhd', dataPath)
|
||||
}
|
||||
|
||||
await vhd.writeBlockAllocationTable()
|
||||
await vhd.writeHeader()
|
||||
await vhd.writeFooter()
|
||||
return vhd
|
||||
}
|
||||
|
||||
test('It remove broken vhd', async () => {
|
||||
// todo also tests a directory and an alias
|
||||
|
||||
await handler.writeFile(`${basePath}/notReallyAVhd.vhd`, 'I AM NOT A VHD')
|
||||
expect((await handler.list(basePath)).length).toEqual(1)
|
||||
let loggued = ''
|
||||
const onLog = message => {
|
||||
loggued += message
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: false, onLog })
|
||||
expect(loggued).toEqual(`error while checking the VHD with path /${basePath}/notReallyAVhd.vhd`)
|
||||
// not removed
|
||||
expect((await handler.list(basePath)).length).toEqual(1)
|
||||
// really remove it
|
||||
await adapter.cleanVm('/', { remove: true, onLog })
|
||||
expect((await handler.list(basePath)).length).toEqual(0)
|
||||
})
|
||||
|
||||
test('it remove vhd with missing or multiple ancestors', async () => {
|
||||
// one with a broken parent
|
||||
await generateVhd(`${basePath}/abandonned.vhd`, {
|
||||
header: {
|
||||
parentUnicodeName: 'gone.vhd',
|
||||
parentUid: Buffer.from(crypto.randomBytes(16)),
|
||||
},
|
||||
})
|
||||
|
||||
// one orphan, which is a full vhd, no parent
|
||||
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
|
||||
// a child to the orphan
|
||||
await generateVhd(`${basePath}/child.vhd`, {
|
||||
header: {
|
||||
parentUnicodeName: 'orphan.vhd',
|
||||
parentUid: orphan.footer.uuid,
|
||||
},
|
||||
})
|
||||
|
||||
// clean
|
||||
let loggued = ''
|
||||
const onLog = message => {
|
||||
loggued += message + '\n'
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, onLog })
|
||||
|
||||
const deletedOrphanVhd = loggued.match(/deleting orphan VHD/g) || []
|
||||
expect(deletedOrphanVhd.length).toEqual(1) // only one vhd should have been deleted
|
||||
const deletedAbandonnedVhd = loggued.match(/abandonned.vhd is missing/g) || []
|
||||
expect(deletedAbandonnedVhd.length).toEqual(1) // and it must be abandonned.vhd
|
||||
|
||||
// we don't test the filew on disk, since they will all be marker as unused and deleted without a metadata.json file
|
||||
})
|
||||
|
||||
test('it remove backup meta data referencing a missing vhd in delta backup', async () => {
|
||||
// create a metadata file marking child and orphan as ok
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
vhds: [
|
||||
`${basePath}/orphan.vhd`,
|
||||
`${basePath}/child.vhd`,
|
||||
// abandonned.json is not here
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
await generateVhd(`${basePath}/abandonned.vhd`)
|
||||
|
||||
// one orphan, which is a full vhd, no parent
|
||||
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
|
||||
|
||||
// a child to the orphan
|
||||
await generateVhd(`${basePath}/child.vhd`, {
|
||||
header: {
|
||||
parentUnicodeName: 'orphan.vhd',
|
||||
parentUid: orphan.footer.uuid,
|
||||
},
|
||||
})
|
||||
|
||||
let loggued = ''
|
||||
const onLog = message => {
|
||||
loggued += message + '\n'
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, onLog })
|
||||
let matched = loggued.match(/deleting unused VHD /g) || []
|
||||
expect(matched.length).toEqual(1) // only one vhd should have been deleted
|
||||
matched = loggued.match(/abandonned.vhd is unused/g) || []
|
||||
expect(matched.length).toEqual(1) // and it must be abandonned.vhd
|
||||
|
||||
// a missing vhd cause clean to remove all vhds
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
vhds: [
|
||||
`${basePath}/deleted.vhd`, // in metadata but not in vhds
|
||||
`${basePath}/orphan.vhd`,
|
||||
`${basePath}/child.vhd`,
|
||||
// abandonned.json is not here
|
||||
],
|
||||
}),
|
||||
{ flags: 'w' }
|
||||
)
|
||||
loggued = ''
|
||||
await adapter.cleanVm('/', { remove: true, onLog })
|
||||
matched = loggued.match(/deleting unused VHD /g) || []
|
||||
expect(matched.length).toEqual(2) // all vhds (orphan and child ) should have been deleted
|
||||
})
|
||||
|
||||
test('it merges delta of non destroyed chain', async () => {
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
size: 209920,
|
||||
vhds: [
|
||||
`${basePath}/grandchild.vhd`, // grand child should not be merged
|
||||
`${basePath}/child.vhd`,
|
||||
// orphan is not here, he should be merged in child
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// one orphan, which is a full vhd, no parent
|
||||
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
|
||||
// a child to the orphan
|
||||
const child = await generateVhd(`${basePath}/child.vhd`, {
|
||||
header: {
|
||||
parentUnicodeName: 'orphan.vhd',
|
||||
parentUid: orphan.footer.uuid,
|
||||
},
|
||||
})
|
||||
// a grand child
|
||||
await generateVhd(`${basePath}/grandchild.vhd`, {
|
||||
header: {
|
||||
parentUnicodeName: 'child.vhd',
|
||||
parentUid: child.footer.uuid,
|
||||
},
|
||||
})
|
||||
|
||||
let loggued = ''
|
||||
const onLog = message => {
|
||||
loggued += message + '\n'
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, onLog })
|
||||
expect(loggued).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused\n`)
|
||||
loggued = ''
|
||||
await adapter.cleanVm('/', { remove: true, merge: true, onLog })
|
||||
const [unused, merging] = loggued.split('\n')
|
||||
expect(unused).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused`)
|
||||
expect(merging).toEqual(`merging /${basePath}/child.vhd into /${basePath}/orphan.vhd`)
|
||||
|
||||
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
|
||||
|
||||
// only check deletion
|
||||
const remainingVhds = await handler.list(basePath)
|
||||
expect(remainingVhds.length).toEqual(2)
|
||||
expect(remainingVhds.includes('child.vhd')).toEqual(true)
|
||||
expect(remainingVhds.includes('grandchild.vhd')).toEqual(true)
|
||||
})
|
||||
|
||||
test('it finish unterminated merge ', async () => {
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
size: 209920,
|
||||
vhds: [
|
||||
`${basePath}/orphan.vhd`, // grand child should not be merged
|
||||
`${basePath}/child.vhd`,
|
||||
// orphan is not here, he should be merged in child
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// one orphan, which is a full vhd, no parent
|
||||
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
|
||||
// a child to the orphan
|
||||
const child = await generateVhd(`${basePath}/child.vhd`, {
|
||||
header: {
|
||||
parentUnicodeName: 'orphan.vhd',
|
||||
parentUid: orphan.footer.uuid,
|
||||
},
|
||||
})
|
||||
// a merge in progress file
|
||||
await handler.writeFile(
|
||||
`${basePath}/.orphan.vhd.merge.json`,
|
||||
JSON.stringify({
|
||||
parent: {
|
||||
header: orphan.header.checksum,
|
||||
},
|
||||
child: {
|
||||
header: child.header.checksum,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// a unfinished merging
|
||||
await adapter.cleanVm('/', { remove: true, merge: true })
|
||||
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
|
||||
|
||||
// only check deletion
|
||||
const remainingVhds = await handler.list(basePath)
|
||||
expect(remainingVhds.length).toEqual(1)
|
||||
expect(remainingVhds.includes('child.vhd')).toEqual(true)
|
||||
})
|
||||
|
||||
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
|
||||
// the message an resulting files should be identical to the output with vhd files which is tested independantly
|
||||
|
||||
describe('tests mulitple combination ', () => {
|
||||
for (const useAlias of [true, false]) {
|
||||
for (const vhdMode of ['file', 'directory']) {
|
||||
test(`alias : ${useAlias}, mode: ${vhdMode}`, async () => {
|
||||
// a broken VHD
|
||||
const brokenVhdDataPath = basePath + useAlias ? 'broken.data' : 'broken.vhd'
|
||||
if (vhdMode === 'directory') {
|
||||
await handler.mkdir(brokenVhdDataPath)
|
||||
} else {
|
||||
await handler.writeFile(brokenVhdDataPath, 'notreallyavhd')
|
||||
}
|
||||
if (useAlias) {
|
||||
await VhdAbstract.createAlias(handler, 'broken.alias.vhd', brokenVhdDataPath)
|
||||
}
|
||||
|
||||
// a vhd non referenced in metada
|
||||
await generateVhd(`${basePath}/nonreference.vhd`, { useAlias, mode: vhdMode })
|
||||
// an abandonded delta vhd without its parent
|
||||
await generateVhd(`${basePath}/abandonned.vhd`, {
|
||||
useAlias,
|
||||
mode: vhdMode,
|
||||
header: {
|
||||
parentUnicodeName: 'gone.vhd',
|
||||
parentUid: crypto.randomBytes(16),
|
||||
},
|
||||
})
|
||||
// an ancestor of a vhd present in metadata
|
||||
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, {
|
||||
useAlias,
|
||||
mode: vhdMode,
|
||||
})
|
||||
const child = await generateVhd(`${basePath}/child.vhd`, {
|
||||
useAlias,
|
||||
mode: vhdMode,
|
||||
header: {
|
||||
parentUnicodeName: 'ancestor.vhd' + (useAlias ? '.alias.vhd' : ''),
|
||||
parentUid: ancestor.footer.uuid,
|
||||
},
|
||||
})
|
||||
// a grand child vhd in metadata
|
||||
await generateVhd(`${basePath}/grandchild.vhd`, {
|
||||
useAlias,
|
||||
mode: vhdMode,
|
||||
header: {
|
||||
parentUnicodeName: 'child.vhd' + (useAlias ? '.alias.vhd' : ''),
|
||||
parentUid: child.footer.uuid,
|
||||
},
|
||||
})
|
||||
|
||||
// an older parent that was merging in clean
|
||||
const cleanAncestor = await generateVhd(`${basePath}/cleanAncestor.vhd`, {
|
||||
useAlias,
|
||||
mode: vhdMode,
|
||||
})
|
||||
// a clean vhd in metadata
|
||||
const clean = await generateVhd(`${basePath}/clean.vhd`, {
|
||||
useAlias,
|
||||
mode: vhdMode,
|
||||
header: {
|
||||
parentUnicodeName: 'cleanAncestor.vhd' + (useAlias ? '.alias.vhd' : ''),
|
||||
parentUid: cleanAncestor.footer.uuid,
|
||||
},
|
||||
})
|
||||
|
||||
await handler.writeFile(
|
||||
`${basePath}/.cleanAncestor.vhd${useAlias ? '.alias.vhd' : ''}.merge.json`,
|
||||
JSON.stringify({
|
||||
parent: {
|
||||
header: cleanAncestor.header.checksum,
|
||||
},
|
||||
child: {
|
||||
header: clean.header.checksum,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// the metadata file
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
vhds: [
|
||||
`${basePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
|
||||
`${basePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
|
||||
`${basePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
|
||||
],
|
||||
})
|
||||
)
|
||||
await adapter.cleanVm('/', { remove: true, merge: true })
|
||||
|
||||
// broken vhd, non referenced, abandonned should be deleted ( alias and data)
|
||||
// ancestor and child should be merged
|
||||
// grand child and clean vhd should not have changed
|
||||
const survivors = await handler.list(basePath)
|
||||
// console.log(survivors)
|
||||
if (useAlias) {
|
||||
// the goal of the alias : do not move a full folder
|
||||
expect(survivors).toContain('ancestor.vhd.data')
|
||||
expect(survivors).toContain('grandchild.vhd.data')
|
||||
expect(survivors).toContain('cleanAncestor.vhd.data')
|
||||
expect(survivors).toContain('clean.vhd.alias.vhd')
|
||||
expect(survivors).toContain('child.vhd.alias.vhd')
|
||||
expect(survivors).toContain('grandchild.vhd.alias.vhd')
|
||||
expect(survivors.length).toEqual(6)
|
||||
} else {
|
||||
expect(survivors).toContain('clean.vhd')
|
||||
expect(survivors).toContain('child.vhd')
|
||||
expect(survivors).toContain('grandchild.vhd')
|
||||
expect(survivors.length).toEqual(3)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,20 +1,17 @@
|
||||
const assert = require('assert')
|
||||
const sum = require('lodash/sum')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
|
||||
const { default: Vhd, mergeVhd } = require('vhd-lib')
|
||||
const { dirname, resolve } = require('path')
|
||||
const { DISK_TYPES } = Constants
|
||||
const { DISK_TYPE_DIFFERENCING } = require('vhd-lib/dist/_constants.js')
|
||||
const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
|
||||
const { limitConcurrency } = require('limit-concurrency-decorator')
|
||||
|
||||
const { Task } = require('./Task.js')
|
||||
const { Disposable } = require('promise-toolbox')
|
||||
|
||||
// chain is an array of VHDs from child to parent
|
||||
//
|
||||
// the whole chain will be merged into parent, parent will be renamed to child
|
||||
// and all the others will deleted
|
||||
async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
assert(chain.length >= 2)
|
||||
|
||||
let child = chain[0]
|
||||
@@ -47,7 +44,7 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
}
|
||||
}, 10e3)
|
||||
|
||||
const mergedSize = await mergeVhd(
|
||||
await mergeVhd(
|
||||
handler,
|
||||
parent,
|
||||
handler,
|
||||
@@ -66,19 +63,17 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
clearInterval(handle)
|
||||
|
||||
await Promise.all([
|
||||
VhdAbstract.rename(handler, parent, child),
|
||||
handler.rename(parent, child),
|
||||
asyncMap(children.slice(0, -1), child => {
|
||||
onLog(`the VHD ${child} is unused`)
|
||||
if (remove) {
|
||||
onLog(`deleting unused VHD ${child}`)
|
||||
return VhdAbstract.unlink(handler, child)
|
||||
return handler.unlink(child)
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
return mergedSize
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
@@ -119,14 +114,7 @@ const listVhds = async (handler, vmDir) => {
|
||||
return { vhds, interruptedVhds }
|
||||
}
|
||||
|
||||
const defaultMergeLimiter = limitConcurrency(1)
|
||||
|
||||
exports.cleanVm = async function cleanVm(
|
||||
vmDir,
|
||||
{ fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
|
||||
) {
|
||||
const limitedMergeVhdChain = mergeLimiter(mergeVhdChain)
|
||||
|
||||
exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, onLog = noop }) {
|
||||
const handler = this._handler
|
||||
|
||||
const vhds = new Set()
|
||||
@@ -138,55 +126,53 @@ exports.cleanVm = async function cleanVm(
|
||||
// remove broken VHDs
|
||||
await asyncMap(vhdsList.vhds, async path => {
|
||||
try {
|
||||
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !vhdsList.interruptedVhds.has(path) }), vhd => {
|
||||
vhds.add(path)
|
||||
if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
|
||||
const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
|
||||
vhdParents[path] = parent
|
||||
if (parent in vhdChildren) {
|
||||
const error = new Error('this script does not support multiple VHD children')
|
||||
error.parent = parent
|
||||
error.child1 = vhdChildren[parent]
|
||||
error.child2 = path
|
||||
throw error // should we throw?
|
||||
}
|
||||
vhdChildren[parent] = path
|
||||
const vhd = new Vhd(handler, path)
|
||||
await vhd.readHeaderAndFooter(!vhdsList.interruptedVhds.has(path))
|
||||
vhds.add(path)
|
||||
if (vhd.footer.diskType === DISK_TYPE_DIFFERENCING) {
|
||||
const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
|
||||
vhdParents[path] = parent
|
||||
if (parent in vhdChildren) {
|
||||
const error = new Error('this script does not support multiple VHD children')
|
||||
error.parent = parent
|
||||
error.child1 = vhdChildren[parent]
|
||||
error.child2 = path
|
||||
throw error // should we throw?
|
||||
}
|
||||
})
|
||||
vhdChildren[parent] = path
|
||||
}
|
||||
} catch (error) {
|
||||
onLog(`error while checking the VHD with path ${path}`, { error })
|
||||
if (error?.code === 'ERR_ASSERTION' && remove) {
|
||||
onLog(`deleting broken ${path}`)
|
||||
return VhdAbstract.unlink(handler, path)
|
||||
await handler.unlink(path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// @todo : add check for data folder of alias not referenced in a valid alias
|
||||
|
||||
// remove VHDs with missing ancestors
|
||||
{
|
||||
const deletions = []
|
||||
|
||||
// return true if the VHD has been deleted or is missing
|
||||
const deleteIfOrphan = vhdPath => {
|
||||
const parent = vhdParents[vhdPath]
|
||||
const deleteIfOrphan = vhd => {
|
||||
const parent = vhdParents[vhd]
|
||||
if (parent === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// no longer needs to be checked
|
||||
delete vhdParents[vhdPath]
|
||||
delete vhdParents[vhd]
|
||||
|
||||
deleteIfOrphan(parent)
|
||||
|
||||
if (!vhds.has(parent)) {
|
||||
vhds.delete(vhdPath)
|
||||
vhds.delete(vhd)
|
||||
|
||||
onLog(`the parent ${parent} of the VHD ${vhdPath} is missing`)
|
||||
onLog(`the parent ${parent} of the VHD ${vhd} is missing`)
|
||||
if (remove) {
|
||||
onLog(`deleting orphan VHD ${vhdPath}`)
|
||||
deletions.push(VhdAbstract.unlink(handler, vhdPath))
|
||||
onLog(`deleting orphan VHD ${vhd}`)
|
||||
deletions.push(handler.unlink(vhd))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,30 +243,16 @@ exports.cleanVm = async function cleanVm(
|
||||
return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
|
||||
})()
|
||||
|
||||
const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
|
||||
|
||||
// FIXME: find better approach by keeping as much of the backup as
|
||||
// possible (existing disks) even if one disk is missing
|
||||
if (missingVhds.length === 0) {
|
||||
if (linkedVhds.every(_ => vhds.has(_))) {
|
||||
linkedVhds.forEach(_ => unusedVhds.delete(_))
|
||||
|
||||
// checking the size of a vhd directory is costly
|
||||
// 1 Http Query per 1000 blocks
|
||||
// we only check size of all the vhd are VhdFiles
|
||||
|
||||
const shouldComputeSize = linkedVhds.every(vhd => vhd instanceof VhdFile)
|
||||
if (shouldComputeSize) {
|
||||
try {
|
||||
await Disposable.use(Disposable.all(linkedVhds.map(vhdPath => openVhd(handler, vhdPath))), async vhds => {
|
||||
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
|
||||
size = sum(sizes)
|
||||
})
|
||||
} catch (error) {
|
||||
onLog(`failed to get size of ${json}`, { error })
|
||||
}
|
||||
}
|
||||
size = await asyncMap(linkedVhds, vhd => handler.getSize(vhd)).then(sum, error => {
|
||||
onLog(`failed to get size of ${json}`, { error })
|
||||
})
|
||||
} else {
|
||||
onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
|
||||
onLog(`Some VHDs linked to the metadata ${json} are missing`)
|
||||
if (remove) {
|
||||
onLog(`deleting incomplete backup ${json}`)
|
||||
await handler.unlink(json)
|
||||
@@ -307,7 +279,6 @@ exports.cleanVm = async function cleanVm(
|
||||
|
||||
// TODO: parallelize by vm/job/vdi
|
||||
const unusedVhdsDeletion = []
|
||||
const toMerge = []
|
||||
{
|
||||
// VHD chains (as list from child to ancestor) to merge indexed by last
|
||||
// ancestor
|
||||
@@ -341,7 +312,7 @@ exports.cleanVm = async function cleanVm(
|
||||
onLog(`the VHD ${vhd} is unused`)
|
||||
if (remove) {
|
||||
onLog(`deleting unused VHD ${vhd}`)
|
||||
unusedVhdsDeletion.push(VhdAbstract.unlink(handler, vhd))
|
||||
unusedVhdsDeletion.push(handler.unlink(vhd))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,25 +321,22 @@ exports.cleanVm = async function cleanVm(
|
||||
})
|
||||
|
||||
// merge interrupted VHDs
|
||||
vhdsList.interruptedVhds.forEach(parent => {
|
||||
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
|
||||
})
|
||||
if (merge) {
|
||||
vhdsList.interruptedVhds.forEach(parent => {
|
||||
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
|
||||
})
|
||||
}
|
||||
|
||||
Object.values(vhdChainsToMerge).forEach(chain => {
|
||||
Object.keys(vhdChainsToMerge).forEach(key => {
|
||||
const chain = vhdChainsToMerge[key]
|
||||
if (chain !== undefined) {
|
||||
toMerge.push(chain)
|
||||
unusedVhdsDeletion.push(mergeVhdChain(chain, { handler, onLog, remove, merge }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const doMerge = () => {
|
||||
const promise = asyncMap(toMerge, async chain => limitedMergeVhdChain(chain, { handler, onLog, remove, merge }))
|
||||
return merge ? promise.then(sizes => ({ size: sum(sizes) })) : promise
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...unusedVhdsDeletion,
|
||||
toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : doMerge()),
|
||||
asyncMap(unusedXvas, path => {
|
||||
onLog(`the XVA ${path} is unused`)
|
||||
if (remove) {
|
||||
@@ -387,9 +355,4 @@ exports.cleanVm = async function cleanVm(
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
// boolean whether some VHDs were merged (or should be merged)
|
||||
merge: toMerge.length !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { getSyncedHandler } = require('@xen-orchestra/fs')
|
||||
const { join } = require('path')
|
||||
const Disposable = require('promise-toolbox/Disposable')
|
||||
const min = require('lodash/min')
|
||||
|
||||
const { getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
const { RemoteAdapter } = require('../RemoteAdapter.js')
|
||||
|
||||
const { CLEAN_VM_QUEUE } = require('./index.js')
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
catchGlobalErrors(createLogger('xo:backups:mergeWorker'))
|
||||
|
||||
const { fatal, info, warn } = createLogger('xo:backups:mergeWorker')
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const main = Disposable.wrap(async function* main(args) {
|
||||
const handler = yield getSyncedHandler({ url: 'file://' + process.cwd() })
|
||||
|
||||
yield handler.lock(CLEAN_VM_QUEUE)
|
||||
|
||||
const adapter = new RemoteAdapter(handler)
|
||||
|
||||
const listRetry = async () => {
|
||||
const timeoutResolver = resolve => setTimeout(resolve, 10e3)
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
const entries = await handler.list(CLEAN_VM_QUEUE)
|
||||
if (entries.length !== 0) {
|
||||
return entries
|
||||
}
|
||||
await new Promise(timeoutResolver)
|
||||
}
|
||||
}
|
||||
|
||||
let taskFiles
|
||||
while ((taskFiles = await listRetry()) !== undefined) {
|
||||
const taskFileBasename = min(taskFiles)
|
||||
const taskFile = join(CLEAN_VM_QUEUE, '_' + taskFileBasename)
|
||||
|
||||
// move this task to the end
|
||||
await handler.rename(join(CLEAN_VM_QUEUE, taskFileBasename), taskFile)
|
||||
try {
|
||||
const vmDir = getVmBackupDir(String(await handler.readFile(taskFile)))
|
||||
await adapter.cleanVm(vmDir, { merge: true, onLog: info, remove: true })
|
||||
|
||||
handler.unlink(taskFile).catch(error => warn('deleting task failure', { error }))
|
||||
} catch (error) {
|
||||
warn('failure handling task', { error })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
info('starting')
|
||||
main(process.argv.slice(2)).then(
|
||||
() => {
|
||||
info('bye :-)')
|
||||
},
|
||||
error => {
|
||||
fatal(error)
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
const { join, resolve } = require('path')
|
||||
const { spawn } = require('child_process')
|
||||
const { check } = require('proper-lockfile')
|
||||
|
||||
const CLEAN_VM_QUEUE = (exports.CLEAN_VM_QUEUE = '/xo-vm-backups/.queue/clean-vm/')
|
||||
|
||||
const CLI_PATH = resolve(__dirname, 'cli.js')
|
||||
exports.run = async function runMergeWorker(remotePath) {
|
||||
try {
|
||||
// TODO: find a way to pass the acquire the lock and then pass it down the worker
|
||||
if (await check(join(remotePath, CLEAN_VM_QUEUE))) {
|
||||
// already locked, don't start another worker
|
||||
return
|
||||
}
|
||||
|
||||
spawn(CLI_PATH, {
|
||||
cwd: remotePath,
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
}).unref()
|
||||
} catch (error) {
|
||||
// we usually don't want to throw if the merge worker failed to start
|
||||
return error
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.16.2",
|
||||
"version": "0.13.0",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
@@ -16,14 +16,14 @@
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/compose": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^0.19.2",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^4.0.1",
|
||||
"compare-versions": "^3.6.0",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"end-of-stream": "^1.4.4",
|
||||
"fs-extra": "^10.0.0",
|
||||
@@ -32,15 +32,13 @@
|
||||
"lodash": "^4.17.20",
|
||||
"node-zone": "^0.4.0",
|
||||
"parse-pairs": "^1.1.0",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"pump": "^3.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vhd-lib": "^2.0.3",
|
||||
"vhd-lib": "^1.2.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^0.8.4"
|
||||
"@xen-orchestra/xapi": "^0.7.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// a valid footer of a 2
|
||||
exports.VHDFOOTER = {
|
||||
cookie: 'conectix',
|
||||
features: 2,
|
||||
fileFormatVersion: 65536,
|
||||
dataOffset: 512,
|
||||
timestamp: 0,
|
||||
creatorApplication: 'caml',
|
||||
creatorVersion: 1,
|
||||
creatorHostOs: 0,
|
||||
originalSize: 53687091200,
|
||||
currentSize: 53687091200,
|
||||
diskGeometry: { cylinders: 25700, heads: 16, sectorsPerTrackCylinder: 255 },
|
||||
diskType: 3,
|
||||
checksum: 4294962945,
|
||||
uuid: Buffer.from('d8dbcad85265421e8b298d99c2eec551', 'utf-8'),
|
||||
saved: '',
|
||||
hidden: '',
|
||||
reserved: '',
|
||||
}
|
||||
exports.VHDHEADER = {
|
||||
cookie: 'cxsparse',
|
||||
dataOffset: undefined,
|
||||
tableOffset: 2048,
|
||||
headerVersion: 65536,
|
||||
maxTableEntries: 25600,
|
||||
blockSize: 2097152,
|
||||
checksum: 4294964241,
|
||||
parentUuid: null,
|
||||
parentTimestamp: 0,
|
||||
reserved1: 0,
|
||||
parentUnicodeName: '',
|
||||
parentLocatorEntry: [
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
{
|
||||
platformCode: 0,
|
||||
platformDataSpace: 0,
|
||||
platformDataLength: 0,
|
||||
reserved: 0,
|
||||
platformDataOffset: 0,
|
||||
},
|
||||
],
|
||||
reserved2: '',
|
||||
}
|
||||
@@ -3,7 +3,7 @@ const map = require('lodash/map.js')
|
||||
const mapValues = require('lodash/mapValues.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
|
||||
const { chainVhd, checkVhdChain, default: Vhd } = require('vhd-lib')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { dirname } = require('path')
|
||||
|
||||
@@ -16,7 +16,6 @@ const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
|
||||
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
|
||||
const { checkVhd } = require('./_checkVhd.js')
|
||||
const { packUuid } = require('./_packUuid.js')
|
||||
const { Disposable } = require('promise-toolbox')
|
||||
|
||||
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
|
||||
@@ -38,13 +37,13 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
await asyncMap(vhds, async path => {
|
||||
try {
|
||||
await checkVhdChain(handler, path)
|
||||
await Disposable.use(
|
||||
openVhd(handler, path),
|
||||
vhd => (found = found || vhd.footer.uuid.equals(packUuid(baseUuid)))
|
||||
)
|
||||
|
||||
const vhd = new Vhd(handler, path)
|
||||
await vhd.readHeaderAndFooter()
|
||||
found = found || vhd.footer.uuid.equals(packUuid(baseUuid))
|
||||
} catch (error) {
|
||||
warn('checkBaseVdis', { error })
|
||||
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
|
||||
await ignoreErrors.call(handler.unlink(path))
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -114,13 +113,19 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
}
|
||||
|
||||
async _deleteOldEntries() {
|
||||
const adapter = this._adapter
|
||||
const oldEntries = this._oldEntries
|
||||
return Task.run({ name: 'merge' }, async () => {
|
||||
const adapter = this._adapter
|
||||
const oldEntries = this._oldEntries
|
||||
|
||||
// delete sequentially from newest to oldest to avoid unnecessary merges
|
||||
for (let i = oldEntries.length; i-- > 0; ) {
|
||||
await adapter.deleteDeltaVmBackups([oldEntries[i]])
|
||||
}
|
||||
let size = 0
|
||||
// delete sequentially from newest to oldest to avoid unnecessary merges
|
||||
for (let i = oldEntries.length; i-- > 0; ) {
|
||||
size += await adapter.deleteDeltaVmBackups([oldEntries[i]])
|
||||
}
|
||||
return {
|
||||
size,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async _transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
@@ -145,7 +150,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
// don't do delta for it
|
||||
vdi.uuid
|
||||
: vdi.$snapshot_of$uuid
|
||||
}/${adapter.getVhdFileName(basename)}`
|
||||
}/${basename}.vhd`
|
||||
)
|
||||
|
||||
const metadataFilename = `${backupDir}/${basename}.json`
|
||||
@@ -189,7 +194,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
await checkVhd(handler, parentPath)
|
||||
}
|
||||
|
||||
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
|
||||
await adapter.outputStream(path, deltaExport.streams[`${id}.vhd`], {
|
||||
// no checksum for VHDs, because they will be invalidated by
|
||||
// merges and chainings
|
||||
checksum: false,
|
||||
@@ -201,11 +206,11 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
}
|
||||
|
||||
// set the correct UUID in the VHD
|
||||
await Disposable.use(openVhd(handler, path), async vhd => {
|
||||
vhd.footer.uuid = packUuid(vdi.uuid)
|
||||
await vhd.readBlockAllocationTable() // required by writeFooter()
|
||||
await vhd.writeFooter()
|
||||
})
|
||||
const vhd = new Vhd(handler, path)
|
||||
await vhd.readHeaderAndFooter()
|
||||
vhd.footer.uuid = packUuid(vdi.uuid)
|
||||
await vhd.readBlockAllocationTable() // required by writeFooter()
|
||||
await vhd.writeFooter()
|
||||
})
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -1,65 +1,34 @@
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { join } = require('path')
|
||||
|
||||
const { BACKUP_DIR, getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
const MergeWorker = require('../merge-worker/index.js')
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
const { getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
|
||||
const { warn } = createLogger('xo:backups:MixinBackupWriter')
|
||||
|
||||
exports.MixinBackupWriter = (BaseClass = Object) =>
|
||||
class MixinBackupWriter extends BaseClass {
|
||||
#lock
|
||||
#vmBackupDir
|
||||
|
||||
constructor({ remoteId, ...rest }) {
|
||||
super(rest)
|
||||
|
||||
this._adapter = rest.backup.remoteAdapters[remoteId]
|
||||
this._remoteId = remoteId
|
||||
|
||||
this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
|
||||
this._lock = undefined
|
||||
}
|
||||
|
||||
async _cleanVm(options) {
|
||||
try {
|
||||
return await this._adapter.cleanVm(this.#vmBackupDir, {
|
||||
...options,
|
||||
fixMetadata: true,
|
||||
onLog: warn,
|
||||
lock: false,
|
||||
})
|
||||
} catch (error) {
|
||||
warn(error)
|
||||
return {}
|
||||
}
|
||||
_cleanVm(options) {
|
||||
return this._adapter
|
||||
.cleanVm(getVmBackupDir(this._backup.vm.uuid), { ...options, fixMetadata: true, onLog: warn, lock: false })
|
||||
.catch(warn)
|
||||
}
|
||||
|
||||
async beforeBackup() {
|
||||
const { handler } = this._adapter
|
||||
const vmBackupDir = this.#vmBackupDir
|
||||
const vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
|
||||
await handler.mktree(vmBackupDir)
|
||||
this.#lock = await handler.lock(vmBackupDir)
|
||||
this._lock = await handler.lock(vmBackupDir)
|
||||
}
|
||||
|
||||
async afterBackup() {
|
||||
const { disableMergeWorker } = this._backup.config
|
||||
|
||||
const { merge } = await this._cleanVm({ remove: true, merge: disableMergeWorker })
|
||||
await this.#lock.dispose()
|
||||
|
||||
// merge worker only compatible with local remotes
|
||||
const { handler } = this._adapter
|
||||
if (merge && !disableMergeWorker && typeof handler._getRealPath === 'function') {
|
||||
const taskFile =
|
||||
join(MergeWorker.CLEAN_VM_QUEUE, formatFilenameDate(new Date())) +
|
||||
'-' +
|
||||
// add a random suffix to avoid collision in case multiple tasks are created at the same second
|
||||
Math.random().toString(36).slice(2)
|
||||
|
||||
await handler.outputFile(taskFile, this._backup.vm.uuid)
|
||||
const remotePath = handler._getRealPath()
|
||||
await MergeWorker.run(remotePath)
|
||||
}
|
||||
await this._cleanVm({ remove: true, merge: true })
|
||||
await this._lock.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const openVhd = require('vhd-lib').openVhd
|
||||
const Disposable = require('promise-toolbox/Disposable')
|
||||
const Vhd = require('vhd-lib').default
|
||||
|
||||
exports.checkVhd = async function checkVhd(handler, path) {
|
||||
await Disposable.use(openVhd(handler, path), () => {})
|
||||
await new Vhd(handler, path).readHeaderAndFooter()
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^0.35.1"
|
||||
"xen-api": "^0.34.3"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { createSchedule } from './'
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
const wrap = value => () => value
|
||||
|
||||
describe('issues', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "0.19.2",
|
||||
"version": "0.18.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
@@ -17,7 +17,7 @@
|
||||
"node": ">=14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsaud/smb2": "^0.18.0",
|
||||
"@marsaud/smb2": "^0.17.2",
|
||||
"@sindresorhus/df": "^3.1.1",
|
||||
"@sullux/aws-sdk": "^1.0.5",
|
||||
"@vates/coalesce-calls": "^0.1.0",
|
||||
@@ -29,11 +29,11 @@
|
||||
"get-stream": "^6.0.0",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash": "^4.17.4",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"readable-stream": "^3.0.6",
|
||||
"through2": "^4.0.2",
|
||||
"xo-remote-parser": "^0.8.0"
|
||||
"xo-remote-parser": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -76,7 +76,6 @@ export default class RemoteHandlerAbstract {
|
||||
|
||||
const sharedLimit = limitConcurrency(options.maxParallelOperations ?? DEFAULT_MAX_PARALLEL_OPERATIONS)
|
||||
this.closeFile = sharedLimit(this.closeFile)
|
||||
this.copy = sharedLimit(this.copy)
|
||||
this.getInfo = sharedLimit(this.getInfo)
|
||||
this.getSize = sharedLimit(this.getSize)
|
||||
this.list = sharedLimit(this.list)
|
||||
@@ -308,17 +307,6 @@ export default class RemoteHandlerAbstract {
|
||||
return p
|
||||
}
|
||||
|
||||
async copy(oldPath, newPath, { checksum = false } = {}) {
|
||||
oldPath = normalizePath(oldPath)
|
||||
newPath = normalizePath(newPath)
|
||||
|
||||
let p = timeout.call(this._copy(oldPath, newPath), this._timeout)
|
||||
if (checksum) {
|
||||
p = Promise.all([p, this._copy(checksumFile(oldPath), checksumFile(newPath))])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
async rmdir(dir) {
|
||||
await timeout.call(this._rmdir(normalizePath(dir)).catch(ignoreEnoent), this._timeout)
|
||||
}
|
||||
@@ -531,9 +519,6 @@ export default class RemoteHandlerAbstract {
|
||||
async _rename(oldPath, newPath) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
async _copy(oldPath, newPath) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async _rmdir(dir) {
|
||||
throw new Error('Not implemented')
|
||||
|
||||
@@ -33,10 +33,6 @@ export default class LocalHandler extends RemoteHandlerAbstract {
|
||||
return fs.close(fd)
|
||||
}
|
||||
|
||||
async _copy(oldPath, newPath) {
|
||||
return fs.copy(this._getFilePath(oldPath), this._getFilePath(newPath))
|
||||
}
|
||||
|
||||
async _createReadStream(file, options) {
|
||||
if (typeof file === 'string') {
|
||||
const stream = fs.createReadStream(this._getFilePath(file), options)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import aws from '@sullux/aws-sdk'
|
||||
import assert from 'assert'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import { parse } from 'xo-remote-parser'
|
||||
|
||||
import RemoteHandlerAbstract from './abstract'
|
||||
@@ -17,7 +16,7 @@ const IDEAL_FRAGMENT_SIZE = Math.ceil(MAX_OBJECT_SIZE / MAX_PARTS_COUNT) // the
|
||||
export default class S3Handler extends RemoteHandlerAbstract {
|
||||
constructor(remote, _opts) {
|
||||
super(remote)
|
||||
const { allowUnauthorized, host, path, username, password, protocol, region } = parse(remote.url)
|
||||
const { host, path, username, password, protocol, region } = parse(remote.url)
|
||||
const params = {
|
||||
accessKeyId: username,
|
||||
apiVersion: '2006-03-01',
|
||||
@@ -30,13 +29,8 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
},
|
||||
}
|
||||
if (protocol === 'http') {
|
||||
params.httpOptions.agent = new http.Agent({ keepAlive: true })
|
||||
params.httpOptions.agent = new http.Agent()
|
||||
params.sslEnabled = false
|
||||
} else if (protocol === 'https') {
|
||||
params.httpOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: !allowUnauthorized,
|
||||
keepAlive: true,
|
||||
})
|
||||
}
|
||||
if (region !== undefined) {
|
||||
params.region = region
|
||||
@@ -57,27 +51,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
return { Bucket: this._bucket, Key: this._dir + file }
|
||||
}
|
||||
|
||||
async _copy(oldPath, newPath) {
|
||||
const size = await this._getSize(oldPath)
|
||||
const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
|
||||
const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
|
||||
try {
|
||||
const parts = []
|
||||
let start = 0
|
||||
while (start < size) {
|
||||
const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
|
||||
const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
|
||||
const upload = await this._s3.uploadPartCopy(partParams)
|
||||
parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
|
||||
start += MAX_PART_SIZE
|
||||
}
|
||||
await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
|
||||
} catch (e) {
|
||||
await this._s3.abortMultipartUpload(multipartParams)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async _isNotEmptyDir(path) {
|
||||
const result = await this._s3.listObjectsV2({
|
||||
Bucket: this._bucket,
|
||||
@@ -152,30 +125,16 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
const splitPrefix = splitPath(prefix)
|
||||
const result = await this._s3.listObjectsV2({
|
||||
Bucket: this._bucket,
|
||||
Prefix: splitPrefix.join('/') + '/', // need slash at the end with the use of delimiters
|
||||
Delimiter: '/', // will only return path until delimiters
|
||||
Prefix: splitPrefix.join('/'),
|
||||
})
|
||||
|
||||
if (result.isTruncated) {
|
||||
const error = new Error('more than 1000 objects, unsupported in this implementation')
|
||||
error.dir = dir
|
||||
throw error
|
||||
}
|
||||
|
||||
const uniq = []
|
||||
|
||||
// sub directories
|
||||
for (const entry of result.CommonPrefixes) {
|
||||
const line = splitPath(entry.Prefix)
|
||||
uniq.push(line[line.length - 1])
|
||||
}
|
||||
// files
|
||||
const uniq = new Set()
|
||||
for (const entry of result.Contents) {
|
||||
const line = splitPath(entry.Key)
|
||||
uniq.push(line[line.length - 1])
|
||||
if (line.length > splitPrefix.length) {
|
||||
uniq.add(line[splitPrefix.length])
|
||||
}
|
||||
}
|
||||
|
||||
return uniq
|
||||
return [...uniq]
|
||||
}
|
||||
|
||||
async _mkdir(path) {
|
||||
@@ -188,9 +147,25 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
// nothing to do, directories do not exist, they are part of the files' path
|
||||
}
|
||||
|
||||
// s3 doesn't have a rename operation, so copy + delete source
|
||||
async _rename(oldPath, newPath) {
|
||||
await this.copy(oldPath, newPath)
|
||||
const size = await this._getSize(oldPath)
|
||||
const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
|
||||
const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
|
||||
try {
|
||||
const parts = []
|
||||
let start = 0
|
||||
while (start < size) {
|
||||
const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
|
||||
const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
|
||||
const upload = await this._s3.uploadPartCopy(partParams)
|
||||
parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
|
||||
start += MAX_PART_SIZE
|
||||
}
|
||||
await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
|
||||
} catch (e) {
|
||||
await this._s3.abortMultipartUpload(multipartParams)
|
||||
throw e
|
||||
}
|
||||
await this._s3.deleteObject(this._createParams(oldPath))
|
||||
}
|
||||
|
||||
@@ -208,21 +183,9 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
}
|
||||
const params = this._createParams(file)
|
||||
params.Range = `bytes=${position}-${position + buffer.length - 1}`
|
||||
try {
|
||||
const result = await this._s3.getObject(params)
|
||||
result.Body.copy(buffer)
|
||||
return { bytesRead: result.Body.length, buffer }
|
||||
} catch (e) {
|
||||
if (e.code === 'NoSuchKey') {
|
||||
if (await this._isNotEmptyDir(file)) {
|
||||
const error = new Error(`${file} is a directory`)
|
||||
error.code = 'EISDIR'
|
||||
error.path = file
|
||||
throw error
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
const result = await this._s3.getObject(params)
|
||||
result.Body.copy(buffer)
|
||||
return { bytesRead: result.Body.length, buffer }
|
||||
}
|
||||
|
||||
async _rmdir(path) {
|
||||
@@ -236,28 +199,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
// nothing to do, directories do not exist, they are part of the files' path
|
||||
}
|
||||
|
||||
// reimplement _rmtree to handle efficiantly path with more than 1000 entries in trees
|
||||
// @todo : use parallel processing for unlink
|
||||
async _rmtree(path) {
|
||||
let NextContinuationToken
|
||||
do {
|
||||
const result = await this._s3.listObjectsV2({
|
||||
Bucket: this._bucket,
|
||||
Prefix: this._dir + path + '/',
|
||||
ContinuationToken: NextContinuationToken,
|
||||
})
|
||||
NextContinuationToken = result.isTruncated ? null : result.NextContinuationToken
|
||||
for (const { Key } of result.Contents) {
|
||||
// _unlink will add the prefix, but Key contains everything
|
||||
// also we don't need to check if we delete a directory, since the list only return files
|
||||
await this._s3.deleteObject({
|
||||
Bucket: this._bucket,
|
||||
Key,
|
||||
})
|
||||
}
|
||||
} while (NextContinuationToken !== null)
|
||||
}
|
||||
|
||||
async _write(file, buffer, position) {
|
||||
if (typeof file !== 'string') {
|
||||
file = file.fd
|
||||
|
||||
6
@xen-orchestra/lite/.babelrc.js
Normal file
6
@xen-orchestra/lite/.babelrc.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'), {
|
||||
'@babel/preset-env': {
|
||||
exclude: ['@babel/plugin-proposal-dynamic-import', '@babel/plugin-transform-regenerator'],
|
||||
modules: false,
|
||||
},
|
||||
})
|
||||
32
@xen-orchestra/lite/.eslintrc.js
Normal file
32
@xen-orchestra/lite/.eslintrc.js
Normal file
@@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
|
||||
sourceType: 'module', // Allows for the use of imports
|
||||
ecmaFeatures: {
|
||||
jsx: true, // Allows for the parsing of JSX
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: '17',
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react
|
||||
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin
|
||||
],
|
||||
rules: {
|
||||
'eslint-comments/disable-enable-pair': 'off',
|
||||
// Necessary to pass empty Effects/State to Reaclette
|
||||
'@typescript-eslint/no-empty-interface': 'off',
|
||||
// https://github.com/typescript-eslint/typescript-eslint/issues/1071
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
// https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
|
||||
'@typescript-eslint/no-use-before-define': ['error'],
|
||||
'no-use-before-define': 'off',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
}
|
||||
24
@xen-orchestra/lite/.gitignore
vendored
Normal file
24
@xen-orchestra/lite/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.eslintcache
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
71
@xen-orchestra/lite/package.json
Normal file
71
@xen-orchestra/lite/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "xo-lite",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.1",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.0",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.13.0",
|
||||
"@babel/preset-env": "^7.13.5",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/lab": "^5.0.0-alpha.48",
|
||||
"@mui/material": "^5.0.1",
|
||||
"@novnc/novnc": "^1.2.0",
|
||||
"@types/immutable": "^3.8.7",
|
||||
"@types/js-cookie": "^2.2.6",
|
||||
"@types/lodash": "^4.14.175",
|
||||
"@types/node": "^14.14.21",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-intl": "^3.0.0",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-syntax-highlighter": "^13.5.0",
|
||||
"@types/styled-components": "^5.1.9",
|
||||
"@types/webpack-env": "^1.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.16.1",
|
||||
"@typescript-eslint/parser": "^4.16.1",
|
||||
"babel-loader": "^8.2.2",
|
||||
"classnames": "^2.3.1",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^10.2.0",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"html-webpack-plugin": "^5.2.0",
|
||||
"human-format": "^0.11.0",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"iterable-backoff": "^0.1.0",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-polyfill-webpack-plugin": "^1.0.3",
|
||||
"process": "^0.11.10",
|
||||
"promise-toolbox": "^0.16.0",
|
||||
"reaclette": "^0.10.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "^5.10.16",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
"styled-components": "^5.2.1",
|
||||
"typescript": "^4.3.1",
|
||||
"webpack": "^5.24.2",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"xen-api": "^0.34.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"styled-components": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"start": "cross-env NODE_ENV=development webpack serve",
|
||||
"start:open": "npm run start -- --open"
|
||||
},
|
||||
"browserslist": "> 0.5%, last 2 versions, Firefox ESR, not dead"
|
||||
}
|
||||
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
13
@xen-orchestra/lite/public/index.html
Normal file
13
@xen-orchestra/lite/public/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Xen Orchestra Lite" />
|
||||
<title>XO Lite</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
@xen-orchestra/lite/public/logo.png
Normal file
BIN
@xen-orchestra/lite/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
90
@xen-orchestra/lite/src/App/Infrastructure.tsx
Normal file
90
@xen-orchestra/lite/src/App/Infrastructure.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Switch, Route, RouteComponentProps } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import Pool from './Pool'
|
||||
import TabConsole from './TabConsole'
|
||||
import TreeView from './TreeView'
|
||||
|
||||
import { ObjectsByType } from '../libs/xapi'
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
`
|
||||
const LeftPanel = styled.div`
|
||||
background: #f5f5f5;
|
||||
min-width: 15em;
|
||||
overflow-y: scroll;
|
||||
width: 20%;
|
||||
`
|
||||
// FIXME: temporary work-around while investigating flew-grow issue:
|
||||
// `overflow: hidden` forces the console to shrink to the max available width
|
||||
// even when the tree component takes more than 20% of the width due to
|
||||
// `min-width`
|
||||
const MainPanel = styled.div`
|
||||
overflow: hidden;
|
||||
width: 80%;
|
||||
`
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
pool?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
selectedObject?: string
|
||||
selectedVm?: string
|
||||
}
|
||||
|
||||
// For compatibility with 'withRouter'
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
initialize: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const selectedNodesToArray = (nodes: Array<string> | string | undefined) =>
|
||||
nodes === undefined ? undefined : Array.isArray(nodes) ? nodes : [nodes]
|
||||
|
||||
const Infrastructure = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: props => ({
|
||||
selectedVm: props.location.pathname.split('/')[3],
|
||||
}),
|
||||
computed: {
|
||||
selectedObject: (state, props) =>
|
||||
props.location.pathname.startsWith('/infrastructure/pool') ? state.pool : state.selectedVm,
|
||||
},
|
||||
},
|
||||
({ state: { pool, selectedObject } }) => (
|
||||
<Container>
|
||||
<LeftPanel>
|
||||
<TreeView defaultSelectedNodes={selectedNodesToArray(selectedObject)} />
|
||||
</LeftPanel>
|
||||
<MainPanel>
|
||||
<Switch>
|
||||
<Route exact path={`/infrastructure/pool/${pool}/dashboard`}>
|
||||
<Pool id={pool} />
|
||||
</Route>
|
||||
<Route
|
||||
path='/infrastructure/vms/:id/console'
|
||||
render={({
|
||||
match: {
|
||||
params: { id },
|
||||
},
|
||||
}) => <TabConsole key={id} vmId={id} />}
|
||||
/>
|
||||
</Switch>
|
||||
</MainPanel>
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default withRouter(Infrastructure)
|
||||
120
@xen-orchestra/lite/src/App/Pool/dashboard/ObjectStatus.tsx
Normal file
120
@xen-orchestra/lite/src/App/Pool/dashboard/ObjectStatus.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import Grid from '@mui/material/Grid'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon from '../../../components/Icon'
|
||||
import IntlMessage from '../../../components/IntlMessage'
|
||||
import ProgressCircle from '../../../components/ProgressCircle'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
nActive?: number
|
||||
nTotal?: number
|
||||
type: 'host' | 'VM'
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
nInactive?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CAPTION_STYLE = { textTransform: 'uppercase', mt: 2 }
|
||||
const TYPOGRAPHY_SX = { mb: 2 }
|
||||
|
||||
const ObjectStatusContainer = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
align-content: space-between;
|
||||
margin-bottom: 1em;
|
||||
`
|
||||
|
||||
const CircularProgressPanel = styled.div`
|
||||
margin-left: 2em;
|
||||
`
|
||||
|
||||
const GridPanel = styled.div`
|
||||
margin-left: 2em;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
// TODO: Add a loading page when data is not loaded as it is in the model(figma).
|
||||
// FIXME: replace the hard-coded colors with the theme colors.
|
||||
const ObjectStatus = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
nInactive: (state, { nTotal = 0, nActive = 0 }) => nTotal - nActive,
|
||||
},
|
||||
},
|
||||
({ state: { nInactive }, nActive = 0, nTotal = 0, type }) => {
|
||||
if (nTotal === 0) {
|
||||
return (
|
||||
<span>
|
||||
<IntlMessage id={type === 'VM' ? 'noVms' : 'noHosts'} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectStatusContainer>
|
||||
<CircularProgressPanel>
|
||||
<ProgressCircle max={nTotal} value={nActive} />
|
||||
</CircularProgressPanel>
|
||||
<GridPanel>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Typography sx={TYPOGRAPHY_SX} variant='h5' component='div'>
|
||||
<IntlMessage id={type === 'VM' ? 'vms' : 'hosts'} />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Icon icon='circle' htmlColor='#00BA34' />
|
||||
|
||||
<Typography variant='body2' component='span'>
|
||||
<IntlMessage id='active' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='body2' component='div'>
|
||||
{nActive}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Icon icon='circle' htmlColor='#E8E8E8' />
|
||||
|
||||
<Typography variant='body2' component='span'>
|
||||
<IntlMessage id='inactive' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='body2' component='div'>
|
||||
{nInactive}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
|
||||
<IntlMessage id='total' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
|
||||
{nTotal}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</GridPanel>
|
||||
</ObjectStatusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default ObjectStatus
|
||||
79
@xen-orchestra/lite/src/App/Pool/dashboard/index.tsx
Normal file
79
@xen-orchestra/lite/src/App/Pool/dashboard/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import Divider from '@mui/material/Divider'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import ObjectStatus from './ObjectStatus'
|
||||
|
||||
import IntlMessage from '../../../components/IntlMessage'
|
||||
import { Host, ObjectsByType, Vm } from '../../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType?: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {
|
||||
hosts?: Map<string, Host>
|
||||
nRunningHosts?: number
|
||||
nRunningVms?: number
|
||||
vms?: Map<string, Vm>
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const DEFAULT_STYLE = { m: 2 }
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
align-content: space-between;
|
||||
gap: 1.25em;
|
||||
background: '#E8E8E8';
|
||||
`
|
||||
|
||||
const Panel = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0px 1px 1px 0px #00000014, 0px 2px 1px 0px #0000000f, 0px 1px 3px 0px #0000001a;
|
||||
margin: 0.5em;
|
||||
`
|
||||
const getHostPowerState = (host: Host) => {
|
||||
const { $metrics } = host
|
||||
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
|
||||
}
|
||||
|
||||
const Dashboard = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
hosts: state => state.objectsByType?.get('host'),
|
||||
vms: state =>
|
||||
state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
|
||||
nRunningHosts: state => (state.hosts?.filter((host: Host) => getHostPowerState(host) === 'Running')).size,
|
||||
nRunningVms: state => (state.vms?.filter((vm: Vm) => vm.power_state === 'Running')).size,
|
||||
},
|
||||
},
|
||||
({ state: { hosts, nRunningHosts, nRunningVms, vms } }) => (
|
||||
<Container>
|
||||
<Panel>
|
||||
<Typography variant='h4' component='div' sx={DEFAULT_STYLE}>
|
||||
<IntlMessage id='status' />
|
||||
</Typography>
|
||||
<ObjectStatus nActive={nRunningHosts} nTotal={hosts?.size} type='host' />
|
||||
<Divider variant='middle' sx={DEFAULT_STYLE} />
|
||||
<ObjectStatus nActive={nRunningVms} nTotal={vms?.size} type='VM' />
|
||||
</Panel>
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default Dashboard
|
||||
46
@xen-orchestra/lite/src/App/Pool/index.tsx
Normal file
46
@xen-orchestra/lite/src/App/Pool/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Dashboard from './dashboard'
|
||||
import Icon from '../../components/Icon'
|
||||
import PanelHeader from '../../components/PanelHeader'
|
||||
import { ObjectsByType, Pool as PoolType } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
pool?: PoolType
|
||||
}
|
||||
|
||||
// TODO: add tabs when https://github.com/vatesfr/xen-orchestra/pull/6096 is merged.
|
||||
const Pool = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
pool: (state, props) => state.objectsByType?.get('pool')?.get(props.id),
|
||||
},
|
||||
},
|
||||
({ state: { pool } }) => (
|
||||
<>
|
||||
<PanelHeader>
|
||||
<span>
|
||||
<Icon icon='warehouse' color='primary' /> {pool?.name_label}
|
||||
</span>
|
||||
</PanelHeader>
|
||||
<Dashboard />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
export default Pool
|
||||
65
@xen-orchestra/lite/src/App/PoolTab/PoolNetworks.tsx
Normal file
65
@xen-orchestra/lite/src/App/PoolTab/PoolNetworks.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import Table, { Column } from '../../components/Table'
|
||||
import { ObjectsByType, Pif } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
objectsFetched: boolean
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
poolId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
managementPifs?: Pif[]
|
||||
pifs?: Map<string, Pif>
|
||||
}
|
||||
|
||||
const COLUMNS: Column<Pif>[] = [
|
||||
{
|
||||
header: <IntlMessage id='device' />,
|
||||
render: pif => pif.device,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='dns' />,
|
||||
render: pif => pif.DNS,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='gateway' />,
|
||||
render: pif => pif.gateway,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='ip' />,
|
||||
render: pif => pif.IP,
|
||||
},
|
||||
]
|
||||
|
||||
const PoolNetworks = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
managementPifs: state =>
|
||||
state.pifs
|
||||
?.filter(pif => pif.management)
|
||||
.map(pif => ({ ...pif, id: pif.$id }))
|
||||
.valueSeq()
|
||||
.toArray(),
|
||||
pifs: state => state.objectsByType.get('PIF'),
|
||||
},
|
||||
},
|
||||
({ state }) => (
|
||||
<Table collection={state.managementPifs} columns={COLUMNS} placeholder={<IntlMessage id='noManagementPifs' />} />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolNetworks
|
||||
89
@xen-orchestra/lite/src/App/PoolTab/PoolUpdates.tsx
Normal file
89
@xen-orchestra/lite/src/App/PoolTab/PoolUpdates.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react'
|
||||
import humanFormat from 'human-format'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import Table, { Column } from '../../components/Table'
|
||||
import XapiConnection, { ObjectsByType, PoolUpdate } from '../../libs/xapi'
|
||||
|
||||
const COLUMN: Column<PoolUpdate>[] = [
|
||||
{
|
||||
header: <IntlMessage id='name' />,
|
||||
render: update => update.name,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='description' />,
|
||||
render: update => update.description,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='version' />,
|
||||
render: update => update.version,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='release' />,
|
||||
render: update => update.release,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='size' />,
|
||||
render: update => humanFormat.bytes(update.size),
|
||||
},
|
||||
]
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
objectsFetched: boolean
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
hostRef: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
availableUpdates?: PoolUpdate[] | JSX.Element
|
||||
}
|
||||
|
||||
const PoolUpdates = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
availableUpdates: async function (state, { hostRef }) {
|
||||
try {
|
||||
const stringifiedPoolUpdates = (await state.xapi.call(
|
||||
'host.call_plugin',
|
||||
hostRef,
|
||||
'updater.py',
|
||||
'check_update',
|
||||
{}
|
||||
)) as string
|
||||
return JSON.parse(stringifiedPoolUpdates)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return <IntlMessage id='errorOccurred' />
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
({ state: { availableUpdates } }) =>
|
||||
availableUpdates !== undefined ? (
|
||||
Array.isArray(availableUpdates) ? (
|
||||
<>
|
||||
{availableUpdates.length !== 0 && (
|
||||
<IntlMessage id='availableUpdates' values={{ nUpdates: availableUpdates.length }} />
|
||||
)}
|
||||
<Table collection={availableUpdates} columns={COLUMN} placeholder={<IntlMessage id='noUpdatesAvailable' />} />
|
||||
</>
|
||||
) : (
|
||||
availableUpdates
|
||||
)
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolUpdates
|
||||
53
@xen-orchestra/lite/src/App/PoolTab/index.tsx
Normal file
53
@xen-orchestra/lite/src/App/PoolTab/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import PoolNetworks from './PoolNetworks'
|
||||
import PoolUpdates from './PoolUpdates'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import { Host, ObjectsByType, Pool } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsFetched: boolean
|
||||
}
|
||||
|
||||
interface State {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
hosts?: Map<string, Host>
|
||||
pool?: Pool
|
||||
}
|
||||
|
||||
const PoolTab = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
hosts: state => (state.objectsFetched ? state.objectsByType?.get('host') : undefined),
|
||||
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.first() : undefined),
|
||||
},
|
||||
},
|
||||
({ state }) =>
|
||||
state.pool !== undefined ? (
|
||||
<>
|
||||
<PoolNetworks poolId={state.pool.$id} />
|
||||
{state.hosts?.valueSeq().map(host => (
|
||||
<div key={host.$id}>
|
||||
<p>{host.name_label}</p>
|
||||
<PoolUpdates hostRef={host.$ref} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolTab
|
||||
110
@xen-orchestra/lite/src/App/Signin/index.tsx
Normal file
110
@xen-orchestra/lite/src/App/Signin/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Button from '../../components/Button'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Input from '../../components/Input'
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
|
||||
interface ParentState {
|
||||
error: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
password: string
|
||||
rememberMe: boolean
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {
|
||||
connectToXapi: (password: string, rememberMe: boolean) => void
|
||||
}
|
||||
|
||||
interface Effects {
|
||||
setRememberMe: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
setPassword: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
submit: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
`
|
||||
|
||||
const Form = styled.form`
|
||||
width: 20em;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const Fieldset = styled.fieldset`
|
||||
border: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
`
|
||||
|
||||
const RememberMe = styled(Fieldset)`
|
||||
text-align: start;
|
||||
vertical-align: baseline;
|
||||
`
|
||||
|
||||
const Error = styled.p`
|
||||
color: #a33;
|
||||
`
|
||||
|
||||
const Signin = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
}),
|
||||
effects: {
|
||||
setRememberMe: function ({ currentTarget: { checked: rememberMe } }) {
|
||||
this.state.rememberMe = rememberMe
|
||||
},
|
||||
setPassword: function ({ currentTarget: { value: password } }) {
|
||||
this.state.password = password
|
||||
},
|
||||
submit: function () {
|
||||
this.effects.connectToXapi(this.state.password, this.state.rememberMe)
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<Wrapper>
|
||||
<Form onSubmit={e => e.preventDefault()}>
|
||||
<img src='logo.png' />
|
||||
<h1>Xen Orchestra Lite</h1>
|
||||
<Fieldset>
|
||||
<Input disabled label={<IntlMessage id='login' />} value='root' />
|
||||
</Fieldset>
|
||||
<Fieldset>
|
||||
<Input
|
||||
autoFocus
|
||||
label={<IntlMessage id='password' />}
|
||||
onChange={effects.setPassword}
|
||||
type='password'
|
||||
value={state.password}
|
||||
/>
|
||||
</Fieldset>
|
||||
<RememberMe>
|
||||
<label>
|
||||
<Checkbox onChange={effects.setRememberMe} checked={state.rememberMe} />
|
||||
|
||||
<IntlMessage id='rememberMe' />
|
||||
</label>
|
||||
</RememberMe>
|
||||
<Error>{state.error}</Error>
|
||||
<Button type='submit' onClick={effects.submit}>
|
||||
<IntlMessage id='connect' />
|
||||
</Button>
|
||||
</Form>
|
||||
</Wrapper>
|
||||
)
|
||||
)
|
||||
|
||||
export default Signin
|
||||
300
@xen-orchestra/lite/src/App/StyleGuide/index.tsx
Normal file
300
@xen-orchestra/lite/src/App/StyleGuide/index.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
// https://mui.com/components/material-icons/
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { materialDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { toNumber } from 'lodash'
|
||||
import { SelectChangeEvent } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import ActionButton from '../../components/ActionButton'
|
||||
import Button from '../../components/Button'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Icon from '../../components/Icon'
|
||||
import Input from '../../components/Input'
|
||||
import ProgressCircle from '../../components/ProgressCircle'
|
||||
import Select from '../../components/Select'
|
||||
import Tabs from '../../components/Tabs'
|
||||
import { alert, confirm } from '../../components/Modal'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
progressBarValue: number
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
onChangeProgressBarValue: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onChangeSelect: (e: SelectChangeEvent<unknown>) => void
|
||||
sayHello: () => void
|
||||
sendPromise: (data: Record<string, unknown>) => Promise<void>
|
||||
showAlertModal: () => void
|
||||
showConfirmModal: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Page = styled.div`
|
||||
margin: 30px;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
`
|
||||
|
||||
const Render = styled.div`
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: solid 1px gray;
|
||||
border-radius: 3px;
|
||||
`
|
||||
|
||||
const Code = styled(SyntaxHighlighter).attrs(() => ({
|
||||
language: 'jsx',
|
||||
style: codeStyle,
|
||||
}))`
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
margin: 0 !important;
|
||||
`
|
||||
|
||||
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
progressBarValue: 100,
|
||||
value: '',
|
||||
}),
|
||||
effects: {
|
||||
onChangeProgressBarValue: function (e) {
|
||||
this.state.progressBarValue = toNumber(e.target.value)
|
||||
},
|
||||
onChangeSelect: function (e) {
|
||||
this.state.value = e.target.value
|
||||
},
|
||||
sayHello: () => alert('hello'),
|
||||
sendPromise: data =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
window.alert(data.foo)
|
||||
}, 1000)
|
||||
}),
|
||||
showAlertModal: () => alert({ message: 'This is an alert modal', title: 'Alert modal', icon: 'info' }),
|
||||
showConfirmModal: () =>
|
||||
confirm({
|
||||
message: 'This is a confirm modal test',
|
||||
title: 'Confirm modal',
|
||||
icon: 'download',
|
||||
}),
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<Page>
|
||||
<h2>ActionButton</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
|
||||
Send promise
|
||||
</ActionButton>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
|
||||
Send promise
|
||||
</ActionButton>`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Button</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Button color='primary' onClick={effects.sayHello} startIcon={<AccountCircleIcon />}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button color='secondary' endIcon={<DeleteIcon />} onClick={effects.sayHello}>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button color='success' onClick={effects.sayHello}>
|
||||
Success
|
||||
</Button>
|
||||
<Button color='warning' onClick={effects.sayHello}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button color='error' onClick={effects.sayHello}>
|
||||
Error
|
||||
</Button>
|
||||
<Button color='info' onClick={effects.sayHello}>
|
||||
Info
|
||||
</Button>
|
||||
</Render>
|
||||
<Code>{`<Button color='primary' onClick={doSomething} startIcon={<AccountCircleIcon />}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button color='secondary' endIcon={<DeleteIcon />} onClick={doSomething}>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button color='success' onClick={doSomething}>
|
||||
Success
|
||||
</Button>
|
||||
<Button color='warning' onClick={doSomething}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button color='error' onClick={doSomething}>
|
||||
Error
|
||||
</Button>
|
||||
<Button color='info' onClick={doSomething}>
|
||||
Info
|
||||
</Button>`}</Code>
|
||||
</Container>
|
||||
<h2>Icon</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Icon icon='truck' htmlColor='#0085FF' />
|
||||
<Icon icon='truck' color='primary' size='2x' />
|
||||
</Render>
|
||||
<Code>{`// https://fontawesome.com/icons
|
||||
<Icon icon='truck' htmlColor='#0085FF'/>
|
||||
<Icon icon='truck' color='primary' size='2x' />`}</Code>
|
||||
</Container>
|
||||
<h2>Input</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Input label='Input' />
|
||||
<Checkbox />
|
||||
</Render>
|
||||
<Code>{`<TextInput label='Input' />
|
||||
<Checkbox />`}</Code>
|
||||
</Container>
|
||||
<h2>Modal</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={effects.showAlertModal}
|
||||
sx={{
|
||||
marginBottom: 1,
|
||||
}}
|
||||
>
|
||||
Alert
|
||||
</Button>
|
||||
<Button color='primary' onClick={effects.showConfirmModal}>
|
||||
Confirm
|
||||
</Button>
|
||||
</Render>
|
||||
<Code>{`<Button
|
||||
color='primary'
|
||||
onClick={() =>
|
||||
alert({
|
||||
message: 'This is an alert modal',
|
||||
title: 'Alert modal',
|
||||
icon: 'info'
|
||||
})
|
||||
}
|
||||
>
|
||||
Alert
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await confirm({
|
||||
message: 'This is a confirm modal',
|
||||
title: 'Confirm modal',
|
||||
icon: 'download',
|
||||
})
|
||||
// The modal has been confirmed
|
||||
} catch (reason) { // "cancel"
|
||||
// The modal has been closed
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>`}</Code>
|
||||
</Container>
|
||||
<h2>ProgressCircle</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<ProgressCircle max={200} value={state.progressBarValue} />
|
||||
</div>
|
||||
<div>
|
||||
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
defaultValue={state.progressBarValue}
|
||||
max='200'
|
||||
min='0'
|
||||
onChange={effects.onChangeProgressBarValue}
|
||||
step='1'
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: '10px auto',
|
||||
}}
|
||||
type='range'
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<ProgressCircle max={200} value={state.progressBarValue} />
|
||||
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Select</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Select
|
||||
onChange={effects.onChangeSelect}
|
||||
options={[
|
||||
{ name: 'Bar', value: 1 },
|
||||
{ name: 'Foo', value: 2 },
|
||||
]}
|
||||
value={state.value}
|
||||
valueRenderer='value'
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<Select
|
||||
onChange={handleChange}
|
||||
optionRenderer={item => item.name}
|
||||
options={[
|
||||
{ name: 'Bar', value: 1 },
|
||||
{ name: 'Foo', value: 2 },
|
||||
]}
|
||||
value={state.value}
|
||||
valueRenderer='value'
|
||||
/>`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Tabs</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
|
||||
{ label: 'FOO', pathname: '/styleguide/foo' },
|
||||
]}
|
||||
useUrl
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<Tabs
|
||||
tabs={[
|
||||
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
|
||||
{ label: 'FOO', pathname: '/styleguide/foo' },
|
||||
]}
|
||||
useUrl
|
||||
/>`}
|
||||
</Code>
|
||||
</Container>
|
||||
</Page>
|
||||
)
|
||||
)
|
||||
|
||||
export default App
|
||||
102
@xen-orchestra/lite/src/App/TabConsole.tsx
Normal file
102
@xen-orchestra/lite/src/App/TabConsole.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Console from '../components/Console'
|
||||
import IntlMessage, { translate } from '../components/IntlMessage'
|
||||
import { ObjectsByType, Vm } from '../libs/xapi'
|
||||
import PanelHeader from '../components/PanelHeader'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {
|
||||
consoleScale: number
|
||||
sendCtrlAltDel?: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
vmId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
scaleConsole: React.ChangeEventHandler<HTMLInputElement>
|
||||
setCtrlAltDel: (sendCtrlAltDel: State['sendCtrlAltDel']) => void
|
||||
showNotImplemented: () => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
vm?: Vm
|
||||
}
|
||||
|
||||
const TabConsole = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
// Value in percent
|
||||
consoleScale: 100,
|
||||
sendCtrlAltDel: undefined,
|
||||
}),
|
||||
effects: {
|
||||
scaleConsole: function (e) {
|
||||
this.state.consoleScale = +e.currentTarget.value
|
||||
|
||||
// With "scaleViewport", the canvas occupies all available space of its
|
||||
// container. But when the size of the container is changed, the canvas
|
||||
// size isn't updated
|
||||
// Issue https://github.com/novnc/noVNC/issues/1364
|
||||
// PR https://github.com/novnc/noVNC/pull/1365
|
||||
window.dispatchEvent(new UIEvent('resize'))
|
||||
},
|
||||
setCtrlAltDel: function (sendCtrlAltDel) {
|
||||
this.state.sendCtrlAltDel = sendCtrlAltDel
|
||||
},
|
||||
showNotImplemented: function () {
|
||||
alert('Not Implemented')
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
vm: (state, { vmId }) => state.objectsByType.get('VM')?.get(vmId),
|
||||
},
|
||||
},
|
||||
({ effects, state, vmId }) => (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<PanelHeader
|
||||
actions={[
|
||||
{
|
||||
key: 'start',
|
||||
icon: 'play',
|
||||
color: 'primary',
|
||||
title: translate({ id: 'vmStartLabel' }),
|
||||
variant: 'contained',
|
||||
onClick: effects.showNotImplemented,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{state.vm?.name_label ?? 'loading'}{' '}
|
||||
</PanelHeader>
|
||||
|
||||
{/* Hide scaling and Ctrl+Alt+Del button temporarily */}
|
||||
{/* <RangeInput max={100} min={1} onChange={effects.scaleConsole} step={1} value={state.consoleScale} />
|
||||
{state.sendCtrlAltDel !== undefined && (
|
||||
<Button onClick={state.sendCtrlAltDel}>
|
||||
<IntlMessage id='ctrlAltDel' />
|
||||
</Button>
|
||||
)} */}
|
||||
{state.vm?.power_state !== 'Running' ? (
|
||||
<p>
|
||||
<IntlMessage id='consoleNotAvailable' />
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<Console vmId={vmId} scale={state.consoleScale} setCtrlAltDel={effects.setCtrlAltDel} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default TabConsole
|
||||
131
@xen-orchestra/lite/src/App/TreeView.tsx
Normal file
131
@xen-orchestra/lite/src/App/TreeView.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import { Collection, Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon from '../components/Icon'
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
import Tree, { ItemType } from '../components/Tree'
|
||||
import { Host, ObjectsByType, Pool, Vm } from '../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
defaultSelectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
collection?: Array<ItemType>
|
||||
hostsByPool?: Collection.Keyed<string, Collection<string, Host>>
|
||||
pools?: Map<string, Pool>
|
||||
vms?: Map<string, Vm>
|
||||
vmsByContainerRef?: Collection.Keyed<string, Collection<string, Vm>>
|
||||
}
|
||||
|
||||
const getHostPowerState = (host: Host) => {
|
||||
const { $metrics } = host
|
||||
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
|
||||
}
|
||||
|
||||
const getIconColor = (obj: Host | Vm) => {
|
||||
const powerState = obj.power_state ?? getHostPowerState(obj as Host)
|
||||
return powerState === 'Running' ? '#198754' : powerState === 'Halted' ? '#dc3545' : '#6c757d'
|
||||
}
|
||||
|
||||
const TreeView = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
collection: state => {
|
||||
if (state.pools === undefined) {
|
||||
return
|
||||
}
|
||||
const collection: ItemType[] = []
|
||||
state.pools.valueSeq().forEach((pool: Pool) => {
|
||||
const hosts = state.hostsByPool
|
||||
?.get(pool.$id)
|
||||
?.valueSeq()
|
||||
.sortBy(host => host.name_label)
|
||||
.map((host: Host) => ({
|
||||
children: state.vmsByContainerRef
|
||||
?.get(host.$ref)
|
||||
?.valueSeq()
|
||||
.sortBy(vm => vm.name_label)
|
||||
.map((vm: Vm) => ({
|
||||
id: vm.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/vms/${vm.$id}/console`,
|
||||
tooltip: <IntlMessage id={vm.power_state.toLowerCase()} />,
|
||||
}))
|
||||
.toArray(),
|
||||
id: host.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='server' htmlColor={getIconColor(host)} /> {host.name_label}
|
||||
</span>
|
||||
),
|
||||
tooltip: <IntlMessage id={getHostPowerState(host).toLowerCase()} />,
|
||||
}))
|
||||
.toArray()
|
||||
|
||||
const haltedVms = state.vmsByContainerRef
|
||||
?.get(pool.$ref)
|
||||
?.valueSeq()
|
||||
.sortBy((vm: Vm) => vm.name_label)
|
||||
.map((vm: Vm) => ({
|
||||
id: vm.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/vms/${vm.$id}/console`,
|
||||
tooltip: <IntlMessage id='halted' />,
|
||||
}))
|
||||
.toArray()
|
||||
|
||||
collection.push({
|
||||
children: (hosts ?? []).concat(haltedVms ?? []),
|
||||
id: pool.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='warehouse' color='primary' /> {pool.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/pool/${pool.$id}/dashboard`,
|
||||
})
|
||||
})
|
||||
|
||||
return collection
|
||||
},
|
||||
hostsByPool: state => state.objectsByType?.get('host')?.groupBy((host: Host) => host.$pool.$id),
|
||||
pools: state => state.objectsByType?.get('pool'),
|
||||
vms: state =>
|
||||
state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
|
||||
vmsByContainerRef: state =>
|
||||
state.vms?.groupBy(({ power_state: powerState, resident_on: host, $pool }: Vm) =>
|
||||
powerState === 'Running' || powerState === 'Paused' ? host : $pool.$ref
|
||||
),
|
||||
},
|
||||
},
|
||||
({ state, defaultSelectedNodes }) =>
|
||||
state.collection === undefined ? null : (
|
||||
<div style={{ padding: '10px' }}>
|
||||
<Tree collection={state.collection} defaultSelectedNodes={defaultSelectedNodes} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default TreeView
|
||||
506
@xen-orchestra/lite/src/App/index.tsx
Normal file
506
@xen-orchestra/lite/src/App/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
// import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import Container from '@mui/material/Container'
|
||||
import Cookies from 'js-cookie'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'
|
||||
import MuiDrawer from '@mui/material/Drawer'
|
||||
import React from 'react'
|
||||
import styledComponent from 'styled-components'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { HashRouter as Router, Switch, Redirect, Route } from 'react-router-dom'
|
||||
import { IntlProvider } from 'react-intl'
|
||||
import { Map } from 'immutable'
|
||||
import { styled, createTheme, ThemeProvider } from '@mui/material/styles'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
// import Button from '../components/Button'
|
||||
import Icon from '../components/Icon'
|
||||
import Infrastructure from './Infrastructure'
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
import Link from '../components/Link'
|
||||
import messagesEn from '../lang/en.json'
|
||||
import Modal from '../components/Modal'
|
||||
import PoolTab from './PoolTab'
|
||||
import Signin from './Signin/index'
|
||||
import StyleGuide from './StyleGuide/index'
|
||||
import TabConsole from './TabConsole'
|
||||
|
||||
import XapiConnection, { ObjectsByType, Pool, Vm } from '../libs/xapi'
|
||||
|
||||
const drawerWidth = 240
|
||||
const redirectPaths = ['/', '/infrastructure']
|
||||
|
||||
interface AppBarProps extends MuiAppBarProps {
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard
|
||||
|
||||
const AppBar = styled(MuiAppBar, {
|
||||
shouldForwardProp: prop => prop !== 'open',
|
||||
})<AppBarProps>(({ theme, open }) => ({
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
...(open && {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const Drawer = styled(MuiDrawer, { shouldForwardProp: prop => prop !== 'open' })(({ theme, open }) => ({
|
||||
'& .MuiDrawer-paper': {
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
boxSizing: 'border-box',
|
||||
...(!open && {
|
||||
overflowX: 'hidden',
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
width: theme.spacing(7),
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: theme.spacing(9),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const MainListItems = (): JSX.Element => (
|
||||
<div>
|
||||
<ListItemButton component='a' href='#infrastructure'>
|
||||
<ListItemIcon>
|
||||
<Icon icon='project-diagram' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<IntlMessage id='infrastructure' />} />
|
||||
</ListItemButton>
|
||||
<ListItemButton component='a' href='#about'>
|
||||
<ListItemIcon>
|
||||
<Icon icon='info-circle' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary='About' />
|
||||
</ListItemButton>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface SecondaryListItemsParentState {}
|
||||
|
||||
interface SecondaryListItemsState {}
|
||||
|
||||
interface SecondaryListItemsProps {}
|
||||
|
||||
interface SecondaryListItemsParentEffects {}
|
||||
|
||||
interface SecondaryListItemsEffects {
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
interface SecondaryListItemsComputed {}
|
||||
|
||||
const ICON_STYLE = { fontSize: '1.5em' }
|
||||
|
||||
const SecondaryListItems = withState<
|
||||
SecondaryListItemsState,
|
||||
SecondaryListItemsProps,
|
||||
SecondaryListItemsEffects,
|
||||
SecondaryListItemsComputed,
|
||||
SecondaryListItemsParentState,
|
||||
SecondaryListItemsParentEffects
|
||||
>({}, ({ effects }) => (
|
||||
<div>
|
||||
<ListItem button onClick={() => effects.disconnect()}>
|
||||
<ListItemIcon style={ICON_STYLE}>
|
||||
<Icon icon='sign-out-alt' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<IntlMessage id='disconnect' />} />
|
||||
</ListItem>
|
||||
</div>
|
||||
))
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Default bootstrap 4 colors
|
||||
// https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss#L67-L74
|
||||
const mdTheme = createTheme({
|
||||
background: {
|
||||
primary: {
|
||||
dark: '#111111',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
error: {
|
||||
main: '#dc3545',
|
||||
},
|
||||
info: {
|
||||
main: '#17a2b8',
|
||||
},
|
||||
primary: {
|
||||
dark: '#168FFF',
|
||||
light: '#0085FF',
|
||||
main: '#007bff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#6c757d',
|
||||
},
|
||||
success: {
|
||||
main: '#28a745',
|
||||
},
|
||||
warning: {
|
||||
main: '#ffc107',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: '#E8E8E8',
|
||||
fontStyle: 'medium',
|
||||
fontSize: '1.25em',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'inter',
|
||||
h1: {
|
||||
fontWeight: 500,
|
||||
fontSize: '3em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '3.75em',
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 500,
|
||||
fontSize: '2.25em',
|
||||
fontStyle: 'medium',
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1.5em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '2em',
|
||||
},
|
||||
h4: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1.25em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.75em',
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.50em',
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 500,
|
||||
fontSize: '0.8em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.25em',
|
||||
},
|
||||
caption: {
|
||||
// styleName: Caps / Caps 1 - 14 Semi Bold
|
||||
fontSize: '0.9em',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.25em',
|
||||
verticalAlign: 'top',
|
||||
letterSpacing: '0.04em',
|
||||
textAlign: 'left',
|
||||
},
|
||||
body2: {
|
||||
// styleName: Paragraph / P2 - 16
|
||||
fontSize: '1em',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.5em',
|
||||
letterSpacing: '0em',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const FullPage = styledComponent.div`
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {
|
||||
connected: boolean
|
||||
drawerOpen: boolean
|
||||
error: React.ReactNode
|
||||
xapiHostname: string
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
connectToXapi: (password: string, rememberMe: boolean) => void
|
||||
disconnect: () => void
|
||||
toggleDrawer: () => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
objectsFetched: boolean
|
||||
pool?: Pool
|
||||
url: string
|
||||
vms?: Map<string, Vm>
|
||||
}
|
||||
|
||||
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
connected: Cookies.get('sessionId') !== undefined,
|
||||
drawerOpen: false,
|
||||
error: '',
|
||||
objectsByType: undefined,
|
||||
xapi: undefined,
|
||||
xapiHostname: process.env.XAPI_HOST || window.location.host,
|
||||
}),
|
||||
effects: {
|
||||
initialize: async function () {
|
||||
const xapi = (this.state.xapi = new XapiConnection())
|
||||
|
||||
xapi.on('connected', () => {
|
||||
this.state.connected = true
|
||||
})
|
||||
|
||||
xapi.on('disconnected', () => {
|
||||
this.state.connected = false
|
||||
})
|
||||
|
||||
xapi.on('objects', (objectsByType: ObjectsByType) => {
|
||||
this.state.objectsByType = objectsByType
|
||||
})
|
||||
|
||||
try {
|
||||
await xapi.reattachSession(this.state.url)
|
||||
} catch (err) {
|
||||
if (err?.code !== 'SESSION_INVALID') {
|
||||
throw err
|
||||
}
|
||||
|
||||
console.log('Session ID is invalid. Asking for credentials.')
|
||||
}
|
||||
},
|
||||
toggleDrawer: function () {
|
||||
this.state.drawerOpen = !this.state.drawerOpen
|
||||
},
|
||||
connectToXapi: async function (password, rememberMe = false) {
|
||||
try {
|
||||
await this.state.xapi.connect({
|
||||
url: this.state.url,
|
||||
user: 'root',
|
||||
password,
|
||||
rememberMe,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.code !== 'SESSION_AUTHENTICATION_FAILED') {
|
||||
throw err
|
||||
}
|
||||
|
||||
this.state.error = <IntlMessage id='badCredentials' />
|
||||
}
|
||||
},
|
||||
disconnect: async function () {
|
||||
await this.state.xapi.disconnect()
|
||||
this.state.connected = false
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
objectsFetched: state => state.objectsByType !== undefined,
|
||||
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.keySeq().first() : undefined),
|
||||
vms: state =>
|
||||
state.objectsFetched
|
||||
? state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template)
|
||||
: undefined,
|
||||
url: state => `${window.location.protocol}//${state.xapiHostname}`,
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<IntlProvider messages={messagesEn} locale='en'>
|
||||
{/* Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard */}
|
||||
<ThemeProvider theme={mdTheme}>
|
||||
<Modal />
|
||||
{!state.connected ? (
|
||||
<Signin />
|
||||
) : !state.objectsFetched ? (
|
||||
<IntlMessage id='loading' />
|
||||
) : (
|
||||
<>
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route exact path={redirectPaths}>
|
||||
<Redirect to={`/infrastructure/pool/${state.pool.$id}/dashboard`} />
|
||||
</Route>
|
||||
<Route exact path='/vm-list'>
|
||||
{state.vms !== undefined && (
|
||||
<>
|
||||
<p>There are {state.vms.size} VMs!</p>
|
||||
<ul>
|
||||
{state.vms.valueSeq().map((vm: Vm) => (
|
||||
<li key={vm.$id}>
|
||||
<Link to={vm.$id}>
|
||||
{vm.name_label} - {vm.name_description} ({vm.power_state})
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Route>
|
||||
<Route exact path='/styleguide'>
|
||||
<StyleGuide />
|
||||
</Route>
|
||||
<Route exact path='/styleguide/foo'>
|
||||
<StyleGuide />
|
||||
</Route>
|
||||
<Route exact path='/pool'>
|
||||
<PoolTab />
|
||||
</Route>
|
||||
<Route path='/'>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position='absolute' open={state.drawerOpen}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
pr: '24px', // keep right padding when drawer closed
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
edge='start'
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
onClick={effects.toggleDrawer}
|
||||
sx={{
|
||||
marginRight: '36px',
|
||||
...(state.drawerOpen && { display: 'none' }),
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography component='h1' variant='h6' color='inherit' noWrap sx={{ flexGrow: 1 }}>
|
||||
<Switch>
|
||||
<Route path='/infrastructure'>
|
||||
<IntlMessage id='infrastructure' />
|
||||
</Route>
|
||||
<Route path='/about'>
|
||||
<IntlMessage id='about' />
|
||||
</Route>
|
||||
<Route>
|
||||
<IntlMessage id='notFound' />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Typography>
|
||||
{/* <IconButton color='inherit'>
|
||||
<Badge badgeContent={4} color='secondary'>
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton> */}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant='permanent' open={state.drawerOpen}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
px: [1],
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={effects.toggleDrawer}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
<MainListItems />
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<SecondaryListItems />
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
backgroundColor: theme =>
|
||||
theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900],
|
||||
flexGrow: 1,
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route path='/infrastructure'>
|
||||
<FullPage>
|
||||
<Toolbar />
|
||||
<Infrastructure />
|
||||
</FullPage>
|
||||
</Route>
|
||||
<Route path='/about'>
|
||||
<Toolbar />
|
||||
<Container maxWidth='lg' sx={{ mt: 4, mb: 4 }}>
|
||||
<p>
|
||||
Check out{' '}
|
||||
<Link to='https://xen-orchestra.com/blog/xen-orchestra-lite/'>Xen Orchestra Lite</Link>{' '}
|
||||
dev blog.
|
||||
</p>
|
||||
<p>
|
||||
<IntlMessage id='versionValue' values={{ version: process.env.NPM_VERSION }} />
|
||||
</p>
|
||||
</Container>
|
||||
</Route>
|
||||
<Route>
|
||||
<Toolbar />
|
||||
<IntlMessage id='pageNotFound' />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Box>
|
||||
</Box>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
)
|
||||
)
|
||||
|
||||
export default App
|
||||
57
@xen-orchestra/lite/src/components/ActionButton.tsx
Normal file
57
@xen-orchestra/lite/src/components/ActionButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import LoadingButton, { LoadingButtonProps } from '@mui/lab/LoadingButton'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Omit the `onClick` props to rewrite its own one.
|
||||
interface Props extends Omit<LoadingButtonProps, 'onClick'> {
|
||||
onClick: (data: Record<string, unknown>) => Promise<void>
|
||||
// to pass props with the following pattern: "data-something"
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
_onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const ActionButton = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({ isLoading: false }),
|
||||
effects: {
|
||||
_onClick: function () {
|
||||
this.state.isLoading = true
|
||||
const data: Record<string, unknown> = {}
|
||||
Object.keys(this.props).forEach(key => {
|
||||
if (key.startsWith('data-')) {
|
||||
data[key.slice(5)] = this.props[key]
|
||||
}
|
||||
})
|
||||
return this.props.onClick(data).finally(() => (this.state.isLoading = false))
|
||||
},
|
||||
},
|
||||
},
|
||||
({ children, color = 'secondary', effects, onClick, resetState, state, variant = 'contained', ...props }) => (
|
||||
<LoadingButton
|
||||
color={color}
|
||||
disabled={state.isLoading}
|
||||
fullWidth
|
||||
loading={state.isLoading}
|
||||
onClick={effects._onClick}
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LoadingButton>
|
||||
)
|
||||
)
|
||||
|
||||
export default ActionButton
|
||||
26
@xen-orchestra/lite/src/components/Button.tsx
Normal file
26
@xen-orchestra/lite/src/components/Button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { Button as MuiButton, ButtonProps } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends ButtonProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Button = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ children, color = 'secondary', effects, resetState, state, variant = 'contained', ...props }) => (
|
||||
<MuiButton color={color} fullWidth variant={variant} {...props}>
|
||||
{children}
|
||||
</MuiButton>
|
||||
)
|
||||
)
|
||||
|
||||
export default Button
|
||||
22
@xen-orchestra/lite/src/components/Checkbox.tsx
Normal file
22
@xen-orchestra/lite/src/components/Checkbox.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { CheckboxProps, Checkbox as MuiCheckbox } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends CheckboxProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Checkbox = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ effects, resetState, state, ...props }) => <MuiCheckbox {...props} />
|
||||
)
|
||||
|
||||
export default Checkbox
|
||||
193
@xen-orchestra/lite/src/components/Console.tsx
Normal file
193
@xen-orchestra/lite/src/components/Console.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from 'react'
|
||||
import RFB from '@novnc/novnc/lib/rfb'
|
||||
import styled from 'styled-components'
|
||||
import { fibonacci } from 'iterable-backoff'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
import { confirm } from './Modal'
|
||||
|
||||
import XapiConnection, { ObjectsByType, Vm } from '../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {
|
||||
// Type error with HTMLDivElement.
|
||||
// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/30451
|
||||
container: React.RefObject<any>
|
||||
// See https://github.com/vatesfr/xen-orchestra/pull/5722#discussion_r619296074
|
||||
rfb: any
|
||||
rfbConnected: boolean
|
||||
timeout?: NodeJS.Timeout
|
||||
tryToReconnect: boolean
|
||||
url?: URL
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scale: number
|
||||
setCtrlAltDel: (sendCtrlAltDel: Effects['sendCtrlAltDel']) => void
|
||||
vmId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
_connect: () => Promise<void>
|
||||
_handleConnect: () => void
|
||||
_handleDisconnect: () => Promise<void>
|
||||
sendCtrlAltDel: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
interface PropsStyledConsole {
|
||||
scale: number
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
enum Protocols {
|
||||
http = 'http:',
|
||||
https = 'https:',
|
||||
ws = 'ws:',
|
||||
wss = 'wss:',
|
||||
}
|
||||
|
||||
const StyledConsole = styled.div<PropsStyledConsole>`
|
||||
height: ${props => props.scale}%;
|
||||
margin: auto;
|
||||
visibility: ${props => (props.visible ? 'visible' : 'hidden')};
|
||||
width: ${props => props.scale}%;
|
||||
`
|
||||
|
||||
// https://github.com/novnc/noVNC/blob/master/docs/API.md
|
||||
const Console = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
container: React.createRef(),
|
||||
rfb: undefined,
|
||||
rfbConnected: false,
|
||||
timeout: undefined,
|
||||
tryToReconnect: true,
|
||||
url: undefined,
|
||||
}),
|
||||
effects: {
|
||||
initialize: function () {
|
||||
this.effects._connect()
|
||||
},
|
||||
_handleConnect: function () {
|
||||
this.state.rfbConnected = true
|
||||
},
|
||||
_handleDisconnect: async function () {
|
||||
this.state.rfbConnected = false
|
||||
const {
|
||||
state: { objectsByType, url },
|
||||
effects: { _connect },
|
||||
} = this
|
||||
const { protocol } = window.location
|
||||
if (protocol === Protocols.https) {
|
||||
try {
|
||||
await fetch(`${protocol}//${url?.host}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
try {
|
||||
await confirm({
|
||||
icon: 'exclamation-triangle',
|
||||
message: (
|
||||
<a href={`${protocol}//${url?.host}`} rel='noopener noreferrer' target='_blank'>
|
||||
<IntlMessage
|
||||
id='unreachableHost'
|
||||
values={{
|
||||
name: objectsByType.get('host')?.find(host => host.address === url?.host)?.name_label,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
title: <IntlMessage id='connectionError' />,
|
||||
})
|
||||
} catch {
|
||||
this.state.tryToReconnect = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.tryToReconnect) {
|
||||
_connect()
|
||||
}
|
||||
},
|
||||
_connect: async function () {
|
||||
const { vmId } = this.props
|
||||
const { objectsByType, rfb, xapi } = this.state
|
||||
let lastError: unknown
|
||||
|
||||
// 8 tries mean 54s
|
||||
for (const delay of fibonacci().toMs().take(8)) {
|
||||
try {
|
||||
const consoles = (objectsByType.get('VM')?.get(vmId) as Vm)?.$consoles.filter(
|
||||
vmConsole => vmConsole.protocol === 'rfb'
|
||||
)
|
||||
|
||||
if (rfb !== undefined) {
|
||||
rfb.removeEventListener('connect', this.effects._handleConnect)
|
||||
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
|
||||
}
|
||||
|
||||
if (consoles === undefined || consoles.length === 0) {
|
||||
throw new Error('Could not find VM console')
|
||||
}
|
||||
|
||||
if (xapi.sessionId === undefined) {
|
||||
throw new Error('Not connected to XAPI')
|
||||
}
|
||||
|
||||
this.state.url = new URL(consoles[0].location)
|
||||
this.state.url.protocol = window.location.protocol === Protocols.https ? Protocols.wss : Protocols.ws
|
||||
this.state.url.searchParams.set('session_id', xapi.sessionId)
|
||||
|
||||
this.state.rfb = new RFB(this.state.container.current, this.state.url, {
|
||||
wsProtocols: ['binary'],
|
||||
})
|
||||
this.state.rfb.addEventListener('connect', this.effects._handleConnect)
|
||||
this.state.rfb.addEventListener('disconnect', this.effects._handleDisconnect)
|
||||
this.state.rfb.scaleViewport = true
|
||||
this.props.setCtrlAltDel(this.effects.sendCtrlAltDel)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise(resolve => (this.state.timeout = setTimeout(resolve, delay)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
},
|
||||
finalize: function () {
|
||||
const { rfb, timeout } = this.state
|
||||
rfb.removeEventListener('connect', this.effects._handleConnect)
|
||||
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
},
|
||||
sendCtrlAltDel: async function () {
|
||||
await confirm({
|
||||
message: <IntlMessage id='confirmCtrlAltDel' />,
|
||||
title: <IntlMessage id='ctrlAltDel' />,
|
||||
})
|
||||
this.state.rfb.sendCtrlAltDel()
|
||||
},
|
||||
},
|
||||
},
|
||||
({ scale, state }) => (
|
||||
<>
|
||||
{state.rfb !== undefined && !state.rfbConnected && (
|
||||
<p>
|
||||
<IntlMessage id={state.tryToReconnect ? 'reconnectionAttempt' : 'hostUnreachable'} />
|
||||
</p>
|
||||
)}
|
||||
<StyledConsole ref={state.container} scale={scale} visible={state.rfbConnected} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
export default Console
|
||||
30
@xen-orchestra/lite/src/components/Icon.tsx
Normal file
30
@xen-orchestra/lite/src/components/Icon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconName as _IconName, library, SizeProp } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
library.add(fas)
|
||||
|
||||
const Icon = ({
|
||||
color,
|
||||
htmlColor,
|
||||
icon,
|
||||
size,
|
||||
}: {
|
||||
color?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
|
||||
htmlColor?: string
|
||||
icon: _IconName
|
||||
size?: SizeProp
|
||||
}): JSX.Element => {
|
||||
const { palette } = useTheme()
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
size={size}
|
||||
color={htmlColor ?? (color !== undefined ? palette[color][palette.mode] : undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default Icon
|
||||
export type IconName = _IconName
|
||||
26
@xen-orchestra/lite/src/components/Input.tsx
Normal file
26
@xen-orchestra/lite/src/components/Input.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
// An interface can only extend an object type or intersection
|
||||
// of object types with statically known members.
|
||||
type Props = _Props & TextFieldProps
|
||||
|
||||
interface _Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Input = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ effects, resetState, state, ...props }) => <TextField fullWidth {...props} />
|
||||
)
|
||||
|
||||
export default Input
|
||||
21
@xen-orchestra/lite/src/components/IntlMessage.tsx
Normal file
21
@xen-orchestra/lite/src/components/IntlMessage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { ElementType, ReactElement, ReactNode } from 'react'
|
||||
import { FormattedMessage, MessageDescriptor, useIntl } from 'react-intl'
|
||||
import intlMessage from '../lang/en.json'
|
||||
|
||||
// Extends FormattedMessage not working: "FormattedMessage refers to a value, but is being used as a type here"
|
||||
// https://stackoverflow.com/questions/62059408/reactjs-and-typescript-refers-to-a-value-but-is-being-used-as-a-type-here-ts
|
||||
// InstanceType<typeof FormattedMessage> not working: "Type [...] does not satisfy the constraint abstract new (...args: any) => any."
|
||||
// See https://formatjs.io/docs/react-intl/components/#formattedmessage
|
||||
interface Props extends MessageDescriptor {
|
||||
children?: (chunks: ReactElement) => ReactElement
|
||||
id?: keyof typeof intlMessage
|
||||
tagName?: ElementType
|
||||
values?: Record<string, ReactNode>
|
||||
}
|
||||
const IntlMessage = (props: Props): JSX.Element => <FormattedMessage {...props} />
|
||||
|
||||
export function translate(message: MessageDescriptor){
|
||||
return useIntl().formatMessage(message)
|
||||
}
|
||||
|
||||
export default React.memo(IntlMessage)
|
||||
38
@xen-orchestra/lite/src/components/Link.tsx
Normal file
38
@xen-orchestra/lite/src/components/Link.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import MaterialLink from '@mui/material/Link'
|
||||
import React from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
decorated?: boolean
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const UNDECORATED_LINK = { textDecoration: 'none', color: 'inherit' }
|
||||
|
||||
const Link = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ to, decorated = true, children }) =>
|
||||
to === undefined ? (
|
||||
<>{children}</>
|
||||
) : to.startsWith('http') ? (
|
||||
<MaterialLink style={decorated ? undefined : UNDECORATED_LINK} target='_blank' rel='noopener noreferrer' href={to}>
|
||||
{children}
|
||||
</MaterialLink>
|
||||
) : (
|
||||
<RouterLink style={decorated ? undefined : UNDECORATED_LINK} component={MaterialLink} to={to}>
|
||||
{children}
|
||||
</RouterLink>
|
||||
)
|
||||
)
|
||||
|
||||
export default Link
|
||||
152
@xen-orchestra/lite/src/components/Modal.tsx
Normal file
152
@xen-orchestra/lite/src/components/Modal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react'
|
||||
import { ButtonProps, Dialog, DialogContent, DialogContentText, DialogActions, DialogTitle } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Button from './Button'
|
||||
import Icon, { IconName } from './Icon'
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
type ModalButton = {
|
||||
color?: ButtonProps['color']
|
||||
label: string | React.ReactNode
|
||||
reason?: unknown
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
interface GeneralParamsModal {
|
||||
icon: IconName
|
||||
message: string | React.ReactNode
|
||||
title: string | React.ReactNode
|
||||
}
|
||||
|
||||
interface ModalParams extends GeneralParamsModal {
|
||||
buttonList: ModalButton[]
|
||||
}
|
||||
|
||||
let instance: EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> | undefined
|
||||
const modal = ({ buttonList, icon, message, title }: ModalParams) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (instance === undefined) {
|
||||
throw new Error('No modal instance')
|
||||
}
|
||||
instance.state.buttonList = buttonList
|
||||
instance.state.icon = icon
|
||||
instance.state.message = message
|
||||
instance.state.onReject = reject
|
||||
instance.state.onSuccess = resolve
|
||||
instance.state.showModal = true
|
||||
instance.state.title = title
|
||||
})
|
||||
|
||||
export const alert = (params: GeneralParamsModal): Promise<unknown> => {
|
||||
const buttonList: ModalButton[] = [
|
||||
{
|
||||
label: <IntlMessage id='ok' />,
|
||||
color: 'primary',
|
||||
value: 'success',
|
||||
},
|
||||
]
|
||||
return modal({ ...params, buttonList })
|
||||
}
|
||||
|
||||
export const confirm = (params: GeneralParamsModal): Promise<unknown> => {
|
||||
const buttonList: ModalButton[] = [
|
||||
{
|
||||
label: <IntlMessage id='confirm' />,
|
||||
value: 'confirm',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
label: <IntlMessage id='cancel' />,
|
||||
color: 'secondary',
|
||||
reason: 'cancel',
|
||||
},
|
||||
]
|
||||
return modal({ ...params, buttonList })
|
||||
}
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
buttonList?: ModalButton[]
|
||||
icon?: IconName
|
||||
message?: string | React.ReactNode
|
||||
onReject?: (reason: unknown) => void
|
||||
onSuccess?: (value: unknown) => void
|
||||
showModal: boolean
|
||||
title?: string | React.ReactNode
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
closeModal: () => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Modal = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
buttonList: undefined,
|
||||
icon: undefined,
|
||||
message: undefined,
|
||||
onReject: undefined,
|
||||
onSuccess: undefined,
|
||||
showModal: false,
|
||||
title: undefined,
|
||||
}),
|
||||
effects: {
|
||||
initialize: function () {
|
||||
if (instance !== undefined) {
|
||||
throw new Error('Modal is a singelton')
|
||||
}
|
||||
instance = this
|
||||
},
|
||||
closeModal: function () {
|
||||
this.state.showModal = false
|
||||
},
|
||||
reject: function (reason) {
|
||||
this.state.onReject?.(reason)
|
||||
this.effects.closeModal()
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state }) => {
|
||||
const { closeModal, reject } = effects
|
||||
const { buttonList, icon, message, onReject, onSuccess, showModal, title } = state
|
||||
|
||||
return showModal ? (
|
||||
<Dialog open={showModal} onClose={reject}>
|
||||
<DialogTitle>
|
||||
{icon !== undefined && <Icon icon={icon} />} {title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{message}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{buttonList?.map(({ label, reason, value, ...props }, index) => {
|
||||
const onClick = () => {
|
||||
if (value !== undefined) {
|
||||
onSuccess?.(value)
|
||||
} else {
|
||||
onReject?.(reason)
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
return (
|
||||
<Button key={index} onClick={onClick} {...props}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
) : null
|
||||
}
|
||||
)
|
||||
|
||||
export default Modal
|
||||
63
@xen-orchestra/lite/src/components/PanelHeader.tsx
Normal file
63
@xen-orchestra/lite/src/components/PanelHeader.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon, { IconName } from './Icon'
|
||||
|
||||
import Button, { ButtonProps } from '@mui/material/Button'
|
||||
import ButtonGroup, { ButtonGroupClassKey } from '@mui/material/ButtonGroup'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography, { TypographyClassKey } from '@mui/material/Typography'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Action extends ButtonProps {
|
||||
icon: IconName
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const DEFAULT_TITLE_STYLE = { marginLeft: '0.5em', flex: 1, fontSize: '250%' }
|
||||
const DEFAULT_BUTTONGROUP_STYLE = { margin: '0.5em', flex: 0 }
|
||||
const DEFAULT_STACK_STYLE = {
|
||||
backgroundColor: (theme: Theme) => {
|
||||
const { background, palette } = theme
|
||||
return palette.mode === 'light' ? background.primary.light : background.primary.dark
|
||||
},
|
||||
paddingTop: '1em',
|
||||
}
|
||||
|
||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
// Accepts an array of Actions. An action accepts all the props of a Button + an icon
|
||||
actions?: Array<Action>
|
||||
// the props passed to the title, accepts all the keys of Typography
|
||||
titleProps?: TypographyClassKey
|
||||
// the props passed to the button group, accepts all the keys of a ButtonGroup
|
||||
buttonGroupProps?: ButtonGroupClassKey
|
||||
}
|
||||
|
||||
const PanelHeader = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ actions = [], titleProps = {}, buttonGroupProps = {}, children = null }) => (
|
||||
<Stack direction='row' justifyContent='space-between' alignItems='center' sx={DEFAULT_STACK_STYLE}>
|
||||
<Typography variant='h2' sx={DEFAULT_TITLE_STYLE} {...titleProps}>
|
||||
{children}
|
||||
</Typography>
|
||||
<ButtonGroup sx={DEFAULT_BUTTONGROUP_STYLE} {...buttonGroupProps}>
|
||||
{(actions as Array<Action>)?.map(({ icon, ...actionProps }) => (
|
||||
<Button {...actionProps} key={actionProps.key}>
|
||||
<Icon icon={icon} />
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
)
|
||||
)
|
||||
|
||||
export default PanelHeader
|
||||
87
@xen-orchestra/lite/src/components/ProgressCircle.tsx
Normal file
87
@xen-orchestra/lite/src/components/ProgressCircle.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import React from 'react'
|
||||
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import { Typography } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
const BackgroundBox = styled(Box)({
|
||||
position: 'absolute',
|
||||
})
|
||||
|
||||
const BackgroundCircle = styled(CircularProgress)({
|
||||
color: '#e3dede',
|
||||
})
|
||||
|
||||
const Container = styled(Box)({
|
||||
display: 'inline-flex',
|
||||
position: 'relative',
|
||||
})
|
||||
|
||||
const StyledLabel = styled(Typography)(({ color, theme: { palette } }) => ({
|
||||
color: (palette[(color as string) ?? 'primary'] ?? palette.primary).main,
|
||||
textAlign: 'center',
|
||||
}))
|
||||
|
||||
const LabelBox = styled(Box)({
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
height: '80%',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
margin: 'auto',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '80%',
|
||||
})
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
color?: CircularProgressProps['color']
|
||||
label?: string
|
||||
max?: number
|
||||
showLabel?: boolean
|
||||
size?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
label: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
const ProgressCircle = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
label: ({ progress }, { label }) => label ?? `${progress}%`,
|
||||
progress: (_, { max = 100, value }) => Math.round((value / max) * 100),
|
||||
},
|
||||
},
|
||||
({ color = 'success', showLabel = true, size = 100, state: { label, progress } }) => (
|
||||
<Container>
|
||||
<BackgroundBox>
|
||||
<BackgroundCircle variant='determinate' value={100} size={size} />
|
||||
</BackgroundBox>
|
||||
<CircularProgress aria-label={label} color={color} size={size} value={progress} variant='determinate' />
|
||||
{showLabel && (
|
||||
<LabelBox>
|
||||
<StyledLabel variant='h5' color={color}>
|
||||
{label}
|
||||
</StyledLabel>
|
||||
</LabelBox>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default ProgressCircle
|
||||
7
@xen-orchestra/lite/src/components/RangeInput.tsx
Normal file
7
@xen-orchestra/lite/src/components/RangeInput.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'>
|
||||
|
||||
const RangeInput = React.memo((props: Props) => <input {...props} type='range' />)
|
||||
|
||||
export default RangeInput
|
||||
97
@xen-orchestra/lite/src/components/Select.tsx
Normal file
97
@xen-orchestra/lite/src/components/Select.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import React from 'react'
|
||||
import SelectMaterialUi, { SelectProps } from '@mui/material/Select'
|
||||
import { iteratee } from 'lodash'
|
||||
import { SelectChangeEvent } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
type AdditionalProps = Record<string, any>
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends SelectProps {
|
||||
additionalProps?: AdditionalProps
|
||||
onChange: (e: SelectChangeEvent<unknown>) => void
|
||||
optionRenderer?: string | { (item: any): number | string }
|
||||
options: any[] | undefined
|
||||
value: any
|
||||
valueRenderer?: string | { (item: any): number | string }
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
renderOption: (item: any, additionalProps?: AdditionalProps) => React.ReactNode
|
||||
renderValue: (item: any, additionalProps?: AdditionalProps) => number | string
|
||||
options?: JSX.Element[]
|
||||
}
|
||||
|
||||
const Select = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
// @ts-ignore
|
||||
renderOption: (_, { optionRenderer }) => iteratee(optionRenderer),
|
||||
// @ts-ignore
|
||||
renderValue: (_, { valueRenderer }) => iteratee(valueRenderer),
|
||||
options: (state, { additionalProps, options, optionRenderer, valueRenderer }) =>
|
||||
options?.map(item => {
|
||||
const label =
|
||||
optionRenderer === undefined
|
||||
? item.name ?? item.label ?? item.name_label
|
||||
: state.renderOption(item, additionalProps)
|
||||
const value =
|
||||
valueRenderer === undefined ? item.value ?? item.id ?? item.$id : state.renderValue(item, additionalProps)
|
||||
|
||||
if (value === undefined) {
|
||||
console.error('Computed value is undefined')
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
)
|
||||
}),
|
||||
},
|
||||
},
|
||||
({
|
||||
additionalProps,
|
||||
displayEmpty = true,
|
||||
effects,
|
||||
multiple,
|
||||
options,
|
||||
required,
|
||||
resetState,
|
||||
state,
|
||||
value,
|
||||
...props
|
||||
}) => (
|
||||
<FormControl>
|
||||
<SelectMaterialUi
|
||||
multiple={multiple}
|
||||
required={required}
|
||||
displayEmpty={displayEmpty}
|
||||
value={value ?? (multiple ? [] : '')}
|
||||
{...props}
|
||||
>
|
||||
{!multiple && (
|
||||
<MenuItem value=''>
|
||||
<em>
|
||||
<IntlMessage id='none' />
|
||||
</em>
|
||||
</MenuItem>
|
||||
)}
|
||||
{state.options}
|
||||
</SelectMaterialUi>
|
||||
</FormControl>
|
||||
)
|
||||
)
|
||||
|
||||
export default Select
|
||||
73
@xen-orchestra/lite/src/components/Table.tsx
Normal file
73
@xen-orchestra/lite/src/components/Table.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
export type Column<Type> = {
|
||||
header: React.ReactNode
|
||||
id?: string
|
||||
render: { (item: Type): React.ReactNode }
|
||||
}
|
||||
|
||||
type Item = {
|
||||
id?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
collection: Item[] | undefined
|
||||
columns: Column<any>[]
|
||||
placeholder?: JSX.Element
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const StyledTable = styled.table`
|
||||
border: 1px solid #333;
|
||||
td {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
thead {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
`
|
||||
const Table = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ collection, columns, placeholder }) =>
|
||||
collection !== undefined ? (
|
||||
collection.length !== 0 ? (
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col, index) => (
|
||||
<td key={col.id ?? index}>{col.header}</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{collection.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
{columns.map((col, index) => (
|
||||
<td key={col.id ?? index}>{col.render(item)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
) : (
|
||||
placeholder ?? <IntlMessage id='noData' />
|
||||
)
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default Table
|
||||
114
@xen-orchestra/lite/src/components/Tabs.tsx
Normal file
114
@xen-orchestra/lite/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import React from 'react'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import TabContext from '@mui/lab/TabContext'
|
||||
import TabList from '@mui/lab/TabList'
|
||||
import TabPanel from '@mui/lab/TabPanel'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { RouteComponentProps } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
|
||||
const BOX_STYLE = { borderBottom: 1, borderColor: 'divider', marginTop: '0.5em' }
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
component?: React.ReactNode
|
||||
disabled?: boolean
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
interface UrlTab extends Tab {
|
||||
pathname: string
|
||||
value?: any
|
||||
}
|
||||
|
||||
interface NoUrlTab extends Tab {
|
||||
value: any
|
||||
}
|
||||
|
||||
// For compatibility with 'withRouter'
|
||||
interface Props extends RouteComponentProps {
|
||||
indicatorColor?: 'primary' | 'secondary'
|
||||
textColor?: 'inherit' | 'primary' | 'secondary'
|
||||
// tabs = [
|
||||
// {
|
||||
// component: <span>BAR</span>,
|
||||
// pathname: '/path',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='cloud' /> {labelA}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// ]
|
||||
tabs: Array<NoUrlTab | UrlTab>
|
||||
useUrl?: boolean
|
||||
value?: any
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
onChange: (event: React.SyntheticEvent, value: string) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
// TODO: improve view as done in the model(figma).
|
||||
const pageUnderConstruction = (
|
||||
<div style={{ color: '#0085FF', textAlign: 'center' }}>
|
||||
<Typography variant='h2'>
|
||||
<IntlMessage id='xoLiteUnderConstruction' />
|
||||
</Typography>
|
||||
<Typography variant='h3'>
|
||||
<IntlMessage id='newFeaturesUnderConstruction' />
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Tabs = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: ({ location: { pathname }, tabs, useUrl = false, value }) => ({
|
||||
value: (useUrl && pathname) || (value ?? tabs[0].value ?? tabs[0].pathname),
|
||||
}),
|
||||
effects: {
|
||||
onChange: function (_, value) {
|
||||
if (this.props.useUrl) {
|
||||
const { history, tabs } = this.props
|
||||
history.push(tabs.find(tab => (tab.value ?? tab.pathname) === value).pathname)
|
||||
}
|
||||
this.state.value = value
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state: { value }, indicatorColor, textColor, tabs }) => (
|
||||
<TabContext value={value}>
|
||||
<Box sx={BOX_STYLE}>
|
||||
<TabList indicatorColor={indicatorColor} onChange={effects.onChange} textColor={textColor}>
|
||||
{tabs.map((tab: UrlTab | NoUrlTab) => {
|
||||
const value = tab.value ?? tab.pathname
|
||||
return <Tab disabled={tab.disabled} key={value} label={tab.label} value={value} />
|
||||
})}
|
||||
</TabList>
|
||||
</Box>
|
||||
{tabs.map((tab: UrlTab | NoUrlTab) => {
|
||||
const value = tab.value ?? tab.pathname
|
||||
return (
|
||||
<TabPanel key={value} value={value}>
|
||||
{tab.component === undefined ? pageUnderConstruction : tab.component}
|
||||
</TabPanel>
|
||||
)
|
||||
})}
|
||||
</TabContext>
|
||||
)
|
||||
)
|
||||
|
||||
export default withRouter(Tabs)
|
||||
196
@xen-orchestra/lite/src/components/Tree.tsx
Normal file
196
@xen-orchestra/lite/src/components/Tree.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import classNames from 'classnames'
|
||||
import React, { useEffect } from 'react'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import TreeView from '@mui/lab/TreeView'
|
||||
import TreeItem, { useTreeItem, TreeItemContentProps } from '@mui/lab/TreeItem'
|
||||
import { withState } from 'reaclette'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import Icon from '../components/Icon'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
expandedNodes?: Array<string>
|
||||
selectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
export interface ItemType {
|
||||
children?: Array<ItemType>
|
||||
id: string
|
||||
label: React.ReactElement
|
||||
to?: string
|
||||
tooltip?: React.ReactNode
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// collection = [
|
||||
// {
|
||||
// id: 'idA',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='warehouse' /> {labelA}
|
||||
// </span>
|
||||
// ),
|
||||
// to: '/routeA',
|
||||
// children: [
|
||||
// {
|
||||
// id: 'ida',
|
||||
// label: label: (
|
||||
// <span>
|
||||
// <Icon icon='server' /> {labela}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// id: 'idB',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='warehouse' /> {labelB}
|
||||
// </span>
|
||||
// ),
|
||||
// to: '/routeB',
|
||||
// tooltip: <IntlMessage id='tooltipB' />
|
||||
// }
|
||||
// ]
|
||||
collection: Array<ItemType>
|
||||
defaultSelectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
interface CustomContentProps extends TreeItemContentProps {
|
||||
defaultSelectedNode?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
setExpandedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
|
||||
setSelectedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
defaultSelectedNode?: string
|
||||
}
|
||||
|
||||
// Inspired by https://mui.com/components/tree-view/#contentcomponent-prop.
|
||||
const CustomContent = React.forwardRef(function CustomContent(props: CustomContentProps, ref) {
|
||||
const { classes, className, defaultSelectedNode, expansionIcon, label, nodeId, to } = props
|
||||
const { focused, handleExpansion, handleSelection, selected } = useTreeItem(nodeId)
|
||||
const history = useHistory()
|
||||
|
||||
useEffect(() => {
|
||||
// There can only be one node selected at once for now.
|
||||
// Auto-revealing more than one node in the tree would require a different implementation.
|
||||
if (defaultSelectedNode === nodeId) {
|
||||
ref?.current?.scrollIntoView()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
to !== undefined && history.push(to)
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
const handleExpansionClick = (event: React.SyntheticEvent) => {
|
||||
event.stopPropagation()
|
||||
handleExpansion(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(className, { [classes.focused]: focused, [classes.selected]: selected })}
|
||||
onClick={handleSelection}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={classes.iconContainer} onClick={handleExpansionClick}>
|
||||
{expansionIcon}
|
||||
</span>
|
||||
<span className={classes.label}>{label}</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
const renderItem = ({ children, id, label, to, tooltip }: ItemType, defaultSelectedNode?: string) => {
|
||||
return (
|
||||
<TreeItem
|
||||
ContentComponent={CustomContent}
|
||||
// FIXME: ContentProps should only be React.HTMLAttributes<HTMLElement> or undefined, it doesn't support other type.
|
||||
// when https://github.com/mui-org/material-ui/issues/28668 is fixed, remove 'as CustomContentProps'.
|
||||
ContentProps={{ defaultSelectedNode, to } as CustomContentProps}
|
||||
label={tooltip ? <Tooltip title={tooltip}>{label}</Tooltip> : label}
|
||||
key={id}
|
||||
nodeId={id}
|
||||
>
|
||||
{Array.isArray(children) ? children.map(item => renderItem(item, defaultSelectedNode)) : null}
|
||||
</TreeItem>
|
||||
)
|
||||
}
|
||||
|
||||
const Tree = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: ({ collection, defaultSelectedNodes }) => {
|
||||
if (defaultSelectedNodes === undefined) {
|
||||
return {
|
||||
expandedNodes: [collection[0].id],
|
||||
selectedNodes: [],
|
||||
}
|
||||
}
|
||||
|
||||
// expandedNodes should contain all nodes up to the defaultSelectedNodes.
|
||||
const expandedNodes = new Set<string>()
|
||||
const pathToNode = new Set<string>()
|
||||
const addExpandedNode = (collection: Array<ItemType> | undefined) => {
|
||||
if (collection === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const node of collection) {
|
||||
if (defaultSelectedNodes.includes(node.id)) {
|
||||
for (const nodeId of pathToNode) {
|
||||
expandedNodes.add(nodeId)
|
||||
}
|
||||
}
|
||||
pathToNode.add(node.id)
|
||||
addExpandedNode(node.children)
|
||||
pathToNode.delete(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
addExpandedNode(collection)
|
||||
|
||||
return { expandedNodes: Array.from(expandedNodes), selectedNodes: defaultSelectedNodes }
|
||||
},
|
||||
effects: {
|
||||
setExpandedNodeIds: function (_, nodeIds) {
|
||||
this.state.expandedNodes = nodeIds
|
||||
},
|
||||
setSelectedNodeIds: function (_, nodeIds) {
|
||||
this.state.selectedNodes = [nodeIds[0]]
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
defaultSelectedNode: (_, { defaultSelectedNodes }) =>
|
||||
defaultSelectedNodes !== undefined ? defaultSelectedNodes[0] : undefined,
|
||||
},
|
||||
},
|
||||
({ effects, state: { defaultSelectedNode, expandedNodes, selectedNodes }, collection }) => (
|
||||
<TreeView
|
||||
defaultCollapseIcon={<Icon icon='chevron-up' />}
|
||||
defaultExpanded={[collection[0].id]}
|
||||
defaultExpandIcon={<Icon icon='chevron-down' />}
|
||||
expanded={expandedNodes}
|
||||
multiSelect
|
||||
onNodeSelect={effects.setSelectedNodeIds}
|
||||
onNodeToggle={effects.setExpandedNodeIds}
|
||||
selected={selectedNodes}
|
||||
>
|
||||
{collection.map(item => renderItem(item, defaultSelectedNode))}
|
||||
</TreeView>
|
||||
)
|
||||
)
|
||||
|
||||
export default Tree
|
||||
26
@xen-orchestra/lite/src/index.tsx
Normal file
26
@xen-orchestra/lite/src/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { createGlobalStyle } from 'styled-components'
|
||||
|
||||
import App from './App/index'
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, Verdana, Helvetica, Ubuntu, sans-serif;
|
||||
box-sizing: border-box;
|
||||
color: #212529;
|
||||
}
|
||||
`
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Helmet>
|
||||
<link rel='shortcut icon' href='favicon.ico' />
|
||||
</Helmet>
|
||||
<GlobalStyle />
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
55
@xen-orchestra/lite/src/lang/en.json
Normal file
55
@xen-orchestra/lite/src/lang/en.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"about": "About",
|
||||
"active": "Active",
|
||||
"availableUpdates": "{nUpdates, number} available update{nUpdates, plural, one {} other {s}}",
|
||||
"badCredentials": "Bad credentials",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmCtrlAltDel": "Send Ctrl+Alt+Del to VM?",
|
||||
"connect": "Connect",
|
||||
"connectionError": "Connection error",
|
||||
"consoleNotAvailable": "Console is only available for running VMs",
|
||||
"ctrlAltDel": "Ctrl+Alt+Del",
|
||||
"description": "Description",
|
||||
"device": "Device",
|
||||
"disconnect": "Disconnect",
|
||||
"dns": "DNS",
|
||||
"errorOccurred": "An error has occurred.",
|
||||
"gateway": "Gateway",
|
||||
"halted": "Halted",
|
||||
"hosts": "Hosts",
|
||||
"hostUnreachable": "Host unreachable",
|
||||
"inactive": "Inactive",
|
||||
"infrastructure": "Infrastructure",
|
||||
"ip": "IP",
|
||||
"loading": "Loading…",
|
||||
"login": "Login",
|
||||
"name": "Name",
|
||||
"newFeaturesUnderConstruction": "New features are coming soon!",
|
||||
"noHosts": "No hosts",
|
||||
"noData": "No data",
|
||||
"noImplemented": "Not implemented",
|
||||
"noManagementPifs": "No management PIFs found",
|
||||
"none": "None",
|
||||
"noVms": "No VMs",
|
||||
"notFound": "Not Found",
|
||||
"pageNotFound": "This page doesn't exist.",
|
||||
"xoLiteUnderConstruction": "XO Lite is under construction",
|
||||
"noUpdatesAvailable": "No updates available",
|
||||
"ok": "OK",
|
||||
"password": "Password",
|
||||
"paused": "Paused",
|
||||
"reconnectionAttempt": "Trying to reconnect…",
|
||||
"release": "Release",
|
||||
"rememberMe": "Remember me",
|
||||
"running": "Running",
|
||||
"size": "Size",
|
||||
"status": "Status",
|
||||
"suspended": "Suspended",
|
||||
"total": "Total",
|
||||
"unreachableHost": "Click here to make sure your host ({name}) is reachable. You may have to allow self-signed SSL certificates in your browser.",
|
||||
"vms": "VMs",
|
||||
"version": "Version",
|
||||
"versionValue": "Version {version}",
|
||||
"vmStartLabel": "Start"
|
||||
}
|
||||
4
@xen-orchestra/lite/src/lang/fr.json
Normal file
4
@xen-orchestra/lite/src/lang/fr.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"connect": "Connexion",
|
||||
"vmStartLabel": "Démarrer"
|
||||
}
|
||||
205
@xen-orchestra/lite/src/libs/xapi.ts
Normal file
205
@xen-orchestra/lite/src/libs/xapi.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { EventEmitter } from 'events'
|
||||
import { Map } from 'immutable'
|
||||
import { Xapi } from 'xen-api'
|
||||
|
||||
export interface XapiObject {
|
||||
$pool: Pool
|
||||
$ref: string
|
||||
$type: keyof types
|
||||
$id: string
|
||||
}
|
||||
|
||||
// Dictionary of XAPI types and their corresponding TypeScript types
|
||||
interface types {
|
||||
PIF: Pif
|
||||
pool: Pool
|
||||
VM: Vm
|
||||
host: Host
|
||||
}
|
||||
|
||||
// XAPI types ---
|
||||
|
||||
export interface Pif extends XapiObject {
|
||||
device: string
|
||||
DNS: string
|
||||
gateway: string
|
||||
IP: string
|
||||
management: boolean
|
||||
network: string
|
||||
}
|
||||
|
||||
export interface Pool extends XapiObject {
|
||||
name_label: string
|
||||
}
|
||||
|
||||
export interface PoolUpdate {
|
||||
changelog: {
|
||||
author: string
|
||||
date: Date
|
||||
description: string
|
||||
}
|
||||
description: string
|
||||
license: string
|
||||
name: string
|
||||
release: string
|
||||
size: number
|
||||
url: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface Vm extends XapiObject {
|
||||
$consoles: Array<{ protocol: string; location: string }>
|
||||
is_a_snapshot: boolean
|
||||
is_a_template: boolean
|
||||
is_control_domain: boolean
|
||||
name_description: string
|
||||
name_label: string
|
||||
power_state: string
|
||||
resident_on: string
|
||||
}
|
||||
|
||||
interface HostMetrics {
|
||||
live: boolean
|
||||
}
|
||||
export interface Host extends XapiObject {
|
||||
$metrics: HostMetrics
|
||||
address: string
|
||||
name_label: string
|
||||
power_state: string
|
||||
}
|
||||
|
||||
// --------
|
||||
|
||||
export interface ObjectsByType extends Map<string, Map<string, XapiObject>> {
|
||||
get<NSV, T extends keyof types>(key: T, notSetValue: NSV): Map<string, types[T]> | NSV
|
||||
get<T extends keyof types>(key: T): Map<string, types[T]> | undefined
|
||||
}
|
||||
|
||||
export default class XapiConnection extends EventEmitter {
|
||||
areObjectsFetched: Promise<void>
|
||||
connected: boolean
|
||||
objectsByType: ObjectsByType
|
||||
sessionId?: string
|
||||
|
||||
_resolveObjectsFetched!: () => void
|
||||
|
||||
_xapi?: {
|
||||
objects: EventEmitter & {
|
||||
all: { [id: string]: XapiObject }
|
||||
}
|
||||
connect(): Promise<void>
|
||||
disconnect(): Promise<void>
|
||||
call: (method: string, ...args: unknown[]) => Promise<unknown>
|
||||
_objectsFetched: Promise<void>
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.objectsByType = Map() as ObjectsByType
|
||||
this.connected = false
|
||||
this.areObjectsFetched = new Promise(resolve => {
|
||||
this._resolveObjectsFetched = resolve
|
||||
})
|
||||
}
|
||||
|
||||
async reattachSession(url: string): Promise<void> {
|
||||
const sessionId = Cookies.get('sessionId')
|
||||
if (sessionId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.connect({ url, sessionId })
|
||||
}
|
||||
|
||||
async connect({
|
||||
url,
|
||||
user = 'root',
|
||||
password,
|
||||
sessionId,
|
||||
rememberMe = Cookies.get('rememberMe') === 'true',
|
||||
}: {
|
||||
url: string
|
||||
user?: string
|
||||
password?: string
|
||||
sessionId?: string
|
||||
rememberMe?: boolean
|
||||
}): Promise<void> {
|
||||
const xapi = (this._xapi = new Xapi({
|
||||
auth: { user, password, sessionId },
|
||||
url,
|
||||
watchEvents: true,
|
||||
readonly: false,
|
||||
}))
|
||||
|
||||
const updateObjects = (objects: { [id: string]: XapiObject }) => {
|
||||
try {
|
||||
this.objectsByType = this.objectsByType.withMutations(objectsByType => {
|
||||
Object.entries(objects).forEach(([id, object]) => {
|
||||
if (object === undefined) {
|
||||
// Remove
|
||||
objectsByType.forEach((objects, type) => {
|
||||
objectsByType.set(type, objects.remove(id))
|
||||
})
|
||||
} else {
|
||||
// Add or update
|
||||
const { $type } = object
|
||||
objectsByType.set($type, objectsByType.get($type, Map<string, XapiObject>()).set(id, object))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.emit('objects', this.objectsByType)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
xapi.on('connected', () => {
|
||||
this.sessionId = xapi.sessionId
|
||||
this.connected = true
|
||||
this.emit('connected')
|
||||
})
|
||||
|
||||
xapi.on('disconnected', () => {
|
||||
Cookies.remove('sessionId')
|
||||
this.emit('disconnected')
|
||||
})
|
||||
|
||||
xapi.on('sessionId', (sessionId: string) => {
|
||||
if (rememberMe) {
|
||||
Cookies.set('rememberMe', 'true', { expires: 7 })
|
||||
}
|
||||
Cookies.set('sessionId', sessionId, rememberMe ? { expires: 7 } : undefined)
|
||||
})
|
||||
|
||||
await xapi.connect()
|
||||
await xapi._objectsFetched
|
||||
|
||||
updateObjects(xapi.objects.all)
|
||||
this._resolveObjectsFetched()
|
||||
|
||||
xapi.objects.on('add', updateObjects)
|
||||
xapi.objects.on('update', updateObjects)
|
||||
xapi.objects.on('remove', updateObjects)
|
||||
}
|
||||
|
||||
disconnect(): Promise<void> | undefined {
|
||||
Cookies.remove('rememberMe')
|
||||
Cookies.remove('sessionId')
|
||||
const { _xapi } = this
|
||||
if (_xapi !== undefined) {
|
||||
return _xapi.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
call(method: string, ...args: unknown[]): Promise<unknown> {
|
||||
const { _xapi, connected } = this
|
||||
if (!connected || _xapi === undefined) {
|
||||
throw new Error('Not connected to XAPI')
|
||||
}
|
||||
|
||||
return _xapi.call(method, ...args)
|
||||
}
|
||||
}
|
||||
63
@xen-orchestra/lite/tsconfig.json
Normal file
63
@xen-orchestra/lite/tsconfig.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
"jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
"declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./dist", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
"noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"resolveJsonModule": true
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
}
|
||||
}
|
||||
6
@xen-orchestra/lite/types/decs.d.ts
vendored
Normal file
6
@xen-orchestra/lite/types/decs.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '@novnc/novnc/lib/rfb'
|
||||
declare module 'human-format'
|
||||
declare module 'iterable-backoff'
|
||||
declare module 'json-rpc-protocol'
|
||||
declare module 'promise-toolbox'
|
||||
declare module 'xen-api'
|
||||
42
@xen-orchestra/lite/types/reaclette.d.ts
vendored
Normal file
42
@xen-orchestra/lite/types/reaclette.d.ts
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
type RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects> = {
|
||||
readonly effects: Effects & ParentEffects
|
||||
readonly state: State & ParentState & Computed
|
||||
readonly resetState: () => void
|
||||
} & Props
|
||||
|
||||
interface EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> {
|
||||
readonly effects: Effects & ParentEffects
|
||||
readonly state: State & ParentState & Computed
|
||||
readonly props: Props
|
||||
}
|
||||
|
||||
interface StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects> {
|
||||
initialState?: State | ((props: Props) => State) // what about Reaclette's state inheritance?
|
||||
effects?: {
|
||||
initialize?: () => void | Promise<void>
|
||||
finalize?: () => void | Promise<void>
|
||||
} & Effects &
|
||||
ThisType<EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
computed?: {
|
||||
[ComputedName in keyof Computed]: (
|
||||
state: State & ParentState & Computed,
|
||||
props: Props
|
||||
) => Computed[ComputedName] | Promise<Computed[ComputedName]>
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'reaclette' {
|
||||
function provideState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>
|
||||
): (component: React.Component<Props>) => React.Component<Props>
|
||||
|
||||
function injectState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
// FIXME: also accept class components
|
||||
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
): React.ElementType<Props>
|
||||
|
||||
function withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>,
|
||||
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
): React.ElementType<Props>
|
||||
}
|
||||
21
@xen-orchestra/lite/types/theme.d.ts
vendored
Normal file
21
@xen-orchestra/lite/types/theme.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Theme as ThemeMui, ThemeOptions as ThemeOptionsMui } from '@mui/material/styles'
|
||||
declare module '@mui/material/styles' {
|
||||
// FIXME: when https://github.com/microsoft/TypeScript/issues/40315 is fixed.
|
||||
// issue: Type 'Theme'/'ThemeOptions' recursively references itself as a base type.
|
||||
interface Theme extends ThemeMui {
|
||||
background: {
|
||||
primary: {
|
||||
dark: string
|
||||
light: string
|
||||
}
|
||||
}
|
||||
}
|
||||
interface ThemeOptions extends ThemeOptionsMui {
|
||||
background?: {
|
||||
primary?: {
|
||||
dark?: string
|
||||
light?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
@xen-orchestra/lite/webpack.config.js
Normal file
72
@xen-orchestra/lite/webpack.config.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
const path = require('path')
|
||||
const webpack = require('webpack')
|
||||
|
||||
const resolveApp = relative => path.resolve(__dirname, relative)
|
||||
|
||||
const { NODE_ENV = 'production' } = process.env
|
||||
const __PROD__ = NODE_ENV === 'production'
|
||||
|
||||
// https://webpack.js.org/configuration/
|
||||
module.exports = {
|
||||
mode: NODE_ENV,
|
||||
target: 'web',
|
||||
devServer: {
|
||||
historyApiFallback: true,
|
||||
},
|
||||
entry: resolveApp('src/index.tsx'),
|
||||
output: {
|
||||
filename: __PROD__ ? '[name].[contenthash:8].js' : '[name].js',
|
||||
path: resolveApp('dist'),
|
||||
},
|
||||
optimization: {
|
||||
moduleIds: __PROD__ ? 'deterministic' : undefined,
|
||||
runtimeChunk: true,
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
dns: false,
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
devtool: __PROD__ ? 'source-map' : 'eval-cheap-module-source-map',
|
||||
plugins: [
|
||||
new (require('clean-webpack-plugin').CleanWebpackPlugin)(),
|
||||
new (require('copy-webpack-plugin'))({
|
||||
patterns: [
|
||||
{
|
||||
from: resolveApp('public'),
|
||||
to: resolveApp('dist'),
|
||||
filter: file => file !== resolveApp('public/index.html'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
new (require('html-webpack-plugin'))({
|
||||
template: resolveApp('public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({ XAPI_HOST: '', NPM_VERSION: require('./package.json').version }),
|
||||
new (require('node-polyfill-webpack-plugin'))(),
|
||||
].filter(Boolean),
|
||||
}
|
||||
@@ -48,10 +48,6 @@ configure([
|
||||
// if filter is a string, then it is pattern
|
||||
// (https://github.com/visionmedia/debug#wildcards) which is
|
||||
// matched against the namespace of the logs
|
||||
//
|
||||
// If it's an array, it will be handled as an array of filters
|
||||
// and the transport will be used if any one of them match the
|
||||
// current log
|
||||
filter: process.env.DEBUG,
|
||||
|
||||
transport: transportConsole(),
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.4",
|
||||
"promise-toolbox": "^0.20.0"
|
||||
"promise-toolbox": "^0.19.2"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -13,7 +13,7 @@ module.exports = class Config {
|
||||
const watchers = (this._watchers = new Set())
|
||||
|
||||
app.hooks.on('start', async () => {
|
||||
app.hooks.once(
|
||||
app.hooks.on(
|
||||
'stop',
|
||||
await watch({ appDir, appName, ignoreUnknownFormats: true }, (error, config) => {
|
||||
if (error != null) {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"xo-proxy-cli": "dist/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.0",
|
||||
@@ -33,12 +33,12 @@
|
||||
"content-type": "^1.0.4",
|
||||
"cson-parser": "^4.0.7",
|
||||
"getopts": "^2.2.3",
|
||||
"http-request-plus": "^0.13.0",
|
||||
"http-request-plus": "^0.12",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"pump": "^3.0.0",
|
||||
"pumpify": "^2.0.1",
|
||||
"split2": "^4.1.0"
|
||||
"split2": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -18,7 +18,6 @@ keepAliveInterval = 10e3
|
||||
#
|
||||
# https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation
|
||||
dirMode = 0o700
|
||||
disableMergeWorker = false
|
||||
snapshotNameLabelTpl = '[XO Backup {job.name}] {vm.name_label}'
|
||||
|
||||
[backups.defaultSettings]
|
||||
@@ -60,13 +59,6 @@ cert = '/var/lib/xo-proxy/certificate.pem'
|
||||
key = '/var/lib/xo-proxy/key.pem'
|
||||
port = 443
|
||||
|
||||
[logs]
|
||||
# Display all logs matching this filter, regardless of their level
|
||||
#filter = 'xo:backups:*'
|
||||
|
||||
# Display all logs with level >=, regardless of their namespace
|
||||
level = 'info'
|
||||
|
||||
[remoteOptions]
|
||||
mountsDir = '/run/xo-proxy/mounts'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.15.5",
|
||||
"version": "0.14.7",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -27,17 +27,17 @@
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.0",
|
||||
"@koa/router": "^10.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/compose": "^2.0.0",
|
||||
"@vates/decorate-with": "^0.1.0",
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.16.2",
|
||||
"@xen-orchestra/fs": "^0.19.2",
|
||||
"@xen-orchestra/backups": "^0.13.0",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.1.1",
|
||||
"@xen-orchestra/self-signed": "^0.1.0",
|
||||
"@xen-orchestra/xapi": "^0.8.4",
|
||||
"@xen-orchestra/xapi": "^0.7.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^0.9.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
@@ -47,18 +47,18 @@
|
||||
"golike-defer": "^0.5.1",
|
||||
"http-server-plus": "^0.11.0",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"jsonrpc-websocket-client": "^0.7.2",
|
||||
"jsonrpc-websocket-client": "^0.6.0",
|
||||
"koa": "^2.5.1",
|
||||
"koa-compress": "^5.0.1",
|
||||
"koa-helmet": "^5.1.0",
|
||||
"lodash": "^4.17.10",
|
||||
"node-zone": "^0.4.0",
|
||||
"parse-pairs": "^1.0.0",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"source-map-support": "^0.5.16",
|
||||
"stoppable": "^1.0.6",
|
||||
"xdg-basedir": "^4.0.0",
|
||||
"xen-api": "^0.35.1",
|
||||
"xen-api": "^0.34.3",
|
||||
"xo-common": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -14,30 +14,25 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
const { debug, warn } = createLogger('xo:proxy:api')
|
||||
|
||||
const ndJsonStream = asyncIteratorToStream(async function*(responseId, iterable) {
|
||||
const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable) {
|
||||
let headerSent = false
|
||||
try {
|
||||
let cursor, iterator
|
||||
try {
|
||||
const getIterator = iterable[Symbol.iterator] ?? iterable[Symbol.asyncIterator]
|
||||
iterator = getIterator.call(iterable)
|
||||
|
||||
cursor = await iterator.next()
|
||||
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
|
||||
} catch (error) {
|
||||
yield format.error(responseId, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
while (!cursor.done) {
|
||||
for await (const data of iterable) {
|
||||
if (!headerSent) {
|
||||
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
|
||||
headerSent = true
|
||||
}
|
||||
try {
|
||||
yield JSON.stringify(cursor.value) + '\n'
|
||||
yield JSON.stringify(data) + '\n'
|
||||
} catch (error) {
|
||||
warn('ndJsonStream, item error', { error })
|
||||
}
|
||||
cursor = await iterator.next()
|
||||
}
|
||||
} catch (error) {
|
||||
warn('ndJsonStream, fatal error', { error })
|
||||
if (!headerSent) {
|
||||
yield format.error(responseId, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -52,7 +47,7 @@ export default class Api {
|
||||
ctx.req.setTimeout(0)
|
||||
|
||||
const profile = await app.authentication.findProfile({
|
||||
authenticationToken: ctx.cookies.get('authenticationToken')
|
||||
authenticationToken: ctx.cookies.get('authenticationToken'),
|
||||
})
|
||||
if (profile === undefined) {
|
||||
ctx.status = 401
|
||||
@@ -123,7 +118,7 @@ export default class Api {
|
||||
this.addMethods({
|
||||
system: {
|
||||
getMethodsInfo: [
|
||||
function*() {
|
||||
function* () {
|
||||
const methods = this._methods
|
||||
for (const name in methods) {
|
||||
const { description, params = {} } = methods[name]
|
||||
@@ -131,25 +126,25 @@ export default class Api {
|
||||
}
|
||||
}.bind(this),
|
||||
{
|
||||
description: 'returns the signatures of all available API methods'
|
||||
}
|
||||
description: 'returns the signatures of all available API methods',
|
||||
},
|
||||
],
|
||||
getServerVersion: [
|
||||
() => appVersion,
|
||||
{
|
||||
description: 'returns the version of xo-server'
|
||||
}
|
||||
description: 'returns the version of xo-server',
|
||||
},
|
||||
],
|
||||
listMethods: [
|
||||
function*() {
|
||||
function* () {
|
||||
const methods = this._methods
|
||||
for (const name in methods) {
|
||||
yield name
|
||||
}
|
||||
}.bind(this),
|
||||
{
|
||||
description: 'returns the name of all available API methods'
|
||||
}
|
||||
description: 'returns the name of all available API methods',
|
||||
},
|
||||
],
|
||||
methodSignature: [
|
||||
({ method: name }) => {
|
||||
@@ -164,14 +159,14 @@ export default class Api {
|
||||
{
|
||||
description: 'returns the signature of an API method',
|
||||
params: {
|
||||
method: { type: 'string' }
|
||||
}
|
||||
}
|
||||
]
|
||||
method: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
range: [
|
||||
function*({ start = 0, stop, step }) {
|
||||
function* ({ start = 0, stop, step }) {
|
||||
if (step === undefined) {
|
||||
step = start > stop ? -1 : 1
|
||||
}
|
||||
@@ -189,11 +184,11 @@ export default class Api {
|
||||
params: {
|
||||
start: { optional: true, type: 'number' },
|
||||
step: { optional: true, type: 'number' },
|
||||
stop: { type: 'number' }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
stop: { type: 'number' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,7 +215,7 @@ export default class Api {
|
||||
return required
|
||||
}),
|
||||
|
||||
type: 'object'
|
||||
type: 'object',
|
||||
})
|
||||
|
||||
const m = params => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import assert from 'assert'
|
||||
import fse from 'fs-extra'
|
||||
import xdg from 'xdg-basedir'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
@@ -11,48 +10,33 @@ const { warn } = createLogger('xo:proxy:authentication')
|
||||
const isValidToken = t => typeof t === 'string' && t.length !== 0
|
||||
|
||||
export default class Authentication {
|
||||
#token
|
||||
constructor(_, { appName, config: { authenticationToken: token } }) {
|
||||
if (!isValidToken(token)) {
|
||||
token = JSON.parse(execFileSync('xenstore-read', ['vm-data/xo-proxy-authenticationToken']))
|
||||
|
||||
constructor(app, { appName, config: { authenticationToken: token } }) {
|
||||
const setToken = ({ token }) => {
|
||||
assert(isValidToken(token), 'invalid authentication token: ' + token)
|
||||
|
||||
// save this token in the automatically handled conf file
|
||||
fse.outputFileSync(
|
||||
// this file must take precedence over normal user config
|
||||
`${xdg.config}/${appName}/config.z-auto.json`,
|
||||
JSON.stringify({ authenticationToken: token }),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
|
||||
this.#token = token
|
||||
}
|
||||
|
||||
if (isValidToken(token)) {
|
||||
this.#token = token
|
||||
} else {
|
||||
setToken({ token: JSON.parse(execFileSync('xenstore-read', ['vm-data/xo-proxy-authenticationToken'])) })
|
||||
if (!isValidToken(token)) {
|
||||
throw new Error('missing authenticationToken in configuration')
|
||||
}
|
||||
|
||||
try {
|
||||
// save this token in the automatically handled conf file
|
||||
fse.outputFileSync(
|
||||
// this file must take precedence over normal user config
|
||||
`${xdg.config}/${appName}/config.z-auto.json`,
|
||||
JSON.stringify({ authenticationToken: token }),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
execFileSync('xenstore-rm', ['vm-data/xo-proxy-authenticationToken'])
|
||||
} catch (error) {
|
||||
warn('failed to remove token from XenStore', { error })
|
||||
}
|
||||
}
|
||||
|
||||
app.api.addMethod('authentication.setToken', setToken, {
|
||||
description: 'change the authentication token used by this XO Proxy',
|
||||
params: {
|
||||
token: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
this._token = token
|
||||
}
|
||||
|
||||
async findProfile(credentials) {
|
||||
if (credentials?.authenticationToken === this.#token) {
|
||||
if (credentials?.authenticationToken === this._token) {
|
||||
return new Profile()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import transportConsole from '@xen-orchestra/log/transports/console.js'
|
||||
import { configure } from '@xen-orchestra/log/configure.js'
|
||||
|
||||
export default class Logs {
|
||||
constructor(app) {
|
||||
const transport = transportConsole()
|
||||
app.config.watch('logs', ({ filter, level }) => {
|
||||
configure([
|
||||
{
|
||||
filter: [process.env.DEBUG, filter],
|
||||
level,
|
||||
transport,
|
||||
},
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
const { execFile } = require('child_process')
|
||||
const { promisify } = require('util')
|
||||
|
||||
const randomBytes = promisify(require('crypto').randomBytes)
|
||||
|
||||
const openssl = (cmd, args, { input, ...opts } = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -13,35 +10,12 @@ const openssl = (cmd, args, { input, ...opts } = {}) =>
|
||||
}
|
||||
})
|
||||
|
||||
const req = (key, selfSigned, { days = 360 } = {}) => {
|
||||
const args = ['-batch', '-new', '-key', '-', '-nodes']
|
||||
if (selfSigned) {
|
||||
args.push('-x509', '-days', String(days))
|
||||
}
|
||||
return openssl('req', args, { input: key })
|
||||
}
|
||||
|
||||
exports.genSelfSignedCert = async opts => {
|
||||
exports.genSelfSignedCert = async ({ days = 360 } = {}) => {
|
||||
const key = await openssl('genrsa', ['2048'])
|
||||
return {
|
||||
cert: await req(key, true, opts),
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
exports.genSignedCert = async (ca, { days = 360 } = {}) => {
|
||||
const key = await openssl('genrsa', ['2048'])
|
||||
const csr = await req(key, false)
|
||||
const serial = '0x' + (await randomBytes(40)).toString('hex')
|
||||
const input = [csr, ca.cert, ca.key].join('\n')
|
||||
return {
|
||||
cert: await openssl(
|
||||
'x509',
|
||||
['-req', '-in', '-', '-CA', '-', '-CAkey', '-', '-days', String(days), '-set_serial', serial],
|
||||
{
|
||||
input,
|
||||
}
|
||||
),
|
||||
cert: await openssl('req', ['-batch', '-new', '-key', '-', '-x509', '-days', String(days), '-nodes'], {
|
||||
input: key,
|
||||
}),
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"form-data": "^4.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"http-request-plus": "^0.13.0",
|
||||
"http-request-plus": "^0.12",
|
||||
"human-format": "^0.11.0",
|
||||
"l33teral": "^3.0.3",
|
||||
"lodash": "^4.17.4",
|
||||
@@ -44,8 +44,8 @@
|
||||
"pw": "^0.0.4",
|
||||
"strip-indent": "^3.0.0",
|
||||
"xdg-basedir": "^4.0.0",
|
||||
"xo-lib": "^0.11.1",
|
||||
"xo-vmdk-to-vhd": "^2.0.1"
|
||||
"xo-lib": "^0.10.1",
|
||||
"xo-vmdk-to-vhd": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/xapi",
|
||||
"version": "0.8.4",
|
||||
"version": "0.7.0",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"xo-common": "^0.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"xen-api": "^0.35.1"
|
||||
"xen-api": "^0.34.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
@@ -44,7 +44,7 @@
|
||||
"d3-time-format": "^3.0.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.20.0"
|
||||
"promise-toolbox": "^0.19.2"
|
||||
},
|
||||
"private": false,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -98,16 +98,18 @@ function removeWatcher(predicate, cb) {
|
||||
|
||||
class Xapi extends Base {
|
||||
constructor({
|
||||
callRetryWhenTooManyPendingTasks = { delay: 5e3, tries: 10 },
|
||||
callRetryWhenTooManyPendingTasks,
|
||||
ignoreNobakVdis,
|
||||
maxUncoalescedVdis,
|
||||
vdiDestroyRetryWhenInUse = { delay: 5e3, tries: 10 },
|
||||
vdiDestroyRetryWhenInUse,
|
||||
...opts
|
||||
}) {
|
||||
assert.notStrictEqual(ignoreNobakVdis, undefined)
|
||||
|
||||
super(opts)
|
||||
this._callRetryWhenTooManyPendingTasks = {
|
||||
delay: 5e3,
|
||||
tries: 10,
|
||||
...callRetryWhenTooManyPendingTasks,
|
||||
onRetry,
|
||||
when: { code: 'TOO_MANY_PENDING_TASKS' },
|
||||
@@ -115,6 +117,8 @@ class Xapi extends Base {
|
||||
this._ignoreNobakVdis = ignoreNobakVdis
|
||||
this._maxUncoalescedVdis = maxUncoalescedVdis
|
||||
this._vdiDestroyRetryWhenInUse = {
|
||||
delay: 5e3,
|
||||
retries: 10,
|
||||
...vdiDestroyRetryWhenInUse,
|
||||
onRetry,
|
||||
when: { code: 'VDI_IN_USE' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const CancelToken = require('promise-toolbox/CancelToken.js')
|
||||
const pCatch = require('promise-toolbox/catch.js')
|
||||
const pRetry = require('promise-toolbox/retry.js')
|
||||
const { decorateWith } = require('@vates/decorate-with')
|
||||
|
||||
const extractOpaqueRef = require('./_extractOpaqueRef.js')
|
||||
|
||||
@@ -12,13 +11,10 @@ module.exports = class Vdi {
|
||||
return extractOpaqueRef(await this.callAsync('VDI.clone', vdiRef))
|
||||
}
|
||||
|
||||
// work around a race condition in XCP-ng/XenServer where the disk is not fully unmounted yet
|
||||
@decorateWith(pRetry.wrap, function () {
|
||||
return this._vdiDestroyRetryWhenInUse
|
||||
})
|
||||
async destroy(vdiRef) {
|
||||
await pCatch.call(
|
||||
this.callAsync('VDI.destroy', vdiRef),
|
||||
// work around a race condition in XCP-ng/XenServer where the disk is not fully unmounted yet
|
||||
pRetry(() => this.callAsync('VDI.destroy', vdiRef), this._vdiDestroyRetryWhenInUse),
|
||||
// if this VDI is not found, consider it destroyed
|
||||
{ code: 'HANDLE_INVALID' },
|
||||
noop
|
||||
|
||||
@@ -99,7 +99,6 @@ module.exports = class Vm {
|
||||
// should coalesce
|
||||
const children = childrenMap[vdi.uuid]
|
||||
if (
|
||||
children !== undefined && // unused unmanaged VDI, will be GC-ed
|
||||
children.length === 1 &&
|
||||
!children[0].managed && // some SRs do not coalesce the leaf
|
||||
tolerance-- <= 0
|
||||
|
||||
121
CHANGELOG.md
121
CHANGELOG.md
@@ -1,129 +1,18 @@
|
||||
# ChangeLog
|
||||
|
||||
## **5.65.2** (2021-12-10)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Backup] Fix `handler.rmTree` is not a function (Forum [5256](https://xcp-ng.org/forum/topic/5256/s3-backup-try-it/29) PR [#6041](https://github.com/vatesfr/xen-orchestra/pull/6041) )
|
||||
- [Backup] Fix `EEXIST` in logs when multiple merge tasks are created at the same time ([Forum #5301](https://xcp-ng.org/forum/topic/5301/warnings-errors-in-journalctl))
|
||||
- [Backup] Fix missing backup on restore (Forum [5256](https://xcp-ng.org/forum/topic/5256/s3-backup-try-it/29) (PR [#6048](https://github.com/vatesfr/xen-orchestra/pull/6048))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/fs 0.19.2
|
||||
- vhd-lib 2.0.3
|
||||
- @xen-orchestra/backups 0.16.2
|
||||
- xo-server 5.84.3
|
||||
- @xen-orchestra/proxy 0.15.5
|
||||
|
||||
## **5.65.1** (2021-12-03)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Delta Backup Restoration] Fix assertion error [Forum #5257](https://xcp-ng.org/forum/topic/5257/problems-building-from-source/16)
|
||||
- [Delta Backup Restoration] `TypeError: this disposable has already been disposed` [Forum #5257](https://xcp-ng.org/forum/topic/5257/problems-building-from-source/20)
|
||||
- [Backups] Fix: `Error: Chaining alias is forbidden xo-vm-backups/..alias.vhd to xo-vm-backups/....alias.vhd` when backuping a file to s3 [Forum #5226](https://xcp-ng.org/forum/topic/5256/s3-backup-try-it)
|
||||
- [Delta Backup Restoration] `VDI_IO_ERROR(Device I/O errors)` [Forum #5727](https://xcp-ng.org/forum/topic/5257/problems-building-from-source/4) (PR [#6031](https://github.com/vatesfr/xen-orchestra/pull/6031))
|
||||
- [Delta Backup] Fix `Cannot read property 'uuid' of undefined` when a VDI has been removed from a backed up VM (PR [#6034](https://github.com/vatesfr/xen-orchestra/pull/6034))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @vates/compose 2.1.0
|
||||
- vhd-lib 2.0.2
|
||||
- xo-vmdk-to-vhd 2.0.1
|
||||
- @xen-orchestra/backups 0.16.1
|
||||
- @xen-orchestra/proxy 0.15.4
|
||||
- xo-server 5.84.2
|
||||
|
||||
## **5.65.0** (2021-11-30)
|
||||
|
||||
### Highlights
|
||||
|
||||
- [VM] Ability to export a snapshot's memory (PR [#6015](https://github.com/vatesfr/xen-orchestra/pull/6015))
|
||||
- [Cloud config] Ability to create a network cloud config template and reuse it in the VM creation [#5931](https://github.com/vatesfr/xen-orchestra/issues/5931) (PR [#5979](https://github.com/vatesfr/xen-orchestra/pull/5979))
|
||||
- [Backup/logs] identify XAPI errors (PR [#6001](https://github.com/vatesfr/xen-orchestra/pull/6001))
|
||||
- [lite] Highlight selected VM (PR [#5939](https://github.com/vatesfr/xen-orchestra/pull/5939))
|
||||
## **next**
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [S3] Ability to authorize self signed certificates for S3 remote (PR [#5961](https://github.com/vatesfr/xen-orchestra/pull/5961))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Import/VM] Fix the import of OVA files (PR [#5976](https://github.com/vatesfr/xen-orchestra/pull/5976))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @vates/async-each 0.1.0
|
||||
- xo-remote-parser 0.8.4
|
||||
- @xen-orchestra/fs 0.19.0
|
||||
- @xen-orchestra/xapi patch
|
||||
- vhd-lib 2.0.1
|
||||
- @xen-orchestra/backups 0.16.0
|
||||
- xo-lib 0.11.1
|
||||
- @xen-orchestra/proxy 0.15.3
|
||||
- xo-server 5.84.1
|
||||
- vhd-cli 0.6.0
|
||||
- xo-web 5.90.0
|
||||
|
||||
## **5.64.0** (2021-10-29)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
## Highlights
|
||||
|
||||
- [Netbox] Support older versions of Netbox and prevent "active is not a valid choice" error [#5898](https://github.com/vatesfr/xen-orchestra/issues/5898) (PR [#5946](https://github.com/vatesfr/xen-orchestra/pull/5946))
|
||||
- [Tasks] Filter out short tasks using a default filter (PR [#5921](https://github.com/vatesfr/xen-orchestra/pull/5921))
|
||||
- [Host] Handle evacuation failure during host shutdown (PR [#5966](https://github.com/vatesfr/xen-orchestra/pull/#5966))
|
||||
- [Menu] Notify user when proxies need to be upgraded (PR [#5930](https://github.com/vatesfr/xen-orchestra/pull/5930))
|
||||
- [Servers] Ability to use an HTTP proxy between XO and a server (PR [#5958](https://github.com/vatesfr/xen-orchestra/pull/5958))
|
||||
- [VM/export] Ability to copy the export URL (PR [#5948](https://github.com/vatesfr/xen-orchestra/pull/5948))
|
||||
- [Pool/advanced] Ability to define network for importing/exporting VMs/VDIs (PR [#5957](https://github.com/vatesfr/xen-orchestra/pull/5957))
|
||||
- [Host/advanced] Add button to enable/disable the host (PR [#5952](https://github.com/vatesfr/xen-orchestra/pull/5952))
|
||||
- [Backups] Enable merge worker by default
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Jobs] Ability to copy a job ID (PR [#5951](https://github.com/vatesfr/xen-orchestra/pull/5951))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Backups] Delete unused snapshots related to other schedules (even no longer existing) (PR [#5949](https://github.com/vatesfr/xen-orchestra/pull/5949))
|
||||
- [Jobs] Fix `job.runSequence` method (PR [#5944](https://github.com/vatesfr/xen-orchestra/pull/5944))
|
||||
- [Netbox] Fix error when testing plugin on versions older than 2.10 (PR [#5963](https://github.com/vatesfr/xen-orchestra/pull/5963))
|
||||
- [Snapshot] Fix "Create VM from snapshot" creating a template instead of a VM (PR [#5955](https://github.com/vatesfr/xen-orchestra/pull/5955))
|
||||
- [Host/Logs] Improve the display of log content (PR [#5943](https://github.com/vatesfr/xen-orchestra/pull/5943))
|
||||
- [XOA licenses] Fix expiration date displaying "Invalid date" in some rare cases (PR [#5967](https://github.com/vatesfr/xen-orchestra/pull/5967))
|
||||
- [API/pool.listPoolsMatchingCriteria] Fix `checkSrName`/`checkPoolName` `is not a function` error
|
||||
|
||||
### Released packages
|
||||
|
||||
- xo-server-netbox 0.3.3
|
||||
- vhd-lib 1.3.0
|
||||
- xen-api 0.35.1
|
||||
- @xen-orchestra/xapi 0.8.0
|
||||
- @xen-orchestra/backups 0.15.1
|
||||
- @xen-orchestra/proxy 0.15.2
|
||||
- vhd-cli 0.5.0
|
||||
- xapi-explore-sr 0.4.0
|
||||
- xo-server 5.83.0
|
||||
- xo-web 5.89.0
|
||||
|
||||
## **5.63.0** (2021-09-30)
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Backup] Go back to previous page instead of going to the overview after editing a job: keeps current filters and page (PR [#5913](https://github.com/vatesfr/xen-orchestra/pull/5913))
|
||||
- [Health] Do not take into consideration duplicated MAC addresses from CR VMs (PR [#5916](https://github.com/vatesfr/xen-orchestra/pull/5916))
|
||||
- [Health] Ability to filter duplicated MAC addresses by running VMs (PR [#5917](https://github.com/vatesfr/xen-orchestra/pull/5917))
|
||||
- [Tables] Move the search bar and pagination to the top of the table (PR [#5914](https://github.com/vatesfr/xen-orchestra/pull/5914))
|
||||
- [Netbox] Handle nested prefixes by always assigning an IP to the smallest prefix it matches (PR [#5908](https://github.com/vatesfr/xen-orchestra/pull/5908))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [SSH keys] Allow SSH key to be broken anywhere to avoid breaking page formatting (Thanks [@tstivers1990](https://github.com/tstivers1990)!) [#5891](https://github.com/vatesfr/xen-orchestra/issues/5891) (PR [#5892](https://github.com/vatesfr/xen-orchestra/pull/5892))
|
||||
- [Netbox] Handle nested prefixes by always assigning an IP to the smallest prefix it matches (PR [#5908](https://github.com/vatesfr/xen-orchestra/pull/5908))
|
||||
- [Netbox] Better handling and error messages when encountering issues due to UUID custom field not being configured correctly [#5905](https://github.com/vatesfr/xen-orchestra/issues/5905) [#5806](https://github.com/vatesfr/xen-orchestra/issues/5806) [#5834](https://github.com/vatesfr/xen-orchestra/issues/5834) (PR [#5909](https://github.com/vatesfr/xen-orchestra/pull/5909))
|
||||
- [New VM] Don't send network config if untouched as all commented config can make Cloud-init fail [#5918](https://github.com/vatesfr/xen-orchestra/issues/5918) (PR [#5923](https://github.com/vatesfr/xen-orchestra/pull/5923))
|
||||
|
||||
@@ -153,6 +42,8 @@
|
||||
|
||||
## **5.62.0** (2021-08-31)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Host] Add warning in case of unmaintained host version [#5840](https://github.com/vatesfr/xen-orchestra/issues/5840) (PR [#5847](https://github.com/vatesfr/xen-orchestra/pull/5847))
|
||||
@@ -185,6 +76,8 @@
|
||||
|
||||
## **5.61.0** (2021-07-30)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [SR/disks] Display base copies' active VDIs (PR [#5826](https://github.com/vatesfr/xen-orchestra/pull/5826))
|
||||
@@ -257,7 +150,7 @@
|
||||
|
||||
- [Smart backup] Report missing pools [#2844](https://github.com/vatesfr/xen-orchestra/issues/2844) (PR [#5768](https://github.com/vatesfr/xen-orchestra/pull/5768))
|
||||
- [Metadata Backup] Add a warning on restoring a metadata backup (PR [#5769](https://github.com/vatesfr/xen-orchestra/pull/5769))
|
||||
- [Netbox][plugin](https://xen-orchestra.com/docs/advanced.html#netbox) to synchronize pools, VMs and IPs with [Netbox](https://netbox.readthedocs.io/en/stable/) (PR [#5783](https://github.com/vatesfr/xen-orchestra/pull/5783))
|
||||
- [Netbox] [Plugin](https://xen-orchestra.com/docs/advanced.html#netbox) to synchronize pools, VMs and IPs with [Netbox](https://netbox.readthedocs.io/en/stable/) (PR [#5783](https://github.com/vatesfr/xen-orchestra/pull/5783))
|
||||
|
||||
### Enhancements
|
||||
|
||||
|
||||
@@ -7,18 +7,10 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [About] Show commit instead of version numbers for source users (PR [#6045](https://github.com/vatesfr/xen-orchestra/pull/6045))
|
||||
- [Health] Display default SRs that aren't shared [#5871](https://github.com/vatesfr/xen-orchestra/issues/5871) (PR [#6033](https://github.com/vatesfr/xen-orchestra/pull/6033))
|
||||
- [Pool,VM/advanced] Ability to change the suspend SR [#4163](https://github.com/vatesfr/xen-orchestra/issues/4163) (PR [#6044](https://github.com/vatesfr/xen-orchestra/pull/6044))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Tables/actions] Fix collapsed actions being clickable despite being disabled (PR [#6023](https://github.com/vatesfr/xen-orchestra/pull/6023))
|
||||
- [Continuous Replication] Fix `could not find the base VM`
|
||||
- [Backup/Smart mode] Always ignore replicated VMs created by the current job
|
||||
|
||||
### Packages to release
|
||||
|
||||
> Packages will be released in the order they are here, therefore, they should
|
||||
@@ -35,8 +27,3 @@
|
||||
> - major: if the change breaks compatibility
|
||||
>
|
||||
> In case of conflict, the highest (lowest in previous list) `$version` wins.
|
||||
|
||||
- @xen-orchestra/backups minor
|
||||
- @xen-orchestra/proxy minor
|
||||
- xo-server minor
|
||||
- xo-web minor
|
||||
|
||||
27
ISSUE_TEMPLATE.md
Normal file
27
ISSUE_TEMPLATE.md
Normal file
@@ -0,0 +1,27 @@
|
||||
<!--
|
||||
Welcome to the issue section of Xen Orchestra!
|
||||
|
||||
Here you can:
|
||||
- report an issue
|
||||
- propose an enhancement
|
||||
- ask a question
|
||||
|
||||
Please, respect this template as much as possible, it helps us sort
|
||||
the issues :)
|
||||
-->
|
||||
|
||||
### Context
|
||||
|
||||
- **XO origin**: the sources / XO Appliance
|
||||
- **Versions**:
|
||||
- Node: **FILL HERE**
|
||||
- xo-web: **FILL HERE**
|
||||
- xo-server: **FILL HERE**
|
||||
|
||||
### Expected behavior
|
||||
|
||||
<!-- What you expect to happen -->
|
||||
|
||||
### Current behavior
|
||||
|
||||
<!-- What is actually happening -->
|
||||
@@ -327,8 +327,6 @@ Synchronize your pools, VMs, network interfaces and IP addresses with your [Netb
|
||||
|
||||

|
||||
|
||||
### Netbox side
|
||||
|
||||
- Go to your Netbox interface
|
||||
- Configure prefixes:
|
||||
- Go to IPAM > Prefixes > Add
|
||||
@@ -341,29 +339,13 @@ XO will try to find the right prefix for each IP address. If it can't find a pre
|
||||
- Generate a token:
|
||||
- Go to Admin > Tokens > Add token
|
||||
- Create a token with "Write enabled"
|
||||
- The owner of the token must have at least the following permissions:
|
||||
- View permissions on:
|
||||
- extras > custom-fields
|
||||
- ipam > prefixes
|
||||
- All permissions on:
|
||||
- ipam > ip-addresses
|
||||
- virtualization > cluster-types
|
||||
- virtualization > clusters
|
||||
- virtualization > interfaces
|
||||
- virtualization > virtual-machines
|
||||
- Add a UUID custom field (for **Netbox 2.x**):
|
||||
- Add a UUID custom field:
|
||||
- Got to Admin > Custom fields > Add custom field
|
||||
- Create a custom field called "uuid" (lower case!)
|
||||
- Assign it to object types `virtualization > cluster` and `virtualization > virtual machine`
|
||||
|
||||

|
||||
|
||||
:::tip
|
||||
In Netbox 3.x, custom fields can be found directly in the site (no need to go in the admin section). It's available in "Other/Customization/Custom Fields". After creation of the `uuid` field, assign it to the object types `virtualization > cluster` and `virtualization > virtual machine`.
|
||||
:::
|
||||
|
||||
### In Xen Orchestra
|
||||
|
||||
- Go to Xen Orchestra > Settings > Plugins > Netbox and fill out the configuration:
|
||||
- Endpoint: the URL of your Netbox instance (e.g.: `https://netbox.company.net`)
|
||||
- Unauthorized certificate: only for HTTPS, enable this option if your Netbox instance uses a self-signed SSL certificate
|
||||
|
||||
@@ -26,12 +26,6 @@ Each backups' job execution is identified by a `runId`. You can find this `runId
|
||||
|
||||

|
||||
|
||||
## Exclude disks
|
||||
|
||||
During a backup job, you can avoid saving all disks of the VM. To do that is trivial: just edit the VM disk name and add `[NOBAK]` before the current name, eg: `data-disk` will become `[NOBAK] data-disk` (with a space or not, doesn't matter).
|
||||
|
||||
The disks marked with `[NOBAK]` will be now ignored in all following backups.
|
||||
|
||||
## Schedule
|
||||
|
||||
:::tip
|
||||
@@ -292,9 +286,8 @@ When it's done exporting, we'll remove the snapshot. Note: this operation will t
|
||||
Concurrency is a parameter that let you define how many VMs your backup job will manage simultaneously.
|
||||
|
||||
:::tip
|
||||
|
||||
- Default concurrency value is 2 if left empty.
|
||||
:::
|
||||
:::
|
||||
|
||||
Let's say you want to backup 50 VMs (each with 1x disk) at 3:00 AM. There are **2 different strategies**:
|
||||
|
||||
@@ -304,7 +297,7 @@ Let's say you want to backup 50 VMs (each with 1x disk) at 3:00 AM. There are **
|
||||
The first purely sequential strategy will lead to the fact that: **you can't predict when a snapshot of your data will occur**. Because you can't predict the first VM export time (let's say 3 hours), then your second VM will have its snapshot taken 3 hours later, at 6 AM.
|
||||
|
||||
:::tip
|
||||
If you need your backup to be done at a specific time you should consider creating a specific backup task for this VM.
|
||||
If you need your backup to be done at a specific time you should consider creating a specific backup task for this VM.
|
||||
:::
|
||||
|
||||
Strategy number 2 is to parallelise: all the snapshots will be taken at 3 AM. However **it's risky without limits**: it means potentially doing 50 snapshots or more at once on the same storage. **Since XenServer doesn't have a queue**, it will try to do all of them at once. This is also prone to race conditions and could cause crashes on your storage.
|
||||
@@ -319,7 +312,6 @@ You should be aware of your hardware limitation when defining the best concurren
|
||||
The best way to define the best concurrency for you is by increasing it slowly and watching the result on backup time.
|
||||
|
||||
So to summarize, if you set your concurrency at 6 and you have 20 Vms to backup the process will be the following:
|
||||
|
||||
- We start the backup of the first 6 VMs.
|
||||
- When one VM backup as ended we will launch the next VM backup.
|
||||
- We're keep launching new VM backup until the 20 VMs are finished, keeping 6 backups running.
|
||||
|
||||
@@ -43,6 +43,12 @@ Just go into your "Backup" view, and select Delta Backup. Then, it's the same as
|
||||
|
||||
Unlike other types of backup jobs which delete the associated snapshot when the job is done and it has been exported, delta backups always keep a snapshot of every VM in the backup job, and uses it for the delta. Do not delete these snapshots!
|
||||
|
||||
## Exclude disks
|
||||
|
||||
During a delta backup job, you can avoid saving all disks of the VM. To do that is trivial: just edit the VM disk name and add `[NOBAK]` before the current name, eg: `data-disk` will become `[NOBAK] data-disk` (with a space or not, doesn't matter).
|
||||
|
||||
The disks marked with `[NOBAK]` will be now ignored in all following backups.
|
||||
|
||||
## Delta backup initial seed
|
||||
|
||||
If you don't want to do an initial full directly toward the destination, you can create a local delta backup first, then transfer the files to your destination.
|
||||
|
||||
13
docs/xoa.md
13
docs/xoa.md
@@ -61,7 +61,7 @@ Please only use this if you have issues with [the default way to deploy XOA](ins
|
||||
Alternatively, you can deploy it by connecting to your XenServer host and executing the following:
|
||||
|
||||
```
|
||||
bash -c "$(wget -qO- https://xoa.io/deploy)"
|
||||
bash -c "$(curl -sS https://xoa.io/deploy)"
|
||||
```
|
||||
|
||||
:::tip
|
||||
@@ -78,7 +78,7 @@ curl: (35) error:1407742E:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 alert protoc
|
||||
It means that the secure HTTPS protocol is not supported, you can bypass this using the unsecure command instead:
|
||||
|
||||
```
|
||||
bash -c "$(wget -qO- http://xoa.io/deploy)"
|
||||
bash -c "$(curl -sS http://xoa.io/deploy)"
|
||||
```
|
||||
|
||||
:::
|
||||
@@ -103,10 +103,9 @@ In that case, you already set the password for `xoa` user. If you forgot it, see
|
||||
|
||||
### Manually deployed
|
||||
|
||||
If you connect via SSH or console for the first time without using our [web deploy form](https://xen-orchestra.com/#!/xoa), be aware **there is NO default password set for security reasons**. To set it, you need to connect to your host to find the XOA VM UUID (eg via `xe vm-list`).
|
||||
|
||||
Next, you can replace `<UUID>` with the UUID you found previously, and `<password>` with your password:
|
||||
If you connect via SSH or console for the first time without using our [web deploy form](https://xen-orchestra.com/#!/xoa), be aware **there's NO default password set for security reasons**. To set it, you need to connect to your host to find the XOA VM UUID (eg via `xe vm-list`).
|
||||
|
||||
Then replace `<UUID>` with the previously find UUID, and `<password>` with your password:
|
||||
```
|
||||
xe vm-param-set uuid=<UUID> xenstore-data:vm-data/system-account-xoa-password=<password>
|
||||
```
|
||||
@@ -115,9 +114,7 @@ xe vm-param-set uuid=<UUID> xenstore-data:vm-data/system-account-xoa-password=<p
|
||||
Don't forget to use quotes for your password, eg: `xenstore-data:vm-data/system-account-xoa-password='MyPassW0rd!'`
|
||||
:::
|
||||
|
||||
Finally, you must reboot the VM to implement the changes.
|
||||
|
||||
You can now connect with the `xoa` username and password you defined in the previous command, eg with `ssh xoa@<XOA IP ADDRESS>`.
|
||||
Then, you could connect with `xoa` username and the password you defined in the previous command, eg with `ssh xoa@<XOA IP ADDRESS>`.
|
||||
|
||||
### Using sudo
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ XOSAN is a 100% software defined solution for XenServer hyperconvergence. You ca
|
||||
|
||||
You will need to be registered on our website in order to use Xen Orchestra. If you are not yet registered, [here is the way](https://xen-orchestra.com/#!/signup)
|
||||
|
||||
SSH in your XenServer and use the command line `bash -c "$(wget -qO- https://xoa.io/deploy)"` - it will deploy Xen Orchestra Appliance on your XenServer infrastructure which is required to use XOSAN.
|
||||
SSH in your XenServer and use the command line `bash -c "$(curl -sS https://xoa.io/deploy)"` - it will deploy Xen Orchestra Appliance on your XenServer infrastructure which is required to use XOSAN.
|
||||
|
||||
> Note: You can also download the XVA file and follow [these instructions](https://xen-orchestra.com/docs/xoa.html#the-alternative).
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user