Compare commits

..

55 Commits

Author SHA1 Message Date
Mathieu
ae087a6539 feat(xo-lite/ObjectStatus): use ProgressCircle component (#6207) 2022-04-28 09:59:59 +02:00
Mathieu
4db93f8ced feat(lite/ProgressCircle): creation of the component (#6128) 2022-04-26 17:18:41 +02:00
Rajaa.BARHTAOUI
e5c737cba7 feat(lite/pool): dashboard Status card (#6112) 2022-04-21 16:22:49 +02:00
Rajaa.BARHTAOUI
9f0f38ef94 feat(lite): add Tabs component (#6096) 2022-04-08 10:13:26 +02:00
Rajaa.BARHTAOUI
d76996b1d5 feat(lite/tree): auto-reveal active VM (#6088) 2022-04-07 15:43:48 +02:00
Rajaa.BARHTAOUI
3b77897692 feat(lite): update PanelHeader to match mockup (#6111) 2022-03-23 16:57:10 +01:00
Mathieu
d4ed555abd feat(lite/console): handle case where host sends self-signed certificate (#6104) 2022-02-18 15:36:18 +01:00
Mathieu
97d77c0aa5 fix(lite/Select): fix controlled input with undefined value (#6106)
Fix `MUI: The 'value' prop must be an array when using the 'Select' component with 'multiple'.` in case we toggle the `multiple` prop `false -> true` with a controlled input initialized with undefined value.
2022-02-17 15:45:17 +01:00
Rajaa.BARHTAOUI
a9ad0ec455 feat(lite): change pool icon (#6110) 2022-02-03 09:34:25 +01:00
Rajaa.BARHTAOUI
78ec008c26 feat(lite/Icon): possibility to use colors of theme's palette (#6114) 2022-02-02 14:35:56 +01:00
Pierre Donias
2d71bef5d8 chore(lite): handle asset relative paths in prod and dev environment (#6109)
- Make asset URLs relative
- Add a base tag in production to make all the URLs relative to
  lite.xen-orchestra.com (change made directly on the server)
2022-01-31 11:18:12 +01:00
Mathieu
3ec7c61987 fix(lite/tree): navigate with keyboard into tree-view (#6069) 2022-01-27 11:27:22 +01:00
Mathieu
526c2001d3 fix(lite/console): console cropped on window vertical resize (#6077)
Introduced by f3d4e40c6d
2022-01-20 14:11:38 +01:00
Florent BEAUCHAMP
f3d4e40c6d feat(xo-lite): implement Title bar component (#5960) 2021-12-15 17:07:56 +01:00
Mathieu
ac8f93fb0e feat(lite/ActionButton): creation of the ActionButton component (#6021) 2021-12-09 16:34:37 +01:00
Rajaa.BARHTAOUI
d2fbc1b573 fix(lite/Tree,TreeView): fix type errors (#6011) 2021-12-09 10:56:40 +01:00
Rajaa.BARHTAOUI
c5670a047f feat(lite): sort hosts by name_label (#6046) 2021-12-08 16:45:48 +01:00
Mathieu
e9472889f2 feat(xo-lite/Modal): creation of the Modal component (#5775) 2021-12-02 10:07:00 +01:00
Rajaa.BARHTAOUI
9bec4b571c fix(lite/tree): clicking next to VM name should work (#6005) 2021-11-30 16:18:53 +01:00
Rajaa.BARHTAOUI
b56cc96e37 feat(lite): sort VMs by name_label (#5989) 2021-11-30 15:41:55 +01:00
Rajaa.BARHTAOUI
011164f16c feat(lite/tree): highlight selected node (#5939)
Inspired by https://mui.com/components/tree-view/#contentcomponent-prop
2021-11-16 17:05:53 +01:00
Mathieu
b9a9471408 feat(xo-lite): creation of Select component (#5878) 2021-11-10 10:33:33 +01:00
Florent BEAUCHAMP
9abd1429a2 feat(lint-staged): apply validation rules to ts and tsx files (#5985)
See https://github.com/vatesfr/xen-orchestra/pull/5960#discussion_r738332567
2021-11-08 15:05:07 +01:00
Mathieu
7f656973de feat(xo-lite/Input): creation of Input component (#5975) 2021-11-05 16:30:56 +01:00
Mathieu
5e0766fcb1 feat(xo-lite/Button): replace styled component to mui-button (#5964) 2021-11-05 16:22:20 +01:00
Mathieu
2dc5c0e161 fix(lite): console scaling (#5933) 2021-10-15 17:34:55 +02:00
Pierre Donias
d0730d05fd chore(lite): update xen-api (#5945) 2021-10-12 15:52:03 +02:00
Pierre Donias
8fe3a439fc chore(lite): uninstall @material-ui/core (#5928)
Use @mui/material instead
2021-10-04 14:27:05 +02:00
Pierre Donias
12c7113662 fix(lite): use absolute assets URLs 2021-09-30 17:29:05 +02:00
Pierre Donias
36be46b073 feat(lite): add UI template and prepare for initial release (#5922) 2021-09-30 15:03:28 +02:00
Rajaa.BARHTAOUI
25ef579df5 feat(lite): Tree view (#5804) 2021-09-30 15:02:59 +02:00
Mathieu
cbbb07d389 feat(lite): list pool updates (#5794) 2021-09-30 15:02:58 +02:00
Pierre Donias
96df84c9d8 fix(lite): fix credentials error type (#5845) 2021-09-30 15:02:58 +02:00
Pierre Donias
17c4b5cbe7 feat(lite): show version in UI (#5844) 2021-09-30 15:02:58 +02:00
Mathieu
cf642cd720 feat(xo-lite/Pool): display IP, DNS, gateway from management PIF (#5771) 2021-09-30 15:02:58 +02:00
Mathieu
047f3a9b4c feat(xo-lite): use styled-components for console component (#5827) 2021-09-30 15:02:58 +02:00
Mathieu
b0f85e0380 feat(xo-lite/Console): handle disconnection and halted VMs (#5728) 2021-09-30 15:02:58 +02:00
Mathieu
7aa518b43c feat(xo-lite): wrapper for FormattedMessage (#5803) 2021-09-30 15:02:58 +02:00
Pierre Donias
d187d6aeeb chore(lite): allow explicit any 2021-09-30 15:02:58 +02:00
Pierre Donias
289dce3876 chore(lite/types): handle async computed 2021-09-30 15:02:58 +02:00
Pierre Donias
930afea1a1 feat(lite): signin page (#5787) 2021-09-30 15:02:58 +02:00
Mathieu
3801fa9134 feat(lite/Console): add CtrlAltDel button (#5722) 2021-09-30 15:02:58 +02:00
Julien Fontanet
ae211046b8 fix(lite): don't let Babel transpile import/export 2021-09-30 15:02:58 +02:00
Julien Fontanet
87ce9ff63a fix(lite): blacklist dns module
It's used by `xen-api` but should be fine as long as `reverseHostIpAddresses` is not enable.
2021-09-30 15:02:58 +02:00
Pierre Donias
131c6321be chore(xo-lite): fix config and xen-api 2021-09-30 15:02:58 +02:00
Pierre Donias
6abcce498f feat(xo-lite): style guide (#5764) 2021-09-30 15:02:21 +02:00
Pierre Donias
9c38f5b327 feat(xo-lite): styled-components 2021-09-30 15:02:00 +02:00
Pierre Donias
14720d4cbf chore(xo-lite): move all dependencies to devDependencies 2021-09-30 15:01:38 +02:00
Mathieu
940ef2845d feat(xo-lite/Console): ability to scale VM console (#5703) 2021-09-30 15:01:38 +02:00
Pierre Donias
e3dbb7a6c2 fix(xo-lite/novnc): remove types 2021-09-30 15:01:38 +02:00
Pierre Donias
8cba6ebb20 fix(xo-lite/novnc): use @types/novnc-core
See https://github.com/DefinitelyTyped/DefinitelyTyped/pull/18602
2021-09-30 15:01:37 +02:00
Pierre Donias
a1b322f5be chore(xo-lite): update xen-api 2021-09-30 15:01:37 +02:00
Pierre Donias
07ff19c4b8 feat(xo-lite): Reaclette types 2021-09-30 15:01:06 +02:00
Pierre Donias
3a0af4e7e0 fix(xo-lite/console): get console URL from XAPI 2021-09-30 15:01:06 +02:00
Pierre Donias
dbb3f74ab0 feat(xo-lite): initial commit 2021-09-30 15:01:06 +02:00
224 changed files with 10083 additions and 7541 deletions

View File

@@ -1,33 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'status: triaging :triangular_flag_on_post:, type: bug :bug:'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- Node: [e.g. 16.12.1]
- xo-server: [e.g. 5.82.3]
- xo-web: [e.g. 5.87.0]
- hypervisor: [e.g. XCP-ng 8.2.0]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

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

View File

@@ -1,68 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/async-each
[![Package Version](https://badgen.net/npm/v/@vates/async-each)](https://npmjs.org/package/@vates/async-each) ![License](https://badgen.net/npm/license/@vates/async-each) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/async-each)](https://bundlephobia.com/result?p=@vates/async-each) [![Node compatibility](https://badgen.net/npm/node/@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)

View File

@@ -1,35 +0,0 @@
### `asyncEach(iterable, iteratee, [opts])`
Executes `iteratee` in order for each value yielded by `iterable`.
Returns a promise wich rejects as soon as a call to `iteratee` throws or a promise returned by it rejects, and which resolves when all promises returned by `iteratee` have resolved.
`iterable` must be an iterable or async iterable.
`iteratee` is called with the same `this` value as `asyncEach`, and with the following arguments:
- `value`: the value yielded by `iterable`
- `index`: the 0-based index for this value
- `iterable`: the iterable itself
`opts` is an object that can contains the following options:
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `1`
- `signal`: an abort signal to stop the iteration
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'
const contents = []
await asyncEach(
['foo.txt', 'bar.txt', 'baz.txt'],
async function (filename, i) {
contents[i] = await readFile(filename)
},
{
// reads two files at a time
concurrency: 2,
}
)
```

View File

@@ -1,99 +0,0 @@
'use strict'
const noop = Function.prototype
class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.errors = errors
}
}
exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 1, signal, stopOnError = true } = {}) {
return new Promise((resolve, reject) => {
const it = (iterable[Symbol.iterator] || iterable[Symbol.asyncIterator]).call(iterable)
const errors = []
let running = 0
let index = 0
let onAbort
if (signal !== undefined) {
onAbort = () => {
onRejectedWrapper(new Error('asyncEach aborted'))
}
signal.addEventListener('abort', onAbort)
}
const clean = () => {
onFulfilled = onRejected = noop
if (onAbort !== undefined) {
signal.removeEventListener('abort', onAbort)
}
}
resolve = (resolve =>
function resolveAndClean(value) {
resolve(value)
clean()
})(resolve)
reject = (reject =>
function rejectAndClean(reason) {
reject(reason)
clean()
})(reject)
let onFulfilled = value => {
--running
next()
}
const onFulfilledWrapper = value => onFulfilled(value)
let onRejected = stopOnError
? reject
: error => {
--running
errors.push(error)
next()
}
const onRejectedWrapper = reason => onRejected(reason)
let nextIsRunning = false
let next = async () => {
if (nextIsRunning) {
return
}
nextIsRunning = true
if (running < concurrency) {
const cursor = await it.next()
if (cursor.done) {
next = () => {
if (running === 0) {
if (errors.length === 0) {
resolve()
} else {
reject(new AggregateError(errors))
}
}
}
} else {
++running
try {
const result = iteratee.call(this, cursor.value, index++, iterable)
let then
if (result != null && typeof result === 'object' && typeof (then = result.then) === 'function') {
then.call(result, onFulfilledWrapper, onRejectedWrapper)
} else {
onFulfilled(result)
}
} catch (error) {
onRejected(error)
}
}
nextIsRunning = false
return next()
}
nextIsRunning = false
}
next()
})
}

View File

@@ -1,99 +0,0 @@
'use strict'
/* eslint-env jest */
const { asyncEach } = require('./')
const randomDelay = (max = 10) =>
new Promise(resolve => {
setTimeout(resolve, Math.floor(Math.random() * max + 1))
})
const rejectionOf = p =>
new Promise((resolve, reject) => {
p.then(reject, resolve)
})
describe('asyncEach', () => {
const thisArg = 'qux'
const values = ['foo', 'bar', 'baz']
Object.entries({
'sync iterable': () => values,
'async iterable': async function* () {
for (const value of values) {
await randomDelay()
yield value
}
},
}).forEach(([what, getIterable]) =>
describe('with ' + what, () => {
let iterable
beforeEach(() => {
iterable = getIterable()
})
it('works', async () => {
const iteratee = jest.fn(async () => {})
await asyncEach.call(thisArg, iterable, iteratee)
expect(iteratee.mock.instances).toEqual(Array.from(values, () => thisArg))
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
})
;[1, 2, 4].forEach(concurrency => {
it('respects a concurrency of ' + concurrency, async () => {
let running = 0
await asyncEach(
values,
async () => {
++running
expect(running).toBeLessThanOrEqual(concurrency)
await randomDelay()
--running
},
{ concurrency }
)
})
})
it('stops on first error when stopOnError is true', async () => {
const error = new Error()
const iteratee = jest.fn((_, i) => {
if (i === 1) {
throw error
}
})
expect(await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: true }))).toBe(error)
expect(iteratee).toHaveBeenCalledTimes(2)
})
it('rejects AggregateError when stopOnError is false', async () => {
const errors = []
const iteratee = jest.fn(() => {
const error = new Error()
errors.push(error)
throw error
})
const error = await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: false }))
expect(error.errors).toEqual(errors)
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
})
it('can be interrupted with an AbortSignal', async () => {
const ac = new AbortController()
const iteratee = jest.fn((_, i) => {
if (i === 1) {
ac.abort()
}
})
await expect(asyncEach(iterable, iteratee, { signal: ac.signal })).rejects.toThrow('asyncEach aborted')
expect(iteratee).toHaveBeenCalledTimes(2)
})
})
)
})

View File

@@ -1,34 +0,0 @@
{
"private": false,
"name": "@vates/async-each",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/async-each",
"description": "Run async fn for each item in (async) iterable",
"keywords": [
"array",
"async",
"collection",
"each",
"for",
"foreach",
"iterable",
"iterator"
],
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/async-each",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"
}
}

View File

@@ -65,23 +65,6 @@ const f = compose(
)
```
Functions can receive extra parameters:
```js
const isIn = (value, min, max) => min <= value && value <= max
// Only compatible when `fns` is passed as an array!
const f = compose([
[add, 2],
[isIn, 3, 10],
])
console.log(f(1))
// → true
```
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -46,20 +46,3 @@ const f = compose(
[add2, mul3]
)
```
Functions can receive extra parameters:
```js
const isIn = (value, min, max) => min <= value && value <= max
// Only compatible when `fns` is passed as an array!
const f = compose([
[add, 2],
[isIn, 3, 10],
])
console.log(f(1))
// → true
```
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.

View File

@@ -4,13 +4,11 @@ const defaultOpts = { async: false, right: false }
exports.compose = function compose(opts, fns) {
if (Array.isArray(opts)) {
fns = opts.slice() // don't mutate passed array
fns = opts
opts = defaultOpts
} else if (typeof opts === 'object') {
opts = Object.assign({}, defaultOpts, opts)
if (Array.isArray(fns)) {
fns = fns.slice() // don't mutate passed array
} else {
if (!Array.isArray(fns)) {
fns = Array.prototype.slice.call(arguments, 1)
}
} else {
@@ -22,24 +20,6 @@ exports.compose = function compose(opts, fns) {
if (n === 0) {
throw new TypeError('at least one function must be passed')
}
for (let i = 0; i < n; ++i) {
const entry = fns[i]
if (Array.isArray(entry)) {
const fn = entry[0]
const args = entry.slice()
args[0] = undefined
fns[i] = function composeWithArgs(value) {
args[0] = value
try {
return fn.apply(this, args)
} finally {
args[0] = undefined
}
}
}
}
if (n === 1) {
return fns[0]
}

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "2.1.0",
"version": "2.0.0",
"engines": {
"node": ">=7.6"
},

View File

@@ -59,17 +59,6 @@ decorateMethodsWith(Foo, {
The decorated class is returned, so you can export it directly.
To apply multiple transforms to a method, you can either call `decorateMethodsWith` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
```js
decorateMethodsWith(Foo, {
bar: compose([
[lodash.debounce, 150]
lodash.curry,
])
})
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -40,14 +40,3 @@ decorateMethodsWith(Foo, {
```
The decorated class is returned, so you can export it directly.
To apply multiple transforms to a method, you can either call `decorateMethodsWith` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
```js
decorateMethodsWith(Foo, {
bar: compose([
[lodash.debounce, 150]
lodash.curry,
])
})
```

View File

@@ -46,7 +46,7 @@ module.exports = function (pkg, configs = {}) {
return {
comments: !__PROD__,
ignore: __PROD__ ? [/\btests?\//, /\.spec\.js$/] : undefined,
ignore: __PROD__ ? [/\.spec\.js$/] : undefined,
plugins: Object.keys(plugins)
.map(plugin => [plugin, plugins[plugin]])
.sort(([a], [b]) => {
@@ -56,14 +56,22 @@ module.exports = function (pkg, configs = {}) {
}),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
targets: (() => {
const targets = {}
if (pkg.browserslist !== undefined) {
targets.browsers = pkg.browserslist
}
let node = (pkg.engines || {}).node
if (node !== undefined) {
const trimChars = '^=>~'
while (trimChars.includes(node[0])) {
node = node.slice(1)
}
targets.node = node
}
return { browsers: pkg.browserslist, node }
return targets
})(),
}
}

View File

@@ -7,12 +7,12 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.16.2",
"@xen-orchestra/fs": "^0.19.2",
"@xen-orchestra/backups": "^0.13.0",
"@xen-orchestra/fs": "^0.18.0",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
"promise-toolbox": "^0.20.0"
"promise-toolbox": "^0.19.2"
},
"engines": {
"node": ">=7.10.1"
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "0.6.1",
"version": "0.6.0",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -3,19 +3,19 @@ const Disposable = require('promise-toolbox/Disposable.js')
const fromCallback = require('promise-toolbox/fromCallback.js')
const fromEvent = require('promise-toolbox/fromEvent.js')
const pDefer = require('promise-toolbox/defer.js')
const { dirname, join, normalize, resolve } = require('path')
const pump = require('pump')
const { basename, dirname, join, normalize, resolve } = require('path')
const { createLogger } = require('@xen-orchestra/log')
const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdSynthetic } = require('vhd-lib')
const { createSyntheticStream, mergeVhd, default: Vhd } = require('vhd-lib')
const { deduped } = require('@vates/disposable/deduped.js')
const { execFile } = require('child_process')
const { readdir, stat } = require('fs-extra')
const { v4: uuidv4 } = require('uuid')
const { ZipFile } = require('yazl')
const { BACKUP_DIR } = require('./_getVmBackupDir.js')
const { cleanVm } = require('./_cleanVm.js')
const { getTmpDir } = require('./_getTmpDir.js')
const { isMetadataFile } = require('./_backupType.js')
const { isMetadataFile, isVhdFile } = require('./_backupType.js')
const { isValidXva } = require('./_isValidXva.js')
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
const { lvs, pvs } = require('./_lvm.js')
@@ -77,6 +77,48 @@ class RemoteAdapter {
return this._handler
}
async _deleteVhd(path) {
const handler = this._handler
const vhds = await asyncMapSettled(
await handler.list(dirname(path), {
filter: isVhdFile,
prependDir: true,
}),
async path => {
try {
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
return {
footer: vhd.footer,
header: vhd.header,
path,
}
} catch (error) {
// Do not fail on corrupted VHDs (usually uncleaned temporary files),
// they are probably inconsequent to the backup process and should not
// fail it.
warn(`BackupNg#_deleteVhd ${path}`, { error })
}
}
)
const base = basename(path)
const child = vhds.find(_ => _ !== undefined && _.header.parentUnicodeName === base)
if (child === undefined) {
await handler.unlink(path)
return 0
}
try {
const childPath = child.path
const mergedDataSize = await mergeVhd(handler, path, handler, childPath)
await handler.rename(path, childPath)
return mergedDataSize
} catch (error) {
handler.unlink(path).catch(warn)
throw error
}
}
async _findPartition(devicePath, partitionId) {
const partitions = await listPartitions(devicePath)
const partition = partitions.find(_ => _.id === partitionId)
@@ -211,9 +253,16 @@ class RemoteAdapter {
async deleteDeltaVmBackups(backups) {
const handler = this._handler
// unused VHDs will be detected by `cleanVm`
await asyncMapSettled(backups, ({ _filename }) => VhdAbstract.unlink(handler, _filename))
let mergedDataSize = 0
await asyncMapSettled(backups, ({ _filename, vhds }) =>
Promise.all([
handler.unlink(_filename),
asyncMap(Object.values(vhds), async _ => {
mergedDataSize += await this._deleteVhd(resolveRelativeFromFile(_filename, _))
}),
])
)
return mergedDataSize
}
async deleteMetadataBackup(backupId) {
@@ -312,17 +361,6 @@ class RemoteAdapter {
return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
}
// this function will be the one where we plug the logic of the storage format by fs type/user settings
// if the file is named .vhd => vhd
// if the file is named alias.vhd => alias to a vhd
getVhdFileName(baseName) {
if (this._handler.type === 's3') {
return `${baseName}.alias.vhd` // we want an alias to a vhddirectory
}
return `${baseName}.vhd`
}
async listAllVmBackups() {
const handler = this._handler
@@ -467,24 +505,6 @@ class RemoteAdapter {
return backups.sort(compareTimestamp)
}
async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
const handler = this._handler
if (path.endsWith('.alias.vhd')) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
await createVhdDirectoryFromStream(handler, dataPath, input, {
concurrency: 16,
async validator() {
await input.task
return validator.apply(this, arguments)
},
})
await VhdAbstract.createAlias(handler, path, dataPath)
} else {
await this.outputStream(path, input, { checksum, validator })
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
await this._handler.outputStream(path, input, {
checksum,
@@ -496,52 +516,6 @@ class RemoteAdapter {
})
}
async _createSyntheticStream(handler, paths) {
let disposableVhds = []
// if it's a path : open all hierarchy of parent
if (typeof paths === 'string') {
let vhd,
vhdPath = paths
do {
const disposable = await openVhd(handler, vhdPath)
vhd = disposable.value
disposableVhds.push(disposable)
vhdPath = resolveRelativeFromFile(vhdPath, vhd.header.parentUnicodeName)
} while (vhd.footer.diskType !== Constants.DISK_TYPES.DYNAMIC)
} else {
// only open the list of path given
disposableVhds = paths.map(path => openVhd(handler, path))
}
// I don't want the vhds to be disposed on return
// but only when the stream is done ( or failed )
const disposables = await Disposable.all(disposableVhds)
const vhds = disposables.value
let disposed = false
const disposeOnce = async () => {
if (!disposed) {
disposed = true
try {
await disposables.dispose()
} catch (error) {
warn('_createSyntheticStream: failed to dispose VHDs', { error })
}
}
}
const synthetic = new VhdSynthetic(vhds)
await synthetic.readHeaderAndFooter()
await synthetic.readBlockAllocationTable()
const stream = await synthetic.stream()
stream.on('end', disposeOnce)
stream.on('close', disposeOnce)
stream.on('error', disposeOnce)
return stream
}
async readDeltaVmBackup(metadata) {
const handler = this._handler
const { vbds, vdis, vhds, vifs, vm } = metadata
@@ -549,7 +523,7 @@ class RemoteAdapter {
const streams = {}
await asyncMapSettled(Object.keys(vdis), async id => {
streams[`${id}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[id]))
streams[`${id}.vhd`] = await createSyntheticStream(handler, join(dir, vhds[id]))
})
return {

View File

@@ -1,10 +1,9 @@
const assert = require('assert')
const findLast = require('lodash/findLast.js')
const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
@@ -36,11 +35,6 @@ const forkDeltaExport = deltaExport =>
exports.VmBackup = class VmBackup {
constructor({ config, getSnapshotNameLabel, job, remoteAdapters, remotes, schedule, settings, srs, vm }) {
if (vm.other_config['xo:backup:job'] === job.id) {
// otherwise replicated VMs would be matched and replicated again and again
throw new Error('cannot backup a VM created by this very job')
}
this.config = config
this.job = job
this.remoteAdapters = remoteAdapters
@@ -290,28 +284,17 @@ exports.VmBackup = class VmBackup {
}
async _removeUnusedSnapshots() {
const jobSettings = this.job.settings
const baseVmRef = this._baseVm?.$ref
const { config } = this
const baseSettings = {
...config.defaultSettings,
...config.metadata.defaultSettings,
...jobSettings[''],
}
// TODO: handle all schedules (no longer existing schedules default to 0 retention)
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
const { scheduleId } = this
const scheduleSnapshots = this._jobSnapshots.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
const baseVmRef = this._baseVm?.$ref
const xapi = this._xapi
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
const settings = {
...baseSettings,
...jobSettings[scheduleId],
...jobSettings[this.vm.uuid],
await asyncMap(getOldEntries(this._settings.snapshotRetention, scheduleSnapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
}
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
}
})
})
}
@@ -320,14 +303,12 @@ exports.VmBackup = class VmBackup {
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
if (baseVm === undefined) {
debug('no base VM found')
return
}
const fullInterval = this._settings.fullInterval
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
debug('not using base VM becaust fullInterval reached')
return
}
@@ -338,17 +319,10 @@ exports.VmBackup = class VmBackup {
const baseUuidToSrcVdi = new Map()
await asyncMap(await baseVm.$getDisks(), async baseRef => {
const [baseUuid, snapshotOf] = await Promise.all([
xapi.getField('VDI', baseRef, 'uuid'),
xapi.getField('VDI', baseRef, 'snapshot_of'),
])
const snapshotOf = await xapi.getField('VDI', baseRef, 'snapshot_of')
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
})
baseUuidToSrcVdi.set(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
}
})
@@ -359,23 +333,9 @@ exports.VmBackup = class VmBackup {
false
)
if (presentBaseVdis.size === 0) {
debug('no base VM found')
return
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
if (!presentBaseVdis.has(baseUuid)) {
fullVdisRequired.add(srcVdi.uuid)
}
})

View File

@@ -1,390 +0,0 @@
/* eslint-env jest */
const rimraf = require('rimraf')
const tmp = require('tmp')
const fs = require('fs-extra')
const { getHandler } = require('@xen-orchestra/fs')
const { pFromCallback } = require('promise-toolbox')
const crypto = require('crypto')
const { RemoteAdapter } = require('./RemoteAdapter')
const { VHDFOOTER, VHDHEADER } = require('./tests.fixtures.js')
const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
let tempDir, adapter, handler, jobId, vdiId, basePath
jest.setTimeout(60000)
beforeEach(async () => {
tempDir = await pFromCallback(cb => tmp.dir(cb))
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
adapter = new RemoteAdapter(handler)
jobId = uniqueId()
vdiId = uniqueId()
basePath = `vdis/${jobId}/${vdiId}`
await fs.mkdirp(`${tempDir}/${basePath}`)
})
afterEach(async () => {
await pFromCallback(cb => rimraf(tempDir, cb))
await handler.forget()
})
const uniqueId = () => crypto.randomBytes(16).toString('hex')
async function generateVhd(path, opts = {}) {
let vhd
const dataPath = opts.useAlias ? path + '.data' : path
if (opts.mode === 'directory') {
await handler.mkdir(dataPath)
vhd = new VhdDirectory(handler, dataPath)
} else {
const fd = await handler.openFile(dataPath, 'wx')
vhd = new VhdFile(handler, fd)
}
vhd.header = { ...VHDHEADER, ...opts.header }
vhd.footer = { ...VHDFOOTER, ...opts.footer }
vhd.footer.uuid = Buffer.from(crypto.randomBytes(16))
if (vhd.header.parentUnicodeName) {
vhd.footer.diskType = Constants.DISK_TYPES.DIFFERENCING
} else {
vhd.footer.diskType = Constants.DISK_TYPES.DYNAMIC
}
if (opts.useAlias === true) {
await VhdAbstract.createAlias(handler, path + '.alias.vhd', dataPath)
}
await vhd.writeBlockAllocationTable()
await vhd.writeHeader()
await vhd.writeFooter()
return vhd
}
test('It remove broken vhd', async () => {
// todo also tests a directory and an alias
await handler.writeFile(`${basePath}/notReallyAVhd.vhd`, 'I AM NOT A VHD')
expect((await handler.list(basePath)).length).toEqual(1)
let loggued = ''
const onLog = message => {
loggued += message
}
await adapter.cleanVm('/', { remove: false, onLog })
expect(loggued).toEqual(`error while checking the VHD with path /${basePath}/notReallyAVhd.vhd`)
// not removed
expect((await handler.list(basePath)).length).toEqual(1)
// really remove it
await adapter.cleanVm('/', { remove: true, onLog })
expect((await handler.list(basePath)).length).toEqual(0)
})
test('it remove vhd with missing or multiple ancestors', async () => {
// one with a broken parent
await generateVhd(`${basePath}/abandonned.vhd`, {
header: {
parentUnicodeName: 'gone.vhd',
parentUid: Buffer.from(crypto.randomBytes(16)),
},
})
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
// clean
let loggued = ''
const onLog = message => {
loggued += message + '\n'
}
await adapter.cleanVm('/', { remove: true, onLog })
const deletedOrphanVhd = loggued.match(/deleting orphan VHD/g) || []
expect(deletedOrphanVhd.length).toEqual(1) // only one vhd should have been deleted
const deletedAbandonnedVhd = loggued.match(/abandonned.vhd is missing/g) || []
expect(deletedAbandonnedVhd.length).toEqual(1) // and it must be abandonned.vhd
// we don't test the filew on disk, since they will all be marker as unused and deleted without a metadata.json file
})
test('it remove backup meta data referencing a missing vhd in delta backup', async () => {
// create a metadata file marking child and orphan as ok
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/orphan.vhd`,
`${basePath}/child.vhd`,
// abandonned.json is not here
],
})
)
await generateVhd(`${basePath}/abandonned.vhd`)
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
let loggued = ''
const onLog = message => {
loggued += message + '\n'
}
await adapter.cleanVm('/', { remove: true, onLog })
let matched = loggued.match(/deleting unused VHD /g) || []
expect(matched.length).toEqual(1) // only one vhd should have been deleted
matched = loggued.match(/abandonned.vhd is unused/g) || []
expect(matched.length).toEqual(1) // and it must be abandonned.vhd
// a missing vhd cause clean to remove all vhds
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/deleted.vhd`, // in metadata but not in vhds
`${basePath}/orphan.vhd`,
`${basePath}/child.vhd`,
// abandonned.json is not here
],
}),
{ flags: 'w' }
)
loggued = ''
await adapter.cleanVm('/', { remove: true, onLog })
matched = loggued.match(/deleting unused VHD /g) || []
expect(matched.length).toEqual(2) // all vhds (orphan and child ) should have been deleted
})
test('it merges delta of non destroyed chain', async () => {
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
size: 209920,
vhds: [
`${basePath}/grandchild.vhd`, // grand child should not be merged
`${basePath}/child.vhd`,
// orphan is not here, he should be merged in child
],
})
)
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
const child = await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
// a grand child
await generateVhd(`${basePath}/grandchild.vhd`, {
header: {
parentUnicodeName: 'child.vhd',
parentUid: child.footer.uuid,
},
})
let loggued = ''
const onLog = message => {
loggued += message + '\n'
}
await adapter.cleanVm('/', { remove: true, onLog })
expect(loggued).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused\n`)
loggued = ''
await adapter.cleanVm('/', { remove: true, merge: true, onLog })
const [unused, merging] = loggued.split('\n')
expect(unused).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused`)
expect(merging).toEqual(`merging /${basePath}/child.vhd into /${basePath}/orphan.vhd`)
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
const remainingVhds = await handler.list(basePath)
expect(remainingVhds.length).toEqual(2)
expect(remainingVhds.includes('child.vhd')).toEqual(true)
expect(remainingVhds.includes('grandchild.vhd')).toEqual(true)
})
test('it finish unterminated merge ', async () => {
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
size: 209920,
vhds: [
`${basePath}/orphan.vhd`, // grand child should not be merged
`${basePath}/child.vhd`,
// orphan is not here, he should be merged in child
],
})
)
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
const child = await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
// a merge in progress file
await handler.writeFile(
`${basePath}/.orphan.vhd.merge.json`,
JSON.stringify({
parent: {
header: orphan.header.checksum,
},
child: {
header: child.header.checksum,
},
})
)
// a unfinished merging
await adapter.cleanVm('/', { remove: true, merge: true })
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
const remainingVhds = await handler.list(basePath)
expect(remainingVhds.length).toEqual(1)
expect(remainingVhds.includes('child.vhd')).toEqual(true)
})
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
// the message an resulting files should be identical to the output with vhd files which is tested independantly
describe('tests mulitple combination ', () => {
for (const useAlias of [true, false]) {
for (const vhdMode of ['file', 'directory']) {
test(`alias : ${useAlias}, mode: ${vhdMode}`, async () => {
// a broken VHD
const brokenVhdDataPath = basePath + useAlias ? 'broken.data' : 'broken.vhd'
if (vhdMode === 'directory') {
await handler.mkdir(brokenVhdDataPath)
} else {
await handler.writeFile(brokenVhdDataPath, 'notreallyavhd')
}
if (useAlias) {
await VhdAbstract.createAlias(handler, 'broken.alias.vhd', brokenVhdDataPath)
}
// a vhd non referenced in metada
await generateVhd(`${basePath}/nonreference.vhd`, { useAlias, mode: vhdMode })
// an abandonded delta vhd without its parent
await generateVhd(`${basePath}/abandonned.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'gone.vhd',
parentUid: crypto.randomBytes(16),
},
})
// an ancestor of a vhd present in metadata
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, {
useAlias,
mode: vhdMode,
})
const child = await generateVhd(`${basePath}/child.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'ancestor.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUid: ancestor.footer.uuid,
},
})
// a grand child vhd in metadata
await generateVhd(`${basePath}/grandchild.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'child.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUid: child.footer.uuid,
},
})
// an older parent that was merging in clean
const cleanAncestor = await generateVhd(`${basePath}/cleanAncestor.vhd`, {
useAlias,
mode: vhdMode,
})
// a clean vhd in metadata
const clean = await generateVhd(`${basePath}/clean.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'cleanAncestor.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUid: cleanAncestor.footer.uuid,
},
})
await handler.writeFile(
`${basePath}/.cleanAncestor.vhd${useAlias ? '.alias.vhd' : ''}.merge.json`,
JSON.stringify({
parent: {
header: cleanAncestor.header.checksum,
},
child: {
header: clean.header.checksum,
},
})
)
// the metadata file
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
`${basePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
`${basePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
],
})
)
await adapter.cleanVm('/', { remove: true, merge: true })
// broken vhd, non referenced, abandonned should be deleted ( alias and data)
// ancestor and child should be merged
// grand child and clean vhd should not have changed
const survivors = await handler.list(basePath)
// console.log(survivors)
if (useAlias) {
// the goal of the alias : do not move a full folder
expect(survivors).toContain('ancestor.vhd.data')
expect(survivors).toContain('grandchild.vhd.data')
expect(survivors).toContain('cleanAncestor.vhd.data')
expect(survivors).toContain('clean.vhd.alias.vhd')
expect(survivors).toContain('child.vhd.alias.vhd')
expect(survivors).toContain('grandchild.vhd.alias.vhd')
expect(survivors.length).toEqual(6)
} else {
expect(survivors).toContain('clean.vhd')
expect(survivors).toContain('child.vhd')
expect(survivors).toContain('grandchild.vhd')
expect(survivors.length).toEqual(3)
}
})
}
}
})

View File

@@ -1,20 +1,17 @@
const assert = require('assert')
const sum = require('lodash/sum')
const { asyncMap } = require('@xen-orchestra/async-map')
const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
const { default: Vhd, mergeVhd } = require('vhd-lib')
const { dirname, resolve } = require('path')
const { DISK_TYPES } = Constants
const { DISK_TYPE_DIFFERENCING } = require('vhd-lib/dist/_constants.js')
const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
const { limitConcurrency } = require('limit-concurrency-decorator')
const { Task } = require('./Task.js')
const { Disposable } = require('promise-toolbox')
// chain is an array of VHDs from child to parent
//
// the whole chain will be merged into parent, parent will be renamed to child
// and all the others will deleted
async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
assert(chain.length >= 2)
let child = chain[0]
@@ -47,7 +44,7 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
}
}, 10e3)
const mergedSize = await mergeVhd(
await mergeVhd(
handler,
parent,
handler,
@@ -66,19 +63,17 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
clearInterval(handle)
await Promise.all([
VhdAbstract.rename(handler, parent, child),
handler.rename(parent, child),
asyncMap(children.slice(0, -1), child => {
onLog(`the VHD ${child} is unused`)
if (remove) {
onLog(`deleting unused VHD ${child}`)
return VhdAbstract.unlink(handler, child)
return handler.unlink(child)
}
}),
])
return mergedSize
}
}
})
const noop = Function.prototype
@@ -119,14 +114,7 @@ const listVhds = async (handler, vmDir) => {
return { vhds, interruptedVhds }
}
const defaultMergeLimiter = limitConcurrency(1)
exports.cleanVm = async function cleanVm(
vmDir,
{ fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
) {
const limitedMergeVhdChain = mergeLimiter(mergeVhdChain)
exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, onLog = noop }) {
const handler = this._handler
const vhds = new Set()
@@ -138,55 +126,53 @@ exports.cleanVm = async function cleanVm(
// remove broken VHDs
await asyncMap(vhdsList.vhds, async path => {
try {
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !vhdsList.interruptedVhds.has(path) }), vhd => {
vhds.add(path)
if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
vhdParents[path] = parent
if (parent in vhdChildren) {
const error = new Error('this script does not support multiple VHD children')
error.parent = parent
error.child1 = vhdChildren[parent]
error.child2 = path
throw error // should we throw?
}
vhdChildren[parent] = path
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter(!vhdsList.interruptedVhds.has(path))
vhds.add(path)
if (vhd.footer.diskType === DISK_TYPE_DIFFERENCING) {
const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
vhdParents[path] = parent
if (parent in vhdChildren) {
const error = new Error('this script does not support multiple VHD children')
error.parent = parent
error.child1 = vhdChildren[parent]
error.child2 = path
throw error // should we throw?
}
})
vhdChildren[parent] = path
}
} catch (error) {
onLog(`error while checking the VHD with path ${path}`, { error })
if (error?.code === 'ERR_ASSERTION' && remove) {
onLog(`deleting broken ${path}`)
return VhdAbstract.unlink(handler, path)
await handler.unlink(path)
}
}
})
// @todo : add check for data folder of alias not referenced in a valid alias
// remove VHDs with missing ancestors
{
const deletions = []
// return true if the VHD has been deleted or is missing
const deleteIfOrphan = vhdPath => {
const parent = vhdParents[vhdPath]
const deleteIfOrphan = vhd => {
const parent = vhdParents[vhd]
if (parent === undefined) {
return
}
// no longer needs to be checked
delete vhdParents[vhdPath]
delete vhdParents[vhd]
deleteIfOrphan(parent)
if (!vhds.has(parent)) {
vhds.delete(vhdPath)
vhds.delete(vhd)
onLog(`the parent ${parent} of the VHD ${vhdPath} is missing`)
onLog(`the parent ${parent} of the VHD ${vhd} is missing`)
if (remove) {
onLog(`deleting orphan VHD ${vhdPath}`)
deletions.push(VhdAbstract.unlink(handler, vhdPath))
onLog(`deleting orphan VHD ${vhd}`)
deletions.push(handler.unlink(vhd))
}
}
}
@@ -257,30 +243,16 @@ exports.cleanVm = async function cleanVm(
return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
})()
const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
// FIXME: find better approach by keeping as much of the backup as
// possible (existing disks) even if one disk is missing
if (missingVhds.length === 0) {
if (linkedVhds.every(_ => vhds.has(_))) {
linkedVhds.forEach(_ => unusedVhds.delete(_))
// checking the size of a vhd directory is costly
// 1 Http Query per 1000 blocks
// we only check size of all the vhd are VhdFiles
const shouldComputeSize = linkedVhds.every(vhd => vhd instanceof VhdFile)
if (shouldComputeSize) {
try {
await Disposable.use(Disposable.all(linkedVhds.map(vhdPath => openVhd(handler, vhdPath))), async vhds => {
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
size = sum(sizes)
})
} catch (error) {
onLog(`failed to get size of ${json}`, { error })
}
}
size = await asyncMap(linkedVhds, vhd => handler.getSize(vhd)).then(sum, error => {
onLog(`failed to get size of ${json}`, { error })
})
} else {
onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
onLog(`Some VHDs linked to the metadata ${json} are missing`)
if (remove) {
onLog(`deleting incomplete backup ${json}`)
await handler.unlink(json)
@@ -307,7 +279,6 @@ exports.cleanVm = async function cleanVm(
// TODO: parallelize by vm/job/vdi
const unusedVhdsDeletion = []
const toMerge = []
{
// VHD chains (as list from child to ancestor) to merge indexed by last
// ancestor
@@ -341,7 +312,7 @@ exports.cleanVm = async function cleanVm(
onLog(`the VHD ${vhd} is unused`)
if (remove) {
onLog(`deleting unused VHD ${vhd}`)
unusedVhdsDeletion.push(VhdAbstract.unlink(handler, vhd))
unusedVhdsDeletion.push(handler.unlink(vhd))
}
}
@@ -350,25 +321,22 @@ exports.cleanVm = async function cleanVm(
})
// merge interrupted VHDs
vhdsList.interruptedVhds.forEach(parent => {
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
})
if (merge) {
vhdsList.interruptedVhds.forEach(parent => {
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
})
}
Object.values(vhdChainsToMerge).forEach(chain => {
Object.keys(vhdChainsToMerge).forEach(key => {
const chain = vhdChainsToMerge[key]
if (chain !== undefined) {
toMerge.push(chain)
unusedVhdsDeletion.push(mergeVhdChain(chain, { handler, onLog, remove, merge }))
}
})
}
const doMerge = () => {
const promise = asyncMap(toMerge, async chain => limitedMergeVhdChain(chain, { handler, onLog, remove, merge }))
return merge ? promise.then(sizes => ({ size: sum(sizes) })) : promise
}
await Promise.all([
...unusedVhdsDeletion,
toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : doMerge()),
asyncMap(unusedXvas, path => {
onLog(`the XVA ${path} is unused`)
if (remove) {
@@ -387,9 +355,4 @@ exports.cleanVm = async function cleanVm(
}
}),
])
return {
// boolean whether some VHDs were merged (or should be merged)
merge: toMerge.length !== 0,
}
}

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env node
const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
const { createLogger } = require('@xen-orchestra/log')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { join } = require('path')
const Disposable = require('promise-toolbox/Disposable')
const min = require('lodash/min')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { RemoteAdapter } = require('../RemoteAdapter.js')
const { CLEAN_VM_QUEUE } = require('./index.js')
// -------------------------------------------------------------------
catchGlobalErrors(createLogger('xo:backups:mergeWorker'))
const { fatal, info, warn } = createLogger('xo:backups:mergeWorker')
// -------------------------------------------------------------------
const main = Disposable.wrap(async function* main(args) {
const handler = yield getSyncedHandler({ url: 'file://' + process.cwd() })
yield handler.lock(CLEAN_VM_QUEUE)
const adapter = new RemoteAdapter(handler)
const listRetry = async () => {
const timeoutResolver = resolve => setTimeout(resolve, 10e3)
for (let i = 0; i < 10; ++i) {
const entries = await handler.list(CLEAN_VM_QUEUE)
if (entries.length !== 0) {
return entries
}
await new Promise(timeoutResolver)
}
}
let taskFiles
while ((taskFiles = await listRetry()) !== undefined) {
const taskFileBasename = min(taskFiles)
const taskFile = join(CLEAN_VM_QUEUE, '_' + taskFileBasename)
// move this task to the end
await handler.rename(join(CLEAN_VM_QUEUE, taskFileBasename), taskFile)
try {
const vmDir = getVmBackupDir(String(await handler.readFile(taskFile)))
await adapter.cleanVm(vmDir, { merge: true, onLog: info, remove: true })
handler.unlink(taskFile).catch(error => warn('deleting task failure', { error }))
} catch (error) {
warn('failure handling task', { error })
}
}
})
info('starting')
main(process.argv.slice(2)).then(
() => {
info('bye :-)')
},
error => {
fatal(error)
process.exit(1)
}
)

View File

@@ -1,25 +0,0 @@
const { join, resolve } = require('path')
const { spawn } = require('child_process')
const { check } = require('proper-lockfile')
const CLEAN_VM_QUEUE = (exports.CLEAN_VM_QUEUE = '/xo-vm-backups/.queue/clean-vm/')
const CLI_PATH = resolve(__dirname, 'cli.js')
exports.run = async function runMergeWorker(remotePath) {
try {
// TODO: find a way to pass the acquire the lock and then pass it down the worker
if (await check(join(remotePath, CLEAN_VM_QUEUE))) {
// already locked, don't start another worker
return
}
spawn(CLI_PATH, {
cwd: remotePath,
detached: true,
stdio: 'inherit',
}).unref()
} catch (error) {
// we usually don't want to throw if the merge worker failed to start
return error
}
}

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.16.2",
"version": "0.13.0",
"engines": {
"node": ">=14.6"
},
@@ -16,14 +16,14 @@
"postversion": "npm publish --access public"
},
"dependencies": {
"@vates/compose": "^2.1.0",
"@vates/compose": "^2.0.0",
"@vates/disposable": "^0.1.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^0.19.2",
"@xen-orchestra/fs": "^0.18.0",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^4.0.1",
"compare-versions": "^3.6.0",
"d3-time-format": "^3.0.0",
"end-of-stream": "^1.4.4",
"fs-extra": "^10.0.0",
@@ -32,15 +32,13 @@
"lodash": "^4.17.20",
"node-zone": "^0.4.0",
"parse-pairs": "^1.1.0",
"promise-toolbox": "^0.20.0",
"proper-lockfile": "^4.1.2",
"promise-toolbox": "^0.19.2",
"pump": "^3.0.0",
"uuid": "^8.3.2",
"vhd-lib": "^2.0.3",
"vhd-lib": "^1.2.0",
"yazl": "^2.5.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^0.8.4"
"@xen-orchestra/xapi": "^0.7.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -1,92 +0,0 @@
// a valid footer of a 2
exports.VHDFOOTER = {
cookie: 'conectix',
features: 2,
fileFormatVersion: 65536,
dataOffset: 512,
timestamp: 0,
creatorApplication: 'caml',
creatorVersion: 1,
creatorHostOs: 0,
originalSize: 53687091200,
currentSize: 53687091200,
diskGeometry: { cylinders: 25700, heads: 16, sectorsPerTrackCylinder: 255 },
diskType: 3,
checksum: 4294962945,
uuid: Buffer.from('d8dbcad85265421e8b298d99c2eec551', 'utf-8'),
saved: '',
hidden: '',
reserved: '',
}
exports.VHDHEADER = {
cookie: 'cxsparse',
dataOffset: undefined,
tableOffset: 2048,
headerVersion: 65536,
maxTableEntries: 25600,
blockSize: 2097152,
checksum: 4294964241,
parentUuid: null,
parentTimestamp: 0,
reserved1: 0,
parentUnicodeName: '',
parentLocatorEntry: [
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
],
reserved2: '',
}

View File

@@ -3,7 +3,7 @@ const map = require('lodash/map.js')
const mapValues = require('lodash/mapValues.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
const { chainVhd, checkVhdChain, default: Vhd } = require('vhd-lib')
const { createLogger } = require('@xen-orchestra/log')
const { dirname } = require('path')
@@ -16,7 +16,6 @@ const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { checkVhd } = require('./_checkVhd.js')
const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
@@ -38,13 +37,13 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
await asyncMap(vhds, async path => {
try {
await checkVhdChain(handler, path)
await Disposable.use(
openVhd(handler, path),
vhd => (found = found || vhd.footer.uuid.equals(packUuid(baseUuid)))
)
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
found = found || vhd.footer.uuid.equals(packUuid(baseUuid))
} catch (error) {
warn('checkBaseVdis', { error })
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
await ignoreErrors.call(handler.unlink(path))
}
})
} catch (error) {
@@ -114,13 +113,19 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
async _deleteOldEntries() {
const adapter = this._adapter
const oldEntries = this._oldEntries
return Task.run({ name: 'merge' }, async () => {
const adapter = this._adapter
const oldEntries = this._oldEntries
// delete sequentially from newest to oldest to avoid unnecessary merges
for (let i = oldEntries.length; i-- > 0; ) {
await adapter.deleteDeltaVmBackups([oldEntries[i]])
}
let size = 0
// delete sequentially from newest to oldest to avoid unnecessary merges
for (let i = oldEntries.length; i-- > 0; ) {
size += await adapter.deleteDeltaVmBackups([oldEntries[i]])
}
return {
size,
}
})
}
async _transfer({ timestamp, deltaExport, sizeContainers }) {
@@ -145,7 +150,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
// don't do delta for it
vdi.uuid
: vdi.$snapshot_of$uuid
}/${adapter.getVhdFileName(basename)}`
}/${basename}.vhd`
)
const metadataFilename = `${backupDir}/${basename}.json`
@@ -189,7 +194,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
await checkVhd(handler, parentPath)
}
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
await adapter.outputStream(path, deltaExport.streams[`${id}.vhd`], {
// no checksum for VHDs, because they will be invalidated by
// merges and chainings
checksum: false,
@@ -201,11 +206,11 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
// set the correct UUID in the VHD
await Disposable.use(openVhd(handler, path), async vhd => {
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
})
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
})
)
return {

View File

@@ -1,65 +1,34 @@
const { createLogger } = require('@xen-orchestra/log')
const { join } = require('path')
const { BACKUP_DIR, getVmBackupDir } = require('../_getVmBackupDir.js')
const MergeWorker = require('../merge-worker/index.js')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { warn } = createLogger('xo:backups:MixinBackupWriter')
exports.MixinBackupWriter = (BaseClass = Object) =>
class MixinBackupWriter extends BaseClass {
#lock
#vmBackupDir
constructor({ remoteId, ...rest }) {
super(rest)
this._adapter = rest.backup.remoteAdapters[remoteId]
this._remoteId = remoteId
this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
this._lock = undefined
}
async _cleanVm(options) {
try {
return await this._adapter.cleanVm(this.#vmBackupDir, {
...options,
fixMetadata: true,
onLog: warn,
lock: false,
})
} catch (error) {
warn(error)
return {}
}
_cleanVm(options) {
return this._adapter
.cleanVm(getVmBackupDir(this._backup.vm.uuid), { ...options, fixMetadata: true, onLog: warn, lock: false })
.catch(warn)
}
async beforeBackup() {
const { handler } = this._adapter
const vmBackupDir = this.#vmBackupDir
const vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
await handler.mktree(vmBackupDir)
this.#lock = await handler.lock(vmBackupDir)
this._lock = await handler.lock(vmBackupDir)
}
async afterBackup() {
const { disableMergeWorker } = this._backup.config
const { merge } = await this._cleanVm({ remove: true, merge: disableMergeWorker })
await this.#lock.dispose()
// merge worker only compatible with local remotes
const { handler } = this._adapter
if (merge && !disableMergeWorker && typeof handler._getRealPath === 'function') {
const taskFile =
join(MergeWorker.CLEAN_VM_QUEUE, formatFilenameDate(new Date())) +
'-' +
// add a random suffix to avoid collision in case multiple tasks are created at the same second
Math.random().toString(36).slice(2)
await handler.outputFile(taskFile, this._backup.vm.uuid)
const remotePath = handler._getRealPath()
await MergeWorker.run(remotePath)
}
await this._cleanVm({ remove: true, merge: true })
await this._lock.dispose()
}
}

View File

@@ -1,6 +1,5 @@
const openVhd = require('vhd-lib').openVhd
const Disposable = require('promise-toolbox/Disposable')
const Vhd = require('vhd-lib').default
exports.checkVhd = async function checkVhd(handler, path) {
await Disposable.use(openVhd(handler, path), () => {})
await new Vhd(handler, path).readHeaderAndFooter()
}

View File

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

View File

@@ -2,8 +2,6 @@
import { createSchedule } from './'
jest.useFakeTimers()
const wrap = value => () => value
describe('issues', () => {

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "0.19.2",
"version": "0.18.0",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -17,7 +17,7 @@
"node": ">=14"
},
"dependencies": {
"@marsaud/smb2": "^0.18.0",
"@marsaud/smb2": "^0.17.2",
"@sindresorhus/df": "^3.1.1",
"@sullux/aws-sdk": "^1.0.5",
"@vates/coalesce-calls": "^0.1.0",
@@ -29,11 +29,11 @@
"get-stream": "^6.0.0",
"limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.4",
"promise-toolbox": "^0.20.0",
"promise-toolbox": "^0.19.2",
"proper-lockfile": "^4.1.2",
"readable-stream": "^3.0.6",
"through2": "^4.0.2",
"xo-remote-parser": "^0.8.0"
"xo-remote-parser": "^0.7.0"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -76,7 +76,6 @@ export default class RemoteHandlerAbstract {
const sharedLimit = limitConcurrency(options.maxParallelOperations ?? DEFAULT_MAX_PARALLEL_OPERATIONS)
this.closeFile = sharedLimit(this.closeFile)
this.copy = sharedLimit(this.copy)
this.getInfo = sharedLimit(this.getInfo)
this.getSize = sharedLimit(this.getSize)
this.list = sharedLimit(this.list)
@@ -308,17 +307,6 @@ export default class RemoteHandlerAbstract {
return p
}
async copy(oldPath, newPath, { checksum = false } = {}) {
oldPath = normalizePath(oldPath)
newPath = normalizePath(newPath)
let p = timeout.call(this._copy(oldPath, newPath), this._timeout)
if (checksum) {
p = Promise.all([p, this._copy(checksumFile(oldPath), checksumFile(newPath))])
}
return p
}
async rmdir(dir) {
await timeout.call(this._rmdir(normalizePath(dir)).catch(ignoreEnoent), this._timeout)
}
@@ -531,9 +519,6 @@ export default class RemoteHandlerAbstract {
async _rename(oldPath, newPath) {
throw new Error('Not implemented')
}
async _copy(oldPath, newPath) {
throw new Error('Not implemented')
}
async _rmdir(dir) {
throw new Error('Not implemented')

View File

@@ -33,10 +33,6 @@ export default class LocalHandler extends RemoteHandlerAbstract {
return fs.close(fd)
}
async _copy(oldPath, newPath) {
return fs.copy(this._getFilePath(oldPath), this._getFilePath(newPath))
}
async _createReadStream(file, options) {
if (typeof file === 'string') {
const stream = fs.createReadStream(this._getFilePath(file), options)

View File

@@ -1,7 +1,6 @@
import aws from '@sullux/aws-sdk'
import assert from 'assert'
import http from 'http'
import https from 'https'
import { parse } from 'xo-remote-parser'
import RemoteHandlerAbstract from './abstract'
@@ -17,7 +16,7 @@ const IDEAL_FRAGMENT_SIZE = Math.ceil(MAX_OBJECT_SIZE / MAX_PARTS_COUNT) // the
export default class S3Handler extends RemoteHandlerAbstract {
constructor(remote, _opts) {
super(remote)
const { allowUnauthorized, host, path, username, password, protocol, region } = parse(remote.url)
const { host, path, username, password, protocol, region } = parse(remote.url)
const params = {
accessKeyId: username,
apiVersion: '2006-03-01',
@@ -30,13 +29,8 @@ export default class S3Handler extends RemoteHandlerAbstract {
},
}
if (protocol === 'http') {
params.httpOptions.agent = new http.Agent({ keepAlive: true })
params.httpOptions.agent = new http.Agent()
params.sslEnabled = false
} else if (protocol === 'https') {
params.httpOptions.agent = new https.Agent({
rejectUnauthorized: !allowUnauthorized,
keepAlive: true,
})
}
if (region !== undefined) {
params.region = region
@@ -57,27 +51,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
return { Bucket: this._bucket, Key: this._dir + file }
}
async _copy(oldPath, newPath) {
const size = await this._getSize(oldPath)
const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
try {
const parts = []
let start = 0
while (start < size) {
const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
const upload = await this._s3.uploadPartCopy(partParams)
parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
start += MAX_PART_SIZE
}
await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
} catch (e) {
await this._s3.abortMultipartUpload(multipartParams)
throw e
}
}
async _isNotEmptyDir(path) {
const result = await this._s3.listObjectsV2({
Bucket: this._bucket,
@@ -152,30 +125,16 @@ export default class S3Handler extends RemoteHandlerAbstract {
const splitPrefix = splitPath(prefix)
const result = await this._s3.listObjectsV2({
Bucket: this._bucket,
Prefix: splitPrefix.join('/') + '/', // need slash at the end with the use of delimiters
Delimiter: '/', // will only return path until delimiters
Prefix: splitPrefix.join('/'),
})
if (result.isTruncated) {
const error = new Error('more than 1000 objects, unsupported in this implementation')
error.dir = dir
throw error
}
const uniq = []
// sub directories
for (const entry of result.CommonPrefixes) {
const line = splitPath(entry.Prefix)
uniq.push(line[line.length - 1])
}
// files
const uniq = new Set()
for (const entry of result.Contents) {
const line = splitPath(entry.Key)
uniq.push(line[line.length - 1])
if (line.length > splitPrefix.length) {
uniq.add(line[splitPrefix.length])
}
}
return uniq
return [...uniq]
}
async _mkdir(path) {
@@ -188,9 +147,25 @@ export default class S3Handler extends RemoteHandlerAbstract {
// nothing to do, directories do not exist, they are part of the files' path
}
// s3 doesn't have a rename operation, so copy + delete source
async _rename(oldPath, newPath) {
await this.copy(oldPath, newPath)
const size = await this._getSize(oldPath)
const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
try {
const parts = []
let start = 0
while (start < size) {
const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
const upload = await this._s3.uploadPartCopy(partParams)
parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
start += MAX_PART_SIZE
}
await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
} catch (e) {
await this._s3.abortMultipartUpload(multipartParams)
throw e
}
await this._s3.deleteObject(this._createParams(oldPath))
}
@@ -208,21 +183,9 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
const params = this._createParams(file)
params.Range = `bytes=${position}-${position + buffer.length - 1}`
try {
const result = await this._s3.getObject(params)
result.Body.copy(buffer)
return { bytesRead: result.Body.length, buffer }
} catch (e) {
if (e.code === 'NoSuchKey') {
if (await this._isNotEmptyDir(file)) {
const error = new Error(`${file} is a directory`)
error.code = 'EISDIR'
error.path = file
throw error
}
}
throw e
}
const result = await this._s3.getObject(params)
result.Body.copy(buffer)
return { bytesRead: result.Body.length, buffer }
}
async _rmdir(path) {
@@ -236,28 +199,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
// nothing to do, directories do not exist, they are part of the files' path
}
// reimplement _rmtree to handle efficiantly path with more than 1000 entries in trees
// @todo : use parallel processing for unlink
async _rmtree(path) {
let NextContinuationToken
do {
const result = await this._s3.listObjectsV2({
Bucket: this._bucket,
Prefix: this._dir + path + '/',
ContinuationToken: NextContinuationToken,
})
NextContinuationToken = result.isTruncated ? null : result.NextContinuationToken
for (const { Key } of result.Contents) {
// _unlink will add the prefix, but Key contains everything
// also we don't need to check if we delete a directory, since the list only return files
await this._s3.deleteObject({
Bucket: this._bucket,
Key,
})
}
} while (NextContinuationToken !== null)
}
async _write(file, buffer, position) {
if (typeof file !== 'string') {
file = file.fd

View File

@@ -0,0 +1,6 @@
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'), {
'@babel/preset-env': {
exclude: ['@babel/plugin-proposal-dynamic-import', '@babel/plugin-transform-regenerator'],
modules: false,
},
})

View File

@@ -0,0 +1,32 @@
module.exports = {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
ecmaFeatures: {
jsx: true, // Allows for the parsing of JSX
},
},
settings: {
react: {
version: '17',
},
},
extends: [
'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin
],
rules: {
'eslint-comments/disable-enable-pair': 'off',
// Necessary to pass empty Effects/State to Reaclette
'@typescript-eslint/no-empty-interface': 'off',
// https://github.com/typescript-eslint/typescript-eslint/issues/1071
'@typescript-eslint/no-explicit-any': 'off',
// https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
'@typescript-eslint/no-use-before-define': ['error'],
'no-use-before-define': 'off',
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
'@typescript-eslint/ban-ts-comment': 'off',
},
}

24
@xen-orchestra/lite/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.eslintcache
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,71 @@
{
"name": "xo-lite",
"version": "0.1.0",
"devDependencies": {
"@babel/core": "^7.13.1",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.0",
"@babel/plugin-proposal-optional-chaining": "^7.13.0",
"@babel/preset-env": "^7.13.5",
"@babel/preset-react": "^7.12.13",
"@babel/preset-typescript": "^7.13.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@mui/icons-material": "^5.0.0",
"@mui/lab": "^5.0.0-alpha.48",
"@mui/material": "^5.0.1",
"@novnc/novnc": "^1.2.0",
"@types/immutable": "^3.8.7",
"@types/js-cookie": "^2.2.6",
"@types/lodash": "^4.14.175",
"@types/node": "^14.14.21",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-helmet": "^6.1.5",
"@types/react-intl": "^3.0.0",
"@types/react-router-dom": "^5.1.7",
"@types/react-syntax-highlighter": "^13.5.0",
"@types/styled-components": "^5.1.9",
"@types/webpack-env": "^1.16.0",
"@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1",
"babel-loader": "^8.2.2",
"classnames": "^2.3.1",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^10.2.0",
"eslint": "^7.21.0",
"eslint-plugin-react": "^7.22.0",
"html-webpack-plugin": "^5.2.0",
"human-format": "^0.11.0",
"immutable": "^4.0.0-rc.12",
"iterable-backoff": "^0.1.0",
"json-rpc-protocol": "^0.13.1",
"lodash": "^4.17.21",
"node-polyfill-webpack-plugin": "^1.0.3",
"process": "^0.11.10",
"promise-toolbox": "^0.16.0",
"reaclette": "^0.10.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-helmet": "^6.1.0",
"react-intl": "^5.10.16",
"react-router-dom": "^5.2.0",
"react-syntax-highlighter": "^15.4.3",
"styled-components": "^5.2.1",
"typescript": "^4.3.1",
"webpack": "^5.24.2",
"webpack-cli": "^4.9.1",
"xen-api": "^0.34.3"
},
"resolutions": {
"styled-components": "^5"
},
"scripts": {
"build": "cross-env NODE_ENV=production webpack",
"start": "cross-env NODE_ENV=development webpack serve",
"start:open": "npm run start -- --open"
},
"browserslist": "> 0.5%, last 2 versions, Firefox ESR, not dead"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Xen Orchestra Lite" />
<title>XO Lite</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,90 @@
import React from 'react'
import styled from 'styled-components'
import { Switch, Route, RouteComponentProps } from 'react-router-dom'
import { withState } from 'reaclette'
import { withRouter } from 'react-router'
import Pool from './Pool'
import TabConsole from './TabConsole'
import TreeView from './TreeView'
import { ObjectsByType } from '../libs/xapi'
const Container = styled.div`
display: flex;
overflow: hidden;
`
const LeftPanel = styled.div`
background: #f5f5f5;
min-width: 15em;
overflow-y: scroll;
width: 20%;
`
// FIXME: temporary work-around while investigating flew-grow issue:
// `overflow: hidden` forces the console to shrink to the max available width
// even when the tree component takes more than 20% of the width due to
// `min-width`
const MainPanel = styled.div`
overflow: hidden;
width: 80%;
`
interface ParentState {
objectsByType: ObjectsByType
pool?: string
}
interface State {
selectedObject?: string
selectedVm?: string
}
// For compatibility with 'withRouter'
interface Props extends RouteComponentProps {}
interface ParentEffects {}
interface Effects {
initialize: () => void
}
interface Computed {}
const selectedNodesToArray = (nodes: Array<string> | string | undefined) =>
nodes === undefined ? undefined : Array.isArray(nodes) ? nodes : [nodes]
const Infrastructure = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: props => ({
selectedVm: props.location.pathname.split('/')[3],
}),
computed: {
selectedObject: (state, props) =>
props.location.pathname.startsWith('/infrastructure/pool') ? state.pool : state.selectedVm,
},
},
({ state: { pool, selectedObject } }) => (
<Container>
<LeftPanel>
<TreeView defaultSelectedNodes={selectedNodesToArray(selectedObject)} />
</LeftPanel>
<MainPanel>
<Switch>
<Route exact path={`/infrastructure/pool/${pool}/dashboard`}>
<Pool id={pool} />
</Route>
<Route
path='/infrastructure/vms/:id/console'
render={({
match: {
params: { id },
},
}) => <TabConsole key={id} vmId={id} />}
/>
</Switch>
</MainPanel>
</Container>
)
)
export default withRouter(Infrastructure)

View File

@@ -0,0 +1,120 @@
import Grid from '@mui/material/Grid'
import React from 'react'
import styled from 'styled-components'
import Typography from '@mui/material/Typography'
import { withState } from 'reaclette'
import Icon from '../../../components/Icon'
import IntlMessage from '../../../components/IntlMessage'
import ProgressCircle from '../../../components/ProgressCircle'
interface ParentState {}
interface State {}
interface Props {
nActive?: number
nTotal?: number
type: 'host' | 'VM'
}
interface ParentEffects {}
interface Effects {}
interface Computed {
nInactive?: number
}
const DEFAULT_CAPTION_STYLE = { textTransform: 'uppercase', mt: 2 }
const TYPOGRAPHY_SX = { mb: 2 }
const ObjectStatusContainer = styled.div`
display: flex;
overflow: hidden;
flex-direction: row;
align-content: space-between;
margin-bottom: 1em;
`
const CircularProgressPanel = styled.div`
margin-left: 2em;
`
const GridPanel = styled.div`
margin-left: 2em;
width: 100%;
height: 100%;
`
// TODO: Add a loading page when data is not loaded as it is in the model(figma).
// FIXME: replace the hard-coded colors with the theme colors.
const ObjectStatus = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
nInactive: (state, { nTotal = 0, nActive = 0 }) => nTotal - nActive,
},
},
({ state: { nInactive }, nActive = 0, nTotal = 0, type }) => {
if (nTotal === 0) {
return (
<span>
<IntlMessage id={type === 'VM' ? 'noVms' : 'noHosts'} />
</span>
)
}
return (
<ObjectStatusContainer>
<CircularProgressPanel>
<ProgressCircle max={nTotal} value={nActive} />
</CircularProgressPanel>
<GridPanel>
<Grid container>
<Grid item xs={12}>
<Typography sx={TYPOGRAPHY_SX} variant='h5' component='div'>
<IntlMessage id={type === 'VM' ? 'vms' : 'hosts'} />
</Typography>
</Grid>
<Grid item xs={10}>
<Icon icon='circle' htmlColor='#00BA34' />
&nbsp;
<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' />
&nbsp;
<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

View File

@@ -0,0 +1,79 @@
import Divider from '@mui/material/Divider'
import React from 'react'
import styled from 'styled-components'
import Typography from '@mui/material/Typography'
import { withState } from 'reaclette'
import ObjectStatus from './ObjectStatus'
import IntlMessage from '../../../components/IntlMessage'
import { Host, ObjectsByType, Vm } from '../../../libs/xapi'
interface ParentState {
objectsByType?: ObjectsByType
}
interface State {
hosts?: Map<string, Host>
nRunningHosts?: number
nRunningVms?: number
vms?: Map<string, Vm>
}
interface Props {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const DEFAULT_STYLE = { m: 2 }
const Container = styled.div`
display: flex;
overflow: hidden;
flex-direction: row;
align-content: space-between;
gap: 1.25em;
background: '#E8E8E8';
`
const Panel = styled.div`
background: #ffffff;
border-radius: 0.5em;
box-shadow: 0px 1px 1px 0px #00000014, 0px 2px 1px 0px #0000000f, 0px 1px 3px 0px #0000001a;
margin: 0.5em;
`
const getHostPowerState = (host: Host) => {
const { $metrics } = host
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
}
const Dashboard = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
hosts: state => state.objectsByType?.get('host'),
vms: state =>
state.objectsByType
?.get('VM')
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
nRunningHosts: state => (state.hosts?.filter((host: Host) => getHostPowerState(host) === 'Running')).size,
nRunningVms: state => (state.vms?.filter((vm: Vm) => vm.power_state === 'Running')).size,
},
},
({ state: { hosts, nRunningHosts, nRunningVms, vms } }) => (
<Container>
<Panel>
<Typography variant='h4' component='div' sx={DEFAULT_STYLE}>
<IntlMessage id='status' />
</Typography>
<ObjectStatus nActive={nRunningHosts} nTotal={hosts?.size} type='host' />
<Divider variant='middle' sx={DEFAULT_STYLE} />
<ObjectStatus nActive={nRunningVms} nTotal={vms?.size} type='VM' />
</Panel>
</Container>
)
)
export default Dashboard

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { withState } from 'reaclette'
import Dashboard from './dashboard'
import Icon from '../../components/Icon'
import PanelHeader from '../../components/PanelHeader'
import { ObjectsByType, Pool as PoolType } from '../../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
}
interface State {}
interface Props {
id: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {
pool?: PoolType
}
// TODO: add tabs when https://github.com/vatesfr/xen-orchestra/pull/6096 is merged.
const Pool = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
pool: (state, props) => state.objectsByType?.get('pool')?.get(props.id),
},
},
({ state: { pool } }) => (
<>
<PanelHeader>
<span>
<Icon icon='warehouse' color='primary' /> {pool?.name_label}
</span>
</PanelHeader>
<Dashboard />
</>
)
)
export default Pool

View File

@@ -0,0 +1,65 @@
import React from 'react'
import { Map } from 'immutable'
import { withState } from 'reaclette'
import IntlMessage from '../../components/IntlMessage'
import Table, { Column } from '../../components/Table'
import { ObjectsByType, Pif } from '../../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
objectsFetched: boolean
}
interface State {}
interface Props {
poolId: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {
managementPifs?: Pif[]
pifs?: Map<string, Pif>
}
const COLUMNS: Column<Pif>[] = [
{
header: <IntlMessage id='device' />,
render: pif => pif.device,
},
{
header: <IntlMessage id='dns' />,
render: pif => pif.DNS,
},
{
header: <IntlMessage id='gateway' />,
render: pif => pif.gateway,
},
{
header: <IntlMessage id='ip' />,
render: pif => pif.IP,
},
]
const PoolNetworks = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
managementPifs: state =>
state.pifs
?.filter(pif => pif.management)
.map(pif => ({ ...pif, id: pif.$id }))
.valueSeq()
.toArray(),
pifs: state => state.objectsByType.get('PIF'),
},
},
({ state }) => (
<Table collection={state.managementPifs} columns={COLUMNS} placeholder={<IntlMessage id='noManagementPifs' />} />
)
)
export default PoolNetworks

View File

@@ -0,0 +1,89 @@
import React from 'react'
import humanFormat from 'human-format'
import { withState } from 'reaclette'
import IntlMessage from '../../components/IntlMessage'
import Table, { Column } from '../../components/Table'
import XapiConnection, { ObjectsByType, PoolUpdate } from '../../libs/xapi'
const COLUMN: Column<PoolUpdate>[] = [
{
header: <IntlMessage id='name' />,
render: update => update.name,
},
{
header: <IntlMessage id='description' />,
render: update => update.description,
},
{
header: <IntlMessage id='version' />,
render: update => update.version,
},
{
header: <IntlMessage id='release' />,
render: update => update.release,
},
{
header: <IntlMessage id='size' />,
render: update => humanFormat.bytes(update.size),
},
]
interface ParentState {
objectsByType: ObjectsByType
objectsFetched: boolean
xapi: XapiConnection
}
interface State {}
interface Props {
hostRef: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {
availableUpdates?: PoolUpdate[] | JSX.Element
}
const PoolUpdates = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
availableUpdates: async function (state, { hostRef }) {
try {
const stringifiedPoolUpdates = (await state.xapi.call(
'host.call_plugin',
hostRef,
'updater.py',
'check_update',
{}
)) as string
return JSON.parse(stringifiedPoolUpdates)
} catch (err) {
console.error(err)
return <IntlMessage id='errorOccurred' />
}
},
},
},
({ state: { availableUpdates } }) =>
availableUpdates !== undefined ? (
Array.isArray(availableUpdates) ? (
<>
{availableUpdates.length !== 0 && (
<IntlMessage id='availableUpdates' values={{ nUpdates: availableUpdates.length }} />
)}
<Table collection={availableUpdates} columns={COLUMN} placeholder={<IntlMessage id='noUpdatesAvailable' />} />
</>
) : (
availableUpdates
)
) : (
<IntlMessage id='loading' />
)
)
export default PoolUpdates

View File

@@ -0,0 +1,53 @@
import React from 'react'
import { Map } from 'immutable'
import { withState } from 'reaclette'
import PoolNetworks from './PoolNetworks'
import PoolUpdates from './PoolUpdates'
import IntlMessage from '../../components/IntlMessage'
import { Host, ObjectsByType, Pool } from '../../libs/xapi'
interface ParentState {
objectsFetched: boolean
}
interface State {
objectsByType: ObjectsByType
}
interface Props {}
interface ParentEffects {}
interface Effects {}
interface Computed {
hosts?: Map<string, Host>
pool?: Pool
}
const PoolTab = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
hosts: state => (state.objectsFetched ? state.objectsByType?.get('host') : undefined),
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.first() : undefined),
},
},
({ state }) =>
state.pool !== undefined ? (
<>
<PoolNetworks poolId={state.pool.$id} />
{state.hosts?.valueSeq().map(host => (
<div key={host.$id}>
<p>{host.name_label}</p>
<PoolUpdates hostRef={host.$ref} />
</div>
))}
</>
) : (
<IntlMessage id='loading' />
)
)
export default PoolTab

View File

@@ -0,0 +1,110 @@
import React from 'react'
import styled from 'styled-components'
import { withState } from 'reaclette'
import Button from '../../components/Button'
import Checkbox from '../../components/Checkbox'
import Input from '../../components/Input'
import IntlMessage from '../../components/IntlMessage'
interface ParentState {
error: string
}
interface State {
password: string
rememberMe: boolean
}
interface Props {}
interface ParentEffects {
connectToXapi: (password: string, rememberMe: boolean) => void
}
interface Effects {
setRememberMe: (event: React.ChangeEvent<HTMLInputElement>) => void
setPassword: (event: React.ChangeEvent<HTMLInputElement>) => void
submit: (event: React.MouseEvent<HTMLButtonElement>) => void
}
interface Computed {}
const Wrapper = styled.div`
height: 100vh;
display: flex;
`
const Form = styled.form`
width: 20em;
margin: auto;
text-align: center;
`
const Fieldset = styled.fieldset`
border: 0;
padding-left: 0;
padding-right: 0;
`
const RememberMe = styled(Fieldset)`
text-align: start;
vertical-align: baseline;
`
const Error = styled.p`
color: #a33;
`
const Signin = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
password: '',
rememberMe: false,
}),
effects: {
setRememberMe: function ({ currentTarget: { checked: rememberMe } }) {
this.state.rememberMe = rememberMe
},
setPassword: function ({ currentTarget: { value: password } }) {
this.state.password = password
},
submit: function () {
this.effects.connectToXapi(this.state.password, this.state.rememberMe)
},
},
},
({ effects, state }) => (
<Wrapper>
<Form onSubmit={e => e.preventDefault()}>
<img src='logo.png' />
<h1>Xen Orchestra Lite</h1>
<Fieldset>
<Input disabled label={<IntlMessage id='login' />} value='root' />
</Fieldset>
<Fieldset>
<Input
autoFocus
label={<IntlMessage id='password' />}
onChange={effects.setPassword}
type='password'
value={state.password}
/>
</Fieldset>
<RememberMe>
<label>
<Checkbox onChange={effects.setRememberMe} checked={state.rememberMe} />
&nbsp;
<IntlMessage id='rememberMe' />
</label>
</RememberMe>
<Error>{state.error}</Error>
<Button type='submit' onClick={effects.submit}>
<IntlMessage id='connect' />
</Button>
</Form>
</Wrapper>
)
)
export default Signin

View File

@@ -0,0 +1,300 @@
// https://mui.com/components/material-icons/
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
import DeleteIcon from '@mui/icons-material/Delete'
import React from 'react'
import styled from 'styled-components'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { materialDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { toNumber } from 'lodash'
import { SelectChangeEvent } from '@mui/material'
import { withState } from 'reaclette'
import ActionButton from '../../components/ActionButton'
import Button from '../../components/Button'
import Checkbox from '../../components/Checkbox'
import Icon from '../../components/Icon'
import Input from '../../components/Input'
import ProgressCircle from '../../components/ProgressCircle'
import Select from '../../components/Select'
import Tabs from '../../components/Tabs'
import { alert, confirm } from '../../components/Modal'
interface ParentState {}
interface State {
progressBarValue: number
value: unknown
}
interface Props {}
interface ParentEffects {}
interface Effects {
onChangeProgressBarValue: (e: React.ChangeEvent<HTMLInputElement>) => void
onChangeSelect: (e: SelectChangeEvent<unknown>) => void
sayHello: () => void
sendPromise: (data: Record<string, unknown>) => Promise<void>
showAlertModal: () => void
showConfirmModal: () => void
}
interface Computed {}
const Page = styled.div`
margin: 30px;
`
const Container = styled.div`
display: flex;
column-gap: 10px;
`
const Render = styled.div`
flex: 1;
padding: 20px;
border: solid 1px gray;
border-radius: 3px;
`
const Code = styled(SyntaxHighlighter).attrs(() => ({
language: 'jsx',
style: codeStyle,
}))`
flex: 1;
border-radius: 3px;
margin: 0 !important;
`
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
progressBarValue: 100,
value: '',
}),
effects: {
onChangeProgressBarValue: function (e) {
this.state.progressBarValue = toNumber(e.target.value)
},
onChangeSelect: function (e) {
this.state.value = e.target.value
},
sayHello: () => alert('hello'),
sendPromise: data =>
new Promise(resolve => {
setTimeout(() => {
resolve()
window.alert(data.foo)
}, 1000)
}),
showAlertModal: () => alert({ message: 'This is an alert modal', title: 'Alert modal', icon: 'info' }),
showConfirmModal: () =>
confirm({
message: 'This is a confirm modal test',
title: 'Confirm modal',
icon: 'download',
}),
},
},
({ effects, state }) => (
<Page>
<h2>ActionButton</h2>
<Container>
<Render>
<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
Send promise
</ActionButton>
</Render>
<Code>
{`<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
Send promise
</ActionButton>`}
</Code>
</Container>
<h2>Button</h2>
<Container>
<Render>
<Button color='primary' onClick={effects.sayHello} startIcon={<AccountCircleIcon />}>
Primary
</Button>
<Button color='secondary' endIcon={<DeleteIcon />} onClick={effects.sayHello}>
Secondary
</Button>
<Button color='success' onClick={effects.sayHello}>
Success
</Button>
<Button color='warning' onClick={effects.sayHello}>
Warning
</Button>
<Button color='error' onClick={effects.sayHello}>
Error
</Button>
<Button color='info' onClick={effects.sayHello}>
Info
</Button>
</Render>
<Code>{`<Button color='primary' onClick={doSomething} startIcon={<AccountCircleIcon />}>
Primary
</Button>
<Button color='secondary' endIcon={<DeleteIcon />} onClick={doSomething}>
Secondary
</Button>
<Button color='success' onClick={doSomething}>
Success
</Button>
<Button color='warning' onClick={doSomething}>
Warning
</Button>
<Button color='error' onClick={doSomething}>
Error
</Button>
<Button color='info' onClick={doSomething}>
Info
</Button>`}</Code>
</Container>
<h2>Icon</h2>
<Container>
<Render>
<Icon icon='truck' htmlColor='#0085FF' />
<Icon icon='truck' color='primary' size='2x' />
</Render>
<Code>{`// https://fontawesome.com/icons
<Icon icon='truck' htmlColor='#0085FF'/>
<Icon icon='truck' color='primary' size='2x' />`}</Code>
</Container>
<h2>Input</h2>
<Container>
<Render>
<Input label='Input' />
<Checkbox />
</Render>
<Code>{`<TextInput label='Input' />
<Checkbox />`}</Code>
</Container>
<h2>Modal</h2>
<Container>
<Render>
<Button
color='primary'
onClick={effects.showAlertModal}
sx={{
marginBottom: 1,
}}
>
Alert
</Button>
<Button color='primary' onClick={effects.showConfirmModal}>
Confirm
</Button>
</Render>
<Code>{`<Button
color='primary'
onClick={() =>
alert({
message: 'This is an alert modal',
title: 'Alert modal',
icon: 'info'
})
}
>
Alert
</Button>
<Button
color='primary'
onClick={async () => {
try {
await confirm({
message: 'This is a confirm modal',
title: 'Confirm modal',
icon: 'download',
})
// The modal has been confirmed
} catch (reason) { // "cancel"
// The modal has been closed
}
}}
>
Confirm
</Button>`}</Code>
</Container>
<h2>ProgressCircle</h2>
<Container>
<Render>
<div style={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap' }}>
<div>
<ProgressCircle max={200} value={state.progressBarValue} />
</div>
<div>
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />
</div>
</div>
<input
defaultValue={state.progressBarValue}
max='200'
min='0'
onChange={effects.onChangeProgressBarValue}
step='1'
style={{
display: 'block',
margin: '10px auto',
}}
type='range'
/>
</Render>
<Code>
{`<ProgressCircle max={200} value={state.progressBarValue} />
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />`}
</Code>
</Container>
<h2>Select</h2>
<Container>
<Render>
<Select
onChange={effects.onChangeSelect}
options={[
{ name: 'Bar', value: 1 },
{ name: 'Foo', value: 2 },
]}
value={state.value}
valueRenderer='value'
/>
</Render>
<Code>
{`<Select
onChange={handleChange}
optionRenderer={item => item.name}
options={[
{ name: 'Bar', value: 1 },
{ name: 'Foo', value: 2 },
]}
value={state.value}
valueRenderer='value'
/>`}
</Code>
</Container>
<h2>Tabs</h2>
<Container>
<Render>
<Tabs
tabs={[
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
{ label: 'FOO', pathname: '/styleguide/foo' },
]}
useUrl
/>
</Render>
<Code>
{`<Tabs
tabs={[
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
{ label: 'FOO', pathname: '/styleguide/foo' },
]}
useUrl
/>`}
</Code>
</Container>
</Page>
)
)
export default App

View File

@@ -0,0 +1,102 @@
import React from 'react'
import { withState } from 'reaclette'
import Console from '../components/Console'
import IntlMessage, { translate } from '../components/IntlMessage'
import { ObjectsByType, Vm } from '../libs/xapi'
import PanelHeader from '../components/PanelHeader'
interface ParentState {
objectsByType: ObjectsByType
}
interface State {
consoleScale: number
sendCtrlAltDel?: () => void
}
interface Props {
vmId: string
}
interface ParentEffects {}
interface Effects {
scaleConsole: React.ChangeEventHandler<HTMLInputElement>
setCtrlAltDel: (sendCtrlAltDel: State['sendCtrlAltDel']) => void
showNotImplemented: () => void
}
interface Computed {
vm?: Vm
}
const TabConsole = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
// Value in percent
consoleScale: 100,
sendCtrlAltDel: undefined,
}),
effects: {
scaleConsole: function (e) {
this.state.consoleScale = +e.currentTarget.value
// With "scaleViewport", the canvas occupies all available space of its
// container. But when the size of the container is changed, the canvas
// size isn't updated
// Issue https://github.com/novnc/noVNC/issues/1364
// PR https://github.com/novnc/noVNC/pull/1365
window.dispatchEvent(new UIEvent('resize'))
},
setCtrlAltDel: function (sendCtrlAltDel) {
this.state.sendCtrlAltDel = sendCtrlAltDel
},
showNotImplemented: function () {
alert('Not Implemented')
},
},
computed: {
vm: (state, { vmId }) => state.objectsByType.get('VM')?.get(vmId),
},
},
({ effects, state, vmId }) => (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<PanelHeader
actions={[
{
key: 'start',
icon: 'play',
color: 'primary',
title: translate({ id: 'vmStartLabel' }),
variant: 'contained',
onClick: effects.showNotImplemented,
},
]}
>
{state.vm?.name_label ?? 'loading'}{' '}
</PanelHeader>
{/* Hide scaling and Ctrl+Alt+Del button temporarily */}
{/* <RangeInput max={100} min={1} onChange={effects.scaleConsole} step={1} value={state.consoleScale} />
{state.sendCtrlAltDel !== undefined && (
<Button onClick={state.sendCtrlAltDel}>
<IntlMessage id='ctrlAltDel' />
</Button>
)} */}
{state.vm?.power_state !== 'Running' ? (
<p>
<IntlMessage id='consoleNotAvailable' />
</p>
) : (
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ height: '100%', width: '100%' }}>
<Console vmId={vmId} scale={state.consoleScale} setCtrlAltDel={effects.setCtrlAltDel} />
</div>
</div>
)}
</div>
)
)
export default TabConsole

View File

@@ -0,0 +1,131 @@
import React from 'react'
import { Collection, Map } from 'immutable'
import { withState } from 'reaclette'
import Icon from '../components/Icon'
import IntlMessage from '../components/IntlMessage'
import Tree, { ItemType } from '../components/Tree'
import { Host, ObjectsByType, Pool, Vm } from '../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
}
interface State {}
interface Props {
defaultSelectedNodes?: Array<string>
}
interface ParentEffects {}
interface Effects {}
interface Computed {
collection?: Array<ItemType>
hostsByPool?: Collection.Keyed<string, Collection<string, Host>>
pools?: Map<string, Pool>
vms?: Map<string, Vm>
vmsByContainerRef?: Collection.Keyed<string, Collection<string, Vm>>
}
const getHostPowerState = (host: Host) => {
const { $metrics } = host
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
}
const getIconColor = (obj: Host | Vm) => {
const powerState = obj.power_state ?? getHostPowerState(obj as Host)
return powerState === 'Running' ? '#198754' : powerState === 'Halted' ? '#dc3545' : '#6c757d'
}
const TreeView = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
collection: state => {
if (state.pools === undefined) {
return
}
const collection: ItemType[] = []
state.pools.valueSeq().forEach((pool: Pool) => {
const hosts = state.hostsByPool
?.get(pool.$id)
?.valueSeq()
.sortBy(host => host.name_label)
.map((host: Host) => ({
children: state.vmsByContainerRef
?.get(host.$ref)
?.valueSeq()
.sortBy(vm => vm.name_label)
.map((vm: Vm) => ({
id: vm.$id,
label: (
<span>
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
</span>
),
to: `/infrastructure/vms/${vm.$id}/console`,
tooltip: <IntlMessage id={vm.power_state.toLowerCase()} />,
}))
.toArray(),
id: host.$id,
label: (
<span>
<Icon icon='server' htmlColor={getIconColor(host)} /> {host.name_label}
</span>
),
tooltip: <IntlMessage id={getHostPowerState(host).toLowerCase()} />,
}))
.toArray()
const haltedVms = state.vmsByContainerRef
?.get(pool.$ref)
?.valueSeq()
.sortBy((vm: Vm) => vm.name_label)
.map((vm: Vm) => ({
id: vm.$id,
label: (
<span>
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
</span>
),
to: `/infrastructure/vms/${vm.$id}/console`,
tooltip: <IntlMessage id='halted' />,
}))
.toArray()
collection.push({
children: (hosts ?? []).concat(haltedVms ?? []),
id: pool.$id,
label: (
<span>
<Icon icon='warehouse' color='primary' /> {pool.name_label}
</span>
),
to: `/infrastructure/pool/${pool.$id}/dashboard`,
})
})
return collection
},
hostsByPool: state => state.objectsByType?.get('host')?.groupBy((host: Host) => host.$pool.$id),
pools: state => state.objectsByType?.get('pool'),
vms: state =>
state.objectsByType
?.get('VM')
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
vmsByContainerRef: state =>
state.vms?.groupBy(({ power_state: powerState, resident_on: host, $pool }: Vm) =>
powerState === 'Running' || powerState === 'Paused' ? host : $pool.$ref
),
},
},
({ state, defaultSelectedNodes }) =>
state.collection === undefined ? null : (
<div style={{ padding: '10px' }}>
<Tree collection={state.collection} defaultSelectedNodes={defaultSelectedNodes} />
</div>
)
)
export default TreeView

View File

@@ -0,0 +1,506 @@
// import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import Container from '@mui/material/Container'
import Cookies from 'js-cookie'
import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import MenuIcon from '@mui/icons-material/Menu'
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'
import MuiDrawer from '@mui/material/Drawer'
import React from 'react'
import styledComponent from 'styled-components'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { HashRouter as Router, Switch, Redirect, Route } from 'react-router-dom'
import { IntlProvider } from 'react-intl'
import { Map } from 'immutable'
import { styled, createTheme, ThemeProvider } from '@mui/material/styles'
import { withState } from 'reaclette'
// import Button from '../components/Button'
import Icon from '../components/Icon'
import Infrastructure from './Infrastructure'
import IntlMessage from '../components/IntlMessage'
import Link from '../components/Link'
import messagesEn from '../lang/en.json'
import Modal from '../components/Modal'
import PoolTab from './PoolTab'
import Signin from './Signin/index'
import StyleGuide from './StyleGuide/index'
import TabConsole from './TabConsole'
import XapiConnection, { ObjectsByType, Pool, Vm } from '../libs/xapi'
const drawerWidth = 240
const redirectPaths = ['/', '/infrastructure']
interface AppBarProps extends MuiAppBarProps {
open?: boolean
}
// -----------------------------------------------------------------------------
// Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard
const AppBar = styled(MuiAppBar, {
shouldForwardProp: prop => prop !== 'open',
})<AppBarProps>(({ theme, open }) => ({
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
}),
}))
const Drawer = styled(MuiDrawer, { shouldForwardProp: prop => prop !== 'open' })(({ theme, open }) => ({
'& .MuiDrawer-paper': {
position: 'relative',
whiteSpace: 'nowrap',
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
boxSizing: 'border-box',
...(!open && {
overflowX: 'hidden',
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
width: theme.spacing(7),
[theme.breakpoints.up('sm')]: {
width: theme.spacing(9),
},
}),
},
}))
const MainListItems = (): JSX.Element => (
<div>
<ListItemButton component='a' href='#infrastructure'>
<ListItemIcon>
<Icon icon='project-diagram' />
</ListItemIcon>
<ListItemText primary={<IntlMessage id='infrastructure' />} />
</ListItemButton>
<ListItemButton component='a' href='#about'>
<ListItemIcon>
<Icon icon='info-circle' />
</ListItemIcon>
<ListItemText primary='About' />
</ListItemButton>
</div>
)
interface SecondaryListItemsParentState {}
interface SecondaryListItemsState {}
interface SecondaryListItemsProps {}
interface SecondaryListItemsParentEffects {}
interface SecondaryListItemsEffects {
disconnect: () => void
}
interface SecondaryListItemsComputed {}
const ICON_STYLE = { fontSize: '1.5em' }
const SecondaryListItems = withState<
SecondaryListItemsState,
SecondaryListItemsProps,
SecondaryListItemsEffects,
SecondaryListItemsComputed,
SecondaryListItemsParentState,
SecondaryListItemsParentEffects
>({}, ({ effects }) => (
<div>
<ListItem button onClick={() => effects.disconnect()}>
<ListItemIcon style={ICON_STYLE}>
<Icon icon='sign-out-alt' />
</ListItemIcon>
<ListItemText primary={<IntlMessage id='disconnect' />} />
</ListItem>
</div>
))
// -----------------------------------------------------------------------------
// Default bootstrap 4 colors
// https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss#L67-L74
const mdTheme = createTheme({
background: {
primary: {
dark: '#111111',
light: '#FFFFFF',
},
},
palette: {
error: {
main: '#dc3545',
},
info: {
main: '#17a2b8',
},
primary: {
dark: '#168FFF',
light: '#0085FF',
main: '#007bff',
},
secondary: {
main: '#6c757d',
},
success: {
main: '#28a745',
},
warning: {
main: '#ffc107',
},
},
components: {
MuiTab: {
styleOverrides: {
root: {
color: '#E8E8E8',
fontStyle: 'medium',
fontSize: '1.25em',
textAlign: 'center',
},
},
},
},
typography: {
fontFamily: 'inter',
h1: {
fontWeight: 500,
fontSize: '3em',
fontStyle: 'medium',
lineHeight: '3.75em',
},
h2: {
fontWeight: 500,
fontSize: '2.25em',
fontStyle: 'medium',
},
h3: {
fontWeight: 500,
fontSize: '1.5em',
fontStyle: 'medium',
lineHeight: '2em',
},
h4: {
fontWeight: 500,
fontSize: '1.25em',
fontStyle: 'medium',
lineHeight: '1.75em',
},
h5: {
fontWeight: 500,
fontSize: '1em',
fontStyle: 'medium',
lineHeight: '1.50em',
},
h6: {
fontWeight: 500,
fontSize: '0.8em',
fontStyle: 'medium',
lineHeight: '1.25em',
},
caption: {
// styleName: Caps / Caps 1 - 14 Semi Bold
fontSize: '0.9em',
fontStyle: 'normal',
fontWeight: 600,
lineHeight: '1.25em',
verticalAlign: 'top',
letterSpacing: '0.04em',
textAlign: 'left',
},
body2: {
// styleName: Paragraph / P2 - 16
fontSize: '1em',
fontStyle: 'normal',
fontWeight: 400,
lineHeight: '1.5em',
letterSpacing: '0em',
textAlign: 'left',
},
},
})
const FullPage = styledComponent.div`
height: 100vh;
display: flex;
flex-direction: column;
`
interface ParentState {
objectsByType: ObjectsByType
xapi: XapiConnection
}
interface State {
connected: boolean
drawerOpen: boolean
error: React.ReactNode
xapiHostname: string
}
interface Props {}
interface ParentEffects {}
interface Effects {
connectToXapi: (password: string, rememberMe: boolean) => void
disconnect: () => void
toggleDrawer: () => void
}
interface Computed {
objectsFetched: boolean
pool?: Pool
url: string
vms?: Map<string, Vm>
}
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
connected: Cookies.get('sessionId') !== undefined,
drawerOpen: false,
error: '',
objectsByType: undefined,
xapi: undefined,
xapiHostname: process.env.XAPI_HOST || window.location.host,
}),
effects: {
initialize: async function () {
const xapi = (this.state.xapi = new XapiConnection())
xapi.on('connected', () => {
this.state.connected = true
})
xapi.on('disconnected', () => {
this.state.connected = false
})
xapi.on('objects', (objectsByType: ObjectsByType) => {
this.state.objectsByType = objectsByType
})
try {
await xapi.reattachSession(this.state.url)
} catch (err) {
if (err?.code !== 'SESSION_INVALID') {
throw err
}
console.log('Session ID is invalid. Asking for credentials.')
}
},
toggleDrawer: function () {
this.state.drawerOpen = !this.state.drawerOpen
},
connectToXapi: async function (password, rememberMe = false) {
try {
await this.state.xapi.connect({
url: this.state.url,
user: 'root',
password,
rememberMe,
})
} catch (err) {
if (err?.code !== 'SESSION_AUTHENTICATION_FAILED') {
throw err
}
this.state.error = <IntlMessage id='badCredentials' />
}
},
disconnect: async function () {
await this.state.xapi.disconnect()
this.state.connected = false
},
},
computed: {
objectsFetched: state => state.objectsByType !== undefined,
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.keySeq().first() : undefined),
vms: state =>
state.objectsFetched
? state.objectsByType
?.get('VM')
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template)
: undefined,
url: state => `${window.location.protocol}//${state.xapiHostname}`,
},
},
({ effects, state }) => (
<IntlProvider messages={messagesEn} locale='en'>
{/* Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard */}
<ThemeProvider theme={mdTheme}>
<Modal />
{!state.connected ? (
<Signin />
) : !state.objectsFetched ? (
<IntlMessage id='loading' />
) : (
<>
<Router>
<Switch>
<Route exact path={redirectPaths}>
<Redirect to={`/infrastructure/pool/${state.pool.$id}/dashboard`} />
</Route>
<Route exact path='/vm-list'>
{state.vms !== undefined && (
<>
<p>There are {state.vms.size} VMs!</p>
<ul>
{state.vms.valueSeq().map((vm: Vm) => (
<li key={vm.$id}>
<Link to={vm.$id}>
{vm.name_label} - {vm.name_description} ({vm.power_state})
</Link>
</li>
))}
</ul>
</>
)}
</Route>
<Route exact path='/styleguide'>
<StyleGuide />
</Route>
<Route exact path='/styleguide/foo'>
<StyleGuide />
</Route>
<Route exact path='/pool'>
<PoolTab />
</Route>
<Route path='/'>
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar position='absolute' open={state.drawerOpen}>
<Toolbar
sx={{
pr: '24px', // keep right padding when drawer closed
}}
>
<IconButton
edge='start'
color='inherit'
aria-label='open drawer'
onClick={effects.toggleDrawer}
sx={{
marginRight: '36px',
...(state.drawerOpen && { display: 'none' }),
}}
>
<MenuIcon />
</IconButton>
<Typography component='h1' variant='h6' color='inherit' noWrap sx={{ flexGrow: 1 }}>
<Switch>
<Route path='/infrastructure'>
<IntlMessage id='infrastructure' />
</Route>
<Route path='/about'>
<IntlMessage id='about' />
</Route>
<Route>
<IntlMessage id='notFound' />
</Route>
</Switch>
</Typography>
{/* <IconButton color='inherit'>
<Badge badgeContent={4} color='secondary'>
<NotificationsIcon />
</Badge>
</IconButton> */}
</Toolbar>
</AppBar>
<Drawer variant='permanent' open={state.drawerOpen}>
<Toolbar
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
px: [1],
}}
>
<IconButton onClick={effects.toggleDrawer}>
<ChevronLeftIcon />
</IconButton>
</Toolbar>
<Divider />
<List>
<MainListItems />
</List>
<Divider />
<List>
<SecondaryListItems />
</List>
</Drawer>
<Box
component='main'
sx={{
backgroundColor: theme =>
theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900],
flexGrow: 1,
height: '100vh',
overflow: 'auto',
}}
>
<Switch>
<Route path='/infrastructure'>
<FullPage>
<Toolbar />
<Infrastructure />
</FullPage>
</Route>
<Route path='/about'>
<Toolbar />
<Container maxWidth='lg' sx={{ mt: 4, mb: 4 }}>
<p>
Check out{' '}
<Link to='https://xen-orchestra.com/blog/xen-orchestra-lite/'>Xen Orchestra Lite</Link>{' '}
dev blog.
</p>
<p>
<IntlMessage id='versionValue' values={{ version: process.env.NPM_VERSION }} />
</p>
</Container>
</Route>
<Route>
<Toolbar />
<IntlMessage id='pageNotFound' />
</Route>
</Switch>
</Box>
</Box>
</Route>
</Switch>
</Router>
</>
)}
</ThemeProvider>
</IntlProvider>
)
)
export default App

View File

@@ -0,0 +1,57 @@
import React from 'react'
import LoadingButton, { LoadingButtonProps } from '@mui/lab/LoadingButton'
import { withState } from 'reaclette'
interface ParentState {}
interface State {
isLoading: boolean
}
// Omit the `onClick` props to rewrite its own one.
interface Props extends Omit<LoadingButtonProps, 'onClick'> {
onClick: (data: Record<string, unknown>) => Promise<void>
// to pass props with the following pattern: "data-something"
[key: string]: unknown
}
interface ParentEffects {}
interface Effects {
_onClick: React.MouseEventHandler<HTMLButtonElement>
}
interface Computed {}
const ActionButton = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({ isLoading: false }),
effects: {
_onClick: function () {
this.state.isLoading = true
const data: Record<string, unknown> = {}
Object.keys(this.props).forEach(key => {
if (key.startsWith('data-')) {
data[key.slice(5)] = this.props[key]
}
})
return this.props.onClick(data).finally(() => (this.state.isLoading = false))
},
},
},
({ children, color = 'secondary', effects, onClick, resetState, state, variant = 'contained', ...props }) => (
<LoadingButton
color={color}
disabled={state.isLoading}
fullWidth
loading={state.isLoading}
onClick={effects._onClick}
variant={variant}
{...props}
>
{children}
</LoadingButton>
)
)
export default ActionButton

View File

@@ -0,0 +1,26 @@
import React from 'react'
import { Button as MuiButton, ButtonProps } from '@mui/material'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
interface Props extends ButtonProps {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const Button = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ children, color = 'secondary', effects, resetState, state, variant = 'contained', ...props }) => (
<MuiButton color={color} fullWidth variant={variant} {...props}>
{children}
</MuiButton>
)
)
export default Button

View File

@@ -0,0 +1,22 @@
import React from 'react'
import { CheckboxProps, Checkbox as MuiCheckbox } from '@mui/material'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
interface Props extends CheckboxProps {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const Checkbox = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ effects, resetState, state, ...props }) => <MuiCheckbox {...props} />
)
export default Checkbox

View File

@@ -0,0 +1,193 @@
import React from 'react'
import RFB from '@novnc/novnc/lib/rfb'
import styled from 'styled-components'
import { fibonacci } from 'iterable-backoff'
import { withState } from 'reaclette'
import IntlMessage from './IntlMessage'
import { confirm } from './Modal'
import XapiConnection, { ObjectsByType, Vm } from '../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
xapi: XapiConnection
}
interface State {
// Type error with HTMLDivElement.
// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/30451
container: React.RefObject<any>
// See https://github.com/vatesfr/xen-orchestra/pull/5722#discussion_r619296074
rfb: any
rfbConnected: boolean
timeout?: NodeJS.Timeout
tryToReconnect: boolean
url?: URL
}
interface Props {
scale: number
setCtrlAltDel: (sendCtrlAltDel: Effects['sendCtrlAltDel']) => void
vmId: string
}
interface ParentEffects {}
interface Effects {
_connect: () => Promise<void>
_handleConnect: () => void
_handleDisconnect: () => Promise<void>
sendCtrlAltDel: () => void
}
interface Computed {}
interface PropsStyledConsole {
scale: number
visible: boolean
}
enum Protocols {
http = 'http:',
https = 'https:',
ws = 'ws:',
wss = 'wss:',
}
const StyledConsole = styled.div<PropsStyledConsole>`
height: ${props => props.scale}%;
margin: auto;
visibility: ${props => (props.visible ? 'visible' : 'hidden')};
width: ${props => props.scale}%;
`
// https://github.com/novnc/noVNC/blob/master/docs/API.md
const Console = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
container: React.createRef(),
rfb: undefined,
rfbConnected: false,
timeout: undefined,
tryToReconnect: true,
url: undefined,
}),
effects: {
initialize: function () {
this.effects._connect()
},
_handleConnect: function () {
this.state.rfbConnected = true
},
_handleDisconnect: async function () {
this.state.rfbConnected = false
const {
state: { objectsByType, url },
effects: { _connect },
} = this
const { protocol } = window.location
if (protocol === Protocols.https) {
try {
await fetch(`${protocol}//${url?.host}`)
} catch (error) {
console.error(error)
try {
await confirm({
icon: 'exclamation-triangle',
message: (
<a href={`${protocol}//${url?.host}`} rel='noopener noreferrer' target='_blank'>
<IntlMessage
id='unreachableHost'
values={{
name: objectsByType.get('host')?.find(host => host.address === url?.host)?.name_label,
}}
/>
</a>
),
title: <IntlMessage id='connectionError' />,
})
} catch {
this.state.tryToReconnect = false
}
}
}
if (this.state.tryToReconnect) {
_connect()
}
},
_connect: async function () {
const { vmId } = this.props
const { objectsByType, rfb, xapi } = this.state
let lastError: unknown
// 8 tries mean 54s
for (const delay of fibonacci().toMs().take(8)) {
try {
const consoles = (objectsByType.get('VM')?.get(vmId) as Vm)?.$consoles.filter(
vmConsole => vmConsole.protocol === 'rfb'
)
if (rfb !== undefined) {
rfb.removeEventListener('connect', this.effects._handleConnect)
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
}
if (consoles === undefined || consoles.length === 0) {
throw new Error('Could not find VM console')
}
if (xapi.sessionId === undefined) {
throw new Error('Not connected to XAPI')
}
this.state.url = new URL(consoles[0].location)
this.state.url.protocol = window.location.protocol === Protocols.https ? Protocols.wss : Protocols.ws
this.state.url.searchParams.set('session_id', xapi.sessionId)
this.state.rfb = new RFB(this.state.container.current, this.state.url, {
wsProtocols: ['binary'],
})
this.state.rfb.addEventListener('connect', this.effects._handleConnect)
this.state.rfb.addEventListener('disconnect', this.effects._handleDisconnect)
this.state.rfb.scaleViewport = true
this.props.setCtrlAltDel(this.effects.sendCtrlAltDel)
return
} catch (error) {
lastError = error
await new Promise(resolve => (this.state.timeout = setTimeout(resolve, delay)))
}
}
throw lastError
},
finalize: function () {
const { rfb, timeout } = this.state
rfb.removeEventListener('connect', this.effects._handleConnect)
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
if (timeout !== undefined) {
clearTimeout(timeout)
}
},
sendCtrlAltDel: async function () {
await confirm({
message: <IntlMessage id='confirmCtrlAltDel' />,
title: <IntlMessage id='ctrlAltDel' />,
})
this.state.rfb.sendCtrlAltDel()
},
},
},
({ scale, state }) => (
<>
{state.rfb !== undefined && !state.rfbConnected && (
<p>
<IntlMessage id={state.tryToReconnect ? 'reconnectionAttempt' : 'hostUnreachable'} />
</p>
)}
<StyledConsole ref={state.container} scale={scale} visible={state.rfbConnected} />
</>
)
)
export default Console

View File

@@ -0,0 +1,30 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconName as _IconName, library, SizeProp } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { useTheme } from '@mui/material/styles'
library.add(fas)
const Icon = ({
color,
htmlColor,
icon,
size,
}: {
color?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
htmlColor?: string
icon: _IconName
size?: SizeProp
}): JSX.Element => {
const { palette } = useTheme()
return (
<FontAwesomeIcon
icon={icon}
size={size}
color={htmlColor ?? (color !== undefined ? palette[color][palette.mode] : undefined)}
/>
)
}
export default Icon
export type IconName = _IconName

View File

@@ -0,0 +1,26 @@
import React from 'react'
import { TextField, TextFieldProps } from '@mui/material'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
// An interface can only extend an object type or intersection
// of object types with statically known members.
type Props = _Props & TextFieldProps
interface _Props {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const Input = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ effects, resetState, state, ...props }) => <TextField fullWidth {...props} />
)
export default Input

View File

@@ -0,0 +1,21 @@
import React, { ElementType, ReactElement, ReactNode } from 'react'
import { FormattedMessage, MessageDescriptor, useIntl } from 'react-intl'
import intlMessage from '../lang/en.json'
// Extends FormattedMessage not working: "FormattedMessage refers to a value, but is being used as a type here"
// https://stackoverflow.com/questions/62059408/reactjs-and-typescript-refers-to-a-value-but-is-being-used-as-a-type-here-ts
// InstanceType<typeof FormattedMessage> not working: "Type [...] does not satisfy the constraint abstract new (...args: any) => any."
// See https://formatjs.io/docs/react-intl/components/#formattedmessage
interface Props extends MessageDescriptor {
children?: (chunks: ReactElement) => ReactElement
id?: keyof typeof intlMessage
tagName?: ElementType
values?: Record<string, ReactNode>
}
const IntlMessage = (props: Props): JSX.Element => <FormattedMessage {...props} />
export function translate(message: MessageDescriptor){
return useIntl().formatMessage(message)
}
export default React.memo(IntlMessage)

View File

@@ -0,0 +1,38 @@
import MaterialLink from '@mui/material/Link'
import React from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
interface Props {
children: React.ReactNode
decorated?: boolean
to?: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const UNDECORATED_LINK = { textDecoration: 'none', color: 'inherit' }
const Link = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ to, decorated = true, children }) =>
to === undefined ? (
<>{children}</>
) : to.startsWith('http') ? (
<MaterialLink style={decorated ? undefined : UNDECORATED_LINK} target='_blank' rel='noopener noreferrer' href={to}>
{children}
</MaterialLink>
) : (
<RouterLink style={decorated ? undefined : UNDECORATED_LINK} component={MaterialLink} to={to}>
{children}
</RouterLink>
)
)
export default Link

View File

@@ -0,0 +1,152 @@
import React from 'react'
import { ButtonProps, Dialog, DialogContent, DialogContentText, DialogActions, DialogTitle } from '@mui/material'
import { withState } from 'reaclette'
import Button from './Button'
import Icon, { IconName } from './Icon'
import IntlMessage from './IntlMessage'
type ModalButton = {
color?: ButtonProps['color']
label: string | React.ReactNode
reason?: unknown
value?: unknown
}
interface GeneralParamsModal {
icon: IconName
message: string | React.ReactNode
title: string | React.ReactNode
}
interface ModalParams extends GeneralParamsModal {
buttonList: ModalButton[]
}
let instance: EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> | undefined
const modal = ({ buttonList, icon, message, title }: ModalParams) =>
new Promise((resolve, reject) => {
if (instance === undefined) {
throw new Error('No modal instance')
}
instance.state.buttonList = buttonList
instance.state.icon = icon
instance.state.message = message
instance.state.onReject = reject
instance.state.onSuccess = resolve
instance.state.showModal = true
instance.state.title = title
})
export const alert = (params: GeneralParamsModal): Promise<unknown> => {
const buttonList: ModalButton[] = [
{
label: <IntlMessage id='ok' />,
color: 'primary',
value: 'success',
},
]
return modal({ ...params, buttonList })
}
export const confirm = (params: GeneralParamsModal): Promise<unknown> => {
const buttonList: ModalButton[] = [
{
label: <IntlMessage id='confirm' />,
value: 'confirm',
color: 'success',
},
{
label: <IntlMessage id='cancel' />,
color: 'secondary',
reason: 'cancel',
},
]
return modal({ ...params, buttonList })
}
interface ParentState {}
interface State {
buttonList?: ModalButton[]
icon?: IconName
message?: string | React.ReactNode
onReject?: (reason: unknown) => void
onSuccess?: (value: unknown) => void
showModal: boolean
title?: string | React.ReactNode
}
interface Props {}
interface ParentEffects {}
interface Effects {
closeModal: () => void
reject: (reason: unknown) => void
}
interface Computed {}
const Modal = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
buttonList: undefined,
icon: undefined,
message: undefined,
onReject: undefined,
onSuccess: undefined,
showModal: false,
title: undefined,
}),
effects: {
initialize: function () {
if (instance !== undefined) {
throw new Error('Modal is a singelton')
}
instance = this
},
closeModal: function () {
this.state.showModal = false
},
reject: function (reason) {
this.state.onReject?.(reason)
this.effects.closeModal()
},
},
},
({ effects, state }) => {
const { closeModal, reject } = effects
const { buttonList, icon, message, onReject, onSuccess, showModal, title } = state
return showModal ? (
<Dialog open={showModal} onClose={reject}>
<DialogTitle>
{icon !== undefined && <Icon icon={icon} />} {title}
</DialogTitle>
<DialogContent>
<DialogContentText>{message}</DialogContentText>
</DialogContent>
<DialogActions>
{buttonList?.map(({ label, reason, value, ...props }, index) => {
const onClick = () => {
if (value !== undefined) {
onSuccess?.(value)
} else {
onReject?.(reason)
}
closeModal()
}
return (
<Button key={index} onClick={onClick} {...props}>
{label}
</Button>
)
})}
</DialogActions>
</Dialog>
) : null
}
)
export default Modal

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { withState } from 'reaclette'
import Icon, { IconName } from './Icon'
import Button, { ButtonProps } from '@mui/material/Button'
import ButtonGroup, { ButtonGroupClassKey } from '@mui/material/ButtonGroup'
import Stack from '@mui/material/Stack'
import Typography, { TypographyClassKey } from '@mui/material/Typography'
import { Theme } from '@mui/material/styles'
interface ParentState {}
interface State {}
interface Action extends ButtonProps {
icon: IconName
}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const DEFAULT_TITLE_STYLE = { marginLeft: '0.5em', flex: 1, fontSize: '250%' }
const DEFAULT_BUTTONGROUP_STYLE = { margin: '0.5em', flex: 0 }
const DEFAULT_STACK_STYLE = {
backgroundColor: (theme: Theme) => {
const { background, palette } = theme
return palette.mode === 'light' ? background.primary.light : background.primary.dark
},
paddingTop: '1em',
}
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
// Accepts an array of Actions. An action accepts all the props of a Button + an icon
actions?: Array<Action>
// the props passed to the title, accepts all the keys of Typography
titleProps?: TypographyClassKey
// the props passed to the button group, accepts all the keys of a ButtonGroup
buttonGroupProps?: ButtonGroupClassKey
}
const PanelHeader = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ actions = [], titleProps = {}, buttonGroupProps = {}, children = null }) => (
<Stack direction='row' justifyContent='space-between' alignItems='center' sx={DEFAULT_STACK_STYLE}>
<Typography variant='h2' sx={DEFAULT_TITLE_STYLE} {...titleProps}>
{children}
</Typography>
<ButtonGroup sx={DEFAULT_BUTTONGROUP_STYLE} {...buttonGroupProps}>
{(actions as Array<Action>)?.map(({ icon, ...actionProps }) => (
<Button {...actionProps} key={actionProps.key}>
<Icon icon={icon} />
</Button>
))}
</ButtonGroup>
</Stack>
)
)
export default PanelHeader

View File

@@ -0,0 +1,87 @@
import Box from '@mui/material/Box'
import React from 'react'
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress'
import { styled } from '@mui/material/styles'
import { Typography } from '@mui/material'
import { withState } from 'reaclette'
const BackgroundBox = styled(Box)({
position: 'absolute',
})
const BackgroundCircle = styled(CircularProgress)({
color: '#e3dede',
})
const Container = styled(Box)({
display: 'inline-flex',
position: 'relative',
})
const StyledLabel = styled(Typography)(({ color, theme: { palette } }) => ({
color: (palette[(color as string) ?? 'primary'] ?? palette.primary).main,
textAlign: 'center',
}))
const LabelBox = styled(Box)({
alignItems: 'center',
bottom: 0,
display: 'flex',
height: '80%',
justifyContent: 'center',
left: 0,
margin: 'auto',
overflow: 'hidden',
position: 'absolute',
right: 0,
top: 0,
width: '80%',
})
interface ParentState {}
interface State {}
interface Props {
color?: CircularProgressProps['color']
label?: string
max?: number
showLabel?: boolean
size?: number
value: number
}
interface ParentEffects {}
interface Effects {}
interface Computed {
label: string
progress: number
}
const ProgressCircle = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
label: ({ progress }, { label }) => label ?? `${progress}%`,
progress: (_, { max = 100, value }) => Math.round((value / max) * 100),
},
},
({ color = 'success', showLabel = true, size = 100, state: { label, progress } }) => (
<Container>
<BackgroundBox>
<BackgroundCircle variant='determinate' value={100} size={size} />
</BackgroundBox>
<CircularProgress aria-label={label} color={color} size={size} value={progress} variant='determinate' />
{showLabel && (
<LabelBox>
<StyledLabel variant='h5' color={color}>
{label}
</StyledLabel>
</LabelBox>
)}
</Container>
)
)
export default ProgressCircle

View File

@@ -0,0 +1,7 @@
import React from 'react'
type Props = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'>
const RangeInput = React.memo((props: Props) => <input {...props} type='range' />)
export default RangeInput

View File

@@ -0,0 +1,97 @@
import FormControl from '@mui/material/FormControl'
import MenuItem from '@mui/material/MenuItem'
import React from 'react'
import SelectMaterialUi, { SelectProps } from '@mui/material/Select'
import { iteratee } from 'lodash'
import { SelectChangeEvent } from '@mui/material'
import { withState } from 'reaclette'
import IntlMessage from './IntlMessage'
type AdditionalProps = Record<string, any>
interface ParentState {}
interface State {}
interface Props extends SelectProps {
additionalProps?: AdditionalProps
onChange: (e: SelectChangeEvent<unknown>) => void
optionRenderer?: string | { (item: any): number | string }
options: any[] | undefined
value: any
valueRenderer?: string | { (item: any): number | string }
}
interface ParentEffects {}
interface Effects {}
interface Computed {
renderOption: (item: any, additionalProps?: AdditionalProps) => React.ReactNode
renderValue: (item: any, additionalProps?: AdditionalProps) => number | string
options?: JSX.Element[]
}
const Select = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
// @ts-ignore
renderOption: (_, { optionRenderer }) => iteratee(optionRenderer),
// @ts-ignore
renderValue: (_, { valueRenderer }) => iteratee(valueRenderer),
options: (state, { additionalProps, options, optionRenderer, valueRenderer }) =>
options?.map(item => {
const label =
optionRenderer === undefined
? item.name ?? item.label ?? item.name_label
: state.renderOption(item, additionalProps)
const value =
valueRenderer === undefined ? item.value ?? item.id ?? item.$id : state.renderValue(item, additionalProps)
if (value === undefined) {
console.error('Computed value is undefined')
}
return (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
)
}),
},
},
({
additionalProps,
displayEmpty = true,
effects,
multiple,
options,
required,
resetState,
state,
value,
...props
}) => (
<FormControl>
<SelectMaterialUi
multiple={multiple}
required={required}
displayEmpty={displayEmpty}
value={value ?? (multiple ? [] : '')}
{...props}
>
{!multiple && (
<MenuItem value=''>
<em>
<IntlMessage id='none' />
</em>
</MenuItem>
)}
{state.options}
</SelectMaterialUi>
</FormControl>
)
)
export default Select

View File

@@ -0,0 +1,73 @@
import React from 'react'
import styled from 'styled-components'
import { withState } from 'reaclette'
import IntlMessage from './IntlMessage'
export type Column<Type> = {
header: React.ReactNode
id?: string
render: { (item: Type): React.ReactNode }
}
type Item = {
id?: string
[key: string]: any
}
interface ParentState {}
interface State {}
interface Props {
collection: Item[] | undefined
columns: Column<any>[]
placeholder?: JSX.Element
}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const StyledTable = styled.table`
border: 1px solid #333;
td {
border: 1px solid #333;
}
thead {
background-color: #333;
color: #fff;
}
`
const Table = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ collection, columns, placeholder }) =>
collection !== undefined ? (
collection.length !== 0 ? (
<StyledTable>
<thead>
<tr>
{columns.map((col, index) => (
<td key={col.id ?? index}>{col.header}</td>
))}
</tr>
</thead>
<tbody>
{collection.map((item, index) => (
<tr key={item.id ?? index}>
{columns.map((col, index) => (
<td key={col.id ?? index}>{col.render(item)}</td>
))}
</tr>
))}
</tbody>
</StyledTable>
) : (
placeholder ?? <IntlMessage id='noData' />
)
) : (
<IntlMessage id='loading' />
)
)
export default Table

