Compare commits

...

113 Commits

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

- Check if `uuid` custom field has correctly been configured before synchronizing
- Delete VMs that don't have a UUID before synchronizing VMs to avoid conflicts
2021-09-22 18:08:09 +02:00
Nicolas Raynaud
ffb6a8fa3f feat(VHD import): ensure uploaded file is a VHD (#5906) 2021-09-21 16:25:50 +02:00
Pierre Donias
0966efb7f2 fix(xo-server-netbox): handle nested prefixes (#5908)
See xoa-support#4018

When assigning prefixes to VMs, always pick the smallest prefix that the IP
matches
2021-09-21 09:55:47 +02:00
Julien Fontanet
4a0a708092 feat: release 5.62.1 2021-09-17 10:04:36 +02:00
Julien Fontanet
6bf3b6f3e0 feat(xo-server): 5.82.2 2021-09-17 09:24:32 +02:00
Julien Fontanet
8f197fe266 feat(@xen-orchestra/proxy): 0.14.6 2021-09-17 09:24:05 +02:00
Julien Fontanet
e1a3f680f2 feat(xen-api): 0.34.2 2021-09-17 09:23:28 +02:00
Julien Fontanet
e89cca7e90 feat: technical release 2021-09-17 09:19:26 +02:00
Nicolas Raynaud
5bb2767d62 fix(xo-server/{disk,vm}.import): fix import of very small VMDK files (#5903) 2021-09-17 09:17:34 +02:00
Julien Fontanet
95f029e0e7 fix(xen-api/putResource): fix non-stream use case
Introduced by ea10df8a92
2021-09-14 17:42:20 +02:00
Julien Fontanet
fb21e4d585 chore(xen-api/_setHostAddressInUrl): use _roCall to fetch network ref
Introduced by a84fac1b6
2021-09-14 17:42:20 +02:00
Julien Fontanet
633805cec9 fix(xen-api/_setHostAddressInUrl): correctly fetch network ref
Introduced by a84fac1b6
2021-09-14 17:42:20 +02:00
Marc Ungeschikts
b8801d7d2a "rentention" instead of "retention" (#5904) 2021-09-14 16:30:10 +02:00
Julien Fontanet
a84fac1b6a fix(xen-api/{get,put}Resource): use provided address when possible
Fixes #5896

Introduced by ea10df8a92

Don't use the address provided by XAPI when connecting to the pool master and without a default migration network as it will unnecessarily break NATted hosts.
2021-09-14 13:52:34 +02:00
Julien Fontanet
a9de4ceb30 chore(xo-server/config.toml): explicit auth delay is per user 2021-09-12 10:55:31 +02:00
Julien Fontanet
827b55d60c fix(xo-server/config.toml): typo 2021-09-12 10:54:49 +02:00
Julien Fontanet
0e1fe76b46 chore: update dev deps 2021-09-09 13:48:15 +02:00
Julien Fontanet
097c9e8e12 feat(@xen-orchestra/proxy): 0.14.5 2021-09-07 19:02:57 +02:00
Pierre Donias
266356cb20 fix(xo-server/xapi-objects-to-xo/VM/addresses): handle newline-delimited IPs (#5897)
See xoa-support#3812
See #5860

This is related to a505cd9 which handled space delimited IPs, but apparently,
IPs can also be newline delimited depending on which Xen tools version is used.
2021-09-03 12:30:47 +02:00
Julien Fontanet
6dba39a804 fix(xo-server/vm.set): fix converting to BIOS (#5895)
Fixes xoa-support#3991
2021-09-02 14:11:39 +02:00
Olivier Lambert
3ddafa7aca fix(docs/xoa): clarify first console connection (#5894) 2021-09-01 12:51:33 +02:00
Julien Fontanet
9d8e232684 chore(xen-api): dont import promise-toolbox/retry twice
Introduced by ea10df8a9
2021-08-31 12:28:23 +02:00
Anthony Stivers
bf83c269c4 fix(xo-web/user): SSH key formatting (#5892)
Fixes #5891

Allow SSH key to be broken anywhere to avoid breaking page formatting.
2021-08-31 11:42:25 +02:00
Pierre Donias
54e47c98cc feat: release 5.62.0 (#5893) 2021-08-31 10:59:07 +02:00
Pierre Donias
118f2594ea feat: technical release (#5889) 2021-08-30 15:40:26 +02:00
Julien Fontanet
ab4fcd6ac4 fix(xen-api/{get,put}Resource): correctly fetch host
Introduced by ea10df8a9
2021-08-30 15:23:42 +02:00
Pierre Donias
ca6f345429 feat: technical release (#5888) 2021-08-30 12:08:10 +02:00
Pierre Donias
79b8e1b4e4 fix(xo-server-auth-ldap): ensure-array dependency (#5887) 2021-08-30 12:01:06 +02:00
Pierre Donias
cafa1ffa14 feat: technical release (#5886) 2021-08-30 11:01:14 +02:00
Mathieu
ea10df8a92 feat(xen-api/{get,put}Resource): use default migration network if available (#5883) 2021-08-30 00:14:31 +02:00
Julien Fontanet
85abc42100 chore(xo-web): use sass instead of node-sass
Fixes build with Node 16
2021-08-27 14:22:00 +02:00
Mathieu
4747eb4386 feat(host): display warning for eol host version (#5847)
Fixes #5840
2021-08-24 14:43:01 +02:00
tisteagle
ad9cc900b8 feat(docs/updater): add nodejs.org to required domains (#5881) 2021-08-22 16:33:16 +02:00
Pierre Donias
6cd93a7bb0 feat(xo-server-netbox): add primary IPs to VMs (#5879)
See xoa-support#3812
See #5633
2021-08-20 12:47:29 +02:00
Julien Fontanet
3338a02afb feat(fs/getSyncedHandler): returns disposable to an already synced remote
Also, no need to forget it.
2021-08-20 10:14:39 +02:00
Julien Fontanet
31cfe82224 chore: update to index-modules@0.4.3
Fixes #5877

Introduced by 030477454

This new version fixes the `--auto` mode used by `xo-web`.
2021-08-18 10:08:10 +02:00
Pierre Donias
70a191336b fix(CHANGELOG): missing PR link (#5876) 2021-08-17 10:13:22 +02:00
Julien Fontanet
030477454c chore: update deps 2021-08-17 09:59:42 +02:00
Pierre Donias
2a078d1572 fix(xo-server/host): clearHost argument needs to have a $pool property (#5875)
See xoa-support#3118
Introduced by b2a56c047c
2021-08-17 09:51:36 +02:00
Julien Fontanet
3c1f96bc69 chore: update dev deps 2021-08-16 14:10:18 +02:00
Mathieu
7d30bdc148 fix(xo-web/TabButtonLink): should not be empty on small screens (#5874) 2021-08-16 09:45:44 +02:00
Mathieu
5d42961761 feat(xo-server/network.create): allow pool admins (#5873) 2021-08-13 14:22:58 +02:00
Julien Fontanet
f20d5cd8d3 feat(xo-server): logging is now dynamically configurable 2021-08-12 17:30:56 +02:00
Julien Fontanet
f5111c0f41 fix(mixins/Config#watch): use deep equality to check changes
Because objects (and arrays) will always be new ones and thus different.
2021-08-12 17:29:57 +02:00
Pierre Donias
f5473236d0 fix(xo-web): dont warn when restoring XO config (#5872) 2021-08-12 09:52:45 +02:00
Julien Fontanet
d3cb31f1a7 feat(log/configure): filter can be an array 2021-08-11 18:09:42 +02:00
Pierre Donias
d5f5cdd27a fix(xo-server-auth-ldap): create logger inside plugin (#5864)
The plugin was wrongly expecting a logger instance to be passed on instantiation
2021-08-11 11:21:22 +02:00
Pierre Donias
656dc8fefc fix(xo-server-ldap): handle groups with no members (#5862)
See xoa-support#3906
2021-08-10 14:12:39 +02:00
Pierre Donias
a505cd9567 fix(xo-server/xapi-objects-to-xo/VM/addresses): handle old tools alias properties (#5860)
See https://xcp-ng.org/forum/topic/4810
See #5805
2021-08-10 10:22:13 +02:00
117 changed files with 8184 additions and 1425 deletions

View File

@@ -24,7 +24,7 @@
"dependencies": {
"@vates/multi-key-map": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"ensure-array": "^1.0.0"
}
}

View File

@@ -31,7 +31,7 @@
},
"dependencies": {
"@vates/decorate-with": "^0.1.0",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"golike-defer": "^0.5.1",
"object-hash": "^2.0.1"
},

View File

@@ -56,14 +56,22 @@ module.exports = function (pkg, configs = {}) {
}),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
targets: (() => {
const targets = {}
if (pkg.browserslist !== undefined) {
targets.browsers = pkg.browserslist
}
let node = (pkg.engines || {}).node
if (node !== undefined) {
const trimChars = '^=>~'
while (trimChars.includes(node[0])) {
node = node.slice(1)
}
targets.node = node
}
return { browsers: pkg.browserslist, node }
return targets
})(),
}
}

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.12.2",
"@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",

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.12.2",
"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.1",
"@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.1.0",
"pump": "^3.0.0",
"vhd-lib": "^1.2.0",
"yazl": "^2.5.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^0.6.4"
"@xen-orchestra/xapi": "^0.7.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

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

View File

@@ -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": {

View File

@@ -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'

View File

@@ -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,
}
}

View File

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

View File

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

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -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(),

View File

@@ -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)
}
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/log",
"version": "0.2.1",
"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",

View File

@@ -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)
}

View File

@@ -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.1.0",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"app-conf": "^0.9.0",
"lodash": "^4.17.21"
},

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.14.4",
"version": "0.14.7",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -31,17 +31,17 @@
"@vates/decorate-with": "^0.1.0",
"@vates/disposable": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.12.2",
"@xen-orchestra/fs": "^0.17.0",
"@xen-orchestra/log": "^0.2.1",
"@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.4",
"@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",
@@ -58,7 +58,7 @@
"source-map-support": "^0.5.16",
"stoppable": "^1.0.6",
"xdg-basedir": "^4.0.0",
"xen-api": "^0.33.1",
"xen-api": "^0.34.3",
"xo-common": "^0.7.0"
},
"devDependencies": {
@@ -72,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/",

View File

@@ -15,9 +15,13 @@ 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'
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) {
@@ -26,6 +30,9 @@ const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable
}
} catch (error) {
warn('ndJsonStream, fatal error', { error })
if (!headerSent) {
yield format.error(responseId, error)
}
}
})

View File

@@ -11,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'
@@ -108,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)

View File

@@ -33,7 +33,7 @@
"chalk": "^4.1.0",
"exec-promise": "^0.7.0",
"form-data": "^4.0.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"get-stream": "^6.0.0",
"http-request-plus": "^0.12",
"human-format": "^0.11.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/xapi",
"version": "0.6.4",
"version": "0.7.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
@@ -25,7 +25,7 @@
"xo-common": "^0.7.0"
},
"peerDependencies": {
"xen-api": "^0.33.1"
"xen-api": "^0.34.3"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
@@ -40,7 +40,7 @@
"dependencies": {
"@vates/decorate-with": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"d3-time-format": "^3.0.0",
"golike-defer": "^0.5.1",
"lodash": "^4.17.15",

View File

@@ -1,8 +1,82 @@
# ChangeLog
## **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.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.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="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Highlights
@@ -31,8 +105,6 @@
## **5.60.0** (2021-06-30)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Highlights
- [VM/disks] Ability to rescan ISO SRs (PR [#5814](https://github.com/vatesfr/xen-orchestra/pull/5814))

View File

@@ -1,6 +1,6 @@
# Full backups
You can schedule full backups of your VMs, by exporting them to the local XOA file-system, or directly to an NFS or SMB share. The "rentention" parameter allows you to modify how many backups are retained (by removing the oldest one).
You can schedule full backups of your VMs, by exporting them to the local XOA file-system, or directly to an NFS or SMB share. The "retention" parameter allows you to modify how many backups are retained (by removing the oldest one).
[![](./assets/backupexample.png)](https://xen-orchestra.com/blog/backup-your-xenserver-vms-with-xen-orchestra/)

View File

@@ -10,7 +10,7 @@ By design, the updater is only available in XOA. If you are using XO from the so
## Requirements
In order to work, the updater needs access to `xen-orchestra.com` (port 443).
In order to work, the updater needs access to `xen-orchestra.com` (port 443) and `nodejs.org` (port 443).
## Usage

View File

@@ -97,59 +97,25 @@ After the VM is imported, you just need to start it with `xe vm-start vm="XOA"`
## First console connection
If you connect via SSH or console, the default credentials are:
### Deployed with the [web deploy form](https://xen-orchestra.com/#!/xoa)
- user: xoa
- password: xoa
In that case, you already set the password for `xoa` user. If you forgot it, see below.
During your first connection, the system will ask you to:
### Manually deployed
- enter the current password again (`xoa`)
- enter your new password
- retype your new password
When it's done, you'll be disconnected, so reconnect again with your new password.
Here is an example when you connect via SSH for the first time:
If you connect via SSH or console for the first time without using our [web deploy form](https://xen-orchestra.com/#!/xoa), be aware **there's NO default password set for security reasons**. To set it, you need to connect to your host to find the XOA VM UUID (eg via `xe vm-list`).
Then replace `<UUID>` with the previously find UUID, and `<password>` with your password:
```
$ ssh xoa@192.168.100.146
Warning: Permanently added '192.168.100.146' (ECDSA) to the list of known hosts.
xoa@192.168.100.146's password:
You are required to change your password immediately (root enforced)
__ __ ____ _ _
\ \ / / / __ \ | | | |
\ V / ___ _ __ | | | |_ __ ___| |__ ___ ___| |_ _ __ __ _
> < / _ \ '_ \ | | | | '__/ __| '_ \ / _ \/ __| __| '__/ _` |
/ . \ __/ | | | | |__| | | | (__| | | | __/\__ \ |_| | | (_| |
/_/ \_\___|_| |_| \____/|_| \___|_| |_|\___||___/\__|_| \__,_|
Welcome to XOA Unified Edition, with Pro Support.
* Restart XO: sudo systemctl restart xo-server.service
* Display logs: sudo systemctl status xo-server.service
* Register your XOA: sudo xoa-updater --register
* Update your XOA: sudo xoa-updater --upgrade
OFFICIAL XOA DOCUMENTATION HERE: https://xen-orchestra.com/docs/xoa.html
Support available at https://xen-orchestra.com/#!/member/support
Build number: 16.10.24
Based on Debian GNU/Linux 8 (Stable) 64bits in PVHVM mode
WARNING: Your password has expired.
You must change your password now and login again!
Changing password for xoa.
(current) UNIX password:
Enter new UNIX password:
Retype new UNIX password:
passwd: password updated successfully
Connection to 192.168.100.146 closed.
$
xe vm-param-set uuid=<UUID> xenstore-data:vm-data/system-account-xoa-password=<password>
```
:::tip
Don't forget to use quotes for your password, eg: `xenstore-data:vm-data/system-account-xoa-password='MyPassW0rd!'`
:::
Then, you could connect with `xoa` username and the password you defined in the previous command, eg with `ssh xoa@<XOA IP ADDRESS>`.
### Using sudo
To avoid typing `sudo` for any admin command, you can have a root shell with `sudo -s`:

View File

@@ -12,14 +12,14 @@
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-react": "^7.21.5",
"exec-promise": "^0.7.0",
"globby": "^11.0.1",
"handlebars": "^4.7.6",
"husky": "^4.2.5",
"jest": "^26.0.1",
"lint-staged": "^10.2.7",
"lint-staged": "^11.1.2",
"lodash": "^4.17.4",
"prettier": "^2.0.5",
"promise-toolbox": "^0.19.2",

View File

@@ -24,11 +24,11 @@
"node": ">=8.10"
},
"dependencies": {
"@xen-orchestra/fs": "^0.17.0",
"@xen-orchestra/fs": "^0.18.0",
"cli-progress": "^3.1.0",
"exec-promise": "^0.7.0",
"getopts": "^2.2.3",
"vhd-lib": "^1.1.0"
"vhd-lib": "^1.2.0"
},
"devDependencies": {
"@babel/cli": "^7.0.0",
@@ -36,7 +36,7 @@
"@babel/preset-env": "^7.0.0",
"cross-env": "^7.0.2",
"execa": "^5.0.0",
"index-modules": "^0.3.0",
"index-modules": "^0.4.3",
"promise-toolbox": "^0.19.2",
"rimraf": "^3.0.0",
"tmp": "^0.2.1"

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "vhd-lib",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0-or-later",
"description": "Primitives for VHD file handling",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-lib",
@@ -17,9 +17,9 @@
},
"dependencies": {
"@vates/read-chunk": "^0.1.2",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"async-iterator-to-stream": "^1.0.2",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.4",
"promise-toolbox": "^0.19.2",
@@ -30,7 +30,7 @@
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@xen-orchestra/fs": "^0.17.0",
"@xen-orchestra/fs": "^0.18.0",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^7.0.2",
"execa": "^5.0.0",

View File

@@ -2,7 +2,7 @@ import assert from 'assert'
import { pipeline, Transform } from 'readable-stream'
import { readChunk } from '@vates/read-chunk'
import checkFooter from './_checkFooter'
import checkFooter from './checkFooter'
import checkHeader from './_checkHeader'
import noop from './_noop'
import getFirstAndLastBlocks from './_getFirstAndLastBlocks'

View File

@@ -8,3 +8,4 @@ export { default as createSyntheticStream } from './createSyntheticStream'
export { default as mergeVhd } from './merge'
export { default as createVhdStreamWithLength } from './createVhdStreamWithLength'
export { default as peekFooterFromVhdStream } from './peekFooterFromVhdStream'
export { default as checkFooter } from './checkFooter'

View File

@@ -1,7 +1,7 @@
import assert from 'assert'
import { createLogger } from '@xen-orchestra/log'
import checkFooter from './_checkFooter'
import checkFooter from './checkFooter'
import checkHeader from './_checkHeader'
import getFirstAndLastBlocks from './_getFirstAndLastBlocks'
import { fuFooter, fuHeader, checksumStruct, unpackField } from './_structs'

View File

@@ -39,7 +39,7 @@
"human-format": "^0.11.0",
"lodash": "^4.17.4",
"pw": "^0.0.4",
"xen-api": "^0.33.1"
"xen-api": "^0.34.3"
},
"devDependencies": {
"@babel/cli": "^7.1.5",

View File

@@ -8,6 +8,6 @@
"promise-toolbox": "^0.19.2",
"readable-stream": "^3.1.1",
"throttle": "^1.0.3",
"vhd-lib": "^1.1.0"
"vhd-lib": "^1.2.0"
}
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xen-api",
"version": "0.33.1",
"version": "0.34.3",
"license": "ISC",
"description": "Connector to the Xen API",
"keywords": [

View File

@@ -359,22 +359,35 @@ export class Xapi extends EventEmitter {
}
}
const response = await httpRequest(
$cancelToken,
this._url,
host !== undefined && {
hostname: await this._getHostAddress(this.getObject(host)),
},
let url = new URL('http://localhost')
url.protocol = this._url.protocol
url.pathname = pathname
url.search = new URLSearchParams(query)
await this._setHostAddressInUrl(url, host)
const response = await pRetry(
async () =>
httpRequest($cancelToken, url.href, {
rejectUnauthorized: !this._allowUnauthorized,
// this is an inactivity timeout (unclear in Node doc)
timeout: this._httpInactivityTimeout,
maxRedirects: 0,
// Support XS <= 6.5 with Node => 12
minVersion: 'TLSv1',
}),
{
pathname,
query,
rejectUnauthorized: !this._allowUnauthorized,
// this is an inactivity timeout (unclear in Node doc)
timeout: this._httpInactivityTimeout,
// Support XS <= 6.5 with Node => 12
minVersion: 'TLSv1',
when: { code: 302 },
onRetry: async error => {
const response = error.response
if (response === undefined) {
throw error
}
response.cancel()
url = await this._replaceHostAddressInUrl(new URL(response.headers.location, url))
},
}
)
@@ -421,32 +434,28 @@ export class Xapi extends EventEmitter {
headers['content-length'] = '1125899906842624'
}
const doRequest = httpRequest.put.bind(
undefined,
$cancelToken,
this._url,
host !== undefined && {
hostname: await this._getHostAddress(this.getObject(host)),
},
{
body,
headers,
pathname,
query,
rejectUnauthorized: !this._allowUnauthorized,
const url = new URL('http://localhost')
url.protocol = this._url.protocol
url.pathname = pathname
url.search = new URLSearchParams(query)
await this._setHostAddressInUrl(url, host)
// this is an inactivity timeout (unclear in Node doc)
timeout: this._httpInactivityTimeout,
const doRequest = httpRequest.put.bind(undefined, $cancelToken, {
body,
headers,
rejectUnauthorized: !this._allowUnauthorized,
// Support XS <= 6.5 with Node => 12
minVersion: 'TLSv1',
}
)
// this is an inactivity timeout (unclear in Node doc)
timeout: this._httpInactivityTimeout,
// Support XS <= 6.5 with Node => 12
minVersion: 'TLSv1',
})
// if body is a stream, sends a dummy request to probe for a redirection
// before consuming body
const response = await (isStream
? doRequest({
? doRequest(url.href, {
body: '',
// omit task_id because this request will fail on purpose
@@ -456,9 +465,9 @@ export class Xapi extends EventEmitter {
}).then(
response => {
response.cancel()
return doRequest()
return doRequest(url.href)
},
error => {
async error => {
let response
if (error != null && (response = error.response) != null) {
response.cancel()
@@ -469,14 +478,16 @@ export class Xapi extends EventEmitter {
} = response
if (statusCode === 302 && location !== undefined) {
// ensure the original query is sent
return doRequest(location, { query })
const newUrl = new URL(location, url)
newUrl.searchParams.set('task_id', query.task_id)
return doRequest((await this._replaceHostAddressInUrl(newUrl)).href)
}
}
throw error
}
)
: doRequest())
: doRequest(url.href))
if (pTaskResult !== undefined) {
pTaskResult = pTaskResult.catch(error => {
@@ -792,7 +803,35 @@ export class Xapi extends EventEmitter {
}
}
async _getHostAddress({ address }) {
async _setHostAddressInUrl(url, host) {
const pool = this._pool
const poolMigrationNetwork = pool.other_config['xo:migrationNetwork']
if (host === undefined) {
if (poolMigrationNetwork === undefined) {
const xapiUrl = this._url
url.hostname = xapiUrl.hostname
url.port = xapiUrl.port
return
}
host = await this.getRecord('host', pool.master)
}
let { address } = host
if (poolMigrationNetwork !== undefined) {
const hostPifs = new Set(host.PIFs)
try {
const networkRef = await this._roCall('network.get_by_uuid', [poolMigrationNetwork])
const networkPifs = await this.getField('network', networkRef, 'PIFs')
const migrationNetworkPifRef = networkPifs.find(hostPifs.has, hostPifs)
address = await this.getField('PIF', migrationNetworkPifRef, 'IP')
} catch (error) {
console.warn('unable to get the host address linked to the pool migration network', poolMigrationNetwork, error)
}
}
if (this._reverseHostIpAddresses) {
try {
;[address] = await fromCallback(dns.reverse, address)
@@ -800,7 +839,8 @@ export class Xapi extends EventEmitter {
console.warn('reversing host address', address, error)
}
}
return address
url.hostname = address
}
_setUrl(url) {
@@ -862,6 +902,19 @@ export class Xapi extends EventEmitter {
}
}
async _replaceHostAddressInUrl(url) {
try {
// TODO: look for hostname in all addresses of this host (including all its PIFs)
const host = (await this.getAllRecords('host')).find(host => host.address === url.hostname)
if (host !== undefined) {
await this._setHostAddressInUrl(url, host)
}
} catch (error) {
console.warn('_replaceHostAddressInUrl', url, error)
}
return url
}
_processEvents(events) {
const flush = this._objects.bufferEvents()
events.forEach(event => {

View File

@@ -33,7 +33,7 @@
"bluebird": "^3.5.1",
"chalk": "^4.1.0",
"exec-promise": "^0.7.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"http-request-plus": "^0.12",
"human-format": "^0.11.0",
"l33teral": "^3.0.3",

View File

@@ -47,7 +47,7 @@
"dependencies": {
"@xen-orchestra/audit-core": "^0.2.0",
"@xen-orchestra/cron": "^1.0.6",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"async-iterator-to-stream": "^1.1.0",
"promise-toolbox": "^0.19.2",
"readable-stream": "^3.5.0",

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-auth-ldap",
"version": "0.10.2",
"version": "0.10.4",
"license": "AGPL-3.0-or-later",
"description": "LDAP authentication plugin for XO-Server",
"keywords": [
@@ -31,6 +31,8 @@
"node": ">=10"
},
"dependencies": {
"@xen-orchestra/log": "^0.3.0",
"ensure-array": "^1.0.0",
"exec-promise": "^0.7.0",
"inquirer": "^8.0.0",
"ldapts": "^2.2.1",

View File

@@ -1,10 +1,14 @@
/* eslint no-throw-literal: 0 */
import ensureArray from 'ensure-array'
import fromCallback from 'promise-toolbox/fromCallback'
import { Client } from 'ldapts'
import { createLogger } from '@xen-orchestra/log'
import { Filter } from 'ldapts/filters/Filter'
import { readFile } from 'fs'
const logger = createLogger('xo:xo-server-auth-ldap')
// ===================================================================
const DEFAULTS = {
@@ -26,8 +30,6 @@ const evalFilter = (filter, vars) =>
return escape(value)
})
const noop = Function.prototype
export const configurationSchema = {
type: 'object',
properties: {
@@ -183,8 +185,7 @@ export const testSchema = {
// ===================================================================
class AuthLdap {
constructor({ logger = noop, xo }) {
this._logger = logger
constructor({ xo } = {}) {
this._xo = xo
this._authenticate = this._authenticate.bind(this)
@@ -256,10 +257,8 @@ class AuthLdap {
}
async _authenticate({ username, password }) {
const logger = this._logger
if (username === undefined || password === undefined) {
logger('require `username` and `password` to authenticate!')
logger.debug('require `username` and `password` to authenticate!')
return null
}
@@ -275,29 +274,34 @@ class AuthLdap {
{
const { _credentials: credentials } = this
if (credentials) {
logger(`attempting to bind with as ${credentials.dn}...`)
logger.debug(`attempting to bind with as ${credentials.dn}...`)
await client.bind(credentials.dn, credentials.password)
logger(`successfully bound as ${credentials.dn}`)
logger.debug(`successfully bound as ${credentials.dn}`)
}
}
// Search for the user.
logger('searching for entries...')
logger.debug('searching for entries...')
const { searchEntries: entries } = await client.search(this._searchBase, {
scope: 'sub',
filter: evalFilter(this._searchFilter, {
name: username,
}),
})
logger(`${entries.length} entries found`)
logger.debug(`${entries.length} entries found`)
// Try to find an entry which can be bind with the given password.
for (const entry of entries) {
try {
logger(`attempting to bind as ${entry.dn}`)
logger.debug(`attempting to bind as ${entry.dn}`)
await client.bind(entry.dn, password)
logger(`successfully bound as ${entry.dn} => ${username} authenticated`)
logger(JSON.stringify(entry, null, 2))
logger.info(`successfully bound as ${entry.dn} => ${username} authenticated`)
logger.debug(JSON.stringify(entry, null, 2))
// CLI test: don't register user/sync groups
if (this._xo === undefined) {
return
}
let user
if (this._userIdAttribute === undefined) {
@@ -314,18 +318,18 @@ class AuthLdap {
try {
await this._synchronizeGroups(user, entry[groupsConfig.membersMapping.userAttribute])
} catch (error) {
logger(`failed to synchronize groups: ${error.message}`)
logger.error(`failed to synchronize groups: ${error.message}`)
}
}
}
return { userId: user.id }
} catch (error) {
logger(`failed to bind as ${entry.dn}: ${error.message}`)
logger.debug(`failed to bind as ${entry.dn}: ${error.message}`)
}
}
logger(`could not authenticate ${username}`)
logger.debug(`could not authenticate ${username}`)
return null
} finally {
await client.unbind()
@@ -334,7 +338,6 @@ class AuthLdap {
// Synchronize user's groups OR all groups if no user is passed
async _synchronizeGroups(user, memberId) {
const logger = this._logger
const client = new Client(this._clientOpts)
try {
@@ -346,12 +349,12 @@ class AuthLdap {
{
const { _credentials: credentials } = this
if (credentials) {
logger(`attempting to bind with as ${credentials.dn}...`)
logger.debug(`attempting to bind with as ${credentials.dn}...`)
await client.bind(credentials.dn, credentials.password)
logger(`successfully bound as ${credentials.dn}`)
logger.debug(`successfully bound as ${credentials.dn}`)
}
}
logger('syncing groups...')
logger.info('syncing groups...')
const { base, displayNameAttribute, filter, idAttribute, membersMapping } = this._groupsConfig
const { searchEntries: ldapGroups } = await client.search(base, {
scope: 'sub',
@@ -373,12 +376,11 @@ class AuthLdap {
// Empty or undefined names/IDs are invalid
if (!groupLdapId || !groupLdapName) {
logger(`Invalid group ID (${groupLdapId}) or name (${groupLdapName})`)
logger.error(`Invalid group ID (${groupLdapId}) or name (${groupLdapName})`)
continue
}
let ldapGroupMembers = ldapGroup[membersMapping.groupAttribute]
ldapGroupMembers = Array.isArray(ldapGroupMembers) ? ldapGroupMembers : [ldapGroupMembers]
const ldapGroupMembers = ensureArray(ldapGroup[membersMapping.groupAttribute])
// If a user was passed, only update the user's groups
if (user !== undefined && !ldapGroupMembers.includes(memberId)) {
@@ -393,7 +395,7 @@ class AuthLdap {
if (xoGroupIndex === -1) {
if (xoGroups.find(group => group.name === groupLdapName) !== undefined) {
// TODO: check against LDAP groups that are being created as well
logger(`A group called ${groupLdapName} already exists`)
logger.error(`A group called ${groupLdapName} already exists`)
continue
}
xoGroup = await this._xo.createGroup({
@@ -459,6 +461,8 @@ class AuthLdap {
xoGroups.filter(group => group.provider === 'ldap').map(group => this._xo.deleteGroup(group.id))
)
}
logger.info('done syncing groups')
} finally {
await client.unbind()
}

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node
import execPromise from 'exec-promise'
import transportConsole from '@xen-orchestra/log/transports/console'
import { configure } from '@xen-orchestra/log/configure.js'
import { fromCallback } from 'promise-toolbox'
import { readFile, writeFile } from 'fs'
@@ -28,9 +30,14 @@ execPromise(async args => {
}
)
const plugin = createPlugin({
logger: console.log.bind(console),
})
configure([
{
filter: process.env.DEBUG ?? 'xo:xo-server-auth-ldap',
transport: transportConsole(),
},
])
const plugin = createPlugin()
await plugin.configure(config)
await plugin._authenticate({

View File

@@ -33,7 +33,7 @@
},
"dependencies": {
"@xen-orchestra/defined": "^0.0.1",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"human-format": "^0.11.0",
"lodash": "^4.13.1",
"moment-timezone": "^0.5.13"

View File

@@ -28,7 +28,7 @@
},
"dependencies": {
"@xen-orchestra/cron": "^1.0.6",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"lodash": "^4.16.2"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-netbox",
"version": "0.2.0",
"version": "0.3.1",
"license": "AGPL-3.0-or-later",
"description": "Synchronizes pools managed by Xen Orchestra with Netbox",
"keywords": [
@@ -29,7 +29,7 @@
"node": ">=14.6"
},
"dependencies": {
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"ipaddr.js": "^2.0.1",
"lodash": "^4.17.21"
},

View File

@@ -1,7 +1,7 @@
import assert from 'assert'
import ipaddr from 'ipaddr.js'
import { createLogger } from '@xen-orchestra/log'
import { find, flatten, forEach, groupBy, isEmpty, keyBy, mapValues, trimEnd, zipObject } from 'lodash'
import { find, flatten, forEach, groupBy, isEmpty, keyBy, mapValues, omit, trimEnd, zipObject } from 'lodash'
const log = createLogger('xo:netbox')
@@ -167,7 +167,23 @@ class Netbox {
return results
}
async #checkCustomFields() {
const customFields = await this.#makeRequest('/extras/custom-fields/', 'GET')
const uuidCustomField = customFields.find(field => field.name === 'uuid')
if (uuidCustomField === undefined) {
throw new Error('UUID custom field was not found. Please create it manually from your Netbox interface.')
}
const { content_types: types } = uuidCustomField
if (!types.includes('virtualization.cluster') || !types.includes('virtualization.virtualmachine')) {
throw new Error(
'UUID custom field must be assigned to types virtualization.cluster and virtualization.virtualmachine'
)
}
}
async #synchronize(pools = this.#pools) {
await this.#checkCustomFields()
const xo = this.#xo
log.debug('synchronizing')
// Cluster type
@@ -218,6 +234,10 @@ class Netbox {
}
}
// FIXME: Should we deduplicate cluster names even though it also fails when
// a cluster within another cluster type has the same name?
// FIXME: Should we delete clusters from this cluster type that don't have a
// UUID?
Object.assign(
clusters,
keyBy(
@@ -237,30 +257,54 @@ class Netbox {
// VMs
const vms = xo.getObjects({ filter: object => object.type === 'VM' && pools.includes(object.$pool) })
const oldNetboxVms = keyBy(
flatten(
// FIXME: It should be doable with one request:
// `cluster_id=1&cluster_id=2` but it doesn't work
// https://netbox.readthedocs.io/en/stable/rest-api/filtering/#filtering-objects
await Promise.all(
pools.map(poolId =>
this.#makeRequest(`/virtualization/virtual-machines/?cluster_id=${clusters[poolId].id}`, 'GET')
)
let oldNetboxVms = flatten(
// FIXME: It should be doable with one request:
// `cluster_id=1&cluster_id=2` but it doesn't work
// https://netbox.readthedocs.io/en/stable/rest-api/filtering/#filtering-objects
await Promise.all(
pools.map(poolId =>
this.#makeRequest(`/virtualization/virtual-machines/?cluster_id=${clusters[poolId].id}`, 'GET')
)
),
'custom_fields.uuid'
)
)
const vmsWithNoUuid = oldNetboxVms.filter(vm => vm.custom_fields.uuid === null)
oldNetboxVms = omit(keyBy(oldNetboxVms, 'custom_fields.uuid'), null)
// Delete VMs that don't have a UUID custom field. This can happen if they
// were created manually or if the custom field config was changed after
// their creation
if (vmsWithNoUuid !== undefined) {
log.warn(`Found ${vmsWithNoUuid.length} VMs with no UUID. Deleting them.`)
await this.#makeRequest(
'/virtualization/virtual-machines/',
'DELETE',
vmsWithNoUuid.map(vm => ({ id: vm.id }))
)
}
// Build collections for later
const netboxVms = {} // VM UUID → Netbox VM
const vifsByVm = {} // VM UUID → VIF
const vifsByVm = {} // VM UUID → VIF UUID[]
const ipsByDeviceByVm = {} // VM UUID → (VIF device → IP)
const primaryIpsByVm = {} // VM UUID → { ipv4, ipv6 }
const vmsToCreate = []
const vmsToUpdate = []
let vmsToUpdate = [] // will be reused for primary IPs
for (const vm of Object.values(vms)) {
vifsByVm[vm.uuid] = vm.VIFs
const vmIpsByDevice = (ipsByDeviceByVm[vm.uuid] = {})
if (primaryIpsByVm[vm.uuid] === undefined) {
primaryIpsByVm[vm.uuid] = {}
}
if (vm.addresses['0/ipv4/0'] !== undefined) {
primaryIpsByVm[vm.uuid].ipv4 = vm.addresses['0/ipv4/0']
}
if (vm.addresses['0/ipv6/0'] !== undefined) {
primaryIpsByVm[vm.uuid].ipv6 = ipaddr.parse(vm.addresses['0/ipv6/0']).toString()
}
forEach(vm.addresses, (address, key) => {
const device = key.split('/')[0]
if (vmIpsByDevice[device] === undefined) {
@@ -466,7 +510,7 @@ class Netbox {
.forEach(newInterfaces => Object.assign(interfaces, newInterfaces))
// IPs
const [oldNetboxIps, prefixes] = await Promise.all([
const [oldNetboxIps, netboxPrefixes] = await Promise.all([
this.#makeRequest('/ipam/ip-addresses/', 'GET').then(addresses =>
groupBy(
// In Netbox, a device interface and a VM interface can have the same
@@ -483,6 +527,7 @@ class Netbox {
const ipsToDelete = []
const ipsToCreate = []
const ignoredIps = []
const netboxIpsByVif = {}
for (const [vmUuid, vifs] of Object.entries(vifsByVm)) {
const vmIpsByDevice = ipsByDeviceByVm[vmUuid]
if (vmIpsByDevice === undefined) {
@@ -495,6 +540,8 @@ class Netbox {
continue
}
netboxIpsByVif[vifId] = []
const interface_ = interfaces[vif.uuid]
const interfaceOldIps = oldNetboxIps[interface_.id] ?? []
@@ -502,28 +549,36 @@ class Netbox {
const parsedIp = ipaddr.parse(ip)
const ipKind = parsedIp.kind()
const ipCompactNotation = parsedIp.toString()
// FIXME: Should we compare the IPs with their range? ie: can 2 IPs
// look identical but belong to 2 different ranges?
const netboxIpIndex = interfaceOldIps.findIndex(
netboxIp => ipaddr.parse(netboxIp.address.split('/')[0]).toString() === ipCompactNotation
)
let smallestPrefix
let highestBits = 0
netboxPrefixes.forEach(({ prefix }) => {
const [range, bits] = prefix.split('/')
const parsedRange = ipaddr.parse(range)
if (parsedRange.kind() === ipKind && parsedIp.match(parsedRange, bits) && bits > highestBits) {
smallestPrefix = prefix
highestBits = bits
}
})
if (smallestPrefix === undefined) {
ignoredIps.push(ip)
continue
}
const netboxIpIndex = interfaceOldIps.findIndex(netboxIp => {
const [ip, bits] = netboxIp.address.split('/')
return ipaddr.parse(ip).toString() === ipCompactNotation && bits === highestBits
})
if (netboxIpIndex >= 0) {
netboxIpsByVif[vifId].push(interfaceOldIps[netboxIpIndex])
interfaceOldIps.splice(netboxIpIndex, 1)
} else {
const prefix = prefixes.find(({ prefix }) => {
const [range, bits] = prefix.split('/')
const parsedRange = ipaddr.parse(range)
return parsedRange.kind() === ipKind && parsedIp.match(parsedRange, bits)
})
if (prefix === undefined) {
ignoredIps.push(ip)
continue
}
ipsToCreate.push({
address: `${ip}/${prefix.prefix.split('/')[1]}`,
address: `${ip}/${smallestPrefix.split('/')[1]}`,
assigned_object_type: 'virtualization.vminterface',
assigned_object_id: interface_.id,
vifId, // needed to populate netboxIpsByVif with newly created IPs
})
}
}
@@ -537,9 +592,61 @@ class Netbox {
await Promise.all([
ipsToDelete.length !== 0 && this.#makeRequest('/ipam/ip-addresses/', 'DELETE', ipsToDelete),
ipsToCreate.length !== 0 && this.#makeRequest('/ipam/ip-addresses/', 'POST', ipsToCreate),
ipsToCreate.length !== 0 &&
this.#makeRequest(
'/ipam/ip-addresses/',
'POST',
ipsToCreate.map(ip => omit(ip, 'vifId'))
).then(newNetboxIps => {
newNetboxIps.forEach((newNetboxIp, i) => {
const { vifId } = ipsToCreate[i]
if (netboxIpsByVif[vifId] === undefined) {
netboxIpsByVif[vifId] = []
}
netboxIpsByVif[vifId].push(newNetboxIp)
})
}),
])
// Primary IPs
vmsToUpdate = []
Object.entries(netboxVms).forEach(([vmId, netboxVm]) => {
if (netboxVm.primary_ip4 !== null && netboxVm.primary_ip6 !== null) {
return
}
const newNetboxVm = { id: netboxVm.id }
const vifs = vifsByVm[vmId]
vifs.forEach(vifId => {
const netboxIps = netboxIpsByVif[vifId]
const vmMainIps = primaryIpsByVm[vmId]
netboxIps?.forEach(netboxIp => {
const address = netboxIp.address.split('/')[0]
if (
newNetboxVm.primary_ip4 === undefined &&
address === vmMainIps.ipv4 &&
netboxVm.primary_ip4?.address !== netboxIp.address
) {
newNetboxVm.primary_ip4 = netboxIp.id
}
if (
newNetboxVm.primary_ip6 === undefined &&
address === vmMainIps.ipv6 &&
netboxVm.primary_ip6?.address !== netboxIp.address
) {
newNetboxVm.primary_ip6 = netboxIp.id
}
})
})
if (newNetboxVm.primary_ip4 !== undefined || newNetboxVm.primary_ip6 !== undefined) {
vmsToUpdate.push(newNetboxVm)
}
})
if (vmsToUpdate.length > 0) {
await this.#makeRequest('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate)
}
log.debug('synchronized')
}
@@ -557,6 +664,8 @@ class Netbox {
'GET'
)
await this.#checkCustomFields()
if (clusterTypes.length !== 1) {
throw new Error('Could not properly write and read Netbox')
}

View File

@@ -29,7 +29,7 @@
"cross-env": "^7.0.2"
},
"dependencies": {
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/openflow": "^0.1.1",
"@vates/coalesce-calls": "^0.1.0",
"ipaddr.js": "^1.9.1",

View File

@@ -31,7 +31,7 @@
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/cron": "^1.0.6",
"@xen-orchestra/log": "^0.2.1",
"@xen-orchestra/log": "^0.3.0",
"csv-stringify": "^5.5.0",
"handlebars": "^4.0.6",
"html-minifier": "^4.0.0",

View File

@@ -29,7 +29,7 @@
"node": ">=8.10"
},
"dependencies": {
"@xen-orchestra/log": "^0.2.1"
"@xen-orchestra/log": "^0.3.0"
},
"devDependencies": {
"@babel/cli": "^7.7.0",

View File

@@ -70,7 +70,7 @@ mergeProvidersUsers = true
# should be used by default.
defaultSignInPage = '/signin'
# Minimum delay between two password authentication attemps.
# Minimum delay between two password authentication attempts for a specific user.
#
# This is used to mitigate bruteforce attacks without being visible to users.
throttlingDelay = '2 seconds'
@@ -131,6 +131,13 @@ port = 80
[http.mounts]
'/' = '../xo-web/dist'
[logs]
# Display all logs matching this filter, regardless of their level
#filter = 'xo:load-balancer'
# Display all logs with level >=, regardless of their namespace
level = 'info'
[plugins]
[remoteOptions]

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "xo-server",
"version": "5.81.2",
"version": "5.82.3",
"license": "AGPL-3.0-or-later",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -35,17 +35,17 @@
"@vates/parse-duration": "^0.1.1",
"@vates/read-chunk": "^0.1.2",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.12.2",
"@xen-orchestra/backups": "^0.13.0",
"@xen-orchestra/cron": "^1.0.6",
"@xen-orchestra/defined": "^0.0.1",
"@xen-orchestra/emit-async": "^0.1.0",
"@xen-orchestra/fs": "^0.17.0",
"@xen-orchestra/log": "^0.2.1",
"@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/template": "^0.1.0",
"@xen-orchestra/xapi": "^0.6.4",
"@xen-orchestra/xapi": "^0.7.0",
"ajv": "^8.0.3",
"app-conf": "^0.9.0",
"async-iterator-to-stream": "^1.0.1",
@@ -68,7 +68,7 @@
"express-session": "^1.15.6",
"fast-xml-parser": "^3.17.4",
"fatfs": "^0.10.4",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"get-stream": "^6.0.0",
"golike-defer": "^0.5.1",
"hashy": "^0.10.0",
@@ -122,10 +122,10 @@
"unzipper": "^0.10.5",
"uuid": "^8.3.1",
"value-matcher": "^0.2.0",
"vhd-lib": "^1.1.0",
"vhd-lib": "^1.2.0",
"ws": "^7.1.2",
"xdg-basedir": "^4.0.0",
"xen-api": "^0.33.1",
"xen-api": "^0.34.3",
"xo-acl-resolver": "^0.4.1",
"xo-collection": "^0.5.0",
"xo-common": "^0.7.0",
@@ -146,7 +146,7 @@
"@babel/preset-env": "^7.0.0",
"babel-plugin-transform-dev": "^2.0.1",
"cross-env": "^7.0.2",
"index-modules": "^0.4.2"
"index-modules": "^0.4.3"
},
"scripts": {
"_build": "index-modules --index-file index.mjs src/api src/xapi/mixins src/xo-mixins && babel --delete-dir-on-start --keep-file-extension --source-maps --out-dir=dist/ src/",

View File

@@ -1,11 +1,12 @@
import * as multiparty from 'multiparty'
import assert from 'assert'
import getStream from 'get-stream'
import pump from 'pump'
import { createLogger } from '@xen-orchestra/log'
import { defer } from 'golike-defer'
import { format } from 'json-rpc-peer'
import { format, JsonRpcError } from 'json-rpc-peer'
import { noSuchObject } from 'xo-common/api-errors.js'
import { peekFooterFromVhdStream } from 'vhd-lib'
import { checkFooter, peekFooterFromVhdStream } from 'vhd-lib'
import { vmdkToVhd } from 'xo-vmdk-to-vhd'
import { VDI_FORMAT_VHD } from '../xapi/index.mjs'
@@ -161,44 +162,59 @@ async function handleImport(req, res, { type, name, description, vmdkData, srId,
const form = new multiparty.Form()
form.on('error', reject)
form.on('part', async part => {
if (part.name !== 'file') {
promises.push(
(async () => {
const view = new DataView((await getStream.buffer(part)).buffer)
const result = new Uint32Array(view.byteLength / 4)
for (const i in result) {
result[i] = view.getUint32(i * 4, true)
}
vmdkData[part.name] = result
})()
)
} else {
await Promise.all(promises)
part.length = part.byteCount
if (type === 'vmdk') {
vhdStream = await vmdkToVhd(part, vmdkData.grainLogicalAddressList, vmdkData.grainFileOffsetList)
size = vmdkData.capacity
} else if (type === 'vhd') {
vhdStream = part
const footer = await peekFooterFromVhdStream(vhdStream)
size = footer.currentSize
try {
if (part.name !== 'file') {
promises.push(
(async () => {
const buffer = await getStream.buffer(part)
vmdkData[part.name] = new Uint32Array(
buffer.buffer,
buffer.byteOffset,
buffer.length / Uint32Array.BYTES_PER_ELEMENT
)
})()
)
} else {
throw new Error(`Unknown disk type, expected "vhd" or "vmdk", got ${type}`)
await Promise.all(promises)
part.length = part.byteCount
if (type === 'vmdk') {
vhdStream = await vmdkToVhd(part, vmdkData.grainLogicalAddressList, vmdkData.grainFileOffsetList)
size = vmdkData.capacity
} else if (type === 'vhd') {
vhdStream = part
const footer = await peekFooterFromVhdStream(vhdStream)
try {
checkFooter(footer)
} catch (e) {
if (e instanceof assert.AssertionError) {
throw new JsonRpcError(`Vhd file had an invalid header ${e}`)
}
}
size = footer.currentSize
} else {
throw new JsonRpcError(`Unknown disk type, expected "vhd" or "vmdk", got ${type}`)
}
const vdi = await xapi.createVdi({
name_description: description,
name_label: name,
size,
sr: srId,
})
try {
await xapi.importVdiContent(vdi, vhdStream, VDI_FORMAT_VHD)
res.end(format.response(0, vdi.$id))
} catch (e) {
await vdi.$destroy()
throw e
}
resolve()
}
const vdi = await xapi.createVdi({
name_description: description,
name_label: name,
size,
sr: srId,
})
try {
await xapi.importVdiContent(vdi, vhdStream, VDI_FORMAT_VHD)
res.end(format.response(0, vdi.$id))
} catch (e) {
await vdi.$destroy()
throw e
}
resolve()
} catch (e) {
res.writeHead(500)
res.end(format.error(0, new JsonRpcError(e.message)))
// destroy the reader to stop the file upload
req.destroy()
reject(e)
}
})
form.parse(req)

View File

@@ -6,7 +6,7 @@ import { format } from 'json-rpc-peer'
export function setMaintenanceMode({ host, maintenance }) {
const xapi = this.getXapi(host)
return maintenance ? xapi.clearHost({ $ref: host._xapiRef }) : xapi.enableHost(host._xapiId)
return maintenance ? xapi.clearHost(xapi.getObject(host)) : xapi.enableHost(host._xapiId)
}
setMaintenanceMode.description = 'manage the maintenance mode'

View File

@@ -28,7 +28,6 @@ create.params = {
create.resolve = {
pool: ['pool', 'pool', 'administrate'],
}
create.permission = 'admin'
// =================================================================
@@ -63,7 +62,6 @@ createBonded.params = {
createBonded.resolve = {
pool: ['pool', 'pool', 'administrate'],
}
createBonded.permission = 'admin'
createBonded.description = 'Create a bonded network. bondMode can be balance-slb, active-backup or lacp'
// ===================================================================

View File

@@ -1050,12 +1050,12 @@ async function handleVmImport(req, res, { data, srId, type, xapi }) {
if (!(part.filename in tables)) {
tables[part.filename] = {}
}
const view = new DataView((await getStream.buffer(part)).buffer)
const result = new Uint32Array(view.byteLength / 4)
for (const i in result) {
result[i] = view.getUint32(i * 4, true)
}
tables[part.filename][part.name] = result
const buffer = await getStream.buffer(part)
tables[part.filename][part.name] = new Uint32Array(
buffer.buffer,
buffer.byteOffset,
buffer.length / Uint32Array.BYTES_PER_ELEMENT
)
data.tables = tables
})()
)

View File

@@ -326,18 +326,33 @@ const TRANSFORMS = {
// Merge old ipv4 protocol with the new protocol
// See: https://github.com/xapi-project/xen-api/blob/324bc6ee6664dd915c0bbe57185f1d6243d9ed7e/ocaml/xapi/xapi_guest_agent.ml#L59-L81
// Old protocol: when there's more than 1 IP on an interface, the IPs
// are space or newline delimited in the same `x/ip` field
// See https://github.com/vatesfr/xen-orchestra/issues/5801#issuecomment-854337568
// The `x/ip` field may have a `x/ipv4/0` alias
// e.g:
// {
// '1/ip': '<IP1> <IP2>',
// '1/ipv4/0': '<IP1> <IP2>',
// }
// See https://xcp-ng.org/forum/topic/4810
const addresses = {}
for (const key in networks) {
const [, device] = /^(\d+)\/ip$/.exec(key) ?? []
if (device !== undefined) {
// Old protocol: when there's more than 1 IP on an interface, the IPs
// are space-delimited in the same field
// See https://github.com/vatesfr/xen-orchestra/issues/5801#issuecomment-854337568
networks[key].split(' ').forEach((ip, i) => {
const [, device, index] = /^(\d+)\/ip(?:v[46]\/(\d))?$/.exec(key) ?? []
const ips = networks[key].split(/\s+/)
if (ips.length === 1 && index !== undefined) {
// New protocol or alias
addresses[key] = networks[key]
} else if (index !== '0' && index !== undefined) {
// Should never happen (alias with index >0)
continue
} else {
// Old protocol
ips.forEach((ip, i) => {
addresses[`${device}/ipv4/${i}`] = ip
})
} else {
addresses[key] = networks[key]
}
}

View File

@@ -451,7 +451,11 @@ export default {
set: (secureBoot, vm) => vm.update_platform('secureboot', secureBoot.toString()),
},
hvmBootFirmware: {
set: (firmware, vm) => vm.update_HVM_boot_params('firmware', firmware),
set: (firmware, vm) =>
Promise.all([
vm.update_HVM_boot_params('firmware', firmware),
vm.update_platform('device-model', 'qemu-upstream-' + (firmware === 'uefi' ? 'uefi' : 'compat')),
]),
},
}),

View File

@@ -1,3 +1,5 @@
import transportConsole from '@xen-orchestra/log/transports/console.js'
import { configure } from '@xen-orchestra/log/configure.js'
import { defer, fromEvent } from 'promise-toolbox'
import LevelDbLogger from './loggers/leveldb.mjs'
@@ -7,6 +9,17 @@ export default class Logs {
this._app = app
app.hooks.on('clean', () => this._gc())
const transport = transportConsole()
app.config.watch('logs', ({ filter, level }) => {
configure([
{
filter: [process.env.DEBUG, filter],
level,
transport,
},
])
})
}
async _gc(keep = 2e4) {

View File

@@ -25,7 +25,7 @@
"lodash": "^4.17.15",
"pako": "^1.0.11",
"promise-toolbox": "^0.19.2",
"vhd-lib": "^1.1.0",
"vhd-lib": "^1.2.0",
"xml2js": "^0.4.23"
},
"devDependencies": {
@@ -35,7 +35,7 @@
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^7.0.2",
"execa": "^5.0.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"get-stream": "^6.0.0",
"promise-toolbox": "^0.19.2",
"rimraf": "^3.0.0",

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