Compare commits
257 Commits
xo-lite
...
xapi-suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d274c34b6b | ||
|
|
b78a946458 | ||
|
|
e8a5694d51 | ||
|
|
514fa72ee2 | ||
|
|
e9ca13aa12 | ||
|
|
57f1ec6716 | ||
|
|
02e32cc9b9 | ||
|
|
902abd5d94 | ||
|
|
53380802ec | ||
|
|
af5d8d02b6 | ||
|
|
7abba76f03 | ||
|
|
79b22057d9 | ||
|
|
366daef718 | ||
|
|
a5ff0ba799 | ||
|
|
c2c6febb88 | ||
|
|
f119c72a7f | ||
|
|
8aee897d23 | ||
|
|
729db5c662 | ||
|
|
61c46df7bf | ||
|
|
9b1a04338d | ||
|
|
d307134d22 | ||
|
|
5bc44363f9 | ||
|
|
68c4fac3ab | ||
|
|
6ad9245019 | ||
|
|
763cf771fb | ||
|
|
3160b08637 | ||
|
|
f8949958a3 | ||
|
|
8b7ac07d2d | ||
|
|
044df9adba | ||
|
|
040139f4cc | ||
|
|
7b73bb9df0 | ||
|
|
24c8370daa | ||
|
|
029c4921d7 | ||
|
|
3a74c71f1a | ||
|
|
6022a1bbaa | ||
|
|
4e88c993f7 | ||
|
|
c9a61f467c | ||
|
|
e6a5f42f63 | ||
|
|
a373823eea | ||
|
|
b5e010eac8 | ||
|
|
50ffe58655 | ||
|
|
07eb3b59b3 | ||
|
|
5177b5e142 | ||
|
|
3c984e21cd | ||
|
|
aa2b27e22b | ||
|
|
14a7f00c90 | ||
|
|
56f98601bd | ||
|
|
027a8c675e | ||
|
|
bdaba9a767 | ||
|
|
4e9090f60d | ||
|
|
73b445d371 | ||
|
|
75bfc283af | ||
|
|
727de19b89 | ||
|
|
5d605d1bd7 | ||
|
|
ffdd1dfd6f | ||
|
|
d45418eb29 | ||
|
|
6ccc9d1ade | ||
|
|
93069159dd | ||
|
|
8c4780131f | ||
|
|
02ae8bceda | ||
|
|
bb10bbc945 | ||
|
|
478d88e97f | ||
|
|
6fb397a729 | ||
|
|
18dae34778 | ||
|
|
243566e936 | ||
|
|
87f4fd675d | ||
|
|
dec6b59a9f | ||
|
|
e51baedf7f | ||
|
|
530da14e24 | ||
|
|
02da7c272f | ||
|
|
a07c5418e9 | ||
|
|
c080db814b | ||
|
|
3abe13c006 | ||
|
|
fb331c0a2c | ||
|
|
19ea78afc5 | ||
|
|
2096c782e3 | ||
|
|
79a6a8a10c | ||
|
|
5a933bad93 | ||
|
|
7e302fd1cb | ||
|
|
cf9f0da6e5 | ||
|
|
10ac23e265 | ||
|
|
dc2e1cba1f | ||
|
|
7bfd190c22 | ||
|
|
c3bafeb468 | ||
|
|
7bacd781cf | ||
|
|
ee005c3679 | ||
|
|
315f54497a | ||
|
|
e30233347b | ||
|
|
56d4a7f01e | ||
|
|
7c110eebd8 | ||
|
|
39394f8c09 | ||
|
|
3283130dfc | ||
|
|
3146a591d0 | ||
|
|
e478b1ec04 | ||
|
|
7bc4d14f46 | ||
|
|
f3eeeef389 | ||
|
|
8d69208197 | ||
|
|
2c689af1a9 | ||
|
|
cb2a34c765 | ||
|
|
465c8f9009 | ||
|
|
8ea4c1c1fd | ||
|
|
ba0f7df9e8 | ||
|
|
e47dd723b0 | ||
|
|
fca6e2f6bf | ||
|
|
faa7ba6f24 | ||
|
|
fc2dbbe3ee | ||
|
|
cc98b81825 | ||
|
|
eb4a7069d4 | ||
|
|
4f65d9214e | ||
|
|
4d3c8ee63c | ||
|
|
e41c1b826a | ||
|
|
644bb48135 | ||
|
|
c9809285f6 | ||
|
|
5704949f4d | ||
|
|
a19e00fbc0 | ||
|
|
470a9b3e27 | ||
|
|
ace31dc566 | ||
|
|
ed252276cb | ||
|
|
26d0ff3c9a | ||
|
|
ff806a3ff9 | ||
|
|
949b17dee6 | ||
|
|
b1fdc68623 | ||
|
|
f502facfd1 | ||
|
|
bf0a74d709 | ||
|
|
7296d98313 | ||
|
|
30568ced49 | ||
|
|
5e1284a9e0 | ||
|
|
27d2de872a | ||
|
|
03d6e3356b | ||
|
|
ca8baa62fb | ||
|
|
2f607357c6 | ||
|
|
2de80f7aff | ||
|
|
386058ed88 | ||
|
|
033fa9e067 | ||
|
|
e8104420b5 | ||
|
|
ae24b10da0 | ||
|
|
407b05b643 | ||
|
|
79bf8bc9f6 | ||
|
|
65d6dca52c | ||
|
|
66eeefbd7b | ||
|
|
c10bbcde00 | ||
|
|
fe69928bcc | ||
|
|
3ad8508ea5 | ||
|
|
1f1ae759e0 | ||
|
|
6e4bfe8f0f | ||
|
|
6276c48768 | ||
|
|
f6005baf1a | ||
|
|
b62fdbc6a6 | ||
|
|
bbd3d31b6a | ||
|
|
481ac92bf8 | ||
|
|
a2f2b50f57 | ||
|
|
bbab9d0f36 | ||
|
|
7f8190056d | ||
|
|
8f4737c5f1 | ||
|
|
c5adba3c97 | ||
|
|
d91eb9e396 | ||
|
|
1b47102d6c | ||
|
|
cd147f3fc5 | ||
|
|
c3acdc8cbd | ||
|
|
c3d755dc7b | ||
|
|
6f49c48bd4 | ||
|
|
446f390b3d | ||
|
|
966091593a | ||
|
|
d5f21bc27c | ||
|
|
8c3b452c0d | ||
|
|
9cacb92c2c | ||
|
|
7a1b56db87 | ||
|
|
56c3d70149 | ||
|
|
1ec8fcc73f | ||
|
|
060b16c5ca | ||
|
|
0acc52e3e9 | ||
|
|
a9c2c9b6ba | ||
|
|
5b2a6bc56b | ||
|
|
19c8693b62 | ||
|
|
c4720e1215 | ||
|
|
b6d4c8044c | ||
|
|
57dd6ebfba | ||
|
|
c75569f278 | ||
|
|
a8757f9074 | ||
|
|
f5c3bf72e5 | ||
|
|
d7ee13f98d | ||
|
|
1f47aa491d | ||
|
|
ffe430758e | ||
|
|
a4bb453401 | ||
|
|
5c8ebce9eb | ||
|
|
8b0cee5e6f | ||
|
|
e5f4f825b6 | ||
|
|
b179dc1d56 | ||
|
|
7281c9505d | ||
|
|
4db82f447d | ||
|
|
834da3d2b4 | ||
|
|
c6c3a33dcc | ||
|
|
fb720d9b05 | ||
|
|
547d318e55 | ||
|
|
cb5a2c18f2 | ||
|
|
e01ca3ad07 | ||
|
|
314d193f35 | ||
|
|
e0200bb730 | ||
|
|
2a3f4a6f97 | ||
|
|
88628bbdc0 | ||
|
|
cb7b695a72 | ||
|
|
ae549e2a88 | ||
|
|
7f9a970714 | ||
|
|
7661d3372d | ||
|
|
dbb4f34015 | ||
|
|
8f15a4c29d | ||
|
|
1b0a885ac3 | ||
|
|
f7195bad88 | ||
|
|
15630aee5e | ||
|
|
a950a1fe24 | ||
|
|
71b8e625fe | ||
|
|
e7391675fb | ||
|
|
84fdd3fe4b | ||
|
|
4dc4b635f2 | ||
|
|
ee0c6d7f8b | ||
|
|
a637af395d | ||
|
|
59fb612315 | ||
|
|
59b21c7a3e | ||
|
|
40f881c2ac | ||
|
|
1d069683ca | ||
|
|
de1d942b90 | ||
|
|
fc73971d63 | ||
|
|
eb238bf107 | ||
|
|
2412f8b1e2 | ||
|
|
0c87dee31c | ||
|
|
215146f663 | ||
|
|
9fe1069df0 | ||
|
|
d2c5b52bf1 | ||
|
|
12153a414d | ||
|
|
5ec1092a83 | ||
|
|
284169a2f2 | ||
|
|
838bfbb75f | ||
|
|
a448da77c9 | ||
|
|
268fb22d5f | ||
|
|
07cc4c853d | ||
|
|
c62d727cbe | ||
|
|
7ef89d5043 | ||
|
|
9ceba1d6e8 | ||
|
|
e2e453985f | ||
|
|
84dccd800f | ||
|
|
f9734d202b | ||
|
|
d3cb0f4672 | ||
|
|
c198bbb6fa | ||
|
|
c965a89509 | ||
|
|
47f9da2160 | ||
|
|
348a75adb4 | ||
|
|
332218a7f7 | ||
|
|
6d7a26d2b9 | ||
|
|
d19a748f0c | ||
|
|
9c83e70a28 | ||
|
|
abcabb736b | ||
|
|
0451aaeb5c | ||
|
|
880c45830c | ||
|
|
5fa16d2344 | ||
|
|
9e50b5dd83 | ||
|
|
29d8753574 | ||
|
|
f93e1e1695 |
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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
@vates/async-each/.npmignore
Symbolic link
1
@vates/async-each/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
68
@vates/async-each/README.md
Normal file
68
@vates/async-each/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
<!-- 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)
|
||||
35
@vates/async-each/USAGE.md
Normal file
35
@vates/async-each/USAGE.md
Normal file
@@ -0,0 +1,35 @@
|
||||
### `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,
|
||||
}
|
||||
)
|
||||
```
|
||||
99
@vates/async-each/index.js
Normal file
99
@vates/async-each/index.js
Normal file
@@ -0,0 +1,99 @@
|
||||
'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()
|
||||
})
|
||||
}
|
||||
99
@vates/async-each/index.spec.js
Normal file
99
@vates/async-each/index.spec.js
Normal file
@@ -0,0 +1,99 @@
|
||||
'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)
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
34
@vates/async-each/package.json
Normal file
34
@vates/async-each/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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,6 +65,23 @@ 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,3 +46,20 @@ 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,11 +4,13 @@ const defaultOpts = { async: false, right: false }
|
||||
|
||||
exports.compose = function compose(opts, fns) {
|
||||
if (Array.isArray(opts)) {
|
||||
fns = opts
|
||||
fns = opts.slice() // don't mutate passed array
|
||||
opts = defaultOpts
|
||||
} else if (typeof opts === 'object') {
|
||||
opts = Object.assign({}, defaultOpts, opts)
|
||||
if (!Array.isArray(fns)) {
|
||||
if (Array.isArray(fns)) {
|
||||
fns = fns.slice() // don't mutate passed array
|
||||
} else {
|
||||
fns = Array.prototype.slice.call(arguments, 1)
|
||||
}
|
||||
} else {
|
||||
@@ -20,6 +22,24 @@ 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.0.0",
|
||||
"version": "2.1.0",
|
||||
"engines": {
|
||||
"node": ">=7.6"
|
||||
},
|
||||
|
||||
@@ -59,6 +59,36 @@ 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,
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
### `perInstance(fn, ...args)`
|
||||
|
||||
Helper to decorate the method by instance instead of for the whole class.
|
||||
|
||||
This is often necessary for caching or deduplicating calls.
|
||||
|
||||
```js
|
||||
import { perInstance } from '@vates/decorateWith'
|
||||
|
||||
class Foo {
|
||||
@decorateWith(perInstance, lodash.memoize)
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Because it's a normal function, it can also be used with `decorateMethodsWith`, with `compose` or even by itself.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -40,3 +40,33 @@ 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,
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
### `perInstance(fn, ...args)`
|
||||
|
||||
Helper to decorate the method by instance instead of for the whole class.
|
||||
|
||||
This is often necessary for caching or deduplicating calls.
|
||||
|
||||
```js
|
||||
import { perInstance } from '@vates/decorateWith'
|
||||
|
||||
class Foo {
|
||||
@decorateWith(perInstance, lodash.memoize)
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Because it's a normal function, it can also be used with `decorateMethodsWith`, with `compose` or even by itself.
|
||||
|
||||
@@ -19,3 +19,15 @@ exports.decorateMethodsWith = function decorateMethodsWith(klass, map) {
|
||||
}
|
||||
return klass
|
||||
}
|
||||
|
||||
exports.perInstance = function perInstance(fn, decorator, ...args) {
|
||||
const map = new WeakMap()
|
||||
return function () {
|
||||
let decorated = map.get(this)
|
||||
if (decorated === undefined) {
|
||||
decorated = decorator(fn, ...args)
|
||||
map.set(this, decorated)
|
||||
}
|
||||
return decorated.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^0.1.0",
|
||||
"@vates/decorate-with": "^1.0.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
|
||||
@@ -46,7 +46,7 @@ module.exports = function (pkg, configs = {}) {
|
||||
|
||||
return {
|
||||
comments: !__PROD__,
|
||||
ignore: __PROD__ ? [/\.spec\.js$/] : undefined,
|
||||
ignore: __PROD__ ? [/\btests?\//, /\.spec\.js$/] : undefined,
|
||||
plugins: Object.keys(plugins)
|
||||
.map(plugin => [plugin, plugins[plugin]])
|
||||
.sort(([a], [b]) => {
|
||||
@@ -56,22 +56,14 @@ 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 targets
|
||||
return { browsers: pkg.browserslist, node }
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.13.0",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"@xen-orchestra/backups": "^0.18.3",
|
||||
"@xen-orchestra/fs": "^0.19.3",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.19.2"
|
||||
"promise-toolbox": "^0.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.10.1"
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -3,19 +3,20 @@ 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 pump = require('pump')
|
||||
const { basename, dirname, join, normalize, resolve } = require('path')
|
||||
const groupBy = require('lodash/groupBy.js')
|
||||
const { dirname, join, normalize, resolve } = require('path')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { createSyntheticStream, mergeVhd, default: Vhd } = require('vhd-lib')
|
||||
const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = 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, isVhdFile } = require('./_backupType.js')
|
||||
const { isMetadataFile } = require('./_backupType.js')
|
||||
const { isValidXva } = require('./_isValidXva.js')
|
||||
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
|
||||
const { lvs, pvs } = require('./_lvm.js')
|
||||
@@ -67,58 +68,17 @@ const debounceResourceFactory = factory =>
|
||||
}
|
||||
|
||||
class RemoteAdapter {
|
||||
constructor(handler, { debounceResource = res => res, dirMode } = {}) {
|
||||
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression } = {}) {
|
||||
this._debounceResource = debounceResource
|
||||
this._dirMode = dirMode
|
||||
this._handler = handler
|
||||
this._vhdDirectoryCompression = vhdDirectoryCompression
|
||||
}
|
||||
|
||||
get handler() {
|
||||
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)
|
||||
@@ -232,6 +192,22 @@ class RemoteAdapter {
|
||||
return files
|
||||
}
|
||||
|
||||
// check if we will be allowed to merge a a vhd created in this adapter
|
||||
// with the vhd at path `path`
|
||||
async isMergeableParent(packedParentUid, path) {
|
||||
return await Disposable.use(openVhd(this.handler, path), vhd => {
|
||||
// this baseUuid is not linked with this vhd
|
||||
if (!vhd.footer.uuid.equals(packedParentUid)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isVhdDirectory = vhd instanceof VhdDirectory
|
||||
return isVhdDirectory
|
||||
? this.#useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
|
||||
: !this.#useVhdDirectory()
|
||||
})
|
||||
}
|
||||
|
||||
fetchPartitionFiles(diskId, partitionId, paths) {
|
||||
const { promise, reject, resolve } = pDefer()
|
||||
Disposable.use(
|
||||
@@ -253,16 +229,9 @@ class RemoteAdapter {
|
||||
|
||||
async deleteDeltaVmBackups(backups) {
|
||||
const handler = this._handler
|
||||
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
|
||||
|
||||
// unused VHDs will be detected by `cleanVm`
|
||||
await asyncMapSettled(backups, ({ _filename }) => VhdAbstract.unlink(handler, _filename))
|
||||
}
|
||||
|
||||
async deleteMetadataBackup(backupId) {
|
||||
@@ -292,17 +261,34 @@ class RemoteAdapter {
|
||||
)
|
||||
}
|
||||
|
||||
async deleteVmBackup(filename) {
|
||||
const metadata = JSON.parse(String(await this._handler.readFile(filename)))
|
||||
metadata._filename = filename
|
||||
deleteVmBackup(file) {
|
||||
return this.deleteVmBackups([file])
|
||||
}
|
||||
|
||||
if (metadata.mode === 'delta') {
|
||||
await this.deleteDeltaVmBackups([metadata])
|
||||
} else if (metadata.mode === 'full') {
|
||||
await this.deleteFullVmBackups([metadata])
|
||||
} else {
|
||||
throw new Error(`no deleter for backup mode ${metadata.mode}`)
|
||||
async deleteVmBackups(files) {
|
||||
const { delta, full, ...others } = groupBy(await asyncMap(files, file => this.readVmBackupMetadata(file)), 'mode')
|
||||
|
||||
const unsupportedModes = Object.keys(others)
|
||||
if (unsupportedModes.length !== 0) {
|
||||
throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
delta !== undefined && this.deleteDeltaVmBackups(delta),
|
||||
full !== undefined && this.deleteFullVmBackups(full),
|
||||
])
|
||||
}
|
||||
|
||||
#getCompressionType() {
|
||||
return this._vhdDirectoryCompression
|
||||
}
|
||||
|
||||
#useVhdDirectory() {
|
||||
return this.handler.type === 's3'
|
||||
}
|
||||
|
||||
#useAlias() {
|
||||
return this.#useVhdDirectory()
|
||||
}
|
||||
|
||||
getDisk = Disposable.factory(this.getDisk)
|
||||
@@ -361,6 +347,14 @@ class RemoteAdapter {
|
||||
return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
|
||||
}
|
||||
|
||||
// if we use alias on this remote, we have to name the file alias.vhd
|
||||
getVhdFileName(baseName) {
|
||||
if (this.#useAlias()) {
|
||||
return `${baseName}.alias.vhd`
|
||||
}
|
||||
return `${baseName}.vhd`
|
||||
}
|
||||
|
||||
async listAllVmBackups() {
|
||||
const handler = this._handler
|
||||
|
||||
@@ -505,6 +499,25 @@ class RemoteAdapter {
|
||||
return backups.sort(compareTimestamp)
|
||||
}
|
||||
|
||||
async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
|
||||
const handler = this._handler
|
||||
|
||||
if (this.#useVhdDirectory()) {
|
||||
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
||||
await createVhdDirectoryFromStream(handler, dataPath, input, {
|
||||
concurrency: 16,
|
||||
compression: this.#getCompressionType(),
|
||||
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,
|
||||
@@ -516,6 +529,52 @@ 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
|
||||
@@ -523,7 +582,7 @@ class RemoteAdapter {
|
||||
|
||||
const streams = {}
|
||||
await asyncMapSettled(Object.keys(vdis), async id => {
|
||||
streams[`${id}.vhd`] = await createSyntheticStream(handler, join(dir, vhds[id]))
|
||||
streams[`${id}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[id]))
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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, asyncMapSettled } = require('@xen-orchestra/async-map')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { defer } = require('golike-defer')
|
||||
const { formatDateTime } = require('@xen-orchestra/xapi')
|
||||
@@ -35,6 +36,11 @@ 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
|
||||
@@ -284,17 +290,28 @@ exports.VmBackup = class VmBackup {
|
||||
}
|
||||
|
||||
async _removeUnusedSnapshots() {
|
||||
// TODO: handle all schedules (no longer existing schedules default to 0 retention)
|
||||
|
||||
const { scheduleId } = this
|
||||
const scheduleSnapshots = this._jobSnapshots.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
|
||||
|
||||
const jobSettings = this.job.settings
|
||||
const baseVmRef = this._baseVm?.$ref
|
||||
const { config } = this
|
||||
const baseSettings = {
|
||||
...config.defaultSettings,
|
||||
...config.metadata.defaultSettings,
|
||||
...jobSettings[''],
|
||||
}
|
||||
|
||||
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
|
||||
const xapi = this._xapi
|
||||
await asyncMap(getOldEntries(this._settings.snapshotRetention, scheduleSnapshots), ({ $ref }) => {
|
||||
if ($ref !== baseVmRef) {
|
||||
return xapi.VM_destroy($ref)
|
||||
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
|
||||
const settings = {
|
||||
...baseSettings,
|
||||
...jobSettings[scheduleId],
|
||||
...jobSettings[this.vm.uuid],
|
||||
}
|
||||
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
|
||||
if ($ref !== baseVmRef) {
|
||||
return xapi.VM_destroy($ref)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -303,12 +320,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -319,10 +338,17 @@ exports.VmBackup = class VmBackup {
|
||||
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
await asyncMap(await baseVm.$getDisks(), async baseRef => {
|
||||
const snapshotOf = await xapi.getField('VDI', baseRef, 'snapshot_of')
|
||||
const [baseUuid, snapshotOf] = await Promise.all([
|
||||
xapi.getField('VDI', baseRef, 'uuid'),
|
||||
xapi.getField('VDI', baseRef, 'snapshot_of'),
|
||||
])
|
||||
const srcVdi = srcVdis[snapshotOf]
|
||||
if (srcVdi !== undefined) {
|
||||
baseUuidToSrcVdi.set(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
|
||||
baseUuidToSrcVdi.set(baseUuid, srcVdi)
|
||||
} else {
|
||||
debug('ignore snapshot VDI because no longer present on VM', {
|
||||
vdi: baseUuid,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -333,9 +359,23 @@ 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)) {
|
||||
if (presentBaseVdis.has(baseUuid)) {
|
||||
debug('found base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdi.uuid,
|
||||
})
|
||||
} else {
|
||||
debug('missing base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdi.uuid,
|
||||
})
|
||||
fullVdisRequired.add(srcVdi.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -70,6 +70,7 @@ class BackupWorker {
|
||||
yield new RemoteAdapter(handler, {
|
||||
debounceResource: this.debounceResource,
|
||||
dirMode: this.#config.dirMode,
|
||||
vhdDirectoryCompression: this.#config.vhdDirectoryCompression,
|
||||
})
|
||||
} finally {
|
||||
await handler.forget()
|
||||
|
||||
407
@xen-orchestra/backups/_cleanVm.integ.spec.js
Normal file
407
@xen-orchestra/backups/_cleanVm.integ.spec.js
Normal file
@@ -0,0 +1,407 @@
|
||||
/* 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: 12000, // a size too small
|
||||
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.push(message)
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, onLog })
|
||||
expect(loggued[0]).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused`)
|
||||
expect(loggued[1]).toEqual(`incorrect size in metadata: 12000 instead of 209920`)
|
||||
|
||||
loggued = []
|
||||
await adapter.cleanVm('/', { remove: true, merge: true, onLog })
|
||||
const [unused, merging] = loggued
|
||||
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`)
|
||||
|
||||
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
|
||||
// size should be the size of children + grand children after the merge
|
||||
expect(metadata.size).toEqual(209920)
|
||||
|
||||
// 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: undefined,
|
||||
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 })
|
||||
|
||||
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
|
||||
// size should be the size of children + grand children + clean after the merge
|
||||
expect(metadata.size).toEqual(vhdMode === 'file' ? 314880 : undefined)
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('it cleans orphan merge states ', async () => {
|
||||
await handler.writeFile(`${basePath}/.orphan.vhd.merge.json`, '')
|
||||
|
||||
await adapter.cleanVm('/', { remove: true })
|
||||
|
||||
expect(await handler.list(basePath)).toEqual([])
|
||||
})
|
||||
@@ -1,17 +1,38 @@
|
||||
const assert = require('assert')
|
||||
const sum = require('lodash/sum')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { default: Vhd, mergeVhd } = require('vhd-lib')
|
||||
const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
|
||||
const { dirname, resolve } = require('path')
|
||||
const { DISK_TYPE_DIFFERENCING } = require('vhd-lib/dist/_constants.js')
|
||||
const { DISK_TYPES } = Constants
|
||||
const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
|
||||
const { limitConcurrency } = require('limit-concurrency-decorator')
|
||||
|
||||
const { Task } = require('./Task.js')
|
||||
const { Disposable } = require('promise-toolbox')
|
||||
|
||||
// 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
|
||||
function shouldComputeVhdsSize(vhds) {
|
||||
return vhds.every(vhd => vhd instanceof VhdFile)
|
||||
}
|
||||
|
||||
const computeVhdsSize = (handler, vhdPaths) =>
|
||||
Disposable.use(
|
||||
vhdPaths.map(vhdPath => openVhd(handler, vhdPath)),
|
||||
async vhds => {
|
||||
if (shouldComputeVhdsSize(vhds)) {
|
||||
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
|
||||
return sum(sizes)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
assert(chain.length >= 2)
|
||||
|
||||
let child = chain[0]
|
||||
@@ -44,7 +65,7 @@ const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, {
|
||||
}
|
||||
}, 10e3)
|
||||
|
||||
await mergeVhd(
|
||||
const mergedSize = await mergeVhd(
|
||||
handler,
|
||||
parent,
|
||||
handler,
|
||||
@@ -63,24 +84,26 @@ const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, {
|
||||
clearInterval(handle)
|
||||
|
||||
await Promise.all([
|
||||
handler.rename(parent, child),
|
||||
VhdAbstract.rename(handler, parent, child),
|
||||
asyncMap(children.slice(0, -1), child => {
|
||||
onLog(`the VHD ${child} is unused`)
|
||||
if (remove) {
|
||||
onLog(`deleting unused VHD ${child}`)
|
||||
return handler.unlink(child)
|
||||
return VhdAbstract.unlink(handler, child)
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
return mergedSize
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
const INTERRUPTED_VHDS_REG = /^(?:(.+)\/)?\.(.+)\.merge.json$/
|
||||
const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
|
||||
const listVhds = async (handler, vmDir) => {
|
||||
const vhds = []
|
||||
const interruptedVhds = new Set()
|
||||
const vhds = new Set()
|
||||
const interruptedVhds = new Map()
|
||||
|
||||
await asyncMap(
|
||||
await handler.list(`${vmDir}/vdis`, {
|
||||
@@ -95,16 +118,14 @@ const listVhds = async (handler, vmDir) => {
|
||||
async vdiDir => {
|
||||
const list = await handler.list(vdiDir, {
|
||||
filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
|
||||
prependDir: true,
|
||||
})
|
||||
|
||||
list.forEach(file => {
|
||||
const res = INTERRUPTED_VHDS_REG.exec(file)
|
||||
if (res === null) {
|
||||
vhds.push(file)
|
||||
vhds.add(`${vdiDir}/${file}`)
|
||||
} else {
|
||||
const [, dir, file] = res
|
||||
interruptedVhds.add(`${dir}/${file}`)
|
||||
interruptedVhds.set(`${vdiDir}/${res[1]}`, `${vdiDir}/${file}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -114,65 +135,91 @@ const listVhds = async (handler, vmDir) => {
|
||||
return { vhds, interruptedVhds }
|
||||
}
|
||||
|
||||
exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, onLog = noop }) {
|
||||
const defaultMergeLimiter = limitConcurrency(1)
|
||||
|
||||
exports.cleanVm = async function cleanVm(
|
||||
vmDir,
|
||||
{ fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
|
||||
) {
|
||||
const limitedMergeVhdChain = mergeLimiter(mergeVhdChain)
|
||||
|
||||
const handler = this._handler
|
||||
|
||||
const vhds = new Set()
|
||||
const vhdsToJSons = new Set()
|
||||
const vhdParents = { __proto__: null }
|
||||
const vhdChildren = { __proto__: null }
|
||||
|
||||
const vhdsList = await listVhds(handler, vmDir)
|
||||
const { vhds, interruptedVhds } = await listVhds(handler, vmDir)
|
||||
|
||||
// remove broken VHDs
|
||||
await asyncMap(vhdsList.vhds, async path => {
|
||||
await asyncMap(vhds, async path => {
|
||||
try {
|
||||
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?
|
||||
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
|
||||
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
|
||||
}
|
||||
vhdChildren[parent] = path
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
vhds.delete(path)
|
||||
onLog(`error while checking the VHD with path ${path}`, { error })
|
||||
if (error?.code === 'ERR_ASSERTION' && remove) {
|
||||
onLog(`deleting broken ${path}`)
|
||||
await handler.unlink(path)
|
||||
return VhdAbstract.unlink(handler, path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// remove interrupted merge states for missing VHDs
|
||||
for (const interruptedVhd of interruptedVhds.keys()) {
|
||||
if (!vhds.has(interruptedVhd)) {
|
||||
const statePath = interruptedVhds.get(interruptedVhd)
|
||||
interruptedVhds.delete(interruptedVhd)
|
||||
|
||||
onLog('orphan merge state', {
|
||||
mergeStatePath: statePath,
|
||||
missingVhdPath: interruptedVhd,
|
||||
})
|
||||
if (remove) {
|
||||
onLog(`deleting orphan merge state ${statePath}`)
|
||||
await handler.unlink(statePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @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 = vhd => {
|
||||
const parent = vhdParents[vhd]
|
||||
const deleteIfOrphan = vhdPath => {
|
||||
const parent = vhdParents[vhdPath]
|
||||
if (parent === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// no longer needs to be checked
|
||||
delete vhdParents[vhd]
|
||||
delete vhdParents[vhdPath]
|
||||
|
||||
deleteIfOrphan(parent)
|
||||
|
||||
if (!vhds.has(parent)) {
|
||||
vhds.delete(vhd)
|
||||
vhds.delete(vhdPath)
|
||||
|
||||
onLog(`the parent ${parent} of the VHD ${vhd} is missing`)
|
||||
onLog(`the parent ${parent} of the VHD ${vhdPath} is missing`)
|
||||
if (remove) {
|
||||
onLog(`deleting orphan VHD ${vhd}`)
|
||||
deletions.push(handler.unlink(vhd))
|
||||
onLog(`deleting orphan VHD ${vhdPath}`)
|
||||
deletions.push(VhdAbstract.unlink(handler, vhdPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +235,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
|
||||
await Promise.all(deletions)
|
||||
}
|
||||
|
||||
const jsons = []
|
||||
const jsons = new Set()
|
||||
const xvas = new Set()
|
||||
const xvaSums = []
|
||||
const entries = await handler.list(vmDir, {
|
||||
@@ -196,7 +243,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
|
||||
})
|
||||
entries.forEach(path => {
|
||||
if (isMetadataFile(path)) {
|
||||
jsons.push(path)
|
||||
jsons.add(path)
|
||||
} else if (isXvaFile(path)) {
|
||||
xvas.add(path)
|
||||
} else if (isXvaSumFile(path)) {
|
||||
@@ -218,22 +265,25 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
|
||||
// compile the list of unused XVAs and VHDs, and remove backup metadata which
|
||||
// reference a missing XVA/VHD
|
||||
await asyncMap(jsons, async json => {
|
||||
const metadata = JSON.parse(await handler.readFile(json))
|
||||
let metadata
|
||||
try {
|
||||
metadata = JSON.parse(await handler.readFile(json))
|
||||
} catch (error) {
|
||||
onLog(`failed to read metadata file ${json}`, { error })
|
||||
jsons.delete(json)
|
||||
return
|
||||
}
|
||||
|
||||
const { mode } = metadata
|
||||
let size
|
||||
if (mode === 'full') {
|
||||
const linkedXva = resolve('/', vmDir, metadata.xva)
|
||||
|
||||
if (xvas.has(linkedXva)) {
|
||||
unusedXvas.delete(linkedXva)
|
||||
|
||||
size = await handler.getSize(linkedXva).catch(error => {
|
||||
onLog(`failed to get size of ${json}`, { error })
|
||||
})
|
||||
} else {
|
||||
onLog(`the XVA linked to the metadata ${json} is missing`)
|
||||
if (remove) {
|
||||
onLog(`deleting incomplete backup ${json}`)
|
||||
jsons.delete(json)
|
||||
await handler.unlink(json)
|
||||
}
|
||||
}
|
||||
@@ -243,42 +293,29 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
|
||||
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 (linkedVhds.every(_ => vhds.has(_))) {
|
||||
if (missingVhds.length === 0) {
|
||||
linkedVhds.forEach(_ => unusedVhds.delete(_))
|
||||
|
||||
size = await asyncMap(linkedVhds, vhd => handler.getSize(vhd)).then(sum, error => {
|
||||
onLog(`failed to get size of ${json}`, { error })
|
||||
linkedVhds.forEach(path => {
|
||||
vhdsToJSons[path] = json
|
||||
})
|
||||
} else {
|
||||
onLog(`Some VHDs linked to the metadata ${json} are missing`)
|
||||
onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
|
||||
if (remove) {
|
||||
onLog(`deleting incomplete backup ${json}`)
|
||||
jsons.delete(json)
|
||||
await handler.unlink(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadataSize = metadata.size
|
||||
if (size !== undefined && metadataSize !== size) {
|
||||
onLog(`incorrect size in metadata: ${metadataSize ?? 'none'} instead of ${size}`)
|
||||
|
||||
// don't update if the the stored size is greater than found files,
|
||||
// it can indicates a problem
|
||||
if (fixMetadata && (metadataSize === undefined || metadataSize < size)) {
|
||||
try {
|
||||
metadata.size = size
|
||||
await handler.writeFile(json, JSON.stringify(metadata), { flags: 'w' })
|
||||
} catch (error) {
|
||||
onLog(`failed to update size in backup metadata ${json}`, { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: parallelize by vm/job/vdi
|
||||
const unusedVhdsDeletion = []
|
||||
const toMerge = []
|
||||
{
|
||||
// VHD chains (as list from child to ancestor) to merge indexed by last
|
||||
// ancestor
|
||||
@@ -312,7 +349,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
|
||||
onLog(`the VHD ${vhd} is unused`)
|
||||
if (remove) {
|
||||
onLog(`deleting unused VHD ${vhd}`)
|
||||
unusedVhdsDeletion.push(handler.unlink(vhd))
|
||||
unusedVhdsDeletion.push(VhdAbstract.unlink(handler, vhd))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,22 +358,31 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
|
||||
})
|
||||
|
||||
// merge interrupted VHDs
|
||||
if (merge) {
|
||||
vhdsList.interruptedVhds.forEach(parent => {
|
||||
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
|
||||
})
|
||||
for (const parent of interruptedVhds.keys()) {
|
||||
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
|
||||
}
|
||||
|
||||
Object.keys(vhdChainsToMerge).forEach(key => {
|
||||
const chain = vhdChainsToMerge[key]
|
||||
Object.values(vhdChainsToMerge).forEach(chain => {
|
||||
if (chain !== undefined) {
|
||||
unusedVhdsDeletion.push(mergeVhdChain(chain, { handler, onLog, remove, merge }))
|
||||
toMerge.push(chain)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const metadataWithMergedVhd = {}
|
||||
const doMerge = async () => {
|
||||
await asyncMap(toMerge, async chain => {
|
||||
const merged = await limitedMergeVhdChain(chain, { handler, onLog, remove, merge })
|
||||
if (merged !== undefined) {
|
||||
const metadataPath = vhdsToJSons[chain[0]] // all the chain should have the same metada file
|
||||
metadataWithMergedVhd[metadataPath] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -355,4 +401,55 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
// update size for delta metadata with merged VHD
|
||||
// check for the other that the size is the same as the real file size
|
||||
|
||||
await asyncMap(jsons, async metadataPath => {
|
||||
const metadata = JSON.parse(await handler.readFile(metadataPath))
|
||||
|
||||
let fileSystemSize
|
||||
const merged = metadataWithMergedVhd[metadataPath] !== undefined
|
||||
|
||||
const { mode, size, vhds, xva } = metadata
|
||||
|
||||
try {
|
||||
if (mode === 'full') {
|
||||
// a full backup : check size
|
||||
const linkedXva = resolve('/', vmDir, xva)
|
||||
fileSystemSize = await handler.getSize(linkedXva)
|
||||
} else if (mode === 'delta') {
|
||||
const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
|
||||
fileSystemSize = await computeVhdsSize(handler, linkedVhds)
|
||||
|
||||
// the size is not computed in some cases (e.g. VhdDirectory)
|
||||
if (fileSystemSize === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// don't warn if the size has changed after a merge
|
||||
if (!merged && fileSystemSize !== size) {
|
||||
onLog(`incorrect size in metadata: ${size ?? 'none'} instead of ${fileSystemSize}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
onLog(`failed to get size of ${metadataPath}`, { error })
|
||||
return
|
||||
}
|
||||
|
||||
// systematically update size after a merge
|
||||
if ((merged || fixMetadata) && size !== fileSystemSize) {
|
||||
metadata.size = fileSystemSize
|
||||
try {
|
||||
await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
|
||||
} catch (error) {
|
||||
onLog(`failed to update size in backup metadata ${metadataPath} after merge`, { error })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// boolean whether some VHDs were merged (or should be merged)
|
||||
merge: toMerge.length !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
const assert = require('assert')
|
||||
|
||||
const isGzipFile = async (handler, fd) => {
|
||||
const COMPRESSED_MAGIC_NUMBERS = [
|
||||
// https://tools.ietf.org/html/rfc1952.html#page-5
|
||||
const magicNumber = Buffer.allocUnsafe(2)
|
||||
Buffer.from('1F8B', 'hex'),
|
||||
|
||||
assert.strictEqual((await handler.read(fd, magicNumber, 0)).bytesRead, magicNumber.length)
|
||||
return magicNumber[0] === 31 && magicNumber[1] === 139
|
||||
// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#zstandard-frames
|
||||
Buffer.from('28B52FFD', 'hex'),
|
||||
]
|
||||
const MAGIC_NUMBER_MAX_LENGTH = Math.max(...COMPRESSED_MAGIC_NUMBERS.map(_ => _.length))
|
||||
|
||||
const isCompressedFile = async (handler, fd) => {
|
||||
const header = Buffer.allocUnsafe(MAGIC_NUMBER_MAX_LENGTH)
|
||||
assert.strictEqual((await handler.read(fd, header, 0)).bytesRead, header.length)
|
||||
|
||||
for (const magicNumber of COMPRESSED_MAGIC_NUMBERS) {
|
||||
if (magicNumber.compare(header, 0, magicNumber.length) === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: better check?
|
||||
@@ -43,8 +56,8 @@ async function isValidXva(path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (await isGzipFile(handler, fd))
|
||||
? true // gzip files cannot be validated at this time
|
||||
return (await isCompressedFile(handler, fd))
|
||||
? true // compressed files cannot be validated at this time
|
||||
: await isValidTar(handler, size, fd)
|
||||
} finally {
|
||||
handler.closeFile(fd).catch(noop)
|
||||
|
||||
69
@xen-orchestra/backups/merge-worker/cli.js
Executable file
69
@xen-orchestra/backups/merge-worker/cli.js
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/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)
|
||||
}
|
||||
)
|
||||
25
@xen-orchestra/backups/merge-worker/index.js
Normal file
25
@xen-orchestra/backups/merge-worker/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
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.13.0",
|
||||
"version": "0.18.3",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
@@ -16,14 +16,14 @@
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/compose": "^2.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"@xen-orchestra/fs": "^0.19.3",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^3.6.0",
|
||||
"compare-versions": "^4.0.1",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"end-of-stream": "^1.4.4",
|
||||
"fs-extra": "^10.0.0",
|
||||
@@ -32,13 +32,15 @@
|
||||
"lodash": "^4.17.20",
|
||||
"node-zone": "^0.4.0",
|
||||
"parse-pairs": "^1.1.0",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"pump": "^3.0.0",
|
||||
"vhd-lib": "^1.2.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vhd-lib": "^3.0.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^0.7.0"
|
||||
"@xen-orchestra/xapi": "^0.8.5"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
92
@xen-orchestra/backups/tests.fixtures.js
Normal file
92
@xen-orchestra/backups/tests.fixtures.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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, default: Vhd } = require('vhd-lib')
|
||||
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { dirname } = require('path')
|
||||
|
||||
@@ -16,6 +16,7 @@ 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')
|
||||
|
||||
@@ -23,6 +24,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
const { handler } = this._adapter
|
||||
const backup = this._backup
|
||||
const adapter = this._adapter
|
||||
|
||||
const backupDir = getVmBackupDir(backup.vm.uuid)
|
||||
const vdisDir = `${backupDir}/vdis/${backup.job.id}`
|
||||
@@ -34,16 +36,21 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
|
||||
prependDir: true,
|
||||
})
|
||||
const packedBaseUuid = packUuid(baseUuid)
|
||||
await asyncMap(vhds, async path => {
|
||||
try {
|
||||
await checkVhdChain(handler, path)
|
||||
// Warning, this should not be written as found = found || await adapter.isMergeableParent(packedBaseUuid, path)
|
||||
//
|
||||
// since all the checks of a path are done in parallel, found would be containing
|
||||
// only the last answer of isMergeableParent which is probably not the right one
|
||||
// this led to the support tickets https://help.vates.fr/#ticket/zoom/4751 , 4729, 4665 and 4300
|
||||
|
||||
const vhd = new Vhd(handler, path)
|
||||
await vhd.readHeaderAndFooter()
|
||||
found = found || vhd.footer.uuid.equals(packUuid(baseUuid))
|
||||
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
|
||||
found = found || isMergeable
|
||||
} catch (error) {
|
||||
warn('checkBaseVdis', { error })
|
||||
await ignoreErrors.call(handler.unlink(path))
|
||||
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -113,19 +120,13 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
}
|
||||
|
||||
async _deleteOldEntries() {
|
||||
return Task.run({ name: 'merge' }, async () => {
|
||||
const adapter = this._adapter
|
||||
const oldEntries = this._oldEntries
|
||||
const adapter = this._adapter
|
||||
const oldEntries = this._oldEntries
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
// delete sequentially from newest to oldest to avoid unnecessary merges
|
||||
for (let i = oldEntries.length; i-- > 0; ) {
|
||||
await adapter.deleteDeltaVmBackups([oldEntries[i]])
|
||||
}
|
||||
}
|
||||
|
||||
async _transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
@@ -150,7 +151,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
// don't do delta for it
|
||||
vdi.uuid
|
||||
: vdi.$snapshot_of$uuid
|
||||
}/${basename}.vhd`
|
||||
}/${adapter.getVhdFileName(basename)}`
|
||||
)
|
||||
|
||||
const metadataFilename = `${backupDir}/${basename}.json`
|
||||
@@ -194,7 +195,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
await checkVhd(handler, parentPath)
|
||||
}
|
||||
|
||||
await adapter.outputStream(path, deltaExport.streams[`${id}.vhd`], {
|
||||
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
|
||||
// no checksum for VHDs, because they will be invalidated by
|
||||
// merges and chainings
|
||||
checksum: false,
|
||||
@@ -206,11 +207,11 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
}
|
||||
|
||||
// set the correct UUID in the VHD
|
||||
const vhd = new Vhd(handler, path)
|
||||
await vhd.readHeaderAndFooter()
|
||||
vhd.footer.uuid = packUuid(vdi.uuid)
|
||||
await vhd.readBlockAllocationTable() // required by writeFooter()
|
||||
await vhd.writeFooter()
|
||||
await Disposable.use(openVhd(handler, path), async vhd => {
|
||||
vhd.footer.uuid = packUuid(vdi.uuid)
|
||||
await vhd.readBlockAllocationTable() // required by writeFooter()
|
||||
await vhd.writeFooter()
|
||||
})
|
||||
})
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -1,34 +1,65 @@
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { join } = require('path')
|
||||
|
||||
const { getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
const { BACKUP_DIR, getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
const MergeWorker = require('../merge-worker/index.js')
|
||||
const { formatFilenameDate } = require('../_filenameDate.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._lock = undefined
|
||||
|
||||
this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
|
||||
}
|
||||
|
||||
_cleanVm(options) {
|
||||
return this._adapter
|
||||
.cleanVm(getVmBackupDir(this._backup.vm.uuid), { ...options, fixMetadata: true, onLog: warn, lock: false })
|
||||
.catch(warn)
|
||||
async _cleanVm(options) {
|
||||
try {
|
||||
return await this._adapter.cleanVm(this.#vmBackupDir, {
|
||||
...options,
|
||||
fixMetadata: true,
|
||||
onLog: warn,
|
||||
lock: false,
|
||||
})
|
||||
} catch (error) {
|
||||
warn(error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async beforeBackup() {
|
||||
const { handler } = this._adapter
|
||||
const vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
|
||||
const vmBackupDir = this.#vmBackupDir
|
||||
await handler.mktree(vmBackupDir)
|
||||
this._lock = await handler.lock(vmBackupDir)
|
||||
this.#lock = await handler.lock(vmBackupDir)
|
||||
}
|
||||
|
||||
async afterBackup() {
|
||||
await this._cleanVm({ remove: true, merge: true })
|
||||
await this._lock.dispose()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const Vhd = require('vhd-lib').default
|
||||
const openVhd = require('vhd-lib').openVhd
|
||||
const Disposable = require('promise-toolbox/Disposable')
|
||||
|
||||
exports.checkVhd = async function checkVhd(handler, path) {
|
||||
await new Vhd(handler, path).readHeaderAndFooter()
|
||||
await Disposable.use(openVhd(handler, path), () => {})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^0.34.3"
|
||||
"xen-api": "^0.35.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { createSchedule } from './'
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
const wrap = value => () => value
|
||||
|
||||
describe('issues', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.3",
|
||||
"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.17.2",
|
||||
"@marsaud/smb2": "^0.18.0",
|
||||
"@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.19.2",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"readable-stream": "^3.0.6",
|
||||
"through2": "^4.0.2",
|
||||
"xo-remote-parser": "^0.7.0"
|
||||
"xo-remote-parser": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -76,6 +76,7 @@ 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)
|
||||
@@ -307,6 +308,17 @@ 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)
|
||||
}
|
||||
@@ -519,6 +531,9 @@ 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,6 +33,10 @@ 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,6 +1,7 @@
|
||||
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'
|
||||
@@ -16,7 +17,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 { host, path, username, password, protocol, region } = parse(remote.url)
|
||||
const { allowUnauthorized, host, path, username, password, protocol, region } = parse(remote.url)
|
||||
const params = {
|
||||
accessKeyId: username,
|
||||
apiVersion: '2006-03-01',
|
||||
@@ -29,8 +30,13 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
},
|
||||
}
|
||||
if (protocol === 'http') {
|
||||
params.httpOptions.agent = new http.Agent()
|
||||
params.httpOptions.agent = new http.Agent({ keepAlive: true })
|
||||
params.sslEnabled = false
|
||||
} else if (protocol === 'https') {
|
||||
params.httpOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: !allowUnauthorized,
|
||||
keepAlive: true,
|
||||
})
|
||||
}
|
||||
if (region !== undefined) {
|
||||
params.region = region
|
||||
@@ -51,6 +57,27 @@ 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,
|
||||
@@ -125,16 +152,30 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
const splitPrefix = splitPath(prefix)
|
||||
const result = await this._s3.listObjectsV2({
|
||||
Bucket: this._bucket,
|
||||
Prefix: splitPrefix.join('/'),
|
||||
Prefix: splitPrefix.join('/') + '/', // need slash at the end with the use of delimiters
|
||||
Delimiter: '/', // will only return path until delimiters
|
||||
})
|
||||
const uniq = new Set()
|
||||
|
||||
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
|
||||
for (const entry of result.Contents) {
|
||||
const line = splitPath(entry.Key)
|
||||
if (line.length > splitPrefix.length) {
|
||||
uniq.add(line[splitPrefix.length])
|
||||
}
|
||||
uniq.push(line[line.length - 1])
|
||||
}
|
||||
return [...uniq]
|
||||
|
||||
return uniq
|
||||
}
|
||||
|
||||
async _mkdir(path) {
|
||||
@@ -147,25 +188,9 @@ 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) {
|
||||
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.copy(oldPath, newPath)
|
||||
await this._s3.deleteObject(this._createParams(oldPath))
|
||||
}
|
||||
|
||||
@@ -183,9 +208,21 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
}
|
||||
const params = this._createParams(file)
|
||||
params.Range = `bytes=${position}-${position + buffer.length - 1}`
|
||||
const result = await this._s3.getObject(params)
|
||||
result.Body.copy(buffer)
|
||||
return { bytesRead: result.Body.length, buffer }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async _rmdir(path) {
|
||||
@@ -199,6 +236,28 @@ 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 ? result.NextContinuationToken : undefined
|
||||
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 !== undefined)
|
||||
}
|
||||
|
||||
async _write(file, buffer, position) {
|
||||
if (typeof file !== 'string') {
|
||||
file = file.fd
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
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
24
@xen-orchestra/lite/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# 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*
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1,90 +0,0 @@
|
||||
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)
|
||||
@@ -1,120 +0,0 @@
|
||||
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
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
@@ -1,46 +0,0 @@
|
||||
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
|
||||
@@ -1,65 +0,0 @@
|
||||
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
|
||||
@@ -1,89 +0,0 @@
|
||||
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
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
@@ -1,300 +0,0 @@
|
||||
// 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
|
||||
@@ -1,102 +0,0 @@
|
||||
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
|
||||
@@ -1,131 +0,0 @@
|
||||
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
|
||||
@@ -1,506 +0,0 @@
|
||||
// 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
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,193 +0,0 @@
|
||||
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
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
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)
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
@@ -1,152 +0,0 @@
|
||||
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
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'>
|
||||
|
||||
const RangeInput = React.memo((props: Props) => <input {...props} type='range' />)
|
||||
|
||||
export default RangeInput
|
||||
@@ -1,97 +0,0 @@
|
||||
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
|
||||
@@ -1,73 +0,0 @@
|
||||
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
|
||||
@@ -1,114 +0,0 @@
|
||||
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)
|
||||
@@ -1,196 +0,0 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
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')
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"connect": "Connexion",
|
||||
"vmStartLabel": "Démarrer"
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"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
6
@xen-orchestra/lite/types/decs.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
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
42
@xen-orchestra/lite/types/reaclette.d.ts
vendored
@@ -1,42 +0,0 @@
|
||||
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
21
@xen-orchestra/lite/types/theme.d.ts
vendored
@@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/* 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,6 +48,10 @@ 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.19.2"
|
||||
"promise-toolbox": "^0.20.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"bind-property-descriptor": "^1.0.0",
|
||||
"bind-property-descriptor": "^2.0.0",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -13,7 +13,7 @@ module.exports = class Config {
|
||||
const watchers = (this._watchers = new Set())
|
||||
|
||||
app.hooks.on('start', async () => {
|
||||
app.hooks.on(
|
||||
app.hooks.once(
|
||||
'stop',
|
||||
await watch({ appDir, appName, ignoreUnknownFormats: true }, (error, config) => {
|
||||
if (error != null) {
|
||||
@@ -32,7 +32,7 @@ module.exports = class Config {
|
||||
get(path) {
|
||||
const value = get(this._config, path)
|
||||
if (value === undefined) {
|
||||
throw new TypeError('missing config entry: ' + value)
|
||||
throw new TypeError('missing config entry: ' + path)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -22,7 +22,7 @@
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/emit-async": "^0.1.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"app-conf": "^0.9.0",
|
||||
"app-conf": "^1.0.0",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -23,22 +23,22 @@
|
||||
"xo-proxy-cli": "dist/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.0",
|
||||
"@vates/read-chunk": "^0.1.2",
|
||||
"ansi-colors": "^4.1.1",
|
||||
"app-conf": "^0.9.0",
|
||||
"app-conf": "^1.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"cson-parser": "^4.0.7",
|
||||
"getopts": "^2.2.3",
|
||||
"http-request-plus": "^0.12",
|
||||
"http-request-plus": "^0.13.0",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"pump": "^3.0.0",
|
||||
"pumpify": "^2.0.1",
|
||||
"split2": "^3.1.1"
|
||||
"split2": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -18,7 +18,9 @@ keepAliveInterval = 10e3
|
||||
#
|
||||
# https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation
|
||||
dirMode = 0o700
|
||||
disableMergeWorker = false
|
||||
snapshotNameLabelTpl = '[XO Backup {job.name}] {vm.name_label}'
|
||||
vhdDirectoryCompression = 'brotli'
|
||||
|
||||
[backups.defaultSettings]
|
||||
reportWhen = 'failure'
|
||||
@@ -59,6 +61,13 @@ 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'
|
||||
|
||||
@@ -79,3 +88,20 @@ ignoreNobakVdis = false
|
||||
|
||||
maxUncoalescedVdis = 1
|
||||
watchEvents = ['network', 'PIF', 'pool', 'SR', 'task', 'VBD', 'VDI', 'VIF', 'VM']
|
||||
|
||||
|
||||
|
||||
#compact mode
|
||||
[reverseProxies]
|
||||
# '/http/' = 'http://localhost:8081/'
|
||||
#The target can have a path ( like `http://target/sub/directory/`),
|
||||
# parameters (`?param=one`) and hash (`#jwt:32154`) that are automatically added to all queries transfered by the proxy.
|
||||
# If a parameter is present in the configuration and in the query, only the config parameter is transferred.
|
||||
# '/another' = http://hiddenServer:8765/path/
|
||||
|
||||
# And use the extended mode when required
|
||||
# The additionnal options of a proxy's configuraiton's section are used to instantiate the `https` Agent(respectively the `http`).
|
||||
# A notable option is `rejectUnauthorized` which allow to connect to a HTTPS backend with an invalid/ self signed certificate
|
||||
#[reverseProxies.'/https/']
|
||||
# target = 'https://localhost:8080/'
|
||||
# rejectUnauthorized = false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.14.7",
|
||||
"version": "0.17.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -22,43 +22,44 @@
|
||||
"xo-proxy": "dist/index.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13"
|
||||
"node": ">=14.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.0",
|
||||
"@koa/router": "^10.0.0",
|
||||
"@vates/compose": "^2.0.0",
|
||||
"@vates/decorate-with": "^0.1.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^1.0.0",
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.13.0",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"@xen-orchestra/backups": "^0.18.3",
|
||||
"@xen-orchestra/fs": "^0.19.3",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.1.1",
|
||||
"@xen-orchestra/mixins": "^0.1.2",
|
||||
"@xen-orchestra/self-signed": "^0.1.0",
|
||||
"@xen-orchestra/xapi": "^0.7.0",
|
||||
"@xen-orchestra/xapi": "^0.8.5",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^0.9.0",
|
||||
"app-conf": "^1.0.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"getopts": "^2.2.3",
|
||||
"golike-defer": "^0.5.1",
|
||||
"http-server-plus": "^0.11.0",
|
||||
"http2-proxy": "^5.0.53",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"jsonrpc-websocket-client": "^0.6.0",
|
||||
"jsonrpc-websocket-client": "^0.7.2",
|
||||
"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.19.2",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"source-map-support": "^0.5.16",
|
||||
"stoppable": "^1.0.6",
|
||||
"xdg-basedir": "^4.0.0",
|
||||
"xen-api": "^0.34.3",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"xen-api": "^0.35.1",
|
||||
"xo-common": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -15,24 +15,29 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
const { debug, warn } = createLogger('xo:proxy:api')
|
||||
|
||||
const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable) {
|
||||
let headerSent = false
|
||||
try {
|
||||
for await (const data of iterable) {
|
||||
if (!headerSent) {
|
||||
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
|
||||
headerSent = true
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
yield JSON.stringify(data) + '\n'
|
||||
yield JSON.stringify(cursor.value) + '\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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,8 +45,8 @@ export default class Api {
|
||||
constructor(app, { appVersion, httpServer }) {
|
||||
this._ajv = new Ajv({ allErrors: true })
|
||||
this._methods = { __proto__: null }
|
||||
|
||||
const router = new Router({ prefix: '/api/v1' }).post('/', async ctx => {
|
||||
const PREFIX = '/api/v1'
|
||||
const router = new Router({ prefix: PREFIX }).post('/', async ctx => {
|
||||
// Before Node 13.0 there was an inactivity timeout of 2 mins, which may
|
||||
// not be enough for the API.
|
||||
ctx.req.setTimeout(0)
|
||||
@@ -97,6 +102,7 @@ export default class Api {
|
||||
// breaks, send some data every 10s to keep it opened.
|
||||
const stopTimer = clearInterval.bind(
|
||||
undefined,
|
||||
// @to check : can this add space inside binary data ?
|
||||
setInterval(() => stream.push(' '), keepAliveInterval)
|
||||
)
|
||||
stream.on('end', stopTimer).on('error', stopTimer)
|
||||
@@ -113,7 +119,14 @@ export default class Api {
|
||||
.use(router.routes())
|
||||
.use(router.allowedMethods())
|
||||
|
||||
httpServer.on('request', koa.callback())
|
||||
const callback = koa.callback()
|
||||
httpServer.on('request', (req, res) => {
|
||||
// only answers to query to the root url of this mixin
|
||||
// do it before giving the request to Koa to ensure it's not modified
|
||||
if (req.url.startsWith(PREFIX)) {
|
||||
callback(req, res)
|
||||
}
|
||||
})
|
||||
|
||||
this.addMethods({
|
||||
system: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'assert'
|
||||
import fse from 'fs-extra'
|
||||
import xdg from 'xdg-basedir'
|
||||
import { xdgConfig } from 'xdg-basedir'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
@@ -10,33 +11,48 @@ const { warn } = createLogger('xo:proxy:authentication')
|
||||
const isValidToken = t => typeof t === 'string' && t.length !== 0
|
||||
|
||||
export default class Authentication {
|
||||
constructor(_, { appName, config: { authenticationToken: token } }) {
|
||||
if (!isValidToken(token)) {
|
||||
token = JSON.parse(execFileSync('xenstore-read', ['vm-data/xo-proxy-authenticationToken']))
|
||||
#token
|
||||
|
||||
if (!isValidToken(token)) {
|
||||
throw new Error('missing authenticationToken in configuration')
|
||||
}
|
||||
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
|
||||
`${xdgConfig}/${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'])) })
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
this._token = token
|
||||
app.api.addMethod('authentication.setToken', setToken, {
|
||||
description: 'change the authentication token used by this XO Proxy',
|
||||
params: {
|
||||
token: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async findProfile(credentials) {
|
||||
if (credentials?.authenticationToken === this._token) {
|
||||
if (credentials?.authenticationToken === this.#token) {
|
||||
return new Profile()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,17 @@ export default class Backups {
|
||||
},
|
||||
},
|
||||
],
|
||||
deleteVmBackups: [
|
||||
({ filenames, remote }) =>
|
||||
Disposable.use(this.getAdapter(remote), adapter => adapter.deleteVmBackups(filenames)),
|
||||
{
|
||||
description: 'delete VM backups',
|
||||
params: {
|
||||
filenames: { type: 'array', items: { type: 'string' } },
|
||||
remote: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
fetchPartitionFiles: [
|
||||
({ disk: diskId, remote, partition: partitionId, paths }) =>
|
||||
Disposable.use(this.getAdapter(remote), adapter => adapter.fetchPartitionFiles(diskId, partitionId, paths)),
|
||||
@@ -403,6 +414,7 @@ export default class Backups {
|
||||
return new RemoteAdapter(yield app.remotes.getHandler(remote), {
|
||||
debounceResource: app.debounceResource.bind(app),
|
||||
dirMode: app.config.get('backups.dirMode'),
|
||||
vhdDirectoryCompression: app.config.get('backups.vhdDirectoryCompression'),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
17
@xen-orchestra/proxy/src/app/mixins/logs.mjs
Normal file
17
@xen-orchestra/proxy/src/app/mixins/logs.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
},
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
120
@xen-orchestra/proxy/src/app/mixins/reverseProxy.mjs
Normal file
120
@xen-orchestra/proxy/src/app/mixins/reverseProxy.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import { urlToHttpOptions } from 'url'
|
||||
import proxy from 'http2-proxy'
|
||||
|
||||
function removeSlash(str) {
|
||||
return str.replace(/^\/|\/$/g, '')
|
||||
}
|
||||
|
||||
function mergeUrl(relative, base) {
|
||||
const res = new URL(base)
|
||||
const relativeUrl = new URL(relative, base)
|
||||
res.pathname = relativeUrl.pathname
|
||||
relativeUrl.searchParams.forEach((value, name) => {
|
||||
// we do not allow to modify params already specified by config
|
||||
if (!res.searchParams.has(name)) {
|
||||
res.searchParams.append(name, value)
|
||||
}
|
||||
})
|
||||
res.hash = relativeUrl.hash.length > 0 ? relativeUrl.hash : res.hash
|
||||
return res
|
||||
}
|
||||
|
||||
export function backendToLocalPath(basePath, target, backendUrl) {
|
||||
// keep redirect url relative to local server
|
||||
const localPath = `${basePath}/${backendUrl.pathname.substring(target.pathname.length)}${backendUrl.search}${
|
||||
backendUrl.hash
|
||||
}`
|
||||
return localPath
|
||||
}
|
||||
|
||||
export function localToBackendUrl(basePath, target, localPath) {
|
||||
let localPathWithoutBase = removeSlash(localPath).substring(basePath.length)
|
||||
localPathWithoutBase = './' + removeSlash(localPathWithoutBase)
|
||||
const url = mergeUrl(localPathWithoutBase, target)
|
||||
return url
|
||||
}
|
||||
|
||||
export default class ReverseProxy {
|
||||
constructor(app, { httpServer }) {
|
||||
app.config.watch('reverseProxies', proxies => {
|
||||
this._proxies = Object.keys(proxies)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(path => {
|
||||
let config = proxies[path]
|
||||
if (typeof config === 'string') {
|
||||
config = { target: config }
|
||||
}
|
||||
config.path = '/proxy/v1/' + removeSlash(path) + '/'
|
||||
|
||||
return config
|
||||
})
|
||||
})
|
||||
|
||||
httpServer.on('request', (req, res) => this._proxy(req, res))
|
||||
httpServer.on('upgrade', (req, socket, head) => this._upgrade(req, socket, head))
|
||||
}
|
||||
|
||||
_getConfigFromRequest(req) {
|
||||
return this._proxies.find(({ path }) => req.url.startsWith(path))
|
||||
}
|
||||
|
||||
_proxy(req, res) {
|
||||
const config = this._getConfigFromRequest(req)
|
||||
|
||||
if (config === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end('404')
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(config.target)
|
||||
const targetUrl = localToBackendUrl(config.path, url, req.originalUrl || req.url)
|
||||
proxy.web(req, res, {
|
||||
...urlToHttpOptions(targetUrl),
|
||||
...config.options,
|
||||
onReq: (req, { headers }) => {
|
||||
headers['x-forwarded-for'] = req.socket.remoteAddress
|
||||
headers['x-forwarded-proto'] = req.socket.encrypted ? 'https' : 'http'
|
||||
if (req.headers.host !== undefined) {
|
||||
headers['x-forwarded-host'] = req.headers.host
|
||||
}
|
||||
},
|
||||
onRes: (req, res, proxyRes) => {
|
||||
// rewrite redirect to pass through this proxy
|
||||
if (proxyRes.statusCode === 301 || proxyRes.statusCode === 302) {
|
||||
// handle relative/ absolute location
|
||||
const redirectTargetLocation = new URL(proxyRes.headers.location, url)
|
||||
|
||||
// this proxy should only allow communication between known hosts. Don't open it too much
|
||||
if (redirectTargetLocation.hostname !== url.hostname || redirectTargetLocation.protocol !== url.protocol) {
|
||||
throw new Error(`Can't redirect from ${url.hostname} to ${redirectTargetLocation.hostname} `)
|
||||
}
|
||||
res.writeHead(proxyRes.statusCode, {
|
||||
...proxyRes.headers,
|
||||
location: backendToLocalPath(config.path, url, redirectTargetLocation),
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
// pass through the answer of the remote server
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers)
|
||||
// pass through content
|
||||
proxyRes.pipe(res)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
_upgrade(req, socket, head) {
|
||||
const config = this._getConfigFromRequest(req)
|
||||
if (config === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const { path, target, options } = config
|
||||
const targetUrl = localToBackendUrl(path, target, req.originalUrl || req.url)
|
||||
proxy.ws(req, socket, head, {
|
||||
...urlToHttpOptions(targetUrl),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
}
|
||||
19
@xen-orchestra/proxy/tests/cert.pem
Normal file
19
@xen-orchestra/proxy/tests/cert.pem
Normal file
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDETCCAfkCFHXO1U7YJHI61bPNhYDvyBNJYH4LMA0GCSqGSIb3DQEBCwUAMEUx
|
||||
CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl
|
||||
cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwMTEwMTI0MTU4WhcNNDkwNTI3MTI0
|
||||
MTU4WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE
|
||||
CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC
|
||||
AQ8AMIIBCgKCAQEA1jMLdHuZu2R1fETyB2iRect1alwv76clp/7A8tx4zNaVA9qB
|
||||
BcHbI83mkozuyrXpsEUblTvvcWkheBPAvWD4gj0eWSDSiuf0edcIS6aky+Lr/n0T
|
||||
W/vL5kVNrgPTlsO8OyQcXjDeuUOR1xDWIa8G71Ynd6wtATB7oXe7kaV/Z6b2fENr
|
||||
4wlW0YEDnMHik59c9jXDshhQYDlErwZsSyHuLwkC7xuYO26SUW9fPcHJA3uOfxeG
|
||||
BrCxMuSMOJtdmslRWhLCjbk0PT12OYCCRlvuTvPHa8N57GEQbi4xAu+XATgO1DUm
|
||||
Dq/oCSj0TcWUXXOykN/PAC2cjIyqkU2e7orGaQIDAQABMA0GCSqGSIb3DQEBCwUA
|
||||
A4IBAQCTshhF3V5WVhnpFGHd+tPfeHmUVrUnbC+xW7fSeWpamNmTjHb7XB6uDR0O
|
||||
DGswhEitbbSOsCiwz4/zpfE3/3+X07O8NPbdHTVHCei6D0uyegEeWQ2HoocfZs3X
|
||||
8CORe8TItuvQAevV17D0WkGRoJGVAOiKo+izpjI55QXQ+FjkJ0bfl1iksnUJk0+I
|
||||
ZNmRRNjNyOxo7NAzomSBHfJ5rDE+E440F2uvXIE9OIwHRiq6FGvQmvGijPeeP5J0
|
||||
LzcSK98jfINFSsA/Wn5vWE+gfH9ySD2G3r2cDTS904T77PNiYH+cNSP6ujtmNzvK
|
||||
Bgoa3jXZPRBi82TUOb2jj5DB33bg
|
||||
-----END CERTIFICATE-----
|
||||
27
@xen-orchestra/proxy/tests/key.pem
Normal file
27
@xen-orchestra/proxy/tests/key.pem
Normal file
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEA1jMLdHuZu2R1fETyB2iRect1alwv76clp/7A8tx4zNaVA9qB
|
||||
BcHbI83mkozuyrXpsEUblTvvcWkheBPAvWD4gj0eWSDSiuf0edcIS6aky+Lr/n0T
|
||||
W/vL5kVNrgPTlsO8OyQcXjDeuUOR1xDWIa8G71Ynd6wtATB7oXe7kaV/Z6b2fENr
|
||||
4wlW0YEDnMHik59c9jXDshhQYDlErwZsSyHuLwkC7xuYO26SUW9fPcHJA3uOfxeG
|
||||
BrCxMuSMOJtdmslRWhLCjbk0PT12OYCCRlvuTvPHa8N57GEQbi4xAu+XATgO1DUm
|
||||
Dq/oCSj0TcWUXXOykN/PAC2cjIyqkU2e7orGaQIDAQABAoIBAQC65uVq6WLWGa1O
|
||||
FtbdUggGL1svyGrngYChGvB/uZMKoX57U1DbljDCCCrV23WNmbfkYBjWWervmZ1j
|
||||
qlC2roOJGQ1/Fd3A6O7w1YnegPUxFrt3XunijE55iiVi3uHknryDGlpKcfgVzfjW
|
||||
oVFHKPMzKYjcqnbGn+hwlwoq5y7JYFTOa57/dZbyommbodRyy9Dpn0OES0grQqwR
|
||||
VD1amrQ7XJhukcxQgYPuDc/jM3CuowoBsv9f+Q2zsPgr6CpHxxLLIs+kt8NQJT9v
|
||||
neg/pm8ojcwOa9qoILdtu6ue7ee3VE9cFnB1vutxS1+MPeI5wgTJjaYrgPCMxXBM
|
||||
2LdJJEmBAoGBAPA6LpuU1vv5R3x66hzenSk4LS1fj24K0WuBdTwFvzQmCr70oKdo
|
||||
Yywxt+ZkBw5aEtzQlB8GewolHobDJrzxMorU+qEXX3jP2BIPDVQl2orfjr03Yyus
|
||||
s5mYS/Qa6Zf1yObrjulTNm8oTn1WaG3TIvi8c5DyG2OK28N/9oMI1XGRAoGBAORD
|
||||
YKyII/S66gZsJSf45qmrhq1hHuVt1xae5LUPP6lVD+MCCAmuoJnReV8fc9h7Dvgd
|
||||
YPMINkWUTePFr3o4p1mh2ZC7ldczgDn6X4TldY2J3Zg47xJa5hL0L6JL4NiCGRIE
|
||||
FV5rLJxkGh/DDBfmC9hQQ6Yg6cHvyewso5xVnBtZAoGAI+OdWPMIl0ZrrqYyWbPM
|
||||
aP8SiMfRBtCo7tW9bQUyxpi0XEjxw3Dt+AlJfysMftFoJgMnTedK9H4NLHb1T579
|
||||
PQ6KjwyN39+1WSVUiXDKUJsLmSswLrMzdcvx9PscUO6QYCdrB2K+LCcqasFBAr9b
|
||||
ZyvIXCw/eUSihneUnYjxUnECgYAoPgCzKiU8ph9QFozOaUExNH4/3tl1lVHQOR8V
|
||||
FKUik06DtP35xwGlXJrLPF5OEhPnhjZrYk0/IxBAUb/ICmjmknQq4gdes0Ot9QgW
|
||||
A+Yfl+irR45ObBwXx1kGgd4YDYeh93pU9QweXj+Ezfw50mLQNgZXKYJMoJu2uX/2
|
||||
tdkZsQKBgQCTfDcW8qBntI6V+3Gh+sIThz+fjdv5+qT54heO4EHadc98ykEZX0M1
|
||||
sCWJiAQWM/zWXcsTndQDgDsvo23jpoulVPDitSEISp5gSe9FEN2njsVVID9h1OIM
|
||||
f30s5kwcJoiV9kUCya/BFtuS7kbuQfAyPU0v3I+lUey6VCW6A83OTg==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
60
@xen-orchestra/proxy/tests/localServer.mjs
Normal file
60
@xen-orchestra/proxy/tests/localServer.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createServer as creatServerHttps } from 'https'
|
||||
import { createServer as creatServerHttp } from 'http'
|
||||
|
||||
import { WebSocketServer } from 'ws'
|
||||
import fs from 'fs'
|
||||
|
||||
const httpsServer = creatServerHttps({
|
||||
key: fs.readFileSync('key.pem'),
|
||||
cert: fs.readFileSync('cert.pem'),
|
||||
})
|
||||
const httpServer = creatServerHttp()
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false })
|
||||
|
||||
function upgrade(request, socket, head) {
|
||||
const { pathname } = new URL(request.url)
|
||||
// web socket server only on /foo url
|
||||
if (pathname === '/foo') {
|
||||
wss.handleUpgrade(request, socket, head, function done(ws) {
|
||||
wss.emit('connection', ws, request)
|
||||
ws.on('message', function message(data) {
|
||||
ws.send(data)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function httpHandler(req, res) {
|
||||
switch (req.url) {
|
||||
case '/index.html':
|
||||
res.end('hi')
|
||||
return
|
||||
case '/redirect':
|
||||
res.writeHead(301, {
|
||||
Location: 'index.html',
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
case '/chainRedirect':
|
||||
res.writeHead(301, {
|
||||
Location: '/redirect',
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
default:
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
httpsServer.on('upgrade', upgrade)
|
||||
httpServer.on('upgrade', upgrade)
|
||||
|
||||
httpsServer.on('request', httpHandler)
|
||||
httpServer.on('request', httpHandler)
|
||||
|
||||
httpsServer.listen(8080)
|
||||
httpServer.listen(8081)
|
||||
123
@xen-orchestra/proxy/tests/reverseProxy.unit.spec.mjs
Normal file
123
@xen-orchestra/proxy/tests/reverseProxy.unit.spec.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import ReverseProxy, { backendToLocalPath, localToBackendUrl } from '../dist/app/mixins/reverseProxy.mjs'
|
||||
import { deepEqual, strictEqual } from 'assert'
|
||||
|
||||
function makeApp(reverseProxies) {
|
||||
return {
|
||||
config: {
|
||||
get: () => reverseProxies,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const app = makeApp({
|
||||
https: {
|
||||
target: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
|
||||
oneOption: true,
|
||||
},
|
||||
http: 'http://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
|
||||
})
|
||||
|
||||
// test localToBackendUrl
|
||||
const expectedLocalToRemote = {
|
||||
https: [
|
||||
{
|
||||
local: '/proxy/v1/https/',
|
||||
remote: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/sub',
|
||||
remote: 'https://localhost:8080/remotePath/sub?baseParm=1#one=2&another=3',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/sub/index.html',
|
||||
remote: 'https://localhost:8080/remotePath/sub/index.html?baseParm=1#one=2&another=3',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/sub?param=1',
|
||||
remote: 'https://localhost:8080/remotePath/sub?baseParm=1¶m=1#one=2&another=3',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/sub?baseParm=willbeoverwritten¶m=willstay',
|
||||
remote: 'https://localhost:8080/remotePath/sub?baseParm=1¶m=willstay#one=2&another=3',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/sub?param=1#another=willoverwrite',
|
||||
remote: 'https://localhost:8080/remotePath/sub?baseParm=1¶m=1#another=willoverwrite',
|
||||
},
|
||||
],
|
||||
}
|
||||
const proxy = new ReverseProxy(app, { httpServer: { on: () => {} } })
|
||||
for (const proxyId in expectedLocalToRemote) {
|
||||
for (const { local, remote } of expectedLocalToRemote[proxyId]) {
|
||||
const config = proxy._getConfigFromRequest({ url: local })
|
||||
const url = new URL(config.target)
|
||||
strictEqual(localToBackendUrl(config.path, url, local).href, remote, 'error converting to backend')
|
||||
}
|
||||
}
|
||||
|
||||
// test backendToLocalPath
|
||||
const expectedRemoteToLocal = {
|
||||
https: [
|
||||
{
|
||||
local: '/proxy/v1/https/',
|
||||
remote: 'https://localhost:8080/remotePath/',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/sub/index.html',
|
||||
remote: '/remotePath/sub/index.html',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/?baseParm=1#one=2&another=3',
|
||||
remote: '?baseParm=1#one=2&another=3',
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/sub?baseParm=1#one=2&another=3',
|
||||
remote: 'https://localhost:8080/remotePath/sub?baseParm=1#one=2&another=3',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
for (const proxyId in expectedRemoteToLocal) {
|
||||
for (const { local, remote } of expectedRemoteToLocal[proxyId]) {
|
||||
const config = proxy._getConfigFromRequest({ url: local })
|
||||
const targetUrl = new URL('https://localhost:8080/remotePath/?baseParm=1#one=2&another=3')
|
||||
const remoteUrl = new URL(remote, targetUrl)
|
||||
strictEqual(backendToLocalPath(config.path, targetUrl, remoteUrl), local, 'error converting to local')
|
||||
}
|
||||
}
|
||||
|
||||
// test _getConfigFromRequest
|
||||
|
||||
const expectedConfig = [
|
||||
{
|
||||
local: '/proxy/v1/http/other',
|
||||
config: {
|
||||
target: 'http://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
|
||||
options: {},
|
||||
path: '/proxy/v1/http',
|
||||
},
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/http',
|
||||
config: undefined,
|
||||
},
|
||||
|
||||
{
|
||||
local: '/proxy/v1/other',
|
||||
config: undefined,
|
||||
},
|
||||
{
|
||||
local: '/proxy/v1/https/',
|
||||
config: {
|
||||
target: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
|
||||
options: {
|
||||
oneOption: true,
|
||||
},
|
||||
path: '/proxy/v1/https',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const { local, config } of expectedConfig) {
|
||||
deepEqual(proxy._getConfigFromRequest({ url: local }), config)
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
"form-data": "^4.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"http-request-plus": "^0.12",
|
||||
"http-request-plus": "^0.13.0",
|
||||
"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.10.1",
|
||||
"xo-vmdk-to-vhd": "^2.0.0"
|
||||
"xo-lib": "^0.11.1",
|
||||
"xo-vmdk-to-vhd": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user