View File

@@ -0,0 +1,114 @@
import Box from '@mui/material/Box'
import React from 'react'
import Tab from '@mui/material/Tab'
import TabContext from '@mui/lab/TabContext'
import TabList from '@mui/lab/TabList'
import TabPanel from '@mui/lab/TabPanel'
import Typography from '@mui/material/Typography'
import { RouteComponentProps } from 'react-router-dom'
import { withState } from 'reaclette'
import { withRouter } from 'react-router'
import IntlMessage from '../components/IntlMessage'
const BOX_STYLE = { borderBottom: 1, borderColor: 'divider', marginTop: '0.5em' }
interface ParentState {}
interface State {
value: string
}
interface Tab {
component?: React.ReactNode
disabled?: boolean
label: React.ReactNode
}
interface UrlTab extends Tab {
pathname: string
value?: any
}
interface NoUrlTab extends Tab {
value: any
}
// For compatibility with 'withRouter'
interface Props extends RouteComponentProps {
indicatorColor?: 'primary' | 'secondary'
textColor?: 'inherit' | 'primary' | 'secondary'
// tabs = [
// {
// component: <span>BAR</span>,
// pathname: '/path',
// label: (
// <span>
// <Icon icon='cloud' /> {labelA}
// </span>
// ),
// },
// ]
tabs: Array<NoUrlTab | UrlTab>
useUrl?: boolean
value?: any
}
interface ParentEffects {}
interface Effects {
onChange: (event: React.SyntheticEvent, value: string) => void
}
interface Computed {}
// TODO: improve view as done in the model(figma).
const pageUnderConstruction = (
<div style={{ color: '#0085FF', textAlign: 'center' }}>
<Typography variant='h2'>
<IntlMessage id='xoLiteUnderConstruction' />
</Typography>
<Typography variant='h3'>
<IntlMessage id='newFeaturesUnderConstruction' />
</Typography>
</div>
)
const Tabs = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: ({ location: { pathname }, tabs, useUrl = false, value }) => ({
value: (useUrl && pathname) || (value ?? tabs[0].value ?? tabs[0].pathname),
}),
effects: {
onChange: function (_, value) {
if (this.props.useUrl) {
const { history, tabs } = this.props
history.push(tabs.find(tab => (tab.value ?? tab.pathname) === value).pathname)
}
this.state.value = value
},
},
},
({ effects, state: { value }, indicatorColor, textColor, tabs }) => (
<TabContext value={value}>
<Box sx={BOX_STYLE}>
<TabList indicatorColor={indicatorColor} onChange={effects.onChange} textColor={textColor}>
{tabs.map((tab: UrlTab | NoUrlTab) => {
const value = tab.value ?? tab.pathname
return <Tab disabled={tab.disabled} key={value} label={tab.label} value={value} />
})}
</TabList>
</Box>
{tabs.map((tab: UrlTab | NoUrlTab) => {
const value = tab.value ?? tab.pathname
return (
<TabPanel key={value} value={value}>
{tab.component === undefined ? pageUnderConstruction : tab.component}
</TabPanel>
)
})}
</TabContext>
)
)
export default withRouter(Tabs)

