Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae087a6539 | ||
|
|
4db93f8ced | ||
|
|
e5c737cba7 | ||
|
|
9f0f38ef94 | ||
|
|
d76996b1d5 | ||
|
|
3b77897692 | ||
|
|
d4ed555abd | ||
|
|
97d77c0aa5 | ||
|
|
a9ad0ec455 | ||
|
|
78ec008c26 | ||
|
|
2d71bef5d8 | ||
|
|
3ec7c61987 | ||
|
|
526c2001d3 | ||
|
|
f3d4e40c6d | ||
|
|
ac8f93fb0e | ||
|
|
d2fbc1b573 | ||
|
|
c5670a047f | ||
|
|
e9472889f2 | ||
|
|
9bec4b571c | ||
|
|
b56cc96e37 | ||
|
|
011164f16c | ||
|
|
b9a9471408 | ||
|
|
9abd1429a2 | ||
|
|
7f656973de | ||
|
|
5e0766fcb1 | ||
|
|
2dc5c0e161 | ||
|
|
d0730d05fd | ||
|
|
8fe3a439fc | ||
|
|
12c7113662 | ||
|
|
36be46b073 | ||
|
|
25ef579df5 | ||
|
|
cbbb07d389 | ||
|
|
96df84c9d8 | ||
|
|
17c4b5cbe7 | ||
|
|
cf642cd720 | ||
|
|
047f3a9b4c | ||
|
|
b0f85e0380 | ||
|
|
7aa518b43c | ||
|
|
d187d6aeeb | ||
|
|
289dce3876 | ||
|
|
930afea1a1 | ||
|
|
3801fa9134 | ||
|
|
ae211046b8 | ||
|
|
87ce9ff63a | ||
|
|
131c6321be | ||
|
|
6abcce498f | ||
|
|
9c38f5b327 | ||
|
|
14720d4cbf | ||
|
|
940ef2845d | ||
|
|
e3dbb7a6c2 | ||
|
|
8cba6ebb20 | ||
|
|
a1b322f5be | ||
|
|
07ff19c4b8 | ||
|
|
3a0af4e7e0 | ||
|
|
dbb3f74ab0 | ||
|
|
0eaac8fd7a | ||
|
|
06c71154b9 | ||
|
|
0e8f314dd6 | ||
|
|
f53ec8968b | ||
|
|
919d118f21 | ||
|
|
216b759df1 | ||
|
|
01450db71e | ||
|
|
ed987e1610 | ||
|
|
2773591e1f | ||
|
|
a995276d1e | ||
|
|
ffb6a8fa3f | ||
|
|
0966efb7f2 | ||
|
|
4a0a708092 | ||
|
|
6bf3b6f3e0 | ||
|
|
8f197fe266 | ||
|
|
e1a3f680f2 | ||
|
|
e89cca7e90 | ||
|
|
5bb2767d62 | ||
|
|
95f029e0e7 | ||
|
|
fb21e4d585 | ||
|
|
633805cec9 | ||
|
|
b8801d7d2a | ||
|
|
a84fac1b6a | ||
|
|
a9de4ceb30 | ||
|
|
827b55d60c | ||
|
|
0e1fe76b46 | ||
|
|
097c9e8e12 | ||
|
|
266356cb20 | ||
|
|
6dba39a804 | ||
|
|
3ddafa7aca | ||
|
|
9d8e232684 | ||
|
|
bf83c269c4 | ||
|
|
54e47c98cc | ||
|
|
118f2594ea | ||
|
|
ab4fcd6ac4 | ||
|
|
ca6f345429 | ||
|
|
79b8e1b4e4 | ||
|
|
cafa1ffa14 | ||
|
|
ea10df8a92 | ||
|
|
85abc42100 | ||
|
|
4747eb4386 | ||
|
|
ad9cc900b8 | ||
|
|
6cd93a7bb0 | ||
|
|
3338a02afb | ||
|
|
31cfe82224 | ||
|
|
70a191336b | ||
|
|
030477454c | ||
|
|
2a078d1572 | ||
|
|
3c1f96bc69 | ||
|
|
7d30bdc148 | ||
|
|
5d42961761 | ||
|
|
f20d5cd8d3 | ||
|
|
f5111c0f41 | ||
|
|
f5473236d0 | ||
|
|
d3cb31f1a7 | ||
|
|
d5f5cdd27a | ||
|
|
656dc8fefc | ||
|
|
a505cd9567 | ||
|
|
f2a860b01a | ||
|
|
1a5b93de9c | ||
|
|
0f165b33a6 | ||
|
|
4f53555f09 | ||
|
|
175be44823 | ||
|
|
20a6428290 | ||
|
|
4b4bea5f3b | ||
|
|
c82f860334 | ||
|
|
b2a56c047c | ||
|
|
bc6afc3933 | ||
|
|
280e4b65c3 | ||
|
|
c6f22f4d75 | ||
|
|
4bed8eb86f | ||
|
|
c482f18572 | ||
|
|
d7668acd9b | ||
|
|
05b978c568 | ||
|
|
62e5ab6990 | ||
|
|
12216f1463 | ||
|
|
cbfa13a8b4 | ||
|
|
03ec0cab1e | ||
|
|
d7940292d0 | ||
|
|
9139c5e9d6 | ||
|
|
65e62018e6 | ||
|
|
138a3673ce | ||
|
|
096f443b56 | ||
|
|
b37f30393d | ||
|
|
f095a05c42 | ||
|
|
3d15a73f1b | ||
|
|
bbd571e311 | ||
|
|
a7c554f033 | ||
|
|
25b4532ce3 | ||
|
|
a304f50a6b | ||
|
|
e75f476965 | ||
|
|
1c31460d27 | ||
|
|
19db468bf0 | ||
|
|
5fe05578c4 | ||
|
|
956f5a56cf | ||
|
|
a3f589d740 | ||
|
|
beef09bb6d | ||
|
|
ff0a246c28 | ||
|
|
f1459a1a52 | ||
|
|
f3501acb64 | ||
|
|
2238c98e95 | ||
|
|
9658d43f1f | ||
|
|
1748a0c3e5 | ||
|
|
4463d81758 | ||
|
|
74221a4ab5 | ||
|
|
0d998ed342 | ||
|
|
7d5a01756e | ||
|
|
d66313406b | ||
|
|
d96a267191 | ||
|
|
5467583bb3 | ||
|
|
9a8138d07b | ||
|
|
36c290ffea | ||
|
|
3413bf9f64 | ||
|
|
3c352a3545 | ||
|
|
56e4847b6b | ||
|
|
033b671d0b | ||
|
|
51f013851d | ||
|
|
dafa4ced27 | ||
|
|
05fe154749 | ||
|
|
5ddceb4660 | ||
|
|
341a1b195c | ||
|
|
29c3d1f9a6 | ||
|
|
734d4fb92b | ||
|
|
057a1cbab6 | ||
|
|
d44509b2cd | ||
|
|
58cf69795a | ||
|
|
6d39512576 | ||
|
|
ec4dde86f5 | ||
|
|
1c91fb9dd5 | ||
|
|
cbd650c5ef | ||
|
|
c5a769cb29 | ||
|
|
00a7277377 | ||
|
|
b8c32d41f5 | ||
|
|
49c9fc79c7 | ||
|
|
1284a7708e | ||
|
|
0dd8d15a9a | ||
|
|
90f59e954a | ||
|
|
03d7ec55a7 | ||
|
|
1929b69145 | ||
|
|
fbf194e4be | ||
|
|
a20927343a | ||
|
|
3b465dc09e | ||
|
|
fb8ca00ad1 | ||
|
|
dd7dddaa2b | ||
|
|
f41903c2a1 | ||
|
|
9984b5882d | ||
|
|
9ff20bee5a | ||
|
|
53caa11bc4 | ||
|
|
f6ac08567c | ||
|
|
040c6375c0 | ||
|
|
a03266aaad | ||
|
|
3479064348 | ||
|
|
b02d823b30 | ||
|
|
a204b6fb3f | ||
|
|
c2450843a5 | ||
|
|
00beb6170e | ||
|
|
9f1a300d2a |
@@ -16,7 +16,9 @@ Installation of the [npm package](https://npmjs.org/package/@vates/decorate-with
|
||||
|
||||
## Usage
|
||||
|
||||
For instance, allows using Lodash's functions as decorators:
|
||||
### `decorateWith(fn, ...args)`
|
||||
|
||||
Creates a new ([legacy](https://babeljs.io/docs/en/babel-plugin-syntax-decorators#legacy)) method decorator from a function decorator, for instance, allows using Lodash's functions as decorators:
|
||||
|
||||
```js
|
||||
import { decorateWith } from '@vates/decorate-with'
|
||||
@@ -29,6 +31,34 @@ class Foo {
|
||||
}
|
||||
```
|
||||
|
||||
### `decorateMethodsWith(class, map)`
|
||||
|
||||
Decorates a number of methods directly, without using the decorator syntax:
|
||||
|
||||
```js
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
|
||||
class Foo {
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
|
||||
baz() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
|
||||
decorateMethodsWith(Foo, {
|
||||
// without arguments
|
||||
bar: lodash.curry,
|
||||
|
||||
// with arguments
|
||||
baz: [lodash.debounce, 150],
|
||||
})
|
||||
```
|
||||
|
||||
The decorated class is returned, so you can export it directly.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
For instance, allows using Lodash's functions as decorators:
|
||||
### `decorateWith(fn, ...args)`
|
||||
|
||||
Creates a new ([legacy](https://babeljs.io/docs/en/babel-plugin-syntax-decorators#legacy)) method decorator from a function decorator, for instance, allows using Lodash's functions as decorators:
|
||||
|
||||
```js
|
||||
import { decorateWith } from '@vates/decorate-with'
|
||||
@@ -10,3 +12,31 @@ class Foo {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `decorateMethodsWith(class, map)`
|
||||
|
||||
Decorates a number of methods directly, without using the decorator syntax:
|
||||
|
||||
```js
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
|
||||
class Foo {
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
|
||||
baz() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
|
||||
decorateMethodsWith(Foo, {
|
||||
// without arguments
|
||||
bar: lodash.curry,
|
||||
|
||||
// with arguments
|
||||
baz: [lodash.debounce, 150],
|
||||
})
|
||||
```
|
||||
|
||||
The decorated class is returned, so you can export it directly.
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
exports.decorateWith = (fn, ...args) => (target, name, descriptor) => ({
|
||||
...descriptor,
|
||||
value: fn(descriptor.value, ...args),
|
||||
})
|
||||
exports.decorateWith = function decorateWith(fn, ...args) {
|
||||
return (target, name, descriptor) => ({
|
||||
...descriptor,
|
||||
value: fn(descriptor.value, ...args),
|
||||
})
|
||||
}
|
||||
|
||||
const { getOwnPropertyDescriptor, defineProperty } = Object
|
||||
|
||||
exports.decorateMethodsWith = function decorateMethodsWith(klass, map) {
|
||||
const { prototype } = klass
|
||||
for (const name of Object.keys(map)) {
|
||||
const descriptor = getOwnPropertyDescriptor(prototype, name)
|
||||
const { value } = descriptor
|
||||
|
||||
const decorator = map[name]
|
||||
descriptor.value = typeof decorator === 'function' ? decorator(value) : decorator[0](value, ...decorator.slice(1))
|
||||
defineProperty(prototype, name, descriptor)
|
||||
}
|
||||
return klass
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@vates/multi-key-map": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"ensure-array": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,6 @@ const wrapCall = (fn, arg, thisArg) => {
|
||||
* @returns {Promise<Item[]>}
|
||||
*/
|
||||
exports.asyncMap = function asyncMap(iterable, mapFn, thisArg = iterable) {
|
||||
let onError
|
||||
if (onError !== undefined) {
|
||||
const original = mapFn
|
||||
mapFn = async function () {
|
||||
try {
|
||||
return await original.apply(this, arguments)
|
||||
} catch (error) {
|
||||
return onError.call(this, error, ...arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.all(Array.from(iterable, mapFn, thisArg))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"version": "0.2.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
"node": ">=10"
|
||||
},
|
||||
"main": "dist/",
|
||||
"scripts": {
|
||||
@@ -30,9 +30,8 @@
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^0.0.1",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"core-js": "^3.6.4",
|
||||
"@vates/decorate-with": "^0.1.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// see https://github.com/babel/babel/issues/8450
|
||||
import 'core-js/features/symbol/async-iterator'
|
||||
|
||||
import assert from 'assert'
|
||||
import hash from 'object-hash'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
@@ -17,10 +17,10 @@ interface Record {
|
||||
}
|
||||
|
||||
export class AuditCore {
|
||||
constructor(storage: Storage) { }
|
||||
public add(subject: any, event: string, data: any): Promise<Record> { }
|
||||
public checkIntegrity(oldest: string, newest: string): Promise<number> { }
|
||||
public getFrom(newest?: string): AsyncIterator { }
|
||||
public deleteFrom(newest: string): Promise<void> { }
|
||||
public deleteRangeAndRewrite(newest: string, oldest: string): Promise<void> { }
|
||||
constructor(storage: Storage) {}
|
||||
public add(subject: any, event: string, data: any): Promise<Record> {}
|
||||
public checkIntegrity(oldest: string, newest: string): Promise<number> {}
|
||||
public getFrom(newest?: string): AsyncIterator {}
|
||||
public deleteFrom(newest: string): Promise<void> {}
|
||||
public deleteRangeAndRewrite(newest: string, oldest: string): Promise<void> {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ const { resolve } = require('path')
|
||||
const adapter = new RemoteAdapter(require('@xen-orchestra/fs').getHandler({ url: 'file://' }))
|
||||
|
||||
module.exports = async function main(args) {
|
||||
const { _, remove, merge } = getopts(args, {
|
||||
const { _, fix, remove, merge } = getopts(args, {
|
||||
alias: {
|
||||
fix: 'f',
|
||||
remove: 'r',
|
||||
merge: 'm',
|
||||
},
|
||||
boolean: ['merge', 'remove'],
|
||||
boolean: ['fix', 'merge', 'remove'],
|
||||
default: {
|
||||
merge: false,
|
||||
remove: false,
|
||||
@@ -25,7 +26,7 @@ module.exports = async function main(args) {
|
||||
await asyncMap(_, async vmDir => {
|
||||
vmDir = resolve(vmDir)
|
||||
try {
|
||||
await adapter.cleanVm(vmDir, { remove, merge, onLog: log => console.warn(log) })
|
||||
await adapter.cleanVm(vmDir, { fixMetadata: fix, remove, merge, onLog: (...args) => console.warn(...args) })
|
||||
} catch (error) {
|
||||
console.error('adapter.cleanVm', vmDir, error)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ require('./_composeCommands')({
|
||||
get main() {
|
||||
return require('./commands/clean-vms')
|
||||
},
|
||||
usage: `[--merge] [--remove] xo-vm-backups/*
|
||||
usage: `[--fix] [--merge] [--remove] xo-vm-backups/*
|
||||
|
||||
Detects and repair issues with VM backups.
|
||||
|
||||
Options:
|
||||
-f, --fix Fix metadata issues (like size)
|
||||
-m, --merge Merge (or continue merging) VHD files that are unused
|
||||
-r, --remove Remove unused, incomplete, orphan, or corrupted files
|
||||
`,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.11.0",
|
||||
"@xen-orchestra/fs": "^0.17.0",
|
||||
"@xen-orchestra/backups": "^0.13.0",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
|
||||
@@ -543,40 +543,6 @@ class RemoteAdapter {
|
||||
async readVmBackupMetadata(path) {
|
||||
return Object.defineProperty(JSON.parse(await this._handler.readFile(path)), '_filename', { value: path })
|
||||
}
|
||||
|
||||
async writeFullVmBackup({ jobId, mode, scheduleId, timestamp, vm, vmSnapshot, xva }, sizeContainer, stream) {
|
||||
const basename = formatFilenameDate(timestamp)
|
||||
|
||||
const dataBasename = basename + '.xva'
|
||||
const dataFilename = backupDir + '/' + dataBasename
|
||||
|
||||
const metadataFilename = `${backupDir}/${basename}.json`
|
||||
const metadata = {
|
||||
jobId: job.id,
|
||||
mode: job.mode,
|
||||
scheduleId,
|
||||
timestamp,
|
||||
version: '2.0.0',
|
||||
vm,
|
||||
vmSnapshot: this._backup.exportedVm,
|
||||
xva: './' + dataBasename,
|
||||
}
|
||||
|
||||
const { deleteFirst } = settings
|
||||
if (deleteFirst) {
|
||||
await deleteOldBackups()
|
||||
}
|
||||
|
||||
await adapter.outputStream(stream, dataFilename, {
|
||||
validator: tmpPath => {
|
||||
if (handler._getFilePath !== undefined) {
|
||||
return isValidXva(handler._getFilePath('/' + tmpPath))
|
||||
}
|
||||
},
|
||||
})
|
||||
metadata.size = sizeContainer.size
|
||||
await handler.outputFile(metadataFilename, JSON.stringify(metadata))
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(RemoteAdapter.prototype, {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
const CancelToken = require('promise-toolbox/CancelToken.js')
|
||||
const Zone = require('node-zone')
|
||||
|
||||
const logAfterEnd = function (log) {
|
||||
const error = new Error('task has already ended:' + this.id)
|
||||
error.result = log.result
|
||||
error.log = log
|
||||
throw error
|
||||
const logAfterEnd = () => {
|
||||
throw new Error('task has already ended')
|
||||
}
|
||||
|
||||
const noop = Function.prototype
|
||||
@@ -47,19 +44,11 @@ class Task {
|
||||
}
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.#id
|
||||
}
|
||||
|
||||
#cancelToken
|
||||
#id = Math.random().toString(36).slice(2)
|
||||
#onLog
|
||||
#zone
|
||||
|
||||
get id() {
|
||||
return this.#id
|
||||
}
|
||||
|
||||
constructor({ name, data, onLog }) {
|
||||
let parentCancelToken, parentId
|
||||
if (onLog === undefined) {
|
||||
@@ -111,8 +100,6 @@ class Task {
|
||||
run(fn, last = false) {
|
||||
return this.#zone.run(() => {
|
||||
try {
|
||||
this.#cancelToken.throwIfRequested()
|
||||
|
||||
const result = fn()
|
||||
let then
|
||||
if (result != null && typeof (then = result.then) === 'function') {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const assert = require('assert')
|
||||
// const asyncFn = require('promise-toolbox/asyncFn')
|
||||
const findLast = require('lodash/findLast.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const keyBy = require('lodash/keyBy.js')
|
||||
@@ -104,9 +103,21 @@ exports.VmBackup = class VmBackup {
|
||||
// calls fn for each function, warns of any errors, and throws only if there are no writers left
|
||||
async _callWriters(fn, warnMessage, parallel = true) {
|
||||
const writers = this._writers
|
||||
if (writers.size === 0) {
|
||||
const n = writers.size
|
||||
if (n === 0) {
|
||||
return
|
||||
}
|
||||
if (n === 1) {
|
||||
const [writer] = writers
|
||||
try {
|
||||
await fn(writer)
|
||||
} catch (error) {
|
||||
writers.delete(writer)
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
|
||||
try {
|
||||
await fn(writer)
|
||||
@@ -144,7 +155,6 @@ exports.VmBackup = class VmBackup {
|
||||
|
||||
const doSnapshot =
|
||||
this._isDelta || (!settings.offlineBackup && vm.power_state === 'Running') || settings.snapshotRetention !== 0
|
||||
console.log({ doSnapshot })
|
||||
if (doSnapshot) {
|
||||
await Task.run({ name: 'snapshot' }, async () => {
|
||||
if (!settings.bypassVdiChainsCheck) {
|
||||
@@ -183,7 +193,6 @@ exports.VmBackup = class VmBackup {
|
||||
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
|
||||
|
||||
const deltaExport = await exportDeltaVm(exportedVm, baseVm, {
|
||||
cancelToken: Task.cancelToken,
|
||||
fullVdisRequired,
|
||||
})
|
||||
const sizeContainers = mapValues(deltaExport.streams, stream => watchStreamSize(stream))
|
||||
@@ -229,7 +238,6 @@ exports.VmBackup = class VmBackup {
|
||||
async _copyFull() {
|
||||
const { compression } = this.job
|
||||
const stream = await this._xapi.VM_export(this.exportedVm.$ref, {
|
||||
cancelToken: Task.cancelToken,
|
||||
compress: Boolean(compression) && (compression === 'native' ? 'gzip' : 'zstd'),
|
||||
useSnapshot: false,
|
||||
})
|
||||
@@ -334,22 +342,10 @@ exports.VmBackup = class VmBackup {
|
||||
|
||||
this._baseVm = baseVm
|
||||
this._fullVdisRequired = fullVdisRequired
|
||||
|
||||
Task.info('base data', {
|
||||
vm: baseVm.uuid,
|
||||
fullVdisRequired: Array.from(fullVdisRequired),
|
||||
})
|
||||
}
|
||||
|
||||
run = defer(this.run)
|
||||
async run($defer) {
|
||||
this.exportedVm = this.vm
|
||||
this.timestamp = Date.now()
|
||||
|
||||
const doSnapshot = this._isDelta || vm.power_state === 'Running' || settings.snapshotRetention !== 0
|
||||
if (!this._isDelta) {
|
||||
}
|
||||
|
||||
const settings = this._settings
|
||||
assert(
|
||||
!settings.offlineBackup || settings.snapshotRetention === 0,
|
||||
@@ -396,6 +392,3 @@ exports.VmBackup = class VmBackup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// const { prototype } = exports.VmBackup
|
||||
// prototype.run = asyncFn.cancelable(prototype.run)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const assert = require('assert')
|
||||
const sum = require('lodash/sum')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { default: Vhd, mergeVhd } = require('vhd-lib')
|
||||
const { dirname, resolve } = require('path')
|
||||
@@ -113,7 +114,7 @@ const listVhds = async (handler, vmDir) => {
|
||||
return { vhds, interruptedVhds }
|
||||
}
|
||||
|
||||
exports.cleanVm = async function cleanVm(vmDir, { remove, merge, onLog = noop }) {
|
||||
exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, onLog = noop }) {
|
||||
const handler = this._handler
|
||||
|
||||
const vhds = new Set()
|
||||
@@ -219,11 +220,16 @@ exports.cleanVm = async function cleanVm(vmDir, { remove, merge, onLog = noop })
|
||||
await asyncMap(jsons, async json => {
|
||||
const metadata = JSON.parse(await handler.readFile(json))
|
||||
const { mode } = metadata
|
||||
let size
|
||||
if (mode === 'full') {
|
||||
const linkedXva = resolve('/', vmDir, metadata.xva)
|
||||
|
||||
if (xvas.has(linkedXva)) {
|
||||
unusedXvas.delete(linkedXva)
|
||||
|
||||
size = await handler.getSize(linkedXva).catch(error => {
|
||||
onLog(`failed to get size of ${json}`, { error })
|
||||
})
|
||||
} else {
|
||||
onLog(`the XVA linked to the metadata ${json} is missing`)
|
||||
if (remove) {
|
||||
@@ -241,6 +247,10 @@ exports.cleanVm = async function cleanVm(vmDir, { remove, merge, onLog = noop })
|
||||
// possible (existing disks) even if one disk is missing
|
||||
if (linkedVhds.every(_ => vhds.has(_))) {
|
||||
linkedVhds.forEach(_ => unusedVhds.delete(_))
|
||||
|
||||
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`)
|
||||
if (remove) {
|
||||
@@ -249,6 +259,22 @@ exports.cleanVm = async function cleanVm(vmDir, { remove, merge, onLog = noop })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadataSize = metadata.size
|
||||
if (size !== undefined && metadataSize !== size) {
|
||||
onLog(`incorrect size in metadata: ${metadataSize ?? 'none'} instead of ${size}`)
|
||||
|
||||
// don't update if the the stored size is greater than found files,
|
||||
// it can indicates a problem
|
||||
if (fixMetadata && (metadataSize === undefined || metadataSize < size)) {
|
||||
try {
|
||||
metadata.size = size
|
||||
await handler.writeFile(json, JSON.stringify(metadata), { flags: 'w' })
|
||||
} catch (error) {
|
||||
onLog(`failed to update size in backup metadata ${json}`, { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: parallelize by vm/job/vdi
|
||||
|
||||
@@ -202,6 +202,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
|
||||
blocked_operations: {
|
||||
...vmRecord.blocked_operations,
|
||||
start: 'Importing…',
|
||||
start_on: 'Importing…',
|
||||
},
|
||||
ha_always_run: false,
|
||||
is_a_template: false,
|
||||
@@ -305,9 +306,6 @@ exports.importDeltaVm = defer(async function importDeltaVm(
|
||||
}
|
||||
}),
|
||||
|
||||
// Wait for VDI export tasks (if any) termination.
|
||||
Promise.all(Object.values(streams).map(stream => stream.task)),
|
||||
|
||||
// Create VIFs.
|
||||
asyncMap(Object.values(deltaVm.vifs), vif => {
|
||||
let network = vif.$network$uuid && xapi.getObjectByUuid(vif.$network$uuid, undefined)
|
||||
|
||||
@@ -7,23 +7,25 @@ const { execFile } = require('child_process')
|
||||
const parse = createParser({
|
||||
keyTransform: key => key.slice(5).toLowerCase(),
|
||||
})
|
||||
const makeFunction = command => async (fields, ...args) => {
|
||||
const info = await fromCallback(execFile, command, [
|
||||
'--noheading',
|
||||
'--nosuffix',
|
||||
'--nameprefixes',
|
||||
'--unbuffered',
|
||||
'--units',
|
||||
'b',
|
||||
'-o',
|
||||
String(fields),
|
||||
...args,
|
||||
])
|
||||
return info
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map(Array.isArray(fields) ? parse : line => parse(line)[fields])
|
||||
}
|
||||
const makeFunction =
|
||||
command =>
|
||||
async (fields, ...args) => {
|
||||
const info = await fromCallback(execFile, command, [
|
||||
'--noheading',
|
||||
'--nosuffix',
|
||||
'--nameprefixes',
|
||||
'--unbuffered',
|
||||
'--units',
|
||||
'b',
|
||||
'-o',
|
||||
String(fields),
|
||||
...args,
|
||||
])
|
||||
return info
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map(Array.isArray(fields) ? parse : line => parse(line)[fields])
|
||||
}
|
||||
|
||||
exports.lvs = makeFunction('lvs')
|
||||
exports.pvs = makeFunction('pvs')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.11.0",
|
||||
"version": "0.13.0",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
@@ -20,25 +20,25 @@
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^0.17.0",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"@xen-orchestra/fs": "^0.18.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^3.6.0",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"end-of-stream": "^1.4.4",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash": "^4.17.20",
|
||||
"node-zone": "^0.4.0",
|
||||
"parse-pairs": "^1.1.0",
|
||||
"pump": "^3.0.0",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"vhd-lib": "^1.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"vhd-lib": "^1.2.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^0.6.2"
|
||||
"@xen-orchestra/xapi": "^0.7.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -128,7 +128,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
})
|
||||
}
|
||||
|
||||
async transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
async _transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
const adapter = this._adapter
|
||||
const backup = this._backup
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
|
||||
return asyncMapSettled(this._oldEntries, vm => vm.$destroy())
|
||||
}
|
||||
|
||||
async transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
async _transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
const sr = this._sr
|
||||
const { job, scheduleId, vm } = this._backup
|
||||
|
||||
@@ -106,9 +106,11 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
|
||||
targetVm.ha_restart_priority !== '' &&
|
||||
Promise.all([targetVm.set_ha_restart_priority(''), targetVm.add_tags('HA disabled')]),
|
||||
targetVm.set_name_label(`${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`),
|
||||
targetVm.update_blocked_operations(
|
||||
'start',
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
asyncMap(['start', 'start_on'], op =>
|
||||
targetVm.update_blocked_operations(
|
||||
op,
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
)
|
||||
),
|
||||
targetVm.update_other_config({
|
||||
'xo:backup:sr': srUuid,
|
||||
|
||||
@@ -25,7 +25,7 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
|
||||
)
|
||||
}
|
||||
|
||||
async run({ timestamp, sizeContainer, stream }) {
|
||||
async _run({ timestamp, sizeContainer, stream }) {
|
||||
const backup = this._backup
|
||||
const settings = this._settings
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const { asyncMapSettled } = require('@xen-orchestra/async-map')
|
||||
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
|
||||
const { formatDateTime } = require('@xen-orchestra/xapi')
|
||||
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
@@ -29,7 +29,7 @@ exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplica
|
||||
)
|
||||
}
|
||||
|
||||
async run({ timestamp, sizeContainer, stream }) {
|
||||
async _run({ timestamp, sizeContainer, stream }) {
|
||||
const sr = this._sr
|
||||
const settings = this._settings
|
||||
const { job, scheduleId, vm } = this._backup
|
||||
@@ -64,9 +64,11 @@ exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplica
|
||||
const targetVm = await xapi.getRecord('VM', targetVmRef)
|
||||
|
||||
await Promise.all([
|
||||
targetVm.update_blocked_operations(
|
||||
'start',
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
asyncMap(['start', 'start_on'], op =>
|
||||
targetVm.update_blocked_operations(
|
||||
op,
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
)
|
||||
),
|
||||
targetVm.update_other_config({
|
||||
'xo:backup:sr': srUuid,
|
||||
|
||||
@@ -13,7 +13,14 @@ exports.AbstractDeltaWriter = class AbstractDeltaWriter extends AbstractWriter {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
throw new Error('Not implemented')
|
||||
async transfer({ timestamp, deltaExport, sizeContainers }) {
|
||||
try {
|
||||
return await this._transfer({ timestamp, deltaExport, sizeContainers })
|
||||
} finally {
|
||||
// ensure all streams are properly closed
|
||||
for (const stream of Object.values(deltaExport.streams)) {
|
||||
stream.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const { AbstractWriter } = require('./_AbstractWriter.js')
|
||||
|
||||
exports.AbstractFullWriter = class AbstractFullWriter extends AbstractWriter {
|
||||
run({ timestamp, sizeContainer, stream }) {
|
||||
throw new Error('Not implemented')
|
||||
async run({ timestamp, sizeContainer, stream }) {
|
||||
try {
|
||||
return await this._run({ timestamp, sizeContainer, stream })
|
||||
} finally {
|
||||
// ensure stream is properly closed
|
||||
stream.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
|
||||
|
||||
_cleanVm(options) {
|
||||
return this._adapter
|
||||
.cleanVm(getVmBackupDir(this._backup.vm.uuid), { ...options, onLog: warn, lock: false })
|
||||
.cleanVm(getVmBackupDir(this._backup.vm.uuid), { ...options, fixMetadata: true, onLog: warn, lock: false })
|
||||
.catch(warn)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,11 @@ ${cliName} v${pkg.version}
|
||||
'xo:backup:sr': tgtSr.uuid,
|
||||
'xo:copy_of': srcSnapshotUuid,
|
||||
}),
|
||||
tgtVm.update_blocked_operations('start', 'Start operation for this vm is blocked, clone it if you want to use it.'),
|
||||
Promise.all(
|
||||
['start', 'start_on'].map(op =>
|
||||
tgtVm.update_blocked_operations(op, 'Start operation for this vm is blocked, clone it if you want to use it.')
|
||||
)
|
||||
),
|
||||
Promise.all(
|
||||
userDevices.map(userDevice => {
|
||||
const srcDisk = srcDisks[userDevice]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^0.32.0"
|
||||
"xen-api": "^0.34.3"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/defined",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"license": "ISC",
|
||||
"description": "Utilities to help handling (possibly) undefined values",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/defined",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/emit-async",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"license": "ISC",
|
||||
"description": "Emit an event for async listeners to settle",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/emit-async",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "0.17.0",
|
||||
"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",
|
||||
@@ -23,9 +23,9 @@
|
||||
"@vates/coalesce-calls": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"aws-sdk": "^2.686.0",
|
||||
"decorator-synchronized": "^0.5.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"execa": "^5.0.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash": "^4.17.4",
|
||||
@@ -45,7 +45,7 @@
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
"babel-plugin-lodash": "^3.3.2",
|
||||
"cross-env": "^7.0.2",
|
||||
"dotenv": "^8.0.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy'
|
||||
import getStream from 'get-stream'
|
||||
import path, { basename } from 'path'
|
||||
import synchronized from 'decorator-synchronized'
|
||||
import { coalesceCalls } from '@vates/coalesce-calls'
|
||||
import { fromCallback, fromEvent, ignoreErrors, timeout } from 'promise-toolbox'
|
||||
import { limitConcurrency } from 'limit-concurrency-decorator'
|
||||
import { parse } from 'xo-remote-parser'
|
||||
import { pipeline } from 'stream'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { synchronized } from 'decorator-synchronized'
|
||||
|
||||
import normalizePath from './_normalizePath'
|
||||
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
|
||||
|
||||
@@ -27,3 +27,12 @@ export const getHandler = (remote, ...rest) => {
|
||||
}
|
||||
return new Handler(remote, ...rest)
|
||||
}
|
||||
|
||||
export const getSyncedHandler = async (...opts) => {
|
||||
const handler = getHandler(...opts)
|
||||
await handler.sync()
|
||||
return {
|
||||
dispose: () => handler.forget(),
|
||||
value: handler,
|
||||
}
|
||||
}
|
||||
|
||||
6
@xen-orchestra/lite/.babelrc.js
Normal file
6
@xen-orchestra/lite/.babelrc.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'), {
|
||||
'@babel/preset-env': {
|
||||
exclude: ['@babel/plugin-proposal-dynamic-import', '@babel/plugin-transform-regenerator'],
|
||||
modules: false,
|
||||
},
|
||||
})
|
||||
32
@xen-orchestra/lite/.eslintrc.js
Normal file
32
@xen-orchestra/lite/.eslintrc.js
Normal file
@@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
|
||||
sourceType: 'module', // Allows for the use of imports
|
||||
ecmaFeatures: {
|
||||
jsx: true, // Allows for the parsing of JSX
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: '17',
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react
|
||||
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin
|
||||
],
|
||||
rules: {
|
||||
'eslint-comments/disable-enable-pair': 'off',
|
||||
// Necessary to pass empty Effects/State to Reaclette
|
||||
'@typescript-eslint/no-empty-interface': 'off',
|
||||
// https://github.com/typescript-eslint/typescript-eslint/issues/1071
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
// https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
|
||||
'@typescript-eslint/no-use-before-define': ['error'],
|
||||
'no-use-before-define': 'off',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
}
|
||||
24
@xen-orchestra/lite/.gitignore
vendored
Normal file
24
@xen-orchestra/lite/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.eslintcache
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
71
@xen-orchestra/lite/package.json
Normal file
71
@xen-orchestra/lite/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "xo-lite",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.1",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.0",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.13.0",
|
||||
"@babel/preset-env": "^7.13.5",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/lab": "^5.0.0-alpha.48",
|
||||
"@mui/material": "^5.0.1",
|
||||
"@novnc/novnc": "^1.2.0",
|
||||
"@types/immutable": "^3.8.7",
|
||||
"@types/js-cookie": "^2.2.6",
|
||||
"@types/lodash": "^4.14.175",
|
||||
"@types/node": "^14.14.21",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-intl": "^3.0.0",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-syntax-highlighter": "^13.5.0",
|
||||
"@types/styled-components": "^5.1.9",
|
||||
"@types/webpack-env": "^1.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.16.1",
|
||||
"@typescript-eslint/parser": "^4.16.1",
|
||||
"babel-loader": "^8.2.2",
|
||||
"classnames": "^2.3.1",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^10.2.0",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"html-webpack-plugin": "^5.2.0",
|
||||
"human-format": "^0.11.0",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"iterable-backoff": "^0.1.0",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-polyfill-webpack-plugin": "^1.0.3",
|
||||
"process": "^0.11.10",
|
||||
"promise-toolbox": "^0.16.0",
|
||||
"reaclette": "^0.10.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "^5.10.16",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
"styled-components": "^5.2.1",
|
||||
"typescript": "^4.3.1",
|
||||
"webpack": "^5.24.2",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"xen-api": "^0.34.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"styled-components": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"start": "cross-env NODE_ENV=development webpack serve",
|
||||
"start:open": "npm run start -- --open"
|
||||
},
|
||||
"browserslist": "> 0.5%, last 2 versions, Firefox ESR, not dead"
|
||||
}
|
||||
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
13
@xen-orchestra/lite/public/index.html
Normal file
13
@xen-orchestra/lite/public/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Xen Orchestra Lite" />
|
||||
<title>XO Lite</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
@xen-orchestra/lite/public/logo.png
Normal file
BIN
@xen-orchestra/lite/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
90
@xen-orchestra/lite/src/App/Infrastructure.tsx
Normal file
90
@xen-orchestra/lite/src/App/Infrastructure.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Switch, Route, RouteComponentProps } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import Pool from './Pool'
|
||||
import TabConsole from './TabConsole'
|
||||
import TreeView from './TreeView'
|
||||
|
||||
import { ObjectsByType } from '../libs/xapi'
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
`
|
||||
const LeftPanel = styled.div`
|
||||
background: #f5f5f5;
|
||||
min-width: 15em;
|
||||
overflow-y: scroll;
|
||||
width: 20%;
|
||||
`
|
||||
// FIXME: temporary work-around while investigating flew-grow issue:
|
||||
// `overflow: hidden` forces the console to shrink to the max available width
|
||||
// even when the tree component takes more than 20% of the width due to
|
||||
// `min-width`
|
||||
const MainPanel = styled.div`
|
||||
overflow: hidden;
|
||||
width: 80%;
|
||||
`
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
pool?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
selectedObject?: string
|
||||
selectedVm?: string
|
||||
}
|
||||
|
||||
// For compatibility with 'withRouter'
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
initialize: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const selectedNodesToArray = (nodes: Array<string> | string | undefined) =>
|
||||
nodes === undefined ? undefined : Array.isArray(nodes) ? nodes : [nodes]
|
||||
|
||||
const Infrastructure = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: props => ({
|
||||
selectedVm: props.location.pathname.split('/')[3],
|
||||
}),
|
||||
computed: {
|
||||
selectedObject: (state, props) =>
|
||||
props.location.pathname.startsWith('/infrastructure/pool') ? state.pool : state.selectedVm,
|
||||
},
|
||||
},
|
||||
({ state: { pool, selectedObject } }) => (
|
||||
<Container>
|
||||
<LeftPanel>
|
||||
<TreeView defaultSelectedNodes={selectedNodesToArray(selectedObject)} />
|
||||
</LeftPanel>
|
||||
<MainPanel>
|
||||
<Switch>
|
||||
<Route exact path={`/infrastructure/pool/${pool}/dashboard`}>
|
||||
<Pool id={pool} />
|
||||
</Route>
|
||||
<Route
|
||||
path='/infrastructure/vms/:id/console'
|
||||
render={({
|
||||
match: {
|
||||
params: { id },
|
||||
},
|
||||
}) => <TabConsole key={id} vmId={id} />}
|
||||
/>
|
||||
</Switch>
|
||||
</MainPanel>
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default withRouter(Infrastructure)
|
||||
120
@xen-orchestra/lite/src/App/Pool/dashboard/ObjectStatus.tsx
Normal file
120
@xen-orchestra/lite/src/App/Pool/dashboard/ObjectStatus.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import Grid from '@mui/material/Grid'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon from '../../../components/Icon'
|
||||
import IntlMessage from '../../../components/IntlMessage'
|
||||
import ProgressCircle from '../../../components/ProgressCircle'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
nActive?: number
|
||||
nTotal?: number
|
||||
type: 'host' | 'VM'
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
nInactive?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CAPTION_STYLE = { textTransform: 'uppercase', mt: 2 }
|
||||
const TYPOGRAPHY_SX = { mb: 2 }
|
||||
|
||||
const ObjectStatusContainer = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
align-content: space-between;
|
||||
margin-bottom: 1em;
|
||||
`
|
||||
|
||||
const CircularProgressPanel = styled.div`
|
||||
margin-left: 2em;
|
||||
`
|
||||
|
||||
const GridPanel = styled.div`
|
||||
margin-left: 2em;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
// TODO: Add a loading page when data is not loaded as it is in the model(figma).
|
||||
// FIXME: replace the hard-coded colors with the theme colors.
|
||||
const ObjectStatus = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
nInactive: (state, { nTotal = 0, nActive = 0 }) => nTotal - nActive,
|
||||
},
|
||||
},
|
||||
({ state: { nInactive }, nActive = 0, nTotal = 0, type }) => {
|
||||
if (nTotal === 0) {
|
||||
return (
|
||||
<span>
|
||||
<IntlMessage id={type === 'VM' ? 'noVms' : 'noHosts'} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectStatusContainer>
|
||||
<CircularProgressPanel>
|
||||
<ProgressCircle max={nTotal} value={nActive} />
|
||||
</CircularProgressPanel>
|
||||
<GridPanel>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Typography sx={TYPOGRAPHY_SX} variant='h5' component='div'>
|
||||
<IntlMessage id={type === 'VM' ? 'vms' : 'hosts'} />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Icon icon='circle' htmlColor='#00BA34' />
|
||||
|
||||
<Typography variant='body2' component='span'>
|
||||
<IntlMessage id='active' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='body2' component='div'>
|
||||
{nActive}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Icon icon='circle' htmlColor='#E8E8E8' />
|
||||
|
||||
<Typography variant='body2' component='span'>
|
||||
<IntlMessage id='inactive' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='body2' component='div'>
|
||||
{nInactive}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
|
||||
<IntlMessage id='total' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
|
||||
{nTotal}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</GridPanel>
|
||||
</ObjectStatusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default ObjectStatus
|
||||
79
@xen-orchestra/lite/src/App/Pool/dashboard/index.tsx
Normal file
79
@xen-orchestra/lite/src/App/Pool/dashboard/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import Divider from '@mui/material/Divider'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import ObjectStatus from './ObjectStatus'
|
||||
|
||||
import IntlMessage from '../../../components/IntlMessage'
|
||||
import { Host, ObjectsByType, Vm } from '../../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType?: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {
|
||||
hosts?: Map<string, Host>
|
||||
nRunningHosts?: number
|
||||
nRunningVms?: number
|
||||
vms?: Map<string, Vm>
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const DEFAULT_STYLE = { m: 2 }
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
align-content: space-between;
|
||||
gap: 1.25em;
|
||||
background: '#E8E8E8';
|
||||
`
|
||||
|
||||
const Panel = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0px 1px 1px 0px #00000014, 0px 2px 1px 0px #0000000f, 0px 1px 3px 0px #0000001a;
|
||||
margin: 0.5em;
|
||||
`
|
||||
const getHostPowerState = (host: Host) => {
|
||||
const { $metrics } = host
|
||||
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
|
||||
}
|
||||
|
||||
const Dashboard = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
hosts: state => state.objectsByType?.get('host'),
|
||||
vms: state =>
|
||||
state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
|
||||
nRunningHosts: state => (state.hosts?.filter((host: Host) => getHostPowerState(host) === 'Running')).size,
|
||||
nRunningVms: state => (state.vms?.filter((vm: Vm) => vm.power_state === 'Running')).size,
|
||||
},
|
||||
},
|
||||
({ state: { hosts, nRunningHosts, nRunningVms, vms } }) => (
|
||||
<Container>
|
||||
<Panel>
|
||||
<Typography variant='h4' component='div' sx={DEFAULT_STYLE}>
|
||||
<IntlMessage id='status' />
|
||||
</Typography>
|
||||
<ObjectStatus nActive={nRunningHosts} nTotal={hosts?.size} type='host' />
|
||||
<Divider variant='middle' sx={DEFAULT_STYLE} />
|
||||
<ObjectStatus nActive={nRunningVms} nTotal={vms?.size} type='VM' />
|
||||
</Panel>
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default Dashboard
|
||||
46
@xen-orchestra/lite/src/App/Pool/index.tsx
Normal file
46
@xen-orchestra/lite/src/App/Pool/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Dashboard from './dashboard'
|
||||
import Icon from '../../components/Icon'
|
||||
import PanelHeader from '../../components/PanelHeader'
|
||||
import { ObjectsByType, Pool as PoolType } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
pool?: PoolType
|
||||
}
|
||||
|
||||
// TODO: add tabs when https://github.com/vatesfr/xen-orchestra/pull/6096 is merged.
|
||||
const Pool = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
pool: (state, props) => state.objectsByType?.get('pool')?.get(props.id),
|
||||
},
|
||||
},
|
||||
({ state: { pool } }) => (
|
||||
<>
|
||||
<PanelHeader>
|
||||
<span>
|
||||
<Icon icon='warehouse' color='primary' /> {pool?.name_label}
|
||||
</span>
|
||||
</PanelHeader>
|
||||
<Dashboard />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
export default Pool
|
||||
65
@xen-orchestra/lite/src/App/PoolTab/PoolNetworks.tsx
Normal file
65
@xen-orchestra/lite/src/App/PoolTab/PoolNetworks.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import Table, { Column } from '../../components/Table'
|
||||
import { ObjectsByType, Pif } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
objectsFetched: boolean
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
poolId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
managementPifs?: Pif[]
|
||||
pifs?: Map<string, Pif>
|
||||
}
|
||||
|
||||
const COLUMNS: Column<Pif>[] = [
|
||||
{
|
||||
header: <IntlMessage id='device' />,
|
||||
render: pif => pif.device,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='dns' />,
|
||||
render: pif => pif.DNS,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='gateway' />,
|
||||
render: pif => pif.gateway,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='ip' />,
|
||||
render: pif => pif.IP,
|
||||
},
|
||||
]
|
||||
|
||||
const PoolNetworks = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
managementPifs: state =>
|
||||
state.pifs
|
||||
?.filter(pif => pif.management)
|
||||
.map(pif => ({ ...pif, id: pif.$id }))
|
||||
.valueSeq()
|
||||
.toArray(),
|
||||
pifs: state => state.objectsByType.get('PIF'),
|
||||
},
|
||||
},
|
||||
({ state }) => (
|
||||
<Table collection={state.managementPifs} columns={COLUMNS} placeholder={<IntlMessage id='noManagementPifs' />} />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolNetworks
|
||||
89
@xen-orchestra/lite/src/App/PoolTab/PoolUpdates.tsx
Normal file
89
@xen-orchestra/lite/src/App/PoolTab/PoolUpdates.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react'
|
||||
import humanFormat from 'human-format'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import Table, { Column } from '../../components/Table'
|
||||
import XapiConnection, { ObjectsByType, PoolUpdate } from '../../libs/xapi'
|
||||
|
||||
const COLUMN: Column<PoolUpdate>[] = [
|
||||
{
|
||||
header: <IntlMessage id='name' />,
|
||||
render: update => update.name,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='description' />,
|
||||
render: update => update.description,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='version' />,
|
||||
render: update => update.version,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='release' />,
|
||||
render: update => update.release,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='size' />,
|
||||
render: update => humanFormat.bytes(update.size),
|
||||
},
|
||||
]
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
objectsFetched: boolean
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
hostRef: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
availableUpdates?: PoolUpdate[] | JSX.Element
|
||||
}
|
||||
|
||||
const PoolUpdates = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
availableUpdates: async function (state, { hostRef }) {
|
||||
try {
|
||||
const stringifiedPoolUpdates = (await state.xapi.call(
|
||||
'host.call_plugin',
|
||||
hostRef,
|
||||
'updater.py',
|
||||
'check_update',
|
||||
{}
|
||||
)) as string
|
||||
return JSON.parse(stringifiedPoolUpdates)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return <IntlMessage id='errorOccurred' />
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
({ state: { availableUpdates } }) =>
|
||||
availableUpdates !== undefined ? (
|
||||
Array.isArray(availableUpdates) ? (
|
||||
<>
|
||||
{availableUpdates.length !== 0 && (
|
||||
<IntlMessage id='availableUpdates' values={{ nUpdates: availableUpdates.length }} />
|
||||
)}
|
||||
<Table collection={availableUpdates} columns={COLUMN} placeholder={<IntlMessage id='noUpdatesAvailable' />} />
|
||||
</>
|
||||
) : (
|
||||
availableUpdates
|
||||
)
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolUpdates
|
||||
53
@xen-orchestra/lite/src/App/PoolTab/index.tsx
Normal file
53
@xen-orchestra/lite/src/App/PoolTab/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import PoolNetworks from './PoolNetworks'
|
||||
import PoolUpdates from './PoolUpdates'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import { Host, ObjectsByType, Pool } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsFetched: boolean
|
||||
}
|
||||
|
||||
interface State {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
hosts?: Map<string, Host>
|
||||
pool?: Pool
|
||||
}
|
||||
|
||||
const PoolTab = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
hosts: state => (state.objectsFetched ? state.objectsByType?.get('host') : undefined),
|
||||
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.first() : undefined),
|
||||
},
|
||||
},
|
||||
({ state }) =>
|
||||
state.pool !== undefined ? (
|
||||
<>
|
||||
<PoolNetworks poolId={state.pool.$id} />
|
||||
{state.hosts?.valueSeq().map(host => (
|
||||
<div key={host.$id}>
|
||||
<p>{host.name_label}</p>
|
||||
<PoolUpdates hostRef={host.$ref} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolTab
|
||||
110
@xen-orchestra/lite/src/App/Signin/index.tsx
Normal file
110
@xen-orchestra/lite/src/App/Signin/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Button from '../../components/Button'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Input from '../../components/Input'
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
|
||||
interface ParentState {
|
||||
error: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
password: string
|
||||
rememberMe: boolean
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {
|
||||
connectToXapi: (password: string, rememberMe: boolean) => void
|
||||
}
|
||||
|
||||
interface Effects {
|
||||
setRememberMe: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
setPassword: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
submit: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
`
|
||||
|
||||
const Form = styled.form`
|
||||
width: 20em;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const Fieldset = styled.fieldset`
|
||||
border: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
`
|
||||
|
||||
const RememberMe = styled(Fieldset)`
|
||||
text-align: start;
|
||||
vertical-align: baseline;
|
||||
`
|
||||
|
||||
const Error = styled.p`
|
||||
color: #a33;
|
||||
`
|
||||
|
||||
const Signin = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
}),
|
||||
effects: {
|
||||
setRememberMe: function ({ currentTarget: { checked: rememberMe } }) {
|
||||
this.state.rememberMe = rememberMe
|
||||
},
|
||||
setPassword: function ({ currentTarget: { value: password } }) {
|
||||
this.state.password = password
|
||||
},
|
||||
submit: function () {
|
||||
this.effects.connectToXapi(this.state.password, this.state.rememberMe)
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<Wrapper>
|
||||
<Form onSubmit={e => e.preventDefault()}>
|
||||
<img src='logo.png' />
|
||||
<h1>Xen Orchestra Lite</h1>
|
||||
<Fieldset>
|
||||
<Input disabled label={<IntlMessage id='login' />} value='root' />
|
||||
</Fieldset>
|
||||
<Fieldset>
|
||||
<Input
|
||||
autoFocus
|
||||
label={<IntlMessage id='password' />}
|
||||
onChange={effects.setPassword}
|
||||
type='password'
|
||||
value={state.password}
|
||||
/>
|
||||
</Fieldset>
|
||||
<RememberMe>
|
||||
<label>
|
||||
<Checkbox onChange={effects.setRememberMe} checked={state.rememberMe} />
|
||||
|
||||
<IntlMessage id='rememberMe' />
|
||||
</label>
|
||||
</RememberMe>
|
||||
<Error>{state.error}</Error>
|
||||
<Button type='submit' onClick={effects.submit}>
|
||||
<IntlMessage id='connect' />
|
||||
</Button>
|
||||
</Form>
|
||||
</Wrapper>
|
||||
)
|
||||
)
|
||||
|
||||
export default Signin
|
||||
300
@xen-orchestra/lite/src/App/StyleGuide/index.tsx
Normal file
300
@xen-orchestra/lite/src/App/StyleGuide/index.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
// https://mui.com/components/material-icons/
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { materialDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { toNumber } from 'lodash'
|
||||
import { SelectChangeEvent } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import ActionButton from '../../components/ActionButton'
|
||||
import Button from '../../components/Button'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Icon from '../../components/Icon'
|
||||
import Input from '../../components/Input'
|
||||
import ProgressCircle from '../../components/ProgressCircle'
|
||||
import Select from '../../components/Select'
|
||||
import Tabs from '../../components/Tabs'
|
||||
import { alert, confirm } from '../../components/Modal'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
progressBarValue: number
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
onChangeProgressBarValue: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onChangeSelect: (e: SelectChangeEvent<unknown>) => void
|
||||
sayHello: () => void
|
||||
sendPromise: (data: Record<string, unknown>) => Promise<void>
|
||||
showAlertModal: () => void
|
||||
showConfirmModal: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Page = styled.div`
|
||||
margin: 30px;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
`
|
||||
|
||||
const Render = styled.div`
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: solid 1px gray;
|
||||
border-radius: 3px;
|
||||
`
|
||||
|
||||
const Code = styled(SyntaxHighlighter).attrs(() => ({
|
||||
language: 'jsx',
|
||||
style: codeStyle,
|
||||
}))`
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
margin: 0 !important;
|
||||
`
|
||||
|
||||
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
progressBarValue: 100,
|
||||
value: '',
|
||||
}),
|
||||
effects: {
|
||||
onChangeProgressBarValue: function (e) {
|
||||
this.state.progressBarValue = toNumber(e.target.value)
|
||||
},
|
||||
onChangeSelect: function (e) {
|
||||
this.state.value = e.target.value
|
||||
},
|
||||
sayHello: () => alert('hello'),
|
||||
sendPromise: data =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
window.alert(data.foo)
|
||||
}, 1000)
|
||||
}),
|
||||
showAlertModal: () => alert({ message: 'This is an alert modal', title: 'Alert modal', icon: 'info' }),
|
||||
showConfirmModal: () =>
|
||||
confirm({
|
||||
message: 'This is a confirm modal test',
|
||||
title: 'Confirm modal',
|
||||
icon: 'download',
|
||||
}),
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<Page>
|
||||
<h2>ActionButton</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
|
||||
Send promise
|
||||
</ActionButton>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
|
||||
Send promise
|
||||
</ActionButton>`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Button</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Button color='primary' onClick={effects.sayHello} startIcon={<AccountCircleIcon />}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button color='secondary' endIcon={<DeleteIcon />} onClick={effects.sayHello}>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button color='success' onClick={effects.sayHello}>
|
||||
Success
|
||||
</Button>
|
||||
<Button color='warning' onClick={effects.sayHello}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button color='error' onClick={effects.sayHello}>
|
||||
Error
|
||||
</Button>
|
||||
<Button color='info' onClick={effects.sayHello}>
|
||||
Info
|
||||
</Button>
|
||||
</Render>
|
||||
<Code>{`<Button color='primary' onClick={doSomething} startIcon={<AccountCircleIcon />}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button color='secondary' endIcon={<DeleteIcon />} onClick={doSomething}>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button color='success' onClick={doSomething}>
|
||||
Success
|
||||
</Button>
|
||||
<Button color='warning' onClick={doSomething}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button color='error' onClick={doSomething}>
|
||||
Error
|
||||
</Button>
|
||||
<Button color='info' onClick={doSomething}>
|
||||
Info
|
||||
</Button>`}</Code>
|
||||
</Container>
|
||||
<h2>Icon</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Icon icon='truck' htmlColor='#0085FF' />
|
||||
<Icon icon='truck' color='primary' size='2x' />
|
||||
</Render>
|
||||
<Code>{`// https://fontawesome.com/icons
|
||||
<Icon icon='truck' htmlColor='#0085FF'/>
|
||||
<Icon icon='truck' color='primary' size='2x' />`}</Code>
|
||||
</Container>
|
||||
<h2>Input</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Input label='Input' />
|
||||
<Checkbox />
|
||||
</Render>
|
||||
<Code>{`<TextInput label='Input' />
|
||||
<Checkbox />`}</Code>
|
||||
</Container>
|
||||
<h2>Modal</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={effects.showAlertModal}
|
||||
sx={{
|
||||
marginBottom: 1,
|
||||
}}
|
||||
>
|
||||
Alert
|
||||
</Button>
|
||||
<Button color='primary' onClick={effects.showConfirmModal}>
|
||||
Confirm
|
||||
</Button>
|
||||
</Render>
|
||||
<Code>{`<Button
|
||||
color='primary'
|
||||
onClick={() =>
|
||||
alert({
|
||||
message: 'This is an alert modal',
|
||||
title: 'Alert modal',
|
||||
icon: 'info'
|
||||
})
|
||||
}
|
||||
>
|
||||
Alert
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await confirm({
|
||||
message: 'This is a confirm modal',
|
||||
title: 'Confirm modal',
|
||||
icon: 'download',
|
||||
})
|
||||
// The modal has been confirmed
|
||||
} catch (reason) { // "cancel"
|
||||
// The modal has been closed
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>`}</Code>
|
||||
</Container>
|
||||
<h2>ProgressCircle</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<ProgressCircle max={200} value={state.progressBarValue} />
|
||||
</div>
|
||||
<div>
|
||||
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
defaultValue={state.progressBarValue}
|
||||
max='200'
|
||||
min='0'
|
||||
onChange={effects.onChangeProgressBarValue}
|
||||
step='1'
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: '10px auto',
|
||||
}}
|
||||
type='range'
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<ProgressCircle max={200} value={state.progressBarValue} />
|
||||
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Select</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Select
|
||||
onChange={effects.onChangeSelect}
|
||||
options={[
|
||||
{ name: 'Bar', value: 1 },
|
||||
{ name: 'Foo', value: 2 },
|
||||
]}
|
||||
value={state.value}
|
||||
valueRenderer='value'
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<Select
|
||||
onChange={handleChange}
|
||||
optionRenderer={item => item.name}
|
||||
options={[
|
||||
{ name: 'Bar', value: 1 },
|
||||
{ name: 'Foo', value: 2 },
|
||||
]}
|
||||
value={state.value}
|
||||
valueRenderer='value'
|
||||
/>`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Tabs</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
|
||||
{ label: 'FOO', pathname: '/styleguide/foo' },
|
||||
]}
|
||||
useUrl
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<Tabs
|
||||
tabs={[
|
||||
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
|
||||
{ label: 'FOO', pathname: '/styleguide/foo' },
|
||||
]}
|
||||
useUrl
|
||||
/>`}
|
||||
</Code>
|
||||
</Container>
|
||||
</Page>
|
||||
)
|
||||
)
|
||||
|
||||
export default App
|
||||
102
@xen-orchestra/lite/src/App/TabConsole.tsx
Normal file
102
@xen-orchestra/lite/src/App/TabConsole.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Console from '../components/Console'
|
||||
import IntlMessage, { translate } from '../components/IntlMessage'
|
||||
import { ObjectsByType, Vm } from '../libs/xapi'
|
||||
import PanelHeader from '../components/PanelHeader'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {
|
||||
consoleScale: number
|
||||
sendCtrlAltDel?: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
vmId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
scaleConsole: React.ChangeEventHandler<HTMLInputElement>
|
||||
setCtrlAltDel: (sendCtrlAltDel: State['sendCtrlAltDel']) => void
|
||||
showNotImplemented: () => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
vm?: Vm
|
||||
}
|
||||
|
||||
const TabConsole = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
// Value in percent
|
||||
consoleScale: 100,
|
||||
sendCtrlAltDel: undefined,
|
||||
}),
|
||||
effects: {
|
||||
scaleConsole: function (e) {
|
||||
this.state.consoleScale = +e.currentTarget.value
|
||||
|
||||
// With "scaleViewport", the canvas occupies all available space of its
|
||||
// container. But when the size of the container is changed, the canvas
|
||||
// size isn't updated
|
||||
// Issue https://github.com/novnc/noVNC/issues/1364
|
||||
// PR https://github.com/novnc/noVNC/pull/1365
|
||||
window.dispatchEvent(new UIEvent('resize'))
|
||||
},
|
||||
setCtrlAltDel: function (sendCtrlAltDel) {
|
||||
this.state.sendCtrlAltDel = sendCtrlAltDel
|
||||
},
|
||||
showNotImplemented: function () {
|
||||
alert('Not Implemented')
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
vm: (state, { vmId }) => state.objectsByType.get('VM')?.get(vmId),
|
||||
},
|
||||
},
|
||||
({ effects, state, vmId }) => (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<PanelHeader
|
||||
actions={[
|
||||
{
|
||||
key: 'start',
|
||||
icon: 'play',
|
||||
color: 'primary',
|
||||
title: translate({ id: 'vmStartLabel' }),
|
||||
variant: 'contained',
|
||||
onClick: effects.showNotImplemented,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{state.vm?.name_label ?? 'loading'}{' '}
|
||||
</PanelHeader>
|
||||
|
||||
{/* Hide scaling and Ctrl+Alt+Del button temporarily */}
|
||||
{/* <RangeInput max={100} min={1} onChange={effects.scaleConsole} step={1} value={state.consoleScale} />
|
||||
{state.sendCtrlAltDel !== undefined && (
|
||||
<Button onClick={state.sendCtrlAltDel}>
|
||||
<IntlMessage id='ctrlAltDel' />
|
||||
</Button>
|
||||
)} */}
|
||||
{state.vm?.power_state !== 'Running' ? (
|
||||
<p>
|
||||
<IntlMessage id='consoleNotAvailable' />
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<Console vmId={vmId} scale={state.consoleScale} setCtrlAltDel={effects.setCtrlAltDel} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default TabConsole
|
||||
131
@xen-orchestra/lite/src/App/TreeView.tsx
Normal file
131
@xen-orchestra/lite/src/App/TreeView.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import { Collection, Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon from '../components/Icon'
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
import Tree, { ItemType } from '../components/Tree'
|
||||
import { Host, ObjectsByType, Pool, Vm } from '../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
defaultSelectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
collection?: Array<ItemType>
|
||||
hostsByPool?: Collection.Keyed<string, Collection<string, Host>>
|
||||
pools?: Map<string, Pool>
|
||||
vms?: Map<string, Vm>
|
||||
vmsByContainerRef?: Collection.Keyed<string, Collection<string, Vm>>
|
||||
}
|
||||
|
||||
const getHostPowerState = (host: Host) => {
|
||||
const { $metrics } = host
|
||||
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
|
||||
}
|
||||
|
||||
const getIconColor = (obj: Host | Vm) => {
|
||||
const powerState = obj.power_state ?? getHostPowerState(obj as Host)
|
||||
return powerState === 'Running' ? '#198754' : powerState === 'Halted' ? '#dc3545' : '#6c757d'
|
||||
}
|
||||
|
||||
const TreeView = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
collection: state => {
|
||||
if (state.pools === undefined) {
|
||||
return
|
||||
}
|
||||
const collection: ItemType[] = []
|
||||
state.pools.valueSeq().forEach((pool: Pool) => {
|
||||
const hosts = state.hostsByPool
|
||||
?.get(pool.$id)
|
||||
?.valueSeq()
|
||||
.sortBy(host => host.name_label)
|
||||
.map((host: Host) => ({
|
||||
children: state.vmsByContainerRef
|
||||
?.get(host.$ref)
|
||||
?.valueSeq()
|
||||
.sortBy(vm => vm.name_label)
|
||||
.map((vm: Vm) => ({
|
||||
id: vm.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/vms/${vm.$id}/console`,
|
||||
tooltip: <IntlMessage id={vm.power_state.toLowerCase()} />,
|
||||
}))
|
||||
.toArray(),
|
||||
id: host.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='server' htmlColor={getIconColor(host)} /> {host.name_label}
|
||||
</span>
|
||||
),
|
||||
tooltip: <IntlMessage id={getHostPowerState(host).toLowerCase()} />,
|
||||
}))
|
||||
.toArray()
|
||||
|
||||
const haltedVms = state.vmsByContainerRef
|
||||
?.get(pool.$ref)
|
||||
?.valueSeq()
|
||||
.sortBy((vm: Vm) => vm.name_label)
|
||||
.map((vm: Vm) => ({
|
||||
id: vm.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/vms/${vm.$id}/console`,
|
||||
tooltip: <IntlMessage id='halted' />,
|
||||
}))
|
||||
.toArray()
|
||||
|
||||
collection.push({
|
||||
children: (hosts ?? []).concat(haltedVms ?? []),
|
||||
id: pool.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='warehouse' color='primary' /> {pool.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/pool/${pool.$id}/dashboard`,
|
||||
})
|
||||
})
|
||||
|
||||
return collection
|
||||
},
|
||||
hostsByPool: state => state.objectsByType?.get('host')?.groupBy((host: Host) => host.$pool.$id),
|
||||
pools: state => state.objectsByType?.get('pool'),
|
||||
vms: state =>
|
||||
state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
|
||||
vmsByContainerRef: state =>
|
||||
state.vms?.groupBy(({ power_state: powerState, resident_on: host, $pool }: Vm) =>
|
||||
powerState === 'Running' || powerState === 'Paused' ? host : $pool.$ref
|
||||
),
|
||||
},
|
||||
},
|
||||
({ state, defaultSelectedNodes }) =>
|
||||
state.collection === undefined ? null : (
|
||||
<div style={{ padding: '10px' }}>
|
||||
<Tree collection={state.collection} defaultSelectedNodes={defaultSelectedNodes} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default TreeView
|
||||
506
@xen-orchestra/lite/src/App/index.tsx
Normal file
506
@xen-orchestra/lite/src/App/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
// import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import Container from '@mui/material/Container'
|
||||
import Cookies from 'js-cookie'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'
|
||||
import MuiDrawer from '@mui/material/Drawer'
|
||||
import React from 'react'
|
||||
import styledComponent from 'styled-components'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { HashRouter as Router, Switch, Redirect, Route } from 'react-router-dom'
|
||||
import { IntlProvider } from 'react-intl'
|
||||
import { Map } from 'immutable'
|
||||
import { styled, createTheme, ThemeProvider } from '@mui/material/styles'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
// import Button from '../components/Button'
|
||||
import Icon from '../components/Icon'
|
||||
import Infrastructure from './Infrastructure'
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
import Link from '../components/Link'
|
||||
import messagesEn from '../lang/en.json'
|
||||
import Modal from '../components/Modal'
|
||||
import PoolTab from './PoolTab'
|
||||
import Signin from './Signin/index'
|
||||
import StyleGuide from './StyleGuide/index'
|
||||
import TabConsole from './TabConsole'
|
||||
|
||||
import XapiConnection, { ObjectsByType, Pool, Vm } from '../libs/xapi'
|
||||
|
||||
const drawerWidth = 240
|
||||
const redirectPaths = ['/', '/infrastructure']
|
||||
|
||||
interface AppBarProps extends MuiAppBarProps {
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard
|
||||
|
||||
const AppBar = styled(MuiAppBar, {
|
||||
shouldForwardProp: prop => prop !== 'open',
|
||||
})<AppBarProps>(({ theme, open }) => ({
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
...(open && {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const Drawer = styled(MuiDrawer, { shouldForwardProp: prop => prop !== 'open' })(({ theme, open }) => ({
|
||||
'& .MuiDrawer-paper': {
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
boxSizing: 'border-box',
|
||||
...(!open && {
|
||||
overflowX: 'hidden',
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
width: theme.spacing(7),
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: theme.spacing(9),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const MainListItems = (): JSX.Element => (
|
||||
<div>
|
||||
<ListItemButton component='a' href='#infrastructure'>
|
||||
<ListItemIcon>
|
||||
<Icon icon='project-diagram' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<IntlMessage id='infrastructure' />} />
|
||||
</ListItemButton>
|
||||
<ListItemButton component='a' href='#about'>
|
||||
<ListItemIcon>
|
||||
<Icon icon='info-circle' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary='About' />
|
||||
</ListItemButton>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface SecondaryListItemsParentState {}
|
||||
|
||||
interface SecondaryListItemsState {}
|
||||
|
||||
interface SecondaryListItemsProps {}
|
||||
|
||||
interface SecondaryListItemsParentEffects {}
|
||||
|
||||
interface SecondaryListItemsEffects {
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
interface SecondaryListItemsComputed {}
|
||||
|
||||
const ICON_STYLE = { fontSize: '1.5em' }
|
||||
|
||||
const SecondaryListItems = withState<
|
||||
SecondaryListItemsState,
|
||||
SecondaryListItemsProps,
|
||||
SecondaryListItemsEffects,
|
||||
SecondaryListItemsComputed,
|
||||
SecondaryListItemsParentState,
|
||||
SecondaryListItemsParentEffects
|
||||
>({}, ({ effects }) => (
|
||||
<div>
|
||||
<ListItem button onClick={() => effects.disconnect()}>
|
||||
<ListItemIcon style={ICON_STYLE}>
|
||||
<Icon icon='sign-out-alt' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<IntlMessage id='disconnect' />} />
|
||||
</ListItem>
|
||||
</div>
|
||||
))
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Default bootstrap 4 colors
|
||||
// https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss#L67-L74
|
||||
const mdTheme = createTheme({
|
||||
background: {
|
||||
primary: {
|
||||
dark: '#111111',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
error: {
|
||||
main: '#dc3545',
|
||||
},
|
||||
info: {
|
||||
main: '#17a2b8',
|
||||
},
|
||||
primary: {
|
||||
dark: '#168FFF',
|
||||
light: '#0085FF',
|
||||
main: '#007bff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#6c757d',
|
||||
},
|
||||
success: {
|
||||
main: '#28a745',
|
||||
},
|
||||
warning: {
|
||||
main: '#ffc107',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: '#E8E8E8',
|
||||
fontStyle: 'medium',
|
||||
fontSize: '1.25em',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'inter',
|
||||
h1: {
|
||||
fontWeight: 500,
|
||||
fontSize: '3em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '3.75em',
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 500,
|
||||
fontSize: '2.25em',
|
||||
fontStyle: 'medium',
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1.5em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '2em',
|
||||
},
|
||||
h4: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1.25em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.75em',
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.50em',
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 500,
|
||||
fontSize: '0.8em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.25em',
|
||||
},
|
||||
caption: {
|
||||
// styleName: Caps / Caps 1 - 14 Semi Bold
|
||||
fontSize: '0.9em',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.25em',
|
||||
verticalAlign: 'top',
|
||||
letterSpacing: '0.04em',
|
||||
textAlign: 'left',
|
||||
},
|
||||
body2: {
|
||||
// styleName: Paragraph / P2 - 16
|
||||
fontSize: '1em',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.5em',
|
||||
letterSpacing: '0em',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const FullPage = styledComponent.div`
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {
|
||||
connected: boolean
|
||||
drawerOpen: boolean
|
||||
error: React.ReactNode
|
||||
xapiHostname: string
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
connectToXapi: (password: string, rememberMe: boolean) => void
|
||||
disconnect: () => void
|
||||
toggleDrawer: () => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
objectsFetched: boolean
|
||||
pool?: Pool
|
||||
url: string
|
||||
vms?: Map<string, Vm>
|
||||
}
|
||||
|
||||
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
connected: Cookies.get('sessionId') !== undefined,
|
||||
drawerOpen: false,
|
||||
error: '',
|
||||
objectsByType: undefined,
|
||||
xapi: undefined,
|
||||
xapiHostname: process.env.XAPI_HOST || window.location.host,
|
||||
}),
|
||||
effects: {
|
||||
initialize: async function () {
|
||||
const xapi = (this.state.xapi = new XapiConnection())
|
||||
|
||||
xapi.on('connected', () => {
|
||||
this.state.connected = true
|
||||
})
|
||||
|
||||
xapi.on('disconnected', () => {
|
||||
this.state.connected = false
|
||||
})
|
||||
|
||||
xapi.on('objects', (objectsByType: ObjectsByType) => {
|
||||
this.state.objectsByType = objectsByType
|
||||
})
|
||||
|
||||
try {
|
||||
await xapi.reattachSession(this.state.url)
|
||||
} catch (err) {
|
||||
if (err?.code !== 'SESSION_INVALID') {
|
||||
throw err
|
||||
}
|
||||
|
||||
console.log('Session ID is invalid. Asking for credentials.')
|
||||
}
|
||||
},
|
||||
toggleDrawer: function () {
|
||||
this.state.drawerOpen = !this.state.drawerOpen
|
||||
},
|
||||
connectToXapi: async function (password, rememberMe = false) {
|
||||
try {
|
||||
await this.state.xapi.connect({
|
||||
url: this.state.url,
|
||||
user: 'root',
|
||||
password,
|
||||
rememberMe,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.code !== 'SESSION_AUTHENTICATION_FAILED') {
|
||||
throw err
|
||||
}
|
||||
|
||||
this.state.error = <IntlMessage id='badCredentials' />
|
||||
}
|
||||
},
|
||||
disconnect: async function () {
|
||||
await this.state.xapi.disconnect()
|
||||
this.state.connected = false
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
objectsFetched: state => state.objectsByType !== undefined,
|
||||
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.keySeq().first() : undefined),
|
||||
vms: state =>
|
||||
state.objectsFetched
|
||||
? state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template)
|
||||
: undefined,
|
||||
url: state => `${window.location.protocol}//${state.xapiHostname}`,
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<IntlProvider messages={messagesEn} locale='en'>
|
||||
{/* Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard */}
|
||||
<ThemeProvider theme={mdTheme}>
|
||||
<Modal />
|
||||
{!state.connected ? (
|
||||
<Signin />
|
||||
) : !state.objectsFetched ? (
|
||||
<IntlMessage id='loading' />
|
||||
) : (
|
||||
<>
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route exact path={redirectPaths}>
|
||||
<Redirect to={`/infrastructure/pool/${state.pool.$id}/dashboard`} />
|
||||
</Route>
|
||||
<Route exact path='/vm-list'>
|
||||
{state.vms !== undefined && (
|
||||
<>
|
||||
<p>There are {state.vms.size} VMs!</p>
|
||||
<ul>
|
||||
{state.vms.valueSeq().map((vm: Vm) => (
|
||||
<li key={vm.$id}>
|
||||
<Link to={vm.$id}>
|
||||
{vm.name_label} - {vm.name_description} ({vm.power_state})
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Route>
|
||||
<Route exact path='/styleguide'>
|
||||
<StyleGuide />
|
||||
</Route>
|
||||
<Route exact path='/styleguide/foo'>
|
||||
<StyleGuide />
|
||||
</Route>
|
||||
<Route exact path='/pool'>
|
||||
<PoolTab />
|
||||
</Route>
|
||||
<Route path='/'>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position='absolute' open={state.drawerOpen}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
pr: '24px', // keep right padding when drawer closed
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
edge='start'
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
onClick={effects.toggleDrawer}
|
||||
sx={{
|
||||
marginRight: '36px',
|
||||
...(state.drawerOpen && { display: 'none' }),
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography component='h1' variant='h6' color='inherit' noWrap sx={{ flexGrow: 1 }}>
|
||||
<Switch>
|
||||
<Route path='/infrastructure'>
|
||||
<IntlMessage id='infrastructure' />
|
||||
</Route>
|
||||
<Route path='/about'>
|
||||
<IntlMessage id='about' />
|
||||
</Route>
|
||||
<Route>
|
||||
<IntlMessage id='notFound' />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Typography>
|
||||
{/* <IconButton color='inherit'>
|
||||
<Badge badgeContent={4} color='secondary'>
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton> */}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant='permanent' open={state.drawerOpen}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
px: [1],
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={effects.toggleDrawer}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
<MainListItems />
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<SecondaryListItems />
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
backgroundColor: theme =>
|
||||
theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900],
|
||||
flexGrow: 1,
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route path='/infrastructure'>
|
||||
<FullPage>
|
||||
<Toolbar />
|
||||
<Infrastructure />
|
||||
</FullPage>
|
||||
</Route>
|
||||
<Route path='/about'>
|
||||
<Toolbar />
|
||||
<Container maxWidth='lg' sx={{ mt: 4, mb: 4 }}>
|
||||
<p>
|
||||
Check out{' '}
|
||||
<Link to='https://xen-orchestra.com/blog/xen-orchestra-lite/'>Xen Orchestra Lite</Link>{' '}
|
||||
dev blog.
|
||||
</p>
|
||||
<p>
|
||||
<IntlMessage id='versionValue' values={{ version: process.env.NPM_VERSION }} />
|
||||
</p>
|
||||
</Container>
|
||||
</Route>
|
||||
<Route>
|
||||
<Toolbar />
|
||||
<IntlMessage id='pageNotFound' />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Box>
|
||||
</Box>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
)
|
||||
)
|
||||
|
||||
export default App
|
||||
57
@xen-orchestra/lite/src/components/ActionButton.tsx
Normal file
57
@xen-orchestra/lite/src/components/ActionButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import LoadingButton, { LoadingButtonProps } from '@mui/lab/LoadingButton'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Omit the `onClick` props to rewrite its own one.
|
||||
interface Props extends Omit<LoadingButtonProps, 'onClick'> {
|
||||
onClick: (data: Record<string, unknown>) => Promise<void>
|
||||
// to pass props with the following pattern: "data-something"
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
_onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const ActionButton = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({ isLoading: false }),
|
||||
effects: {
|
||||
_onClick: function () {
|
||||
this.state.isLoading = true
|
||||
const data: Record<string, unknown> = {}
|
||||
Object.keys(this.props).forEach(key => {
|
||||
if (key.startsWith('data-')) {
|
||||
data[key.slice(5)] = this.props[key]
|
||||
}
|
||||
})
|
||||
return this.props.onClick(data).finally(() => (this.state.isLoading = false))
|
||||
},
|
||||
},
|
||||
},
|
||||
({ children, color = 'secondary', effects, onClick, resetState, state, variant = 'contained', ...props }) => (
|
||||
<LoadingButton
|
||||
color={color}
|
||||
disabled={state.isLoading}
|
||||
fullWidth
|
||||
loading={state.isLoading}
|
||||
onClick={effects._onClick}
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LoadingButton>
|
||||
)
|
||||
)
|
||||
|
||||
export default ActionButton
|
||||
26
@xen-orchestra/lite/src/components/Button.tsx
Normal file
26
@xen-orchestra/lite/src/components/Button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { Button as MuiButton, ButtonProps } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends ButtonProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Button = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ children, color = 'secondary', effects, resetState, state, variant = 'contained', ...props }) => (
|
||||
<MuiButton color={color} fullWidth variant={variant} {...props}>
|
||||
{children}
|
||||
</MuiButton>
|
||||
)
|
||||
)
|
||||
|
||||
export default Button
|
||||
22
@xen-orchestra/lite/src/components/Checkbox.tsx
Normal file
22
@xen-orchestra/lite/src/components/Checkbox.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { CheckboxProps, Checkbox as MuiCheckbox } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends CheckboxProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Checkbox = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ effects, resetState, state, ...props }) => <MuiCheckbox {...props} />
|
||||
)
|
||||
|
||||
export default Checkbox
|
||||
193
@xen-orchestra/lite/src/components/Console.tsx
Normal file
193
@xen-orchestra/lite/src/components/Console.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from 'react'
|
||||
import RFB from '@novnc/novnc/lib/rfb'
|
||||
import styled from 'styled-components'
|
||||
import { fibonacci } from 'iterable-backoff'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
import { confirm } from './Modal'
|
||||
|
||||
import XapiConnection, { ObjectsByType, Vm } from '../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {
|
||||
// Type error with HTMLDivElement.
|
||||
// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/30451
|
||||
container: React.RefObject<any>
|
||||
// See https://github.com/vatesfr/xen-orchestra/pull/5722#discussion_r619296074
|
||||
rfb: any
|
||||
rfbConnected: boolean
|
||||
timeout?: NodeJS.Timeout
|
||||
tryToReconnect: boolean
|
||||
url?: URL
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scale: number
|
||||
setCtrlAltDel: (sendCtrlAltDel: Effects['sendCtrlAltDel']) => void
|
||||
vmId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
_connect: () => Promise<void>
|
||||
_handleConnect: () => void
|
||||
_handleDisconnect: () => Promise<void>
|
||||
sendCtrlAltDel: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
interface PropsStyledConsole {
|
||||
scale: number
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
enum Protocols {
|
||||
http = 'http:',
|
||||
https = 'https:',
|
||||
ws = 'ws:',
|
||||
wss = 'wss:',
|
||||
}
|
||||
|
||||
const StyledConsole = styled.div<PropsStyledConsole>`
|
||||
height: ${props => props.scale}%;
|
||||
margin: auto;
|
||||
visibility: ${props => (props.visible ? 'visible' : 'hidden')};
|
||||
width: ${props => props.scale}%;
|
||||
`
|
||||
|
||||
// https://github.com/novnc/noVNC/blob/master/docs/API.md
|
||||
const Console = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
container: React.createRef(),
|
||||
rfb: undefined,
|
||||
rfbConnected: false,
|
||||
timeout: undefined,
|
||||
tryToReconnect: true,
|
||||
url: undefined,
|
||||
}),
|
||||
effects: {
|
||||
initialize: function () {
|
||||
this.effects._connect()
|
||||
},
|
||||
_handleConnect: function () {
|
||||
this.state.rfbConnected = true
|
||||
},
|
||||
_handleDisconnect: async function () {
|
||||
this.state.rfbConnected = false
|
||||
const {
|
||||
state: { objectsByType, url },
|
||||
effects: { _connect },
|
||||
} = this
|
||||
const { protocol } = window.location
|
||||
if (protocol === Protocols.https) {
|
||||
try {
|
||||
await fetch(`${protocol}//${url?.host}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
try {
|
||||
await confirm({
|
||||
icon: 'exclamation-triangle',
|
||||
message: (
|
||||
<a href={`${protocol}//${url?.host}`} rel='noopener noreferrer' target='_blank'>
|
||||
<IntlMessage
|
||||
id='unreachableHost'
|
||||
values={{
|
||||
name: objectsByType.get('host')?.find(host => host.address === url?.host)?.name_label,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
title: <IntlMessage id='connectionError' />,
|
||||
})
|
||||
} catch {
|
||||
this.state.tryToReconnect = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.tryToReconnect) {
|
||||
_connect()
|
||||
}
|
||||
},
|
||||
_connect: async function () {
|
||||
const { vmId } = this.props
|
||||
const { objectsByType, rfb, xapi } = this.state
|
||||
let lastError: unknown
|
||||
|
||||
// 8 tries mean 54s
|
||||
for (const delay of fibonacci().toMs().take(8)) {
|
||||
try {
|
||||
const consoles = (objectsByType.get('VM')?.get(vmId) as Vm)?.$consoles.filter(
|
||||
vmConsole => vmConsole.protocol === 'rfb'
|
||||
)
|
||||
|
||||
if (rfb !== undefined) {
|
||||
rfb.removeEventListener('connect', this.effects._handleConnect)
|
||||
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
|
||||
}
|
||||
|
||||
if (consoles === undefined || consoles.length === 0) {
|
||||
throw new Error('Could not find VM console')
|
||||
}
|
||||
|
||||
if (xapi.sessionId === undefined) {
|
||||
throw new Error('Not connected to XAPI')
|
||||
}
|
||||
|
||||
this.state.url = new URL(consoles[0].location)
|
||||
this.state.url.protocol = window.location.protocol === Protocols.https ? Protocols.wss : Protocols.ws
|
||||
this.state.url.searchParams.set('session_id', xapi.sessionId)
|
||||
|
||||
this.state.rfb = new RFB(this.state.container.current, this.state.url, {
|
||||
wsProtocols: ['binary'],
|
||||
})
|
||||
this.state.rfb.addEventListener('connect', this.effects._handleConnect)
|
||||
this.state.rfb.addEventListener('disconnect', this.effects._handleDisconnect)
|
||||
this.state.rfb.scaleViewport = true
|
||||
this.props.setCtrlAltDel(this.effects.sendCtrlAltDel)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise(resolve => (this.state.timeout = setTimeout(resolve, delay)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
},
|
||||
finalize: function () {
|
||||
const { rfb, timeout } = this.state
|
||||
rfb.removeEventListener('connect', this.effects._handleConnect)
|
||||
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
},
|
||||
sendCtrlAltDel: async function () {
|
||||
await confirm({
|
||||
message: <IntlMessage id='confirmCtrlAltDel' />,
|
||||
title: <IntlMessage id='ctrlAltDel' />,
|
||||
})
|
||||
this.state.rfb.sendCtrlAltDel()
|
||||
},
|
||||
},
|
||||
},
|
||||
({ scale, state }) => (
|
||||
<>
|
||||
{state.rfb !== undefined && !state.rfbConnected && (
|
||||
<p>
|
||||
<IntlMessage id={state.tryToReconnect ? 'reconnectionAttempt' : 'hostUnreachable'} />
|
||||
</p>
|
||||
)}
|
||||
<StyledConsole ref={state.container} scale={scale} visible={state.rfbConnected} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
export default Console
|
||||
30
@xen-orchestra/lite/src/components/Icon.tsx
Normal file
30
@xen-orchestra/lite/src/components/Icon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconName as _IconName, library, SizeProp } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
library.add(fas)
|
||||
|
||||
const Icon = ({
|
||||
color,
|
||||
htmlColor,
|
||||
icon,
|
||||
size,
|
||||
}: {
|
||||
color?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
|
||||
htmlColor?: string
|
||||
icon: _IconName
|
||||
size?: SizeProp
|
||||
}): JSX.Element => {
|
||||
const { palette } = useTheme()
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
size={size}
|
||||
color={htmlColor ?? (color !== undefined ? palette[color][palette.mode] : undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default Icon
|
||||
export type IconName = _IconName
|
||||
26
@xen-orchestra/lite/src/components/Input.tsx
Normal file
26
@xen-orchestra/lite/src/components/Input.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
// An interface can only extend an object type or intersection
|
||||
// of object types with statically known members.
|
||||
type Props = _Props & TextFieldProps
|
||||
|
||||
interface _Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Input = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ effects, resetState, state, ...props }) => <TextField fullWidth {...props} />
|
||||
)
|
||||
|
||||
export default Input
|
||||
21
@xen-orchestra/lite/src/components/IntlMessage.tsx
Normal file
21
@xen-orchestra/lite/src/components/IntlMessage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { ElementType, ReactElement, ReactNode } from 'react'
|
||||
import { FormattedMessage, MessageDescriptor, useIntl } from 'react-intl'
|
||||
import intlMessage from '../lang/en.json'
|
||||
|
||||
// Extends FormattedMessage not working: "FormattedMessage refers to a value, but is being used as a type here"
|
||||
// https://stackoverflow.com/questions/62059408/reactjs-and-typescript-refers-to-a-value-but-is-being-used-as-a-type-here-ts
|
||||
// InstanceType<typeof FormattedMessage> not working: "Type [...] does not satisfy the constraint abstract new (...args: any) => any."
|
||||
// See https://formatjs.io/docs/react-intl/components/#formattedmessage
|
||||
interface Props extends MessageDescriptor {
|
||||
children?: (chunks: ReactElement) => ReactElement
|
||||
id?: keyof typeof intlMessage
|
||||
tagName?: ElementType
|
||||
values?: Record<string, ReactNode>
|
||||
}
|
||||
const IntlMessage = (props: Props): JSX.Element => <FormattedMessage {...props} />
|
||||
|
||||
export function translate(message: MessageDescriptor){
|
||||
return useIntl().formatMessage(message)
|
||||
}
|
||||
|
||||
export default React.memo(IntlMessage)
|
||||
38
@xen-orchestra/lite/src/components/Link.tsx
Normal file
38
@xen-orchestra/lite/src/components/Link.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import MaterialLink from '@mui/material/Link'
|
||||
import React from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
decorated?: boolean
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const UNDECORATED_LINK = { textDecoration: 'none', color: 'inherit' }
|
||||
|
||||
const Link = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ to, decorated = true, children }) =>
|
||||
to === undefined ? (
|
||||
<>{children}</>
|
||||
) : to.startsWith('http') ? (
|
||||
<MaterialLink style={decorated ? undefined : UNDECORATED_LINK} target='_blank' rel='noopener noreferrer' href={to}>
|
||||
{children}
|
||||
</MaterialLink>
|
||||
) : (
|
||||
<RouterLink style={decorated ? undefined : UNDECORATED_LINK} component={MaterialLink} to={to}>
|
||||
{children}
|
||||
</RouterLink>
|
||||
)
|
||||
)
|
||||
|
||||
export default Link
|
||||
152
@xen-orchestra/lite/src/components/Modal.tsx
Normal file
152
@xen-orchestra/lite/src/components/Modal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react'
|
||||
import { ButtonProps, Dialog, DialogContent, DialogContentText, DialogActions, DialogTitle } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Button from './Button'
|
||||
import Icon, { IconName } from './Icon'
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
type ModalButton = {
|
||||
color?: ButtonProps['color']
|
||||
label: string | React.ReactNode
|
||||
reason?: unknown
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
interface GeneralParamsModal {
|
||||
icon: IconName
|
||||
message: string | React.ReactNode
|
||||
title: string | React.ReactNode
|
||||
}
|
||||
|
||||
interface ModalParams extends GeneralParamsModal {
|
||||
buttonList: ModalButton[]
|
||||
}
|
||||
|
||||
let instance: EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> | undefined
|
||||
const modal = ({ buttonList, icon, message, title }: ModalParams) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (instance === undefined) {
|
||||
throw new Error('No modal instance')
|
||||
}
|
||||
instance.state.buttonList = buttonList
|
||||
instance.state.icon = icon
|
||||
instance.state.message = message
|
||||
instance.state.onReject = reject
|
||||
instance.state.onSuccess = resolve
|
||||
instance.state.showModal = true
|
||||
instance.state.title = title
|
||||
})
|
||||
|
||||
export const alert = (params: GeneralParamsModal): Promise<unknown> => {
|
||||
const buttonList: ModalButton[] = [
|
||||
{
|
||||
label: <IntlMessage id='ok' />,
|
||||
color: 'primary',
|
||||
value: 'success',
|
||||
},
|
||||
]
|
||||
return modal({ ...params, buttonList })
|
||||
}
|
||||
|
||||
export const confirm = (params: GeneralParamsModal): Promise<unknown> => {
|
||||
const buttonList: ModalButton[] = [
|
||||
{
|
||||
label: <IntlMessage id='confirm' />,
|
||||
value: 'confirm',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
label: <IntlMessage id='cancel' />,
|
||||
color: 'secondary',
|
||||
reason: 'cancel',
|
||||
},
|
||||
]
|
||||
return modal({ ...params, buttonList })
|
||||
}
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
buttonList?: ModalButton[]
|
||||
icon?: IconName
|
||||
message?: string | React.ReactNode
|
||||
onReject?: (reason: unknown) => void
|
||||
onSuccess?: (value: unknown) => void
|
||||
showModal: boolean
|
||||
title?: string | React.ReactNode
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
closeModal: () => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Modal = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
buttonList: undefined,
|
||||
icon: undefined,
|
||||
message: undefined,
|
||||
onReject: undefined,
|
||||
onSuccess: undefined,
|
||||
showModal: false,
|
||||
title: undefined,
|
||||
}),
|
||||
effects: {
|
||||
initialize: function () {
|
||||
if (instance !== undefined) {
|
||||
throw new Error('Modal is a singelton')
|
||||
}
|
||||
instance = this
|
||||
},
|
||||
closeModal: function () {
|
||||
this.state.showModal = false
|
||||
},
|
||||
reject: function (reason) {
|
||||
this.state.onReject?.(reason)
|
||||
this.effects.closeModal()
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state }) => {
|
||||
const { closeModal, reject } = effects
|
||||
const { buttonList, icon, message, onReject, onSuccess, showModal, title } = state
|
||||
|
||||
return showModal ? (
|
||||
<Dialog open={showModal} onClose={reject}>
|
||||
<DialogTitle>
|
||||
{icon !== undefined && <Icon icon={icon} />} {title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{message}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{buttonList?.map(({ label, reason, value, ...props }, index) => {
|
||||
const onClick = () => {
|
||||
if (value !== undefined) {
|
||||
onSuccess?.(value)
|
||||
} else {
|
||||
onReject?.(reason)
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
return (
|
||||
<Button key={index} onClick={onClick} {...props}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
) : null
|
||||
}
|
||||
)
|
||||
|
||||
export default Modal
|
||||
63
@xen-orchestra/lite/src/components/PanelHeader.tsx
Normal file
63
@xen-orchestra/lite/src/components/PanelHeader.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon, { IconName } from './Icon'
|
||||
|
||||
import Button, { ButtonProps } from '@mui/material/Button'
|
||||
import ButtonGroup, { ButtonGroupClassKey } from '@mui/material/ButtonGroup'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography, { TypographyClassKey } from '@mui/material/Typography'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Action extends ButtonProps {
|
||||
icon: IconName
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const DEFAULT_TITLE_STYLE = { marginLeft: '0.5em', flex: 1, fontSize: '250%' }
|
||||
const DEFAULT_BUTTONGROUP_STYLE = { margin: '0.5em', flex: 0 }
|
||||
const DEFAULT_STACK_STYLE = {
|
||||
backgroundColor: (theme: Theme) => {
|
||||
const { background, palette } = theme
|
||||
return palette.mode === 'light' ? background.primary.light : background.primary.dark
|
||||
},
|
||||
paddingTop: '1em',
|
||||
}
|
||||
|
||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
// Accepts an array of Actions. An action accepts all the props of a Button + an icon
|
||||
actions?: Array<Action>
|
||||
// the props passed to the title, accepts all the keys of Typography
|
||||
titleProps?: TypographyClassKey
|
||||
// the props passed to the button group, accepts all the keys of a ButtonGroup
|
||||
buttonGroupProps?: ButtonGroupClassKey
|
||||
}
|
||||
|
||||
const PanelHeader = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ actions = [], titleProps = {}, buttonGroupProps = {}, children = null }) => (
|
||||
<Stack direction='row' justifyContent='space-between' alignItems='center' sx={DEFAULT_STACK_STYLE}>
|
||||
<Typography variant='h2' sx={DEFAULT_TITLE_STYLE} {...titleProps}>
|
||||
{children}
|
||||
</Typography>
|
||||
<ButtonGroup sx={DEFAULT_BUTTONGROUP_STYLE} {...buttonGroupProps}>
|
||||
{(actions as Array<Action>)?.map(({ icon, ...actionProps }) => (
|
||||
<Button {...actionProps} key={actionProps.key}>
|
||||
<Icon icon={icon} />
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
)
|
||||
)
|
||||
|
||||
export default PanelHeader
|
||||
87
@xen-orchestra/lite/src/components/ProgressCircle.tsx
Normal file
87
@xen-orchestra/lite/src/components/ProgressCircle.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import React from 'react'
|
||||
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import { Typography } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
const BackgroundBox = styled(Box)({
|
||||
position: 'absolute',
|
||||
})
|
||||
|
||||
const BackgroundCircle = styled(CircularProgress)({
|
||||
color: '#e3dede',
|
||||
})
|
||||
|
||||
const Container = styled(Box)({
|
||||
display: 'inline-flex',
|
||||
position: 'relative',
|
||||
})
|
||||
|
||||
const StyledLabel = styled(Typography)(({ color, theme: { palette } }) => ({
|
||||
color: (palette[(color as string) ?? 'primary'] ?? palette.primary).main,
|
||||
textAlign: 'center',
|
||||
}))
|
||||
|
||||
const LabelBox = styled(Box)({
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
height: '80%',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
margin: 'auto',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '80%',
|
||||
})
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
color?: CircularProgressProps['color']
|
||||
label?: string
|
||||
max?: number
|
||||
showLabel?: boolean
|
||||
size?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
label: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
const ProgressCircle = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
label: ({ progress }, { label }) => label ?? `${progress}%`,
|
||||
progress: (_, { max = 100, value }) => Math.round((value / max) * 100),
|
||||
},
|
||||
},
|
||||
({ color = 'success', showLabel = true, size = 100, state: { label, progress } }) => (
|
||||
<Container>
|
||||
<BackgroundBox>
|
||||
<BackgroundCircle variant='determinate' value={100} size={size} />
|
||||
</BackgroundBox>
|
||||
<CircularProgress aria-label={label} color={color} size={size} value={progress} variant='determinate' />
|
||||
{showLabel && (
|
||||
<LabelBox>
|
||||
<StyledLabel variant='h5' color={color}>
|
||||
{label}
|
||||
</StyledLabel>
|
||||
</LabelBox>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default ProgressCircle
|
||||
7
@xen-orchestra/lite/src/components/RangeInput.tsx
Normal file
7
@xen-orchestra/lite/src/components/RangeInput.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'>
|
||||
|
||||
const RangeInput = React.memo((props: Props) => <input {...props} type='range' />)
|
||||
|
||||
export default RangeInput
|
||||
97
@xen-orchestra/lite/src/components/Select.tsx
Normal file
97
@xen-orchestra/lite/src/components/Select.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import React from 'react'
|
||||
import SelectMaterialUi, { SelectProps } from '@mui/material/Select'
|
||||
import { iteratee } from 'lodash'
|
||||
import { SelectChangeEvent } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
type AdditionalProps = Record<string, any>
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends SelectProps {
|
||||
additionalProps?: AdditionalProps
|
||||
onChange: (e: SelectChangeEvent<unknown>) => void
|
||||
optionRenderer?: string | { (item: any): number | string }
|
||||
options: any[] | undefined
|
||||
value: any
|
||||
valueRenderer?: string | { (item: any): number | string }
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
renderOption: (item: any, additionalProps?: AdditionalProps) => React.ReactNode
|
||||
renderValue: (item: any, additionalProps?: AdditionalProps) => number | string
|
||||
options?: JSX.Element[]
|
||||
}
|
||||
|
||||
const Select = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
// @ts-ignore
|
||||
renderOption: (_, { optionRenderer }) => iteratee(optionRenderer),
|
||||
// @ts-ignore
|
||||
renderValue: (_, { valueRenderer }) => iteratee(valueRenderer),
|
||||
options: (state, { additionalProps, options, optionRenderer, valueRenderer }) =>
|
||||
options?.map(item => {
|
||||
const label =
|
||||
optionRenderer === undefined
|
||||
? item.name ?? item.label ?? item.name_label
|
||||
: state.renderOption(item, additionalProps)
|
||||
const value =
|
||||
valueRenderer === undefined ? item.value ?? item.id ?? item.$id : state.renderValue(item, additionalProps)
|
||||
|
||||
if (value === undefined) {
|
||||
console.error('Computed value is undefined')
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
)
|
||||
}),
|
||||
},
|
||||
},
|
||||
({
|
||||
additionalProps,
|
||||
displayEmpty = true,
|
||||
effects,
|
||||
multiple,
|
||||
options,
|
||||
required,
|
||||
resetState,
|
||||
state,
|
||||
value,
|
||||
...props
|
||||
}) => (
|
||||
<FormControl>
|
||||
<SelectMaterialUi
|
||||
multiple={multiple}
|
||||
required={required}
|
||||
displayEmpty={displayEmpty}
|
||||
value={value ?? (multiple ? [] : '')}
|
||||
{...props}
|
||||
>
|
||||
{!multiple && (
|
||||
<MenuItem value=''>
|
||||
<em>
|
||||
<IntlMessage id='none' />
|
||||
</em>
|
||||
</MenuItem>
|
||||
)}
|
||||
{state.options}
|
||||
</SelectMaterialUi>
|
||||
</FormControl>
|
||||
)
|
||||
)
|
||||
|
||||
export default Select
|
||||
73
@xen-orchestra/lite/src/components/Table.tsx
Normal file
73
@xen-orchestra/lite/src/components/Table.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
export type Column<Type> = {
|
||||
header: React.ReactNode
|
||||
id?: string
|
||||
render: { (item: Type): React.ReactNode }
|
||||
}
|
||||
|
||||
type Item = {
|
||||
id?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
collection: Item[] | undefined
|
||||
columns: Column<any>[]
|
||||
placeholder?: JSX.Element
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const StyledTable = styled.table`
|
||||
border: 1px solid #333;
|
||||
td {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
thead {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
`
|
||||
const Table = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ collection, columns, placeholder }) =>
|
||||
collection !== undefined ? (
|
||||
collection.length !== 0 ? (
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col, index) => (
|
||||
<td key={col.id ?? index}>{col.header}</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{collection.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
{columns.map((col, index) => (
|
||||
<td key={col.id ?? index}>{col.render(item)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
) : (
|
||||
placeholder ?? <IntlMessage id='noData' />
|
||||
)
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default Table
|
||||
114
@xen-orchestra/lite/src/components/Tabs.tsx
Normal file
114
@xen-orchestra/lite/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import React from 'react'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import TabContext from '@mui/lab/TabContext'
|
||||
import TabList from '@mui/lab/TabList'
|
||||
import TabPanel from '@mui/lab/TabPanel'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { RouteComponentProps } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
|
||||
const BOX_STYLE = { borderBottom: 1, borderColor: 'divider', marginTop: '0.5em' }
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
component?: React.ReactNode
|
||||
disabled?: boolean
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
interface UrlTab extends Tab {
|
||||
pathname: string
|
||||
value?: any
|
||||
}
|
||||
|
||||
interface NoUrlTab extends Tab {
|
||||
value: any
|
||||
}
|
||||
|
||||
// For compatibility with 'withRouter'
|
||||
interface Props extends RouteComponentProps {
|
||||
indicatorColor?: 'primary' | 'secondary'
|
||||
textColor?: 'inherit' | 'primary' | 'secondary'
|
||||
// tabs = [
|
||||
// {
|
||||
// component: <span>BAR</span>,
|
||||
// pathname: '/path',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='cloud' /> {labelA}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// ]
|
||||
tabs: Array<NoUrlTab | UrlTab>
|
||||
useUrl?: boolean
|
||||
value?: any
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
onChange: (event: React.SyntheticEvent, value: string) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
// TODO: improve view as done in the model(figma).
|
||||
const pageUnderConstruction = (
|
||||
<div style={{ color: '#0085FF', textAlign: 'center' }}>
|
||||
<Typography variant='h2'>
|
||||
<IntlMessage id='xoLiteUnderConstruction' />
|
||||
</Typography>
|
||||
<Typography variant='h3'>
|
||||
<IntlMessage id='newFeaturesUnderConstruction' />
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Tabs = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: ({ location: { pathname }, tabs, useUrl = false, value }) => ({
|
||||
value: (useUrl && pathname) || (value ?? tabs[0].value ?? tabs[0].pathname),
|
||||
}),
|
||||
effects: {
|
||||
onChange: function (_, value) {
|
||||
if (this.props.useUrl) {
|
||||
const { history, tabs } = this.props
|
||||
history.push(tabs.find(tab => (tab.value ?? tab.pathname) === value).pathname)
|
||||
}
|
||||
this.state.value = value
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state: { value }, indicatorColor, textColor, tabs }) => (
|
||||
<TabContext value={value}>
|
||||
<Box sx={BOX_STYLE}>
|
||||
<TabList indicatorColor={indicatorColor} onChange={effects.onChange} textColor={textColor}>
|
||||
{tabs.map((tab: UrlTab | NoUrlTab) => {
|
||||
const value = tab.value ?? tab.pathname
|
||||
return <Tab disabled={tab.disabled} key={value} label={tab.label} value={value} />
|
||||
})}
|
||||
</TabList>
|
||||
</Box>
|
||||
{tabs.map((tab: UrlTab | NoUrlTab) => {
|
||||
const value = tab.value ?? tab.pathname
|
||||
return (
|
||||
<TabPanel key={value} value={value}>
|
||||
{tab.component === undefined ? pageUnderConstruction : tab.component}
|
||||
</TabPanel>
|
||||
)
|
||||
})}
|
||||
</TabContext>
|
||||
)
|
||||
)
|
||||
|
||||
export default withRouter(Tabs)
|
||||
196
@xen-orchestra/lite/src/components/Tree.tsx
Normal file
196
@xen-orchestra/lite/src/components/Tree.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import classNames from 'classnames'
|
||||
import React, { useEffect } from 'react'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import TreeView from '@mui/lab/TreeView'
|
||||
import TreeItem, { useTreeItem, TreeItemContentProps } from '@mui/lab/TreeItem'
|
||||
import { withState } from 'reaclette'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import Icon from '../components/Icon'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
expandedNodes?: Array<string>
|
||||
selectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
export interface ItemType {
|
||||
children?: Array<ItemType>
|
||||
id: string
|
||||
label: React.ReactElement
|
||||
to?: string
|
||||
tooltip?: React.ReactNode
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// collection = [
|
||||
// {
|
||||
// id: 'idA',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='warehouse' /> {labelA}
|
||||
// </span>
|
||||
// ),
|
||||
// to: '/routeA',
|
||||
// children: [
|
||||
// {
|
||||
// id: 'ida',
|
||||
// label: label: (
|
||||
// <span>
|
||||
// <Icon icon='server' /> {labela}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// id: 'idB',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='warehouse' /> {labelB}
|
||||
// </span>
|
||||
// ),
|
||||
// to: '/routeB',
|
||||
// tooltip: <IntlMessage id='tooltipB' />
|
||||
// }
|
||||
// ]
|
||||
collection: Array<ItemType>
|
||||
defaultSelectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
interface CustomContentProps extends TreeItemContentProps {
|
||||
defaultSelectedNode?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
setExpandedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
|
||||
setSelectedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
defaultSelectedNode?: string
|
||||
}
|
||||
|
||||
// Inspired by https://mui.com/components/tree-view/#contentcomponent-prop.
|
||||
const CustomContent = React.forwardRef(function CustomContent(props: CustomContentProps, ref) {
|
||||
const { classes, className, defaultSelectedNode, expansionIcon, label, nodeId, to } = props
|
||||
const { focused, handleExpansion, handleSelection, selected } = useTreeItem(nodeId)
|
||||
const history = useHistory()
|
||||
|
||||
useEffect(() => {
|
||||
// There can only be one node selected at once for now.
|
||||
// Auto-revealing more than one node in the tree would require a different implementation.
|
||||
if (defaultSelectedNode === nodeId) {
|
||||
ref?.current?.scrollIntoView()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
to !== undefined && history.push(to)
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
const handleExpansionClick = (event: React.SyntheticEvent) => {
|
||||
event.stopPropagation()
|
||||
handleExpansion(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(className, { [classes.focused]: focused, [classes.selected]: selected })}
|
||||
onClick={handleSelection}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={classes.iconContainer} onClick={handleExpansionClick}>
|
||||
{expansionIcon}
|
||||
</span>
|
||||
<span className={classes.label}>{label}</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
const renderItem = ({ children, id, label, to, tooltip }: ItemType, defaultSelectedNode?: string) => {
|
||||
return (
|
||||
<TreeItem
|
||||
ContentComponent={CustomContent}
|
||||
// FIXME: ContentProps should only be React.HTMLAttributes<HTMLElement> or undefined, it doesn't support other type.
|
||||
// when https://github.com/mui-org/material-ui/issues/28668 is fixed, remove 'as CustomContentProps'.
|
||||
ContentProps={{ defaultSelectedNode, to } as CustomContentProps}
|
||||
label={tooltip ? <Tooltip title={tooltip}>{label}</Tooltip> : label}
|
||||
key={id}
|
||||
nodeId={id}
|
||||
>
|
||||
{Array.isArray(children) ? children.map(item => renderItem(item, defaultSelectedNode)) : null}
|
||||
</TreeItem>
|
||||
)
|
||||
}
|
||||
|
||||
const Tree = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: ({ collection, defaultSelectedNodes }) => {
|
||||
if (defaultSelectedNodes === undefined) {
|
||||
return {
|
||||
expandedNodes: [collection[0].id],
|
||||
selectedNodes: [],
|
||||
}
|
||||
}
|
||||
|
||||
// expandedNodes should contain all nodes up to the defaultSelectedNodes.
|
||||
const expandedNodes = new Set<string>()
|
||||
const pathToNode = new Set<string>()
|
||||
const addExpandedNode = (collection: Array<ItemType> | undefined) => {
|
||||
if (collection === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const node of collection) {
|
||||
if (defaultSelectedNodes.includes(node.id)) {
|
||||
for (const nodeId of pathToNode) {
|
||||
expandedNodes.add(nodeId)
|
||||
}
|
||||
}
|
||||
pathToNode.add(node.id)
|
||||
addExpandedNode(node.children)
|
||||
pathToNode.delete(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
addExpandedNode(collection)
|
||||
|
||||
return { expandedNodes: Array.from(expandedNodes), selectedNodes: defaultSelectedNodes }
|
||||
},
|
||||
effects: {
|
||||
setExpandedNodeIds: function (_, nodeIds) {
|
||||
this.state.expandedNodes = nodeIds
|
||||
},
|
||||
setSelectedNodeIds: function (_, nodeIds) {
|
||||
this.state.selectedNodes = [nodeIds[0]]
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
defaultSelectedNode: (_, { defaultSelectedNodes }) =>
|
||||
defaultSelectedNodes !== undefined ? defaultSelectedNodes[0] : undefined,
|
||||
},
|
||||
},
|
||||
({ effects, state: { defaultSelectedNode, expandedNodes, selectedNodes }, collection }) => (
|
||||
<TreeView
|
||||
defaultCollapseIcon={<Icon icon='chevron-up' />}
|
||||
defaultExpanded={[collection[0].id]}
|
||||
defaultExpandIcon={<Icon icon='chevron-down' />}
|
||||
expanded={expandedNodes}
|
||||
multiSelect
|
||||
onNodeSelect={effects.setSelectedNodeIds}
|
||||
onNodeToggle={effects.setExpandedNodeIds}
|
||||
selected={selectedNodes}
|
||||
>
|
||||
{collection.map(item => renderItem(item, defaultSelectedNode))}
|
||||
</TreeView>
|
||||
)
|
||||
)
|
||||
|
||||
export default Tree
|
||||
26
@xen-orchestra/lite/src/index.tsx
Normal file
26
@xen-orchestra/lite/src/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { createGlobalStyle } from 'styled-components'
|
||||
|
||||
import App from './App/index'
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, Verdana, Helvetica, Ubuntu, sans-serif;
|
||||
box-sizing: border-box;
|
||||
color: #212529;
|
||||
}
|
||||
`
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Helmet>
|
||||
<link rel='shortcut icon' href='favicon.ico' />
|
||||
</Helmet>
|
||||
<GlobalStyle />
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
55
@xen-orchestra/lite/src/lang/en.json
Normal file
55
@xen-orchestra/lite/src/lang/en.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"about": "About",
|
||||
"active": "Active",
|
||||
"availableUpdates": "{nUpdates, number} available update{nUpdates, plural, one {} other {s}}",
|
||||
"badCredentials": "Bad credentials",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmCtrlAltDel": "Send Ctrl+Alt+Del to VM?",
|
||||
"connect": "Connect",
|
||||
"connectionError": "Connection error",
|
||||
"consoleNotAvailable": "Console is only available for running VMs",
|
||||
"ctrlAltDel": "Ctrl+Alt+Del",
|
||||
"description": "Description",
|
||||
"device": "Device",
|
||||
"disconnect": "Disconnect",
|
||||
"dns": "DNS",
|
||||
"errorOccurred": "An error has occurred.",
|
||||
"gateway": "Gateway",
|
||||
"halted": "Halted",
|
||||
"hosts": "Hosts",
|
||||
"hostUnreachable": "Host unreachable",
|
||||
"inactive": "Inactive",
|
||||
"infrastructure": "Infrastructure",
|
||||
"ip": "IP",
|
||||
"loading": "Loading…",
|
||||
"login": "Login",
|
||||
"name": "Name",
|
||||
"newFeaturesUnderConstruction": "New features are coming soon!",
|
||||
"noHosts": "No hosts",
|
||||
"noData": "No data",
|
||||
"noImplemented": "Not implemented",
|
||||
"noManagementPifs": "No management PIFs found",
|
||||
"none": "None",
|
||||
"noVms": "No VMs",
|
||||
"notFound": "Not Found",
|
||||
"pageNotFound": "This page doesn't exist.",
|
||||
"xoLiteUnderConstruction": "XO Lite is under construction",
|
||||
"noUpdatesAvailable": "No updates available",
|
||||
"ok": "OK",
|
||||
"password": "Password",
|
||||
"paused": "Paused",
|
||||
"reconnectionAttempt": "Trying to reconnect…",
|
||||
"release": "Release",
|
||||
"rememberMe": "Remember me",
|
||||
"running": "Running",
|
||||
"size": "Size",
|
||||
"status": "Status",
|
||||
"suspended": "Suspended",
|
||||
"total": "Total",
|
||||
"unreachableHost": "Click here to make sure your host ({name}) is reachable. You may have to allow self-signed SSL certificates in your browser.",
|
||||
"vms": "VMs",
|
||||
"version": "Version",
|
||||
"versionValue": "Version {version}",
|
||||
"vmStartLabel": "Start"
|
||||
}
|
||||
4
@xen-orchestra/lite/src/lang/fr.json
Normal file
4
@xen-orchestra/lite/src/lang/fr.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"connect": "Connexion",
|
||||
"vmStartLabel": "Démarrer"
|
||||
}
|
||||
205
@xen-orchestra/lite/src/libs/xapi.ts
Normal file
205
@xen-orchestra/lite/src/libs/xapi.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { EventEmitter } from 'events'
|
||||
import { Map } from 'immutable'
|
||||
import { Xapi } from 'xen-api'
|
||||
|
||||
export interface XapiObject {
|
||||
$pool: Pool
|
||||
$ref: string
|
||||
$type: keyof types
|
||||
$id: string
|
||||
}
|
||||
|
||||
// Dictionary of XAPI types and their corresponding TypeScript types
|
||||
interface types {
|
||||
PIF: Pif
|
||||
pool: Pool
|
||||
VM: Vm
|
||||
host: Host
|
||||
}
|
||||
|
||||
// XAPI types ---
|
||||
|
||||
export interface Pif extends XapiObject {
|
||||
device: string
|
||||
DNS: string
|
||||
gateway: string
|
||||
IP: string
|
||||
management: boolean
|
||||
network: string
|
||||
}
|
||||
|
||||
export interface Pool extends XapiObject {
|
||||
name_label: string
|
||||
}
|
||||
|
||||
export interface PoolUpdate {
|
||||
changelog: {
|
||||
author: string
|
||||
date: Date
|
||||
description: string
|
||||
}
|
||||
description: string
|
||||
license: string
|
||||
name: string
|
||||
release: string
|
||||
size: number
|
||||
url: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface Vm extends XapiObject {
|
||||
$consoles: Array<{ protocol: string; location: string }>
|
||||
is_a_snapshot: boolean
|
||||
is_a_template: boolean
|
||||
is_control_domain: boolean
|
||||
name_description: string
|
||||
name_label: string
|
||||
power_state: string
|
||||
resident_on: string
|
||||
}
|
||||
|
||||
interface HostMetrics {
|
||||
live: boolean
|
||||
}
|
||||
export interface Host extends XapiObject {
|
||||
$metrics: HostMetrics
|
||||
address: string
|
||||
name_label: string
|
||||
power_state: string
|
||||
}
|
||||
|
||||
// --------
|
||||
|
||||
export interface ObjectsByType extends Map<string, Map<string, XapiObject>> {
|
||||
get<NSV, T extends keyof types>(key: T, notSetValue: NSV): Map<string, types[T]> | NSV
|
||||
get<T extends keyof types>(key: T): Map<string, types[T]> | undefined
|
||||
}
|
||||
|
||||
export default class XapiConnection extends EventEmitter {
|
||||
areObjectsFetched: Promise<void>
|
||||
connected: boolean
|
||||
objectsByType: ObjectsByType
|
||||
sessionId?: string
|
||||
|
||||
_resolveObjectsFetched!: () => void
|
||||
|
||||
_xapi?: {
|
||||
objects: EventEmitter & {
|
||||
all: { [id: string]: XapiObject }
|
||||
}
|
||||
connect(): Promise<void>
|
||||
disconnect(): Promise<void>
|
||||
call: (method: string, ...args: unknown[]) => Promise<unknown>
|
||||
_objectsFetched: Promise<void>
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.objectsByType = Map() as ObjectsByType
|
||||
this.connected = false
|
||||
this.areObjectsFetched = new Promise(resolve => {
|
||||
this._resolveObjectsFetched = resolve
|
||||
})
|
||||
}
|
||||
|
||||
async reattachSession(url: string): Promise<void> {
|
||||
const sessionId = Cookies.get('sessionId')
|
||||
if (sessionId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.connect({ url, sessionId })
|
||||
}
|
||||
|
||||
async connect({
|
||||
url,
|
||||
user = 'root',
|
||||
password,
|
||||
sessionId,
|
||||
rememberMe = Cookies.get('rememberMe') === 'true',
|
||||
}: {
|
||||
url: string
|
||||
user?: string
|
||||
password?: string
|
||||
sessionId?: string
|
||||
rememberMe?: boolean
|
||||
}): Promise<void> {
|
||||
const xapi = (this._xapi = new Xapi({
|
||||
auth: { user, password, sessionId },
|
||||
url,
|
||||
watchEvents: true,
|
||||
readonly: false,
|
||||
}))
|
||||
|
||||
const updateObjects = (objects: { [id: string]: XapiObject }) => {
|
||||
try {
|
||||
this.objectsByType = this.objectsByType.withMutations(objectsByType => {
|
||||
Object.entries(objects).forEach(([id, object]) => {
|
||||
if (object === undefined) {
|
||||
// Remove
|
||||
objectsByType.forEach((objects, type) => {
|
||||
objectsByType.set(type, objects.remove(id))
|
||||
})
|
||||
} else {
|
||||
// Add or update
|
||||
const { $type } = object
|
||||
objectsByType.set($type, objectsByType.get($type, Map<string, XapiObject>()).set(id, object))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.emit('objects', this.objectsByType)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
xapi.on('connected', () => {
|
||||
this.sessionId = xapi.sessionId
|
||||
this.connected = true
|
||||
this.emit('connected')
|
||||
})
|
||||
|
||||
xapi.on('disconnected', () => {
|
||||
Cookies.remove('sessionId')
|
||||
this.emit('disconnected')
|
||||
})
|
||||
|
||||
xapi.on('sessionId', (sessionId: string) => {
|
||||
if (rememberMe) {
|
||||
Cookies.set('rememberMe', 'true', { expires: 7 })
|
||||
}
|
||||
Cookies.set('sessionId', sessionId, rememberMe ? { expires: 7 } : undefined)
|
||||
})
|
||||
|
||||
await xapi.connect()
|
||||
await xapi._objectsFetched
|
||||
|
||||
updateObjects(xapi.objects.all)
|
||||
this._resolveObjectsFetched()
|
||||
|
||||
xapi.objects.on('add', updateObjects)
|
||||
xapi.objects.on('update', updateObjects)
|
||||
xapi.objects.on('remove', updateObjects)
|
||||
}
|
||||
|
||||
disconnect(): Promise<void> | undefined {
|
||||
Cookies.remove('rememberMe')
|
||||
Cookies.remove('sessionId')
|
||||
const { _xapi } = this
|
||||
if (_xapi !== undefined) {
|
||||
return _xapi.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
call(method: string, ...args: unknown[]): Promise<unknown> {
|
||||
const { _xapi, connected } = this
|
||||
if (!connected || _xapi === undefined) {
|
||||
throw new Error('Not connected to XAPI')
|
||||
}
|
||||
|
||||
return _xapi.call(method, ...args)
|
||||
}
|
||||
}
|
||||
63
@xen-orchestra/lite/tsconfig.json
Normal file
63
@xen-orchestra/lite/tsconfig.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
"jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
"declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./dist", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
"noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"resolveJsonModule": true
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
}
|
||||
}
|
||||
6
@xen-orchestra/lite/types/decs.d.ts
vendored
Normal file
6
@xen-orchestra/lite/types/decs.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '@novnc/novnc/lib/rfb'
|
||||
declare module 'human-format'
|
||||
declare module 'iterable-backoff'
|
||||
declare module 'json-rpc-protocol'
|
||||
declare module 'promise-toolbox'
|
||||
declare module 'xen-api'
|
||||
42
@xen-orchestra/lite/types/reaclette.d.ts
vendored
Normal file
42
@xen-orchestra/lite/types/reaclette.d.ts
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
type RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects> = {
|
||||
readonly effects: Effects & ParentEffects
|
||||
readonly state: State & ParentState & Computed
|
||||
readonly resetState: () => void
|
||||
} & Props
|
||||
|
||||
interface EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> {
|
||||
readonly effects: Effects & ParentEffects
|
||||
readonly state: State & ParentState & Computed
|
||||
readonly props: Props
|
||||
}
|
||||
|
||||
interface StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects> {
|
||||
initialState?: State | ((props: Props) => State) // what about Reaclette's state inheritance?
|
||||
effects?: {
|
||||
initialize?: () => void | Promise<void>
|
||||
finalize?: () => void | Promise<void>
|
||||
} & Effects &
|
||||
ThisType<EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
computed?: {
|
||||
[ComputedName in keyof Computed]: (
|
||||
state: State & ParentState & Computed,
|
||||
props: Props
|
||||
) => Computed[ComputedName] | Promise<Computed[ComputedName]>
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'reaclette' {
|
||||
function provideState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>
|
||||
): (component: React.Component<Props>) => React.Component<Props>
|
||||
|
||||
function injectState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
// FIXME: also accept class components
|
||||
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
): React.ElementType<Props>
|
||||
|
||||
function withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>,
|
||||
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
): React.ElementType<Props>
|
||||
}
|
||||
21
@xen-orchestra/lite/types/theme.d.ts
vendored
Normal file
21
@xen-orchestra/lite/types/theme.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Theme as ThemeMui, ThemeOptions as ThemeOptionsMui } from '@mui/material/styles'
|
||||
declare module '@mui/material/styles' {
|
||||
// FIXME: when https://github.com/microsoft/TypeScript/issues/40315 is fixed.
|
||||
// issue: Type 'Theme'/'ThemeOptions' recursively references itself as a base type.
|
||||
interface Theme extends ThemeMui {
|
||||
background: {
|
||||
primary: {
|
||||
dark: string
|
||||
light: string
|
||||
}
|
||||
}
|
||||
}
|
||||
interface ThemeOptions extends ThemeOptionsMui {
|
||||
background?: {
|
||||
primary?: {
|
||||
dark?: string
|
||||
light?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
@xen-orchestra/lite/webpack.config.js
Normal file
72
@xen-orchestra/lite/webpack.config.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
const path = require('path')
|
||||
const webpack = require('webpack')
|
||||
|
||||
const resolveApp = relative => path.resolve(__dirname, relative)
|
||||
|
||||
const { NODE_ENV = 'production' } = process.env
|
||||
const __PROD__ = NODE_ENV === 'production'
|
||||
|
||||
// https://webpack.js.org/configuration/
|
||||
module.exports = {
|
||||
mode: NODE_ENV,
|
||||
target: 'web',
|
||||
devServer: {
|
||||
historyApiFallback: true,
|
||||
},
|
||||
entry: resolveApp('src/index.tsx'),
|
||||
output: {
|
||||
filename: __PROD__ ? '[name].[contenthash:8].js' : '[name].js',
|
||||
path: resolveApp('dist'),
|
||||
},
|
||||
optimization: {
|
||||
moduleIds: __PROD__ ? 'deterministic' : undefined,
|
||||
runtimeChunk: true,
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
dns: false,
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
devtool: __PROD__ ? 'source-map' : 'eval-cheap-module-source-map',
|
||||
plugins: [
|
||||
new (require('clean-webpack-plugin').CleanWebpackPlugin)(),
|
||||
new (require('copy-webpack-plugin'))({
|
||||
patterns: [
|
||||
{
|
||||
from: resolveApp('public'),
|
||||
to: resolveApp('dist'),
|
||||
filter: file => file !== resolveApp('public/index.html'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
new (require('html-webpack-plugin'))({
|
||||
template: resolveApp('public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({ XAPI_HOST: '', NPM_VERSION: require('./package.json').version }),
|
||||
new (require('node-polyfill-webpack-plugin'))(),
|
||||
].filter(Boolean),
|
||||
}
|
||||
@@ -66,6 +66,10 @@ configure([
|
||||
// if filter is a string, then it is pattern
|
||||
// (https://github.com/visionmedia/debug#wildcards) which is
|
||||
// matched against the namespace of the logs
|
||||
//
|
||||
// If it's an array, it will be handled as an array of filters
|
||||
// and the transport will be used if any one of them match the
|
||||
// current log
|
||||
filter: process.env.DEBUG,
|
||||
|
||||
transport: transportConsole(),
|
||||
|
||||
@@ -4,6 +4,42 @@ const { compileGlobPattern } = require('./utils')
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const compileFilter = filter => {
|
||||
if (filter === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = typeof filter
|
||||
if (type === 'function') {
|
||||
return filter
|
||||
}
|
||||
if (type === 'string') {
|
||||
const re = compileGlobPattern(filter)
|
||||
return log => re.test(log.namespace)
|
||||
}
|
||||
|
||||
if (Array.isArray(filter)) {
|
||||
const filters = filter.map(compileFilter).filter(_ => _ !== undefined)
|
||||
const { length } = filters
|
||||
if (length === 0) {
|
||||
return
|
||||
}
|
||||
if (length === 1) {
|
||||
return filters[0]
|
||||
}
|
||||
return log => {
|
||||
for (let i = 0; i < length; ++i) {
|
||||
if (filters[i](log)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
throw new TypeError('unsupported `filter`')
|
||||
}
|
||||
|
||||
const createTransport = config => {
|
||||
if (typeof config === 'function') {
|
||||
return config
|
||||
@@ -19,26 +55,15 @@ const createTransport = config => {
|
||||
}
|
||||
}
|
||||
|
||||
let { filter } = config
|
||||
let transport = createTransport(config.transport)
|
||||
const level = resolve(config.level)
|
||||
const filter = compileFilter([config.filter, level === undefined ? undefined : log => log.level >= level])
|
||||
|
||||
let transport = createTransport(config.transport)
|
||||
|
||||
if (filter !== undefined) {
|
||||
if (typeof filter === 'string') {
|
||||
const re = compileGlobPattern(filter)
|
||||
filter = log => re.test(log.namespace)
|
||||
}
|
||||
|
||||
const orig = transport
|
||||
transport = function (log) {
|
||||
if ((level !== undefined && log.level >= level) || filter(log)) {
|
||||
return orig.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
} else if (level !== undefined) {
|
||||
const orig = transport
|
||||
transport = function (log) {
|
||||
if (log.level >= level) {
|
||||
if (filter(log)) {
|
||||
return orig.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/log",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "ISC",
|
||||
"description": "Logging system with decoupled producers/consumer",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/log",
|
||||
|
||||
@@ -20,36 +20,8 @@ if (process.stdout !== undefined && process.stdout.isTTY && process.stderr !== u
|
||||
}
|
||||
|
||||
const NAMESPACE_COLORS = [
|
||||
196,
|
||||
202,
|
||||
208,
|
||||
214,
|
||||
220,
|
||||
226,
|
||||
190,
|
||||
154,
|
||||
118,
|
||||
82,
|
||||
46,
|
||||
47,
|
||||
48,
|
||||
49,
|
||||
50,
|
||||
51,
|
||||
45,
|
||||
39,
|
||||
33,
|
||||
27,
|
||||
21,
|
||||
57,
|
||||
93,
|
||||
129,
|
||||
165,
|
||||
201,
|
||||
200,
|
||||
199,
|
||||
198,
|
||||
197,
|
||||
196, 202, 208, 214, 220, 226, 190, 154, 118, 82, 46, 47, 48, 49, 50, 51, 45, 39, 33, 27, 21, 57, 93, 129, 165, 201,
|
||||
200, 199, 198, 197,
|
||||
]
|
||||
formatNamespace = namespace => {
|
||||
// https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const get = require('lodash/get')
|
||||
const identity = require('lodash/identity')
|
||||
const isEqual = require('lodash/isEqual')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { parseDuration } = require('@vates/parse-duration')
|
||||
const { watch } = require('app-conf')
|
||||
@@ -48,7 +49,7 @@ module.exports = class Config {
|
||||
const watcher = config => {
|
||||
try {
|
||||
const value = processor(get(config, path))
|
||||
if (value !== prev) {
|
||||
if (!isEqual(value, prev)) {
|
||||
prev = value
|
||||
cb(value)
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/emit-async": "^0.0.0",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"@xen-orchestra/emit-async": "^0.1.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"app-conf": "^0.9.0",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
|
||||
@@ -28,9 +28,10 @@ export default {
|
||||
buffer.toString('hex', offset + 5, offset + 6),
|
||||
|
||||
stringToEth: (string, buffer, offset) => {
|
||||
const eth = /^([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2})$/.exec(
|
||||
string
|
||||
)
|
||||
const eth =
|
||||
/^([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2})$/.exec(
|
||||
string
|
||||
)
|
||||
assert(eth !== null)
|
||||
buffer.writeUInt8(parseInt(eth[1], 16), offset)
|
||||
buffer.writeUInt8(parseInt(eth[2], 16), offset + 1)
|
||||
@@ -50,9 +51,10 @@ export default {
|
||||
),
|
||||
|
||||
stringToip4: (string, buffer, offset) => {
|
||||
const ip = /^([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])$/.exec(
|
||||
string
|
||||
)
|
||||
const ip =
|
||||
/^([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])$/.exec(
|
||||
string
|
||||
)
|
||||
assert(ip !== null)
|
||||
buffer.writeUInt8(parseInt(ip[1], 10), offset)
|
||||
buffer.writeUInt8(parseInt(ip[2], 10), offset + 1)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"content-type": "^1.0.4",
|
||||
"cson-parser": "^4.0.7",
|
||||
"getopts": "^2.2.3",
|
||||
"http-request-plus": "^0.10.0",
|
||||
"http-request-plus": "^0.12",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"pump": "^3.0.0",
|
||||
|
||||
@@ -36,7 +36,14 @@ async function main(argv) {
|
||||
|
||||
const { hostname = 'localhost', port } = config?.http?.listen?.https ?? {}
|
||||
|
||||
const { _: args, file, help, host, raw, token } = getopts(argv, {
|
||||
const {
|
||||
_: args,
|
||||
file,
|
||||
help,
|
||||
host,
|
||||
raw,
|
||||
token,
|
||||
} = getopts(argv, {
|
||||
alias: { file: 'f', help: 'h' },
|
||||
boolean: ['help', 'raw'],
|
||||
default: {
|
||||
@@ -140,16 +147,6 @@ ${pkg.name} v${pkg.version}`
|
||||
}
|
||||
}
|
||||
|
||||
const $import = ({ $import: path }) => {
|
||||
const data = fs.readFileSync(path, 'utf8')
|
||||
const ext = extname(path).slice(1).toLowerCase()
|
||||
const parse = FORMATS[ext]
|
||||
if (parse === undefined) {
|
||||
throw new Error(`unsupported file: ${path}`)
|
||||
}
|
||||
return visit(parse(data))
|
||||
}
|
||||
|
||||
const seq = async seq => {
|
||||
const j = callPath.length
|
||||
for (let i = 0, n = seq.length; i < n; ++i) {
|
||||
@@ -163,13 +160,17 @@ ${pkg.name} v${pkg.version}`
|
||||
if (Array.isArray(node)) {
|
||||
return seq(node)
|
||||
}
|
||||
const keys = Object.keys(node)
|
||||
return keys.length === 1 && keys[0] === '$import' ? $import(node) : call(node)
|
||||
return call(node)
|
||||
}
|
||||
|
||||
let node
|
||||
if (file !== '') {
|
||||
node = { $import: file }
|
||||
const data = fs.readFileSync(file, 'utf8')
|
||||
const ext = extname(file).slice(1).toLowerCase()
|
||||
const parse = FORMATS[ext]
|
||||
if (parse === undefined) {
|
||||
throw new Error(`unsupported file: ${file}`)
|
||||
}
|
||||
await visit(parse(data))
|
||||
} else {
|
||||
const method = args[0]
|
||||
const params = {}
|
||||
@@ -182,9 +183,8 @@ ${pkg.name} v${pkg.version}`
|
||||
params[param.slice(0, j)] = parseValue(param.slice(j + 1))
|
||||
}
|
||||
|
||||
node = { method, params }
|
||||
await call({ method, params })
|
||||
}
|
||||
await visit(node)
|
||||
}
|
||||
main(process.argv.slice(2)).then(
|
||||
() => {
|
||||
|
||||
@@ -93,10 +93,7 @@ declare namespace event {
|
||||
declare namespace backup {
|
||||
type SimpleIdPattern = { id: string | { __or: string[] } }
|
||||
|
||||
declare namespace backup {
|
||||
type SimpleIdPattern = { id: string | { __or: string[] } }
|
||||
|
||||
interface BackupJob {
|
||||
interface BackupJob {
|
||||
id: string
|
||||
type: 'backup'
|
||||
compression?: 'native' | 'zstd' | ''
|
||||
@@ -146,13 +143,13 @@ declare namespace backup {
|
||||
}
|
||||
|
||||
function listXoMetadataBackups(_: { remotes: { [id: string]: Remote } }): { [remoteId: string]: object[] }
|
||||
|
||||
function run(_: {
|
||||
job: BackupJob | MetadataBackupJob
|
||||
|
||||
function run(_: {
|
||||
job: BackupJob | MetadataBackupJob
|
||||
remotes: { [id: string]: Remote }
|
||||
schedule: Schedule
|
||||
xapis?: { [id: string]: Xapi }
|
||||
recordToXapi?: { [recordUuid: string]: string }
|
||||
schedule: Schedule
|
||||
xapis?: { [id: string]: Xapi }
|
||||
recordToXapi?: { [recordUuid: string]: string }
|
||||
streamLogs: boolean = false
|
||||
}): string
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.7",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -18,9 +18,8 @@
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"preferGlobal": true,
|
||||
"main": "dist/",
|
||||
"bin": {
|
||||
"xo-proxy": "dist/index.js"
|
||||
"xo-proxy": "dist/index.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13"
|
||||
@@ -29,20 +28,20 @@
|
||||
"@iarna/toml": "^2.2.0",
|
||||
"@koa/router": "^10.0.0",
|
||||
"@vates/compose": "^2.0.0",
|
||||
"@vates/decorate-with": "^0.0.1",
|
||||
"@vates/decorate-with": "^0.1.0",
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.11.0",
|
||||
"@xen-orchestra/fs": "^0.17.0",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"@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.0",
|
||||
"@xen-orchestra/mixins": "^0.1.1",
|
||||
"@xen-orchestra/self-signed": "^0.1.0",
|
||||
"@xen-orchestra/xapi": "^0.6.2",
|
||||
"@xen-orchestra/xapi": "^0.7.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^0.9.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
"fs-extra": "^9.1.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"getopts": "^2.2.3",
|
||||
"golike-defer": "^0.5.1",
|
||||
@@ -59,7 +58,7 @@
|
||||
"source-map-support": "^0.5.16",
|
||||
"stoppable": "^1.0.6",
|
||||
"xdg-basedir": "^4.0.0",
|
||||
"xen-api": "^0.32.0",
|
||||
"xen-api": "^0.34.3",
|
||||
"xo-common": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,7 +72,7 @@
|
||||
"@vates/toggle-scripts": "^1.0.0",
|
||||
"babel-plugin-transform-dev": "^2.0.1",
|
||||
"cross-env": "^7.0.2",
|
||||
"index-modules": "^0.4.0"
|
||||
"index-modules": "^0.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
"_build": "index-modules --index-file index.mjs src/app/mixins && babel --delete-dir-on-start --keep-file-extension --source-maps --out-dir=dist/ src/",
|
||||
@@ -84,7 +83,7 @@
|
||||
"prepack": "toggle-scripts +postinstall +preuninstall",
|
||||
"prepublishOnly": "yarn run build",
|
||||
"_preuninstall": "./scripts/systemd-service-installer",
|
||||
"start": "./dist/index.js"
|
||||
"start": "./dist/index.mjs"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -15,12 +15,23 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
const { debug, warn } = createLogger('xo:proxy:api')
|
||||
|
||||
const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable) {
|
||||
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
|
||||
for await (const data of iterable) {
|
||||
try {
|
||||
yield JSON.stringify(data) + '\n'
|
||||
} catch (error) {
|
||||
warn('ndJsonStream', { error })
|
||||
let headerSent = false
|
||||
try {
|
||||
for await (const data of iterable) {
|
||||
if (!headerSent) {
|
||||
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
|
||||
headerSent = true
|
||||
}
|
||||
try {
|
||||
yield JSON.stringify(data) + '\n'
|
||||
} catch (error) {
|
||||
warn('ndJsonStream, item error', { error })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
warn('ndJsonStream, fatal error', { error })
|
||||
if (!headerSent) {
|
||||
yield format.error(responseId, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import Cancel from 'promise-toolbox/Cancel'
|
||||
import CancelToken from 'promise-toolbox/CancelToken'
|
||||
import Disposable from 'promise-toolbox/Disposable.js'
|
||||
import fromCallback from 'promise-toolbox/fromCallback.js'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
@@ -13,6 +11,7 @@ import { DurablePartition } from '@xen-orchestra/backups/DurablePartition.js'
|
||||
import { execFile } from 'child_process'
|
||||
import { formatVmBackups } from '@xen-orchestra/backups/formatVmBackups.js'
|
||||
import { ImportVmBackup } from '@xen-orchestra/backups/ImportVmBackup.js'
|
||||
import { JsonRpcError } from 'json-rpc-protocol'
|
||||
import { Readable } from 'stream'
|
||||
import { RemoteAdapter } from '@xen-orchestra/backups/RemoteAdapter.js'
|
||||
import { RestoreMetadataBackup } from '@xen-orchestra/backups/RestoreMetadataBackup.js'
|
||||
@@ -97,8 +96,7 @@ export default class Backups {
|
||||
error.jobId = jobId
|
||||
throw error
|
||||
}
|
||||
const source = CancelToken.source()
|
||||
runningJobs[jobId] = source.cancel
|
||||
runningJobs[jobId] = true
|
||||
try {
|
||||
return await run.apply(this, arguments)
|
||||
} finally {
|
||||
@@ -111,7 +109,7 @@ export default class Backups {
|
||||
if (!__DEV__) {
|
||||
const license = await app.appliance.getSelfLicense()
|
||||
if (license === undefined) {
|
||||
throw new Error('no valid proxy license')
|
||||
throw new JsonRpcError('no valid proxy license')
|
||||
}
|
||||
}
|
||||
return run.apply(this, arguments)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { asyncMapSettled } from '@xen-orchestra/async-map'
|
||||
|
||||
export default class Task {
|
||||
#tasks = new Map()
|
||||
|
||||
constructor(app) {
|
||||
const tasks = new Map()
|
||||
this.#tasks = tasks
|
||||
|
||||
app.api.addMethods({
|
||||
task: {
|
||||
*list() {
|
||||
for (const id of tasks.keys()) {
|
||||
yield { id }
|
||||
}
|
||||
},
|
||||
cancel: [
|
||||
({ taskId }) => this.cancel(taskId),
|
||||
{
|
||||
params: {
|
||||
taskId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
app.hooks.on('stop', () => asyncMapSettled(tasks.values(), task => task.cancel()))
|
||||
}
|
||||
|
||||
async cancel(taskId) {
|
||||
await this.tasks.get(taskId).cancel()
|
||||
}
|
||||
|
||||
register(task) {
|
||||
this.#tasks.set(task.id, task)
|
||||
}
|
||||
}
|
||||
@@ -27,20 +27,18 @@
|
||||
"xo-upload-ova": "dist/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
"node": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"form-data": "^4.0.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-promise": "^2.0.3",
|
||||
"fs-extra": "^10.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"http-request-plus": "^0.10.0",
|
||||
"http-request-plus": "^0.12",
|
||||
"human-format": "^0.11.0",
|
||||
"l33teral": "^3.0.3",
|
||||
"lodash": "^4.17.4",
|
||||
"nice-pipe": "0.0.0",
|
||||
"pretty-ms": "^7.0.0",
|
||||
"progress-stream": "^2.0.0",
|
||||
"pw": "^0.0.4",
|
||||
|
||||
@@ -6,7 +6,7 @@ import chalk from 'chalk'
|
||||
import execPromise from 'exec-promise'
|
||||
import FormData from 'form-data'
|
||||
import { createReadStream } from 'fs'
|
||||
import { stat } from 'fs-promise'
|
||||
import { stat } from 'fs-extra'
|
||||
import getStream from 'get-stream'
|
||||
import hrp from 'http-request-plus'
|
||||
import humanFormat from 'human-format'
|
||||
@@ -14,7 +14,6 @@ import l33t from 'l33teral'
|
||||
import isObject from 'lodash/isObject'
|
||||
import getKeys from 'lodash/keys'
|
||||
import startsWith from 'lodash/startsWith'
|
||||
import nicePipe from 'nice-pipe'
|
||||
import prettyMs from 'pretty-ms'
|
||||
import progressStream from 'progress-stream'
|
||||
import pw from 'pw'
|
||||
@@ -22,10 +21,13 @@ import stripIndent from 'strip-indent'
|
||||
import { URL } from 'url'
|
||||
import Xo from 'xo-lib'
|
||||
import { parseOVAFile } from 'xo-vmdk-to-vhd'
|
||||
import { pipeline } from 'stream'
|
||||
|
||||
import pkg from '../package'
|
||||
import { load as loadConfig, set as setConfig, unset as unsetConfig } from './config'
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
function help() {
|
||||
return stripIndent(
|
||||
`
|
||||
@@ -206,7 +208,7 @@ export async function upload(args) {
|
||||
url = new URL(result[key], baseUrl)
|
||||
|
||||
const { size: length } = await stat(file)
|
||||
const input = nicePipe([
|
||||
const input = pipeline(
|
||||
createReadStream(file),
|
||||
progressStream(
|
||||
{
|
||||
@@ -215,7 +217,8 @@ export async function upload(args) {
|
||||
},
|
||||
printProgress
|
||||
),
|
||||
])
|
||||
noop
|
||||
)
|
||||
formData.append('file', input, { filename: 'file', knownLength: length })
|
||||
try {
|
||||
return await hrp.post(url.toString(), { body: formData, headers: formData.getHeaders() }).readAll('utf-8')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/xapi",
|
||||
"version": "0.6.2",
|
||||
"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.32.0"
|
||||
"xen-api": "^0.34.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
@@ -38,9 +38,9 @@
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^0.0.1",
|
||||
"@vates/decorate-with": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"lodash": "^4.17.15",
|
||||
|
||||
173
CHANGELOG.md
173
CHANGELOG.md
@@ -2,14 +2,181 @@
|
||||
|
||||
## **next**
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [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))
|
||||
|
||||
### 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))
|
||||
|
||||
### Released packages
|
||||
|
||||
- xen-api 0.32
|
||||
- xen-api 0.34.3
|
||||
- vhd-lib 1.2.0
|
||||
- xo-server-netbox 0.3.1
|
||||
- @xen-orchestra/proxy 0.14.7
|
||||
- xo-server 5.82.3
|
||||
- xo-web 5.88.0
|
||||
|
||||
## **5.58.1** (2021-05-06)
|
||||
## **5.62.1** (2021-09-17)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [VM/Advanced] Fix conversion from UEFI to BIOS boot firmware (PR [#5895](https://github.com/vatesfr/xen-orchestra/pull/5895))
|
||||
- [VM/network] Support newline-delimited IP addresses reported by some guest tools
|
||||
- Fix VM/host stats, VM creation with Cloud-init, and VM backups, with NATted hosts [#5896](https://github.com/vatesfr/xen-orchestra/issues/5896)
|
||||
- [VM/import] Very small VMDK and OVA files were mangled upon import (PR [#5903](https://github.com/vatesfr/xen-orchestra/pull/5903))
|
||||
|
||||
### Released packages
|
||||
|
||||
- xen-api 0.34.2
|
||||
- @xen-orchestra/proxy 0.14.6
|
||||
- xo-server 5.82.2
|
||||
|
||||
## **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))
|
||||
- [Backup] Use default migration network if set when importing/exporting VMs/VDIs (PR [#5883](https://github.com/vatesfr/xen-orchestra/pull/5883))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [New network] Ability for pool's admin to create a new network within the pool (PR [#5873](https://github.com/vatesfr/xen-orchestra/pull/5873))
|
||||
- [Netbox] Synchronize primary IPv4 and IPv6 addresses [#5633](https://github.com/vatesfr/xen-orchestra/issues/5633) (PR [#5879](https://github.com/vatesfr/xen-orchestra/pull/5879))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [VM/network] Fix an issue where multiple IPs would be displayed in the same tag when using old Xen tools. This also fixes Netbox's IP synchronization for the affected VMs. (PR [#5860](https://github.com/vatesfr/xen-orchestra/pull/5860))
|
||||
- [LDAP] Handle groups with no members (PR [#5862](https://github.com/vatesfr/xen-orchestra/pull/5862))
|
||||
- Fix empty button on small size screen (PR [#5874](https://github.com/vatesfr/xen-orchestra/pull/5874))
|
||||
- [Host] Fix `Cannot read property 'other_config' of undefined` error when enabling maintenance mode (PR [#5875](https://github.com/vatesfr/xen-orchestra/pull/5875))
|
||||
|
||||
### Released packages
|
||||
|
||||
- xen-api 0.34.1
|
||||
- @xen-orchestra/xapi 0.7.0
|
||||
- @xen-orchestra/backups 0.13.0
|
||||
- @xen-orchestra/fs 0.18.0
|
||||
- @xen-orchestra/log 0.3.0
|
||||
- @xen-orchestra/mixins 0.1.1
|
||||
- xo-server-auth-ldap 0.10.4
|
||||
- xo-server-netbox 0.3.0
|
||||
- xo-server 5.82.1
|
||||
- xo-web 5.87.0
|
||||
|
||||
## **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))
|
||||
- [Netbox] Optionally allow self-signed certificates (PR [#5850](https://github.com/vatesfr/xen-orchestra/pull/5850))
|
||||
- [Host] When supported, use pool's default migration network to evacuate host [#5802](https://github.com/vatesfr/xen-orchestra/issues/5802) (PR [#5851](https://github.com/vatesfr/xen-orchestra/pull/5851))
|
||||
- [VM] shutdown/reboot: offer to force shutdown/reboot the VM if no Xen tools were detected [#5838](https://github.com/vatesfr/xen-orchestra/issues/5838) (PR [#5855](https://github.com/vatesfr/xen-orchestra/pull/5855))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Netbox] Add information about a failed request to the error log to help better understand what happened [#5834](https://github.com/vatesfr/xen-orchestra/issues/5834) (PR [#5842](https://github.com/vatesfr/xen-orchestra/pull/5842))
|
||||
- [VM/console] Ability to rescan ISO SRs (PR [#5841](https://github.com/vatesfr/xen-orchestra/pull/5841))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [VM/disks] Fix `an error has occured` when self service user was on VM disk view (PR [#5841](https://github.com/vatesfr/xen-orchestra/pull/5841))
|
||||
- [Backup] Protect replicated VMs from being started on specific hosts (PR [#5852](https://github.com/vatesfr/xen-orchestra/pull/5852))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/backups 0.12.2
|
||||
- @xen-orchestra/proxy 0.14.4
|
||||
- xo-server-netbox 0.2.0
|
||||
- xo-web 5.86.0
|
||||
- xo-server 5.81.2
|
||||
|
||||
## **5.60.0** (2021-06-30)
|
||||
|
||||
### Highlights
|
||||
|
||||
- [VM/disks] Ability to rescan ISO SRs (PR [#5814](https://github.com/vatesfr/xen-orchestra/pull/5814))
|
||||
- [VM/snapshots] Identify VM's current snapshot with an icon next to the snapshot's name (PR [#5824](https://github.com/vatesfr/xen-orchestra/pull/5824))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [OVA import] improve OVA import error reporting (PR [#5797](https://github.com/vatesfr/xen-orchestra/pull/5797))
|
||||
- [Backup] Distinguish error messages between cancelation and interrupted HTTP connection
|
||||
- [Jobs] Add `host.emergencyShutdownHost` to the list of methods that jobs can call (PR [#5818](https://github.com/vatesfr/xen-orchestra/pull/5818))
|
||||
- [Host/Load-balancer] Log VM and host names when a VM is migrated + category (density, performance, ...) (PR [#5808](https://github.com/vatesfr/xen-orchestra/pull/5808))
|
||||
- [VM/new disk] Auto-fill disk name input with generated unique name (PR [#5828](https://github.com/vatesfr/xen-orchestra/pull/5828))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [IPs] Handle space-delimited IP address format provided by outdated guest tools [5801](https://github.com/vatesfr/xen-orchestra/issues/5801) (PR [5805](https://github.com/vatesfr/xen-orchestra/pull/5805))
|
||||
- [API/pool.listPoolsMatchingCriteria] fix `unknown error from the peer` error (PR [5807](https://github.com/vatesfr/xen-orchestra/pull/5807))
|
||||
- [Backup] Limit number of connections to hosts, which should reduce the occurences of `ECONNRESET`
|
||||
- [Plugins/perf-alert] All mode: only selects running hosts and VMs (PR [5811](https://github.com/vatesfr/xen-orchestra/pull/5811))
|
||||
- [New VM] Fix summary section always showing "0 B" for RAM (PR [#5817](https://github.com/vatesfr/xen-orchestra/pull/5817))
|
||||
- [Backup/Restore] Fix _start VM after restore_ [5820](https://github.com/vatesfr/xen-orchestra/issues/5820)
|
||||
- [Netbox] Fix a bug where some devices' IPs would get deleted from Netbox (PR [#5821](https://github.com/vatesfr/xen-orchestra/pull/5821))
|
||||
- [Netbox] Fix an issue where some IPv6 would be deleted just to be immediately created again (PR [#5822](https://github.com/vatesfr/xen-orchestra/pull/5822))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @vates/decorate-with 0.1.0
|
||||
- xen-api 0.33.1
|
||||
- @xen-orchestra/xapi 0.6.4
|
||||
- @xen-orchestra/backups 0.12.0
|
||||
- @xen-orchestra/proxy 0.14.3
|
||||
- vhd-lib 1.1.0
|
||||
- vhd-cli 0.4.0
|
||||
- xo-server-netbox 0.1.2
|
||||
- xo-server-perf-alert 0.3.2
|
||||
- xo-server-load-balancer 0.7.0
|
||||
- xo-server 5.80.0
|
||||
- xo-web 5.84.0
|
||||
|
||||
## **5.59.0** (2021-05-31)
|
||||
|
||||
### Highlights
|
||||
|
||||
- [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))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [SAML] Compatible with users created with other authentication providers (PR [#5781](https://github.com/vatesfr/xen-orchestra/pull/5781))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [SDN Controller] Private network creation failure when the tunnels were created on different devices [Forum #4620](https://xcp-ng.org/forum/topic/4620/no-pif-found-in-center) (PR [#5793](https://github.com/vatesfr/xen-orchestra/pull/5793))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/emit-async 0.1.0
|
||||
- @xen-orchestra/defined 0.0.1
|
||||
- xo-collection 0.5.0
|
||||
- @xen-orchestra/log 0.2.1
|
||||
- xen-api 0.33.0
|
||||
- @xen-orchestra/xapi 0.6.3
|
||||
- xo-server-auth-saml 0.9.0
|
||||
- xo-server-backup-reports 0.16.10
|
||||
- xo-server-netbox 0.1.1
|
||||
- xo-server-sdn-controller 1.0.5
|
||||
- xo-web 5.82.0
|
||||
- xo-server 5.79.5
|
||||
|
||||
## **5.58.1** (2021-05-06)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Backups] Better handling of errors in remotes, fix `task has already ended`
|
||||
@@ -64,8 +231,6 @@
|
||||
|
||||
## **5.57.1** (2021-04-13)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Host/Load-balancer] Add option to disable migration (PR [#5706](https://github.com/vatesfr/xen-orchestra/pull/5706))
|
||||
|
||||
@@ -7,28 +7,16 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [Metadata Backup] Add a warning on restoring a metadata backup (PR [#5769](https://github.com/vatesfr/xen-orchestra/pull/5769))
|
||||
- [SAML] Compatible with users created with other authentication providers (PR [#5781](https://github.com/vatesfr/xen-orchestra/pull/5781))
|
||||
- [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))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Smart backup] Report missing pools [#2844](https://github.com/vatesfr/xen-orchestra/issues/2844) (PR [#5768](https://github.com/vatesfr/xen-orchestra/pull/5768))
|
||||
|
||||
### Packages to release
|
||||
|
||||
> Packages will be released in the order they are here, therefore, they should
|
||||
> be listed by inverse order of dependency.
|
||||
>
|
||||
> Global order:
|
||||
>
|
||||
> - @vates/...
|
||||
> - @xen-orchestra/...
|
||||
> - xo-server-...
|
||||
> - xo-server
|
||||
> - xo-web
|
||||
> Rule of thumb: add packages on top.
|
||||
>
|
||||
> The format is the following: - `$packageName` `$version`
|
||||
>
|
||||
@@ -39,14 +27,3 @@
|
||||
> - major: if the change breaks compatibility
|
||||
>
|
||||
> In case of conflict, the highest (lowest in previous list) `$version` wins.
|
||||
|
||||
- @xen-orchestra/emit-async minor
|
||||
- @xen-orchestra/defined patch
|
||||
- xo-collection minor
|
||||
- @xen-orchestra/log patch
|
||||
- xen-api minor
|
||||
- xo-server-auth-saml minor
|
||||
- xo-server-backup-reports patch
|
||||
- xo-server-netbox minor
|
||||
- xo-web minor
|
||||
- xo-server patch
|
||||
|
||||
@@ -114,17 +114,18 @@ We need your feedback on this feature!
|
||||
|
||||
The plugin "web-hooks" needs to be installed and loaded for this feature to work.
|
||||
|
||||
You can trigger an HTTP POST request to a URL when a Xen Orchestra API method is called.
|
||||
You can trigger an HTTP POST request to a URL when a Xen Orchestra API method is called or when a backup job runs.
|
||||
|
||||
- Go to Settings > Plugins > Web hooks
|
||||
- Add new hooks
|
||||
- For each hook, configure:
|
||||
- Method: the XO API method that will trigger the HTTP request when called
|
||||
- Method: the XO API method that will trigger the HTTP request when called. For backup jobs, choose `backupNg.runJob`.
|
||||
- Type:
|
||||
- pre: the request will be sent when the method is called
|
||||
- post: the request will be sent after the method action is completed
|
||||
- pre/post: both
|
||||
- URL: the full URL which the requests will be sent to
|
||||
- Wait for response: you can choose to wait for the web hook response before the method is actually called ("pre" hooks only). This can be useful if you need to automatically run some tasks before a certain method is called.
|
||||
- Save the plugin configuration
|
||||
|
||||
From now on, a request will be sent to the corresponding URLs when a configured method is called by an XO client.
|
||||
@@ -340,13 +341,14 @@ XO will try to find the right prefix for each IP address. If it can't find a pre
|
||||
- Create a token with "Write enabled"
|
||||
- Add a UUID custom field:
|
||||
- Got to Admin > Custom fields > Add custom field
|
||||
- Create a custom field called "uuid"
|
||||
- Create a custom field called "uuid" (lower case!)
|
||||
- Assign it to object types `virtualization > cluster` and `virtualization > virtual machine`
|
||||
|
||||

|
||||
|
||||
- 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
|
||||
- Token: the token you generated earlier
|
||||
- Pools: the pools you wish to automatically synchronize with Netbox
|
||||
- Interval: the time interval (in hours) between 2 auto-synchronizations. Leave empty if you don't want to synchronize automatically.
|
||||
|
||||
BIN
docs/assets/antiaffinity.png
Normal file
BIN
docs/assets/antiaffinity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -87,3 +87,7 @@ You need to be an admin:
|
||||

|
||||
|
||||

|
||||
|
||||
## Web hooks
|
||||
|
||||
You can also configure web hooks to be sent to a custom server before and/or after a backup job runs. This won't send a formatted report but raw JSON data that you can use in custom scripts on your side. Follow the [web-hooks plugin documentation](./advanced.html#web-hooks) to configure it.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user