View File

@@ -0,0 +1,196 @@
import classNames from 'classnames'
import React, { useEffect } from 'react'
import Tooltip from '@mui/material/Tooltip'
import TreeView from '@mui/lab/TreeView'
import TreeItem, { useTreeItem, TreeItemContentProps } from '@mui/lab/TreeItem'
import { withState } from 'reaclette'
import { useHistory } from 'react-router-dom'
import Icon from '../components/Icon'
interface ParentState {}
interface State {
expandedNodes?: Array<string>
selectedNodes?: Array<string>
}
export interface ItemType {
children?: Array<ItemType>
id: string
label: React.ReactElement
to?: string
tooltip?: React.ReactNode
}
interface Props {
// collection = [
// {
// id: 'idA',
// label: (
// <span>
// <Icon icon='warehouse' /> {labelA}
// </span>
// ),
// to: '/routeA',
// children: [
// {
// id: 'ida',
// label: label: (
// <span>
// <Icon icon='server' /> {labela}
// </span>
// ),
// },
// ],
// },
// {
// id: 'idB',
// label: (
// <span>
// <Icon icon='warehouse' /> {labelB}
// </span>
// ),
// to: '/routeB',
// tooltip: <IntlMessage id='tooltipB' />
// }
// ]
collection: Array<ItemType>
defaultSelectedNodes?: Array<string>
}
interface CustomContentProps extends TreeItemContentProps {
defaultSelectedNode?: string
to?: string
}
interface ParentEffects {}
interface Effects {
setExpandedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
setSelectedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
}
interface Computed {
defaultSelectedNode?: string
}
// Inspired by https://mui.com/components/tree-view/#contentcomponent-prop.
const CustomContent = React.forwardRef(function CustomContent(props: CustomContentProps, ref) {
const { classes, className, defaultSelectedNode, expansionIcon, label, nodeId, to } = props
const { focused, handleExpansion, handleSelection, selected } = useTreeItem(nodeId)
const history = useHistory()
useEffect(() => {
// There can only be one node selected at once for now.
// Auto-revealing more than one node in the tree would require a different implementation.
if (defaultSelectedNode === nodeId) {
ref?.current?.scrollIntoView()
}
}, [])
useEffect(() => {
if (selected) {
to !== undefined && history.push(to)
}
}, [selected])
const handleExpansionClick = (event: React.SyntheticEvent) => {
event.stopPropagation()
handleExpansion(event)
}
return (
<span
className={classNames(className, { [classes.focused]: focused, [classes.selected]: selected })}
onClick={handleSelection}
ref={ref}
>
<span className={classes.iconContainer} onClick={handleExpansionClick}>
{expansionIcon}
</span>
<span className={classes.label}>{label}</span>
</span>
)
})
const renderItem = ({ children, id, label, to, tooltip }: ItemType, defaultSelectedNode?: string) => {
return (
<TreeItem
ContentComponent={CustomContent}
// FIXME: ContentProps should only be React.HTMLAttributes<HTMLElement> or undefined, it doesn't support other type.
// when https://github.com/mui-org/material-ui/issues/28668 is fixed, remove 'as CustomContentProps'.
ContentProps={{ defaultSelectedNode, to } as CustomContentProps}
label={tooltip ? <Tooltip title={tooltip}>{label}</Tooltip> : label}
key={id}
nodeId={id}
>
{Array.isArray(children) ? children.map(item => renderItem(item, defaultSelectedNode)) : null}
</TreeItem>
)
}
const Tree = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: ({ collection, defaultSelectedNodes }) => {
if (defaultSelectedNodes === undefined) {
return {
expandedNodes: [collection[0].id],
selectedNodes: [],
}
}
// expandedNodes should contain all nodes up to the defaultSelectedNodes.
const expandedNodes = new Set<string>()
const pathToNode = new Set<string>()
const addExpandedNode = (collection: Array<ItemType> | undefined) => {
if (collection === undefined) {
return
}
for (const node of collection) {
if (defaultSelectedNodes.includes(node.id)) {
for (const nodeId of pathToNode) {
expandedNodes.add(nodeId)
}
}
pathToNode.add(node.id)
addExpandedNode(node.children)
pathToNode.delete(node.id)
}
}
addExpandedNode(collection)
return { expandedNodes: Array.from(expandedNodes), selectedNodes: defaultSelectedNodes }
},
effects: {
setExpandedNodeIds: function (_, nodeIds) {
this.state.expandedNodes = nodeIds
},
setSelectedNodeIds: function (_, nodeIds) {
this.state.selectedNodes = [nodeIds[0]]
},
},
computed: {
defaultSelectedNode: (_, { defaultSelectedNodes }) =>
defaultSelectedNodes !== undefined ? defaultSelectedNodes[0] : undefined,
},
},
({ effects, state: { defaultSelectedNode, expandedNodes, selectedNodes }, collection }) => (
<TreeView
defaultCollapseIcon={<Icon icon='chevron-up' />}
defaultExpanded={[collection[0].id]}
defaultExpandIcon={<Icon icon='chevron-down' />}
expanded={expandedNodes}
multiSelect
onNodeSelect={effects.setSelectedNodeIds}
onNodeToggle={effects.setExpandedNodeIds}
selected={selectedNodes}
>
{collection.map(item => renderItem(item, defaultSelectedNode))}
</TreeView>
)
)
export default Tree

View File

@@ -0,0 +1,26 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Helmet } from 'react-helmet'
import { createGlobalStyle } from 'styled-components'
import App from './App/index'
const GlobalStyle = createGlobalStyle`
body {
margin: 0;
font-family: Arial, Verdana, Helvetica, Ubuntu, sans-serif;
box-sizing: border-box;
color: #212529;
}
`
ReactDOM.render(
<React.StrictMode>
<Helmet>
<link rel='shortcut icon' href='favicon.ico' />
</Helmet>
<GlobalStyle />
<App />
</React.StrictMode>,
document.getElementById('root')
)

View File

@@ -0,0 +1,55 @@
{
"about": "About",
"active": "Active",
"availableUpdates": "{nUpdates, number} available update{nUpdates, plural, one {} other {s}}",
"badCredentials": "Bad credentials",
"cancel": "Cancel",
"confirm": "Confirm",
"confirmCtrlAltDel": "Send Ctrl+Alt+Del to VM?",
"connect": "Connect",
"connectionError": "Connection error",
"consoleNotAvailable": "Console is only available for running VMs",
"ctrlAltDel": "Ctrl+Alt+Del",
"description": "Description",
"device": "Device",
"disconnect": "Disconnect",
"dns": "DNS",
"errorOccurred": "An error has occurred.",
"gateway": "Gateway",
"halted": "Halted",
"hosts": "Hosts",
"hostUnreachable": "Host unreachable",
"inactive": "Inactive",
"infrastructure": "Infrastructure",
"ip": "IP",
"loading": "Loading…",
"login": "Login",
"name": "Name",
"newFeaturesUnderConstruction": "New features are coming soon!",
"noHosts": "No hosts",
"noData": "No data",
"noImplemented": "Not implemented",
"noManagementPifs": "No management PIFs found",
"none": "None",
"noVms": "No VMs",
"notFound": "Not Found",
"pageNotFound": "This page doesn't exist.",
"xoLiteUnderConstruction": "XO Lite is under construction",
"noUpdatesAvailable": "No updates available",
"ok": "OK",
"password": "Password",
"paused": "Paused",
"reconnectionAttempt": "Trying to reconnect…",
"release": "Release",
"rememberMe": "Remember me",
"running": "Running",
"size": "Size",
"status": "Status",
"suspended": "Suspended",
"total": "Total",
"unreachableHost": "Click here to make sure your host ({name}) is reachable. You may have to allow self-signed SSL certificates in your browser.",
"vms": "VMs",
"version": "Version",
"versionValue": "Version {version}",
"vmStartLabel": "Start"
}

View File

@@ -0,0 +1,4 @@
{
"connect": "Connexion",
"vmStartLabel": "Démarrer"
}

View File

@@ -0,0 +1,205 @@
import Cookies from 'js-cookie'
import { EventEmitter } from 'events'
import { Map } from 'immutable'
import { Xapi } from 'xen-api'
export interface XapiObject {
$pool: Pool
$ref: string
$type: keyof types
$id: string
}
// Dictionary of XAPI types and their corresponding TypeScript types
interface types {
PIF: Pif
pool: Pool
VM: Vm
host: Host
}
// XAPI types ---
export interface Pif extends XapiObject {
device: string
DNS: string
gateway: string
IP: string
management: boolean
network: string
}
export interface Pool extends XapiObject {
name_label: string
}
export interface PoolUpdate {
changelog: {
author: string
date: Date
description: string
}
description: string
license: string
name: string
release: string
size: number
url: string
version: string
}
export interface Vm extends XapiObject {
$consoles: Array<{ protocol: string; location: string }>
is_a_snapshot: boolean
is_a_template: boolean
is_control_domain: boolean
name_description: string
name_label: string
power_state: string
resident_on: string
}
interface HostMetrics {
live: boolean
}
export interface Host extends XapiObject {
$metrics: HostMetrics
address: string
name_label: string
power_state: string
}
// --------
export interface ObjectsByType extends Map<string, Map<string, XapiObject>> {
get<NSV, T extends keyof types>(key: T, notSetValue: NSV): Map<string, types[T]> | NSV
get<T extends keyof types>(key: T): Map<string, types[T]> | undefined
}
export default class XapiConnection extends EventEmitter {
areObjectsFetched: Promise<void>
connected: boolean
objectsByType: ObjectsByType
sessionId?: string
_resolveObjectsFetched!: () => void
_xapi?: {
objects: EventEmitter & {
all: { [id: string]: XapiObject }
}
connect(): Promise<void>
disconnect(): Promise<void>
call: (method: string, ...args: unknown[]) => Promise<unknown>
_objectsFetched: Promise<void>
}
constructor() {
super()
this.objectsByType = Map() as ObjectsByType
this.connected = false
this.areObjectsFetched = new Promise(resolve => {
this._resolveObjectsFetched = resolve
})
}
async reattachSession(url: string): Promise<void> {
const sessionId = Cookies.get('sessionId')
if (sessionId === undefined) {
return
}
return this.connect({ url, sessionId })
}
async connect({
url,
user = 'root',
password,
sessionId,
rememberMe = Cookies.get('rememberMe') === 'true',
}: {
url: string
user?: string
password?: string
sessionId?: string
rememberMe?: boolean
}): Promise<void> {
const xapi = (this._xapi = new Xapi({
auth: { user, password, sessionId },
url,
watchEvents: true,
readonly: false,
}))
const updateObjects = (objects: { [id: string]: XapiObject }) => {
try {
this.objectsByType = this.objectsByType.withMutations(objectsByType => {
Object.entries(objects).forEach(([id, object]) => {
if (object === undefined) {
// Remove
objectsByType.forEach((objects, type) => {
objectsByType.set(type, objects.remove(id))
})
} else {
// Add or update
const { $type } = object
objectsByType.set($type, objectsByType.get($type, Map<string, XapiObject>()).set(id, object))
}
})
})
this.emit('objects', this.objectsByType)
} catch (err) {
console.error(err)
}
}
xapi.on('connected', () => {
this.sessionId = xapi.sessionId
this.connected = true
this.emit('connected')
})
xapi.on('disconnected', () => {
Cookies.remove('sessionId')
this.emit('disconnected')
})
xapi.on('sessionId', (sessionId: string) => {
if (rememberMe) {
Cookies.set('rememberMe', 'true', { expires: 7 })
}
Cookies.set('sessionId', sessionId, rememberMe ? { expires: 7 } : undefined)
})
await xapi.connect()
await xapi._objectsFetched
updateObjects(xapi.objects.all)
this._resolveObjectsFetched()
xapi.objects.on('add', updateObjects)
xapi.objects.on('update', updateObjects)
xapi.objects.on('remove', updateObjects)
}
disconnect(): Promise<void> | undefined {
Cookies.remove('rememberMe')
Cookies.remove('sessionId')
const { _xapi } = this
if (_xapi !== undefined) {
return _xapi.disconnect()
}
}
call(method: string, ...args: unknown[]): Promise<unknown> {
const { _xapi, connected } = this
if (!connected || _xapi === undefined) {
throw new Error('Not connected to XAPI')
}
return _xapi.call(method, ...args)
}
}

View File

@@ -0,0 +1,63 @@
{
"compilerOptions": {
/* Basic Options */
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "incremental": true, /* Enable incremental compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
"noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
"noUnusedLocals": true, /* Report errors on unused locals. */
"noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"resolveJsonModule": true
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
}
}

6
@xen-orchestra/lite/types/decs.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '@novnc/novnc/lib/rfb'
declare module 'human-format'
declare module 'iterable-backoff'
declare module 'json-rpc-protocol'
declare module 'promise-toolbox'
declare module 'xen-api'

View File

@@ -0,0 +1,42 @@
type RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects> = {
readonly effects: Effects & ParentEffects
readonly state: State & ParentState & Computed
readonly resetState: () => void
} & Props
interface EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> {
readonly effects: Effects & ParentEffects
readonly state: State & ParentState & Computed
readonly props: Props
}
interface StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects> {
initialState?: State | ((props: Props) => State) // what about Reaclette's state inheritance?
effects?: {
initialize?: () => void | Promise<void>
finalize?: () => void | Promise<void>
} & Effects &
ThisType<EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects>>
computed?: {
[ComputedName in keyof Computed]: (
state: State & ParentState & Computed,
props: Props
) => Computed[ComputedName] | Promise<Computed[ComputedName]>
}
}
declare module 'reaclette' {
function provideState<State, Props, Effects, Computed, ParentState, ParentEffects>(
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>
): (component: React.Component<Props>) => React.Component<Props>
function injectState<State, Props, Effects, Computed, ParentState, ParentEffects>(
// FIXME: also accept class components
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
): React.ElementType<Props>
function withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>,
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
): React.ElementType<Props>
}

21
@xen-orchestra/lite/types/theme.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
import { Theme as ThemeMui, ThemeOptions as ThemeOptionsMui } from '@mui/material/styles'
declare module '@mui/material/styles' {
// FIXME: when https://github.com/microsoft/TypeScript/issues/40315 is fixed.
// issue: Type 'Theme'/'ThemeOptions' recursively references itself as a base type.
interface Theme extends ThemeMui {
background: {
primary: {
dark: string
light: string
}
}
}
interface ThemeOptions extends ThemeOptionsMui {
background?: {
primary?: {
dark?: string
light?: string
}
}
}
}

View File

@@ -0,0 +1,72 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path')
const webpack = require('webpack')
const resolveApp = relative => path.resolve(__dirname, relative)
const { NODE_ENV = 'production' } = process.env
const __PROD__ = NODE_ENV === 'production'
// https://webpack.js.org/configuration/
module.exports = {
mode: NODE_ENV,
target: 'web',
devServer: {
historyApiFallback: true,
},
entry: resolveApp('src/index.tsx'),
output: {
filename: __PROD__ ? '[name].[contenthash:8].js' : '[name].js',
path: resolveApp('dist'),
},
optimization: {
moduleIds: __PROD__ ? 'deterministic' : undefined,
runtimeChunk: true,
splitChunks: {
chunks: 'all',
},
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
},
{
test: /\.css$/i,
use: ['css-loader'],
},
],
},
resolve: {
alias: {
dns: false,
},
extensions: ['.tsx', '.ts', '.js'],
},
devtool: __PROD__ ? 'source-map' : 'eval-cheap-module-source-map',
plugins: [
new (require('clean-webpack-plugin').CleanWebpackPlugin)(),
new (require('copy-webpack-plugin'))({
patterns: [
{
from: resolveApp('public'),
to: resolveApp('dist'),
filter: file => file !== resolveApp('public/index.html'),
},
],
}),
new (require('html-webpack-plugin'))({
template: resolveApp('public/index.html'),
}),
new webpack.EnvironmentPlugin({ XAPI_HOST: '', NPM_VERSION: require('./package.json').version }),
new (require('node-polyfill-webpack-plugin'))(),
].filter(Boolean),
}

View File

@@ -48,10 +48,6 @@ configure([
// if filter is a string, then it is pattern
// (https://github.com/visionmedia/debug#wildcards) which is
// matched against the namespace of the logs
//
// If it's an array, it will be handled as an array of filters
// and the transport will be used if any one of them match the
// current log
filter: process.env.DEBUG,
transport: transportConsole(),

View File

@@ -24,7 +24,7 @@
},
"dependencies": {
"lodash": "^4.17.4",
"promise-toolbox": "^0.20.0"
"promise-toolbox": "^0.19.2"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -13,7 +13,7 @@ module.exports = class Config {
const watchers = (this._watchers = new Set())
app.hooks.on('start', async () => {
app.hooks.once(
app.hooks.on(
'stop',
await watch({ appDir, appName, ignoreUnknownFormats: true }, (error, config) => {
if (error != null) {

View File

@@ -23,7 +23,7 @@
"xo-proxy-cli": "dist/index.js"
},
"engines": {
"node": ">=12"
"node": ">=8.10"
},
"dependencies": {
"@iarna/toml": "^2.2.0",
@@ -33,12 +33,12 @@
"content-type": "^1.0.4",
"cson-parser": "^4.0.7",
"getopts": "^2.2.3",
"http-request-plus": "^0.13.0",
"http-request-plus": "^0.12",
"json-rpc-protocol": "^0.13.1",
"promise-toolbox": "^0.20.0",
"promise-toolbox": "^0.19.2",
"pump": "^3.0.0",
"pumpify": "^2.0.1",
"split2": "^4.1.0"
"split2": "^3.1.1"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -18,7 +18,6 @@ keepAliveInterval = 10e3
#
# https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation
dirMode = 0o700
disableMergeWorker = false
snapshotNameLabelTpl = '[XO Backup {job.name}] {vm.name_label}'
[backups.defaultSettings]
@@ -60,13 +59,6 @@ cert = '/var/lib/xo-proxy/certificate.pem'
key = '/var/lib/xo-proxy/key.pem'
port = 443
[logs]
# Display all logs matching this filter, regardless of their level
#filter = 'xo:backups:*'
# Display all logs with level >=, regardless of their namespace
level = 'info'
[remoteOptions]
mountsDir = '/run/xo-proxy/mounts'

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.15.5",
"version": "0.14.7",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -27,17 +27,17 @@
"dependencies": {
"@iarna/toml": "^2.2.0",
"@koa/router": "^10.0.0",
"@vates/compose": "^2.1.0",
"@vates/compose": "^2.0.0",
"@vates/decorate-with": "^0.1.0",
"@vates/disposable": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.16.2",
"@xen-orchestra/fs": "^0.19.2",
"@xen-orchestra/backups": "^0.13.0",
"@xen-orchestra/fs": "^0.18.0",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.1.1",
"@xen-orchestra/self-signed": "^0.1.0",
"@xen-orchestra/xapi": "^0.8.4",
"@xen-orchestra/xapi": "^0.7.0",
"ajv": "^8.0.3",
"app-conf": "^0.9.0",
"async-iterator-to-stream": "^1.1.0",
@@ -47,18 +47,18 @@
"golike-defer": "^0.5.1",
"http-server-plus": "^0.11.0",
"json-rpc-protocol": "^0.13.1",
"jsonrpc-websocket-client": "^0.7.2",
"jsonrpc-websocket-client": "^0.6.0",
"koa": "^2.5.1",
"koa-compress": "^5.0.1",
"koa-helmet": "^5.1.0",
"lodash": "^4.17.10",
"node-zone": "^0.4.0",
"parse-pairs": "^1.0.0",
"promise-toolbox": "^0.20.0",
"promise-toolbox": "^0.19.2",
"source-map-support": "^0.5.16",
"stoppable": "^1.0.6",
"xdg-basedir": "^4.0.0",
"xen-api": "^0.35.1",
"xen-api": "^0.34.3",
"xo-common": "^0.7.0"
},
"devDependencies": {

View File

@@ -14,30 +14,25 @@ import { createLogger } from '@xen-orchestra/log'
const { debug, warn } = createLogger('xo:proxy:api')
const ndJsonStream = asyncIteratorToStream(async function*(responseId, iterable) {
const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable) {
let headerSent = false
try {
let cursor, iterator
try {
const getIterator = iterable[Symbol.iterator] ?? iterable[Symbol.asyncIterator]
iterator = getIterator.call(iterable)
cursor = await iterator.next()
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
} catch (error) {
yield format.error(responseId, error)
throw error
}
while (!cursor.done) {
for await (const data of iterable) {
if (!headerSent) {
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
headerSent = true
}
try {
yield JSON.stringify(cursor.value) + '\n'
yield JSON.stringify(data) + '\n'
} catch (error) {
warn('ndJsonStream, item error', { error })
}
cursor = await iterator.next()
}
} catch (error) {
warn('ndJsonStream, fatal error', { error })
if (!headerSent) {
yield format.error(responseId, error)
}
}
})
@@ -52,7 +47,7 @@ export default class Api {
ctx.req.setTimeout(0)
const profile = await app.authentication.findProfile({
authenticationToken: ctx.cookies.get('authenticationToken')
authenticationToken: ctx.cookies.get('authenticationToken'),
})
if (profile === undefined) {
ctx.status = 401
@@ -123,7 +118,7 @@ export default class Api {
this.addMethods({
system: {
getMethodsInfo: [
function*() {
function* () {
const methods = this._methods
for (const name in methods) {
const { description, params = {} } = methods[name]
@@ -131,25 +126,25 @@ export default class Api {
}
}.bind(this),
{
description: 'returns the signatures of all available API methods'
}
description: 'returns the signatures of all available API methods',
},
],
getServerVersion: [
() => appVersion,
{
description: 'returns the version of xo-server'
}
description: 'returns the version of xo-server',
},
],
listMethods: [
function*() {
function* () {
const methods = this._methods
for (const name in methods) {
yield name
}
}.bind(this),
{
description: 'returns the name of all available API methods'
}
description: 'returns the name of all available API methods',
},
],
methodSignature: [
({ method: name }) => {
@@ -164,14 +159,14 @@ export default class Api {
{
description: 'returns the signature of an API method',
params: {
method: { type: 'string' }
}
}
]
method: { type: 'string' },
},
},
],
},
test: {
range: [
function*({ start = 0, stop, step }) {
function* ({ start = 0, stop, step }) {
if (step === undefined) {
step = start > stop ? -1 : 1
}
@@ -189,11 +184,11 @@ export default class Api {
params: {
start: { optional: true, type: 'number' },
step: { optional: true, type: 'number' },
stop: { type: 'number' }
}
}
]
}
stop: { type: 'number' },
},
},
],
},
})
}
@@ -220,7 +215,7 @@ export default class Api {
return required
}),
type: 'object'
type: 'object',
})
const m = params => {

View File

@@ -1,4 +1,3 @@
import assert from 'assert'
import fse from 'fs-extra'
import xdg from 'xdg-basedir'
import { createLogger } from '@xen-orchestra/log'
@@ -11,48 +10,33 @@ const { warn } = createLogger('xo:proxy:authentication')
const isValidToken = t => typeof t === 'string' && t.length !== 0
export default class Authentication {
#token
constructor(_, { appName, config: { authenticationToken: token } }) {
if (!isValidToken(token)) {
token = JSON.parse(execFileSync('xenstore-read', ['vm-data/xo-proxy-authenticationToken']))
constructor(app, { appName, config: { authenticationToken: token } }) {
const setToken = ({ token }) => {
assert(isValidToken(token), 'invalid authentication token: ' + token)
// save this token in the automatically handled conf file
fse.outputFileSync(
// this file must take precedence over normal user config
`${xdg.config}/${appName}/config.z-auto.json`,
JSON.stringify({ authenticationToken: token }),
{ mode: 0o600 }
)
this.#token = token
}
if (isValidToken(token)) {
this.#token = token
} else {
setToken({ token: JSON.parse(execFileSync('xenstore-read', ['vm-data/xo-proxy-authenticationToken'])) })
if (!isValidToken(token)) {
throw new Error('missing authenticationToken in configuration')
}
try {
// save this token in the automatically handled conf file
fse.outputFileSync(
// this file must take precedence over normal user config
`${xdg.config}/${appName}/config.z-auto.json`,
JSON.stringify({ authenticationToken: token }),
{ mode: 0o600 }
)
execFileSync('xenstore-rm', ['vm-data/xo-proxy-authenticationToken'])
} catch (error) {
warn('failed to remove token from XenStore', { error })
}
}
app.api.addMethod('authentication.setToken', setToken, {
description: 'change the authentication token used by this XO Proxy',
params: {
token: {
type: 'string',
minLength: 1,
},
},
})
this._token = token
}
async findProfile(credentials) {
if (credentials?.authenticationToken === this.#token) {
if (credentials?.authenticationToken === this._token) {
return new Profile()
}
}

View File

@@ -1,17 +0,0 @@
import transportConsole from '@xen-orchestra/log/transports/console.js'
import { configure } from '@xen-orchestra/log/configure.js'
export default class Logs {
constructor(app) {
const transport = transportConsole()
app.config.watch('logs', ({ filter, level }) => {
configure([
{
filter: [process.env.DEBUG, filter],
level,
transport,
},
])
})
}
}

View File

@@ -1,7 +1,4 @@
const { execFile } = require('child_process')
const { promisify } = require('util')
const randomBytes = promisify(require('crypto').randomBytes)
const openssl = (cmd, args, { input, ...opts } = {}) =>
new Promise((resolve, reject) => {
@@ -13,35 +10,12 @@ const openssl = (cmd, args, { input, ...opts } = {}) =>
}
})
const req = (key, selfSigned, { days = 360 } = {}) => {
const args = ['-batch', '-new', '-key', '-', '-nodes']
if (selfSigned) {
args.push('-x509', '-days', String(days))
}
return openssl('req', args, { input: key })
}
exports.genSelfSignedCert = async opts => {
exports.genSelfSignedCert = async ({ days = 360 } = {}) => {
const key = await openssl('genrsa', ['2048'])
return {
cert: await req(key, true, opts),
key,
}
}
exports.genSignedCert = async (ca, { days = 360 } = {}) => {
const key = await openssl('genrsa', ['2048'])
const csr = await req(key, false)
const serial = '0x' + (await randomBytes(40)).toString('hex')
const input = [csr, ca.cert, ca.key].join('\n')
return {
cert: await openssl(
'x509',
['-req', '-in', '-', '-CA', '-', '-CAkey', '-', '-days', String(days), '-set_serial', serial],
{
input,
}
),
cert: await openssl('req', ['-batch', '-new', '-key', '-', '-x509', '-days', String(days), '-nodes'], {
input: key,
}),
key,
}
}

View File

@@ -35,7 +35,7 @@
"form-data": "^4.0.0",
"fs-extra": "^10.0.0",
"get-stream": "^6.0.0",
"http-request-plus": "^0.13.0",
"http-request-plus": "^0.12",
"human-format": "^0.11.0",
"l33teral": "^3.0.3",
"lodash": "^4.17.4",
@@ -44,8 +44,8 @@
"pw": "^0.0.4",
"strip-indent": "^3.0.0",
"xdg-basedir": "^4.0.0",
"xo-lib": "^0.11.1",
"xo-vmdk-to-vhd": "^2.0.1"
"xo-lib": "^0.10.1",
"xo-vmdk-to-vhd": "^2.0.0"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/xapi",
"version": "0.8.4",
"version": "0.7.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
@@ -25,7 +25,7 @@
"xo-common": "^0.7.0"
},
"peerDependencies": {
"xen-api": "^0.35.1"
"xen-api": "^0.34.3"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
@@ -44,7 +44,7 @@
"d3-time-format": "^3.0.0",
"golike-defer": "^0.5.1",
"lodash": "^4.17.15",
"promise-toolbox": "^0.20.0"
"promise-toolbox": "^0.19.2"
},
"private": false,
"license": "AGPL-3.0-or-later",

View File

@@ -98,16 +98,18 @@ function removeWatcher(predicate, cb) {
class Xapi extends Base {
constructor({
callRetryWhenTooManyPendingTasks = { delay: 5e3, tries: 10 },
callRetryWhenTooManyPendingTasks,
ignoreNobakVdis,
maxUncoalescedVdis,
vdiDestroyRetryWhenInUse = { delay: 5e3, tries: 10 },
vdiDestroyRetryWhenInUse,
...opts
}) {
assert.notStrictEqual(ignoreNobakVdis, undefined)
super(opts)
this._callRetryWhenTooManyPendingTasks = {
delay: 5e3,
tries: 10,
...callRetryWhenTooManyPendingTasks,
onRetry,
when: { code: 'TOO_MANY_PENDING_TASKS' },
@@ -115,6 +117,8 @@ class Xapi extends Base {
this._ignoreNobakVdis = ignoreNobakVdis
this._maxUncoalescedVdis = maxUncoalescedVdis
this._vdiDestroyRetryWhenInUse = {
delay: 5e3,
retries: 10,
...vdiDestroyRetryWhenInUse,
onRetry,
when: { code: 'VDI_IN_USE' },

View File

@@ -1,7 +1,6 @@
const CancelToken = require('promise-toolbox/CancelToken.js')
const pCatch = require('promise-toolbox/catch.js')
const pRetry = require('promise-toolbox/retry.js')
const { decorateWith } = require('@vates/decorate-with')
const extractOpaqueRef = require('./_extractOpaqueRef.js')
@@ -12,13 +11,10 @@ module.exports = class Vdi {
return extractOpaqueRef(await this.callAsync('VDI.clone', vdiRef))
}
// work around a race condition in XCP-ng/XenServer where the disk is not fully unmounted yet
@decorateWith(pRetry.wrap, function () {
return this._vdiDestroyRetryWhenInUse
})
async destroy(vdiRef) {
await pCatch.call(
this.callAsync('VDI.destroy', vdiRef),
// work around a race condition in XCP-ng/XenServer where the disk is not fully unmounted yet
pRetry(() => this.callAsync('VDI.destroy', vdiRef), this._vdiDestroyRetryWhenInUse),
// if this VDI is not found, consider it destroyed
{ code: 'HANDLE_INVALID' },
noop

View File

@@ -99,7 +99,6 @@ module.exports = class Vm {
// should coalesce
const children = childrenMap[vdi.uuid]
if (
children !== undefined && // unused unmanaged VDI, will be GC-ed
children.length === 1 &&
!children[0].managed && // some SRs do not coalesce the leaf
tolerance-- <= 0

View File

@@ -1,129 +1,18 @@
# ChangeLog
## **5.65.2** (2021-12-10)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Bug fixes
- [Backup] Fix `handler.rmTree` is not a function (Forum [5256](https://xcp-ng.org/forum/topic/5256/s3-backup-try-it/29) PR [#6041](https://github.com/vatesfr/xen-orchestra/pull/6041) )
- [Backup] Fix `EEXIST` in logs when multiple merge tasks are created at the same time ([Forum #5301](https://xcp-ng.org/forum/topic/5301/warnings-errors-in-journalctl))
- [Backup] Fix missing backup on restore (Forum [5256](https://xcp-ng.org/forum/topic/5256/s3-backup-try-it/29) (PR [#6048](https://github.com/vatesfr/xen-orchestra/pull/6048))
### Released packages
- @xen-orchestra/fs 0.19.2
- vhd-lib 2.0.3
- @xen-orchestra/backups 0.16.2
- xo-server 5.84.3
- @xen-orchestra/proxy 0.15.5
## **5.65.1** (2021-12-03)
### Bug fixes
- [Delta Backup Restoration] Fix assertion error [Forum #5257](https://xcp-ng.org/forum/topic/5257/problems-building-from-source/16)
- [Delta Backup Restoration] `TypeError: this disposable has already been disposed` [Forum #5257](https://xcp-ng.org/forum/topic/5257/problems-building-from-source/20)
- [Backups] Fix: `Error: Chaining alias is forbidden xo-vm-backups/..alias.vhd to xo-vm-backups/....alias.vhd` when backuping a file to s3 [Forum #5226](https://xcp-ng.org/forum/topic/5256/s3-backup-try-it)
- [Delta Backup Restoration] `VDI_IO_ERROR(Device I/O errors)` [Forum #5727](https://xcp-ng.org/forum/topic/5257/problems-building-from-source/4) (PR [#6031](https://github.com/vatesfr/xen-orchestra/pull/6031))
- [Delta Backup] Fix `Cannot read property 'uuid' of undefined` when a VDI has been removed from a backed up VM (PR [#6034](https://github.com/vatesfr/xen-orchestra/pull/6034))
### Released packages
- @vates/compose 2.1.0
- vhd-lib 2.0.2
- xo-vmdk-to-vhd 2.0.1
- @xen-orchestra/backups 0.16.1
- @xen-orchestra/proxy 0.15.4
- xo-server 5.84.2
## **5.65.0** (2021-11-30)
### Highlights
- [VM] Ability to export a snapshot's memory (PR [#6015](https://github.com/vatesfr/xen-orchestra/pull/6015))
- [Cloud config] Ability to create a network cloud config template and reuse it in the VM creation [#5931](https://github.com/vatesfr/xen-orchestra/issues/5931) (PR [#5979](https://github.com/vatesfr/xen-orchestra/pull/5979))
- [Backup/logs] identify XAPI errors (PR [#6001](https://github.com/vatesfr/xen-orchestra/pull/6001))
- [lite] Highlight selected VM (PR [#5939](https://github.com/vatesfr/xen-orchestra/pull/5939))
## **next**
### Enhancements
- [S3] Ability to authorize self signed certificates for S3 remote (PR [#5961](https://github.com/vatesfr/xen-orchestra/pull/5961))
### Bug fixes
- [Import/VM] Fix the import of OVA files (PR [#5976](https://github.com/vatesfr/xen-orchestra/pull/5976))
### Released packages
- @vates/async-each 0.1.0
- xo-remote-parser 0.8.4
- @xen-orchestra/fs 0.19.0
- @xen-orchestra/xapi patch
- vhd-lib 2.0.1
- @xen-orchestra/backups 0.16.0
- xo-lib 0.11.1
- @xen-orchestra/proxy 0.15.3
- xo-server 5.84.1
- vhd-cli 0.6.0
- xo-web 5.90.0
## **5.64.0** (2021-10-29)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
## Highlights
- [Netbox] Support older versions of Netbox and prevent "active is not a valid choice" error [#5898](https://github.com/vatesfr/xen-orchestra/issues/5898) (PR [#5946](https://github.com/vatesfr/xen-orchestra/pull/5946))
- [Tasks] Filter out short tasks using a default filter (PR [#5921](https://github.com/vatesfr/xen-orchestra/pull/5921))
- [Host] Handle evacuation failure during host shutdown (PR [#5966](https://github.com/vatesfr/xen-orchestra/pull/#5966))
- [Menu] Notify user when proxies need to be upgraded (PR [#5930](https://github.com/vatesfr/xen-orchestra/pull/5930))
- [Servers] Ability to use an HTTP proxy between XO and a server (PR [#5958](https://github.com/vatesfr/xen-orchestra/pull/5958))
- [VM/export] Ability to copy the export URL (PR [#5948](https://github.com/vatesfr/xen-orchestra/pull/5948))
- [Pool/advanced] Ability to define network for importing/exporting VMs/VDIs (PR [#5957](https://github.com/vatesfr/xen-orchestra/pull/5957))
- [Host/advanced] Add button to enable/disable the host (PR [#5952](https://github.com/vatesfr/xen-orchestra/pull/5952))
- [Backups] Enable merge worker by default
### Enhancements
- [Jobs] Ability to copy a job ID (PR [#5951](https://github.com/vatesfr/xen-orchestra/pull/5951))
### Bug fixes
- [Backups] Delete unused snapshots related to other schedules (even no longer existing) (PR [#5949](https://github.com/vatesfr/xen-orchestra/pull/5949))
- [Jobs] Fix `job.runSequence` method (PR [#5944](https://github.com/vatesfr/xen-orchestra/pull/5944))
- [Netbox] Fix error when testing plugin on versions older than 2.10 (PR [#5963](https://github.com/vatesfr/xen-orchestra/pull/5963))
- [Snapshot] Fix "Create VM from snapshot" creating a template instead of a VM (PR [#5955](https://github.com/vatesfr/xen-orchestra/pull/5955))
- [Host/Logs] Improve the display of log content (PR [#5943](https://github.com/vatesfr/xen-orchestra/pull/5943))
- [XOA licenses] Fix expiration date displaying "Invalid date" in some rare cases (PR [#5967](https://github.com/vatesfr/xen-orchestra/pull/5967))
- [API/pool.listPoolsMatchingCriteria] Fix `checkSrName`/`checkPoolName` `is not a function` error
### Released packages
- xo-server-netbox 0.3.3
- vhd-lib 1.3.0
- xen-api 0.35.1
- @xen-orchestra/xapi 0.8.0
- @xen-orchestra/backups 0.15.1
- @xen-orchestra/proxy 0.15.2
- vhd-cli 0.5.0
- xapi-explore-sr 0.4.0
- xo-server 5.83.0
- xo-web 5.89.0
## **5.63.0** (2021-09-30)
### Highlights
- [Backup] Go back to previous page instead of going to the overview after editing a job: keeps current filters and page (PR [#5913](https://github.com/vatesfr/xen-orchestra/pull/5913))
- [Health] Do not take into consideration duplicated MAC addresses from CR VMs (PR [#5916](https://github.com/vatesfr/xen-orchestra/pull/5916))
- [Health] Ability to filter duplicated MAC addresses by running VMs (PR [#5917](https://github.com/vatesfr/xen-orchestra/pull/5917))
- [Tables] Move the search bar and pagination to the top of the table (PR [#5914](https://github.com/vatesfr/xen-orchestra/pull/5914))
- [Netbox] Handle nested prefixes by always assigning an IP to the smallest prefix it matches (PR [#5908](https://github.com/vatesfr/xen-orchestra/pull/5908))
### Bug fixes
- [SSH keys] Allow SSH key to be broken anywhere to avoid breaking page formatting (Thanks [@tstivers1990](https://github.com/tstivers1990)!) [#5891](https://github.com/vatesfr/xen-orchestra/issues/5891) (PR [#5892](https://github.com/vatesfr/xen-orchestra/pull/5892))
- [Netbox] Handle nested prefixes by always assigning an IP to the smallest prefix it matches (PR [#5908](https://github.com/vatesfr/xen-orchestra/pull/5908))
- [Netbox] Better handling and error messages when encountering issues due to UUID custom field not being configured correctly [#5905](https://github.com/vatesfr/xen-orchestra/issues/5905) [#5806](https://github.com/vatesfr/xen-orchestra/issues/5806) [#5834](https://github.com/vatesfr/xen-orchestra/issues/5834) (PR [#5909](https://github.com/vatesfr/xen-orchestra/pull/5909))
- [New VM] Don't send network config if untouched as all commented config can make Cloud-init fail [#5918](https://github.com/vatesfr/xen-orchestra/issues/5918) (PR [#5923](https://github.com/vatesfr/xen-orchestra/pull/5923))
@@ -153,6 +42,8 @@
## **5.62.0** (2021-08-31)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Highlights
- [Host] Add warning in case of unmaintained host version [#5840](https://github.com/vatesfr/xen-orchestra/issues/5840) (PR [#5847](https://github.com/vatesfr/xen-orchestra/pull/5847))
@@ -185,6 +76,8 @@
## **5.61.0** (2021-07-30)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Highlights
- [SR/disks] Display base copies' active VDIs (PR [#5826](https://github.com/vatesfr/xen-orchestra/pull/5826))
@@ -257,7 +150,7 @@
- [Smart backup] Report missing pools [#2844](https://github.com/vatesfr/xen-orchestra/issues/2844) (PR [#5768](https://github.com/vatesfr/xen-orchestra/pull/5768))
- [Metadata Backup] Add a warning on restoring a metadata backup (PR [#5769](https://github.com/vatesfr/xen-orchestra/pull/5769))
- [Netbox][plugin](https://xen-orchestra.com/docs/advanced.html#netbox) to synchronize pools, VMs and IPs with [Netbox](https://netbox.readthedocs.io/en/stable/) (PR [#5783](https://github.com/vatesfr/xen-orchestra/pull/5783))
- [Netbox] [Plugin](https://xen-orchestra.com/docs/advanced.html#netbox) to synchronize pools, VMs and IPs with [Netbox](https://netbox.readthedocs.io/en/stable/) (PR [#5783](https://github.com/vatesfr/xen-orchestra/pull/5783))
### Enhancements

View File

@@ -7,18 +7,10 @@
> Users must be able to say: “Nice enhancement, I'm eager to test it”
- [About] Show commit instead of version numbers for source users (PR [#6045](https://github.com/vatesfr/xen-orchestra/pull/6045))
- [Health] Display default SRs that aren't shared [#5871](https://github.com/vatesfr/xen-orchestra/issues/5871) (PR [#6033](https://github.com/vatesfr/xen-orchestra/pull/6033))
- [Pool,VM/advanced] Ability to change the suspend SR [#4163](https://github.com/vatesfr/xen-orchestra/issues/4163) (PR [#6044](https://github.com/vatesfr/xen-orchestra/pull/6044))
### Bug fixes
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [Tables/actions] Fix collapsed actions being clickable despite being disabled (PR [#6023](https://github.com/vatesfr/xen-orchestra/pull/6023))
- [Continuous Replication] Fix `could not find the base VM`
- [Backup/Smart mode] Always ignore replicated VMs created by the current job
### Packages to release
> Packages will be released in the order they are here, therefore, they should
@@ -35,8 +27,3 @@
> - major: if the change breaks compatibility
>
> In case of conflict, the highest (lowest in previous list) `$version` wins.
- @xen-orchestra/backups minor
- @xen-orchestra/proxy minor
- xo-server minor
- xo-web minor

27
ISSUE_TEMPLATE.md Normal file
View File

@@ -0,0 +1,27 @@
<!--
Welcome to the issue section of Xen Orchestra!
Here you can:
- report an issue
- propose an enhancement
- ask a question
Please, respect this template as much as possible, it helps us sort
the issues :)
-->
### Context
- **XO origin**: the sources / XO Appliance
- **Versions**:
- Node: **FILL HERE**
- xo-web: **FILL HERE**
- xo-server: **FILL HERE**
### Expected behavior
<!-- What you expect to happen -->
### Current behavior
<!-- What is actually happening -->

View File

@@ -327,8 +327,6 @@ Synchronize your pools, VMs, network interfaces and IP addresses with your [Netb
![](./assets/netbox.png)
### Netbox side
- Go to your Netbox interface
- Configure prefixes:
- Go to IPAM > Prefixes > Add
@@ -341,29 +339,13 @@ XO will try to find the right prefix for each IP address. If it can't find a pre
- Generate a token:
- Go to Admin > Tokens > Add token
- Create a token with "Write enabled"
- The owner of the token must have at least the following permissions:
- View permissions on:
- extras > custom-fields
- ipam > prefixes
- All permissions on:
- ipam > ip-addresses
- virtualization > cluster-types
- virtualization > clusters
- virtualization > interfaces
- virtualization > virtual-machines
- Add a UUID custom field (for **Netbox 2.x**):
- Add a UUID custom field:
- Got to Admin > Custom fields > Add custom field
- Create a custom field called "uuid" (lower case!)
- Assign it to object types `virtualization > cluster` and `virtualization > virtual machine`
![](./assets/customfield.png)
:::tip
In Netbox 3.x, custom fields can be found directly in the site (no need to go in the admin section). It's available in "Other/Customization/Custom Fields". After creation of the `uuid` field, assign it to the object types `virtualization > cluster` and `virtualization > virtual machine`.
:::
### In Xen Orchestra
- Go to Xen Orchestra > Settings > Plugins > Netbox and fill out the configuration:
- Endpoint: the URL of your Netbox instance (e.g.: `https://netbox.company.net`)
- Unauthorized certificate: only for HTTPS, enable this option if your Netbox instance uses a self-signed SSL certificate

View File

@@ -26,12 +26,6 @@ Each backups' job execution is identified by a `runId`. You can find this `runId
![](./assets/log-runId.png)
## Exclude disks
During a backup job, you can avoid saving all disks of the VM. To do that is trivial: just edit the VM disk name and add `[NOBAK]` before the current name, eg: `data-disk` will become `[NOBAK] data-disk` (with a space or not, doesn't matter).
The disks marked with `[NOBAK]` will be now ignored in all following backups.
## Schedule
:::tip
@@ -292,9 +286,8 @@ When it's done exporting, we'll remove the snapshot. Note: this operation will t
Concurrency is a parameter that let you define how many VMs your backup job will manage simultaneously.
:::tip
- Default concurrency value is 2 if left empty.
:::
:::
Let's say you want to backup 50 VMs (each with 1x disk) at 3:00 AM. There are **2 different strategies**:
@@ -304,7 +297,7 @@ Let's say you want to backup 50 VMs (each with 1x disk) at 3:00 AM. There are **
The first purely sequential strategy will lead to the fact that: **you can't predict when a snapshot of your data will occur**. Because you can't predict the first VM export time (let's say 3 hours), then your second VM will have its snapshot taken 3 hours later, at 6 AM.
:::tip
If you need your backup to be done at a specific time you should consider creating a specific backup task for this VM.
If you need your backup to be done at a specific time you should consider creating a specific backup task for this VM.
:::
Strategy number 2 is to parallelise: all the snapshots will be taken at 3 AM. However **it's risky without limits**: it means potentially doing 50 snapshots or more at once on the same storage. **Since XenServer doesn't have a queue**, it will try to do all of them at once. This is also prone to race conditions and could cause crashes on your storage.
@@ -319,7 +312,6 @@ You should be aware of your hardware limitation when defining the best concurren
The best way to define the best concurrency for you is by increasing it slowly and watching the result on backup time.
So to summarize, if you set your concurrency at 6 and you have 20 Vms to backup the process will be the following:
- We start the backup of the first 6 VMs.
- When one VM backup as ended we will launch the next VM backup.
- We're keep launching new VM backup until the 20 VMs are finished, keeping 6 backups running.

View File

@@ -43,6 +43,12 @@ Just go into your "Backup" view, and select Delta Backup. Then, it's the same as
Unlike other types of backup jobs which delete the associated snapshot when the job is done and it has been exported, delta backups always keep a snapshot of every VM in the backup job, and uses it for the delta. Do not delete these snapshots!
## Exclude disks
During a delta backup job, you can avoid saving all disks of the VM. To do that is trivial: just edit the VM disk name and add `[NOBAK]` before the current name, eg: `data-disk` will become `[NOBAK] data-disk` (with a space or not, doesn't matter).
The disks marked with `[NOBAK]` will be now ignored in all following backups.
## Delta backup initial seed
If you don't want to do an initial full directly toward the destination, you can create a local delta backup first, then transfer the files to your destination.

View File

@@ -61,7 +61,7 @@ Please only use this if you have issues with [the default way to deploy XOA](ins
Alternatively, you can deploy it by connecting to your XenServer host and executing the following:
```
bash -c "$(wget -qO- https://xoa.io/deploy)"
bash -c "$(curl -sS https://xoa.io/deploy)"
```
:::tip
@@ -78,7 +78,7 @@ curl: (35) error:1407742E:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 alert protoc
It means that the secure HTTPS protocol is not supported, you can bypass this using the unsecure command instead:
```
bash -c "$(wget -qO- http://xoa.io/deploy)"
bash -c "$(curl -sS http://xoa.io/deploy)"
```
:::
@@ -103,10 +103,9 @@ In that case, you already set the password for `xoa` user. If you forgot it, see
### Manually deployed
If you connect via SSH or console for the first time without using our [web deploy form](https://xen-orchestra.com/#!/xoa), be aware **there is NO default password set for security reasons**. To set it, you need to connect to your host to find the XOA VM UUID (eg via `xe vm-list`).
Next, you can replace `<UUID>` with the UUID you found previously, and `<password>` with your password:
If you connect via SSH or console for the first time without using our [web deploy form](https://xen-orchestra.com/#!/xoa), be aware **there's NO default password set for security reasons**. To set it, you need to connect to your host to find the XOA VM UUID (eg via `xe vm-list`).
Then replace `<UUID>` with the previously find UUID, and `<password>` with your password:
```
xe vm-param-set uuid=<UUID> xenstore-data:vm-data/system-account-xoa-password=<password>
```
@@ -115,9 +114,7 @@ xe vm-param-set uuid=<UUID> xenstore-data:vm-data/system-account-xoa-password=<p
Don't forget to use quotes for your password, eg: `xenstore-data:vm-data/system-account-xoa-password='MyPassW0rd!'`
:::
Finally, you must reboot the VM to implement the changes.
You can now connect with the `xoa` username and password you defined in the previous command, eg with `ssh xoa@<XOA IP ADDRESS>`.
Then, you could connect with `xoa` username and the password you defined in the previous command, eg with `ssh xoa@<XOA IP ADDRESS>`.
### Using sudo

View File

@@ -318,7 +318,7 @@ XOSAN is a 100% software defined solution for XenServer hyperconvergence. You ca
You will need to be registered on our website in order to use Xen Orchestra. If you are not yet registered, [here is the way](https://xen-orchestra.com/#!/signup)
SSH in your XenServer and use the command line `bash -c "$(wget -qO- https://xoa.io/deploy)"` - it will deploy Xen Orchestra Appliance on your XenServer infrastructure which is required to use XOSAN.
SSH in your XenServer and use the command line `bash -c "$(curl -sS https://xoa.io/deploy)"` - it will deploy Xen Orchestra Appliance on your XenServer infrastructure which is required to use XOSAN.
> Note: You can also download the XVA file and follow [these instructions](https://xen-orchestra.com/docs/xoa.html#the-alternative).

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