Compare commits
113 Commits
fix-load-b
...
xo-lite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae087a6539 | ||
|
|
4db93f8ced | ||
|
|
e5c737cba7 | ||
|
|
9f0f38ef94 | ||
|
|
d76996b1d5 | ||
|
|
3b77897692 | ||
|
|
d4ed555abd | ||
|
|
97d77c0aa5 | ||
|
|
a9ad0ec455 | ||
|
|
78ec008c26 | ||
|
|
2d71bef5d8 | ||
|
|
3ec7c61987 | ||
|
|
526c2001d3 | ||
|
|
f3d4e40c6d | ||
|
|
ac8f93fb0e | ||
|
|
d2fbc1b573 | ||
|
|
c5670a047f | ||
|
|
e9472889f2 | ||
|
|
9bec4b571c | ||
|
|
b56cc96e37 | ||
|
|
011164f16c | ||
|
|
b9a9471408 | ||
|
|
9abd1429a2 | ||
|
|
7f656973de | ||
|
|
5e0766fcb1 | ||
|
|
2dc5c0e161 | ||
|
|
d0730d05fd | ||
|
|
8fe3a439fc | ||
|
|
12c7113662 | ||
|
|
36be46b073 | ||
|
|
25ef579df5 | ||
|
|
cbbb07d389 | ||
|
|
96df84c9d8 | ||
|
|
17c4b5cbe7 | ||
|
|
cf642cd720 | ||
|
|
047f3a9b4c | ||
|
|
b0f85e0380 | ||
|
|
7aa518b43c | ||
|
|
d187d6aeeb | ||
|
|
289dce3876 | ||
|
|
930afea1a1 | ||
|
|
3801fa9134 | ||
|
|
ae211046b8 | ||
|
|
87ce9ff63a | ||
|
|
131c6321be | ||
|
|
6abcce498f | ||
|
|
9c38f5b327 | ||
|
|
14720d4cbf | ||
|
|
940ef2845d | ||
|
|
e3dbb7a6c2 | ||
|
|
8cba6ebb20 | ||
|
|
a1b322f5be | ||
|
|
07ff19c4b8 | ||
|
|
3a0af4e7e0 | ||
|
|
dbb3f74ab0 | ||
|
|
0eaac8fd7a | ||
|
|
06c71154b9 | ||
|
|
0e8f314dd6 | ||
|
|
f53ec8968b | ||
|
|
919d118f21 | ||
|
|
216b759df1 | ||
|
|
01450db71e | ||
|
|
ed987e1610 | ||
|
|
2773591e1f | ||
|
|
a995276d1e | ||
|
|
ffb6a8fa3f | ||
|
|
0966efb7f2 | ||
|
|
4a0a708092 | ||
|
|
6bf3b6f3e0 | ||
|
|
8f197fe266 | ||
|
|
e1a3f680f2 | ||
|
|
e89cca7e90 | ||
|
|
5bb2767d62 | ||
|
|
95f029e0e7 | ||
|
|
fb21e4d585 | ||
|
|
633805cec9 | ||
|
|
b8801d7d2a | ||
|
|
a84fac1b6a | ||
|
|
a9de4ceb30 | ||
|
|
827b55d60c | ||
|
|
0e1fe76b46 | ||
|
|
097c9e8e12 | ||
|
|
266356cb20 | ||
|
|
6dba39a804 | ||
|
|
3ddafa7aca | ||
|
|
9d8e232684 | ||
|
|
bf83c269c4 | ||
|
|
54e47c98cc | ||
|
|
118f2594ea | ||
|
|
ab4fcd6ac4 | ||
|
|
ca6f345429 | ||
|
|
79b8e1b4e4 | ||
|
|
cafa1ffa14 | ||
|
|
ea10df8a92 | ||
|
|
85abc42100 | ||
|
|
4747eb4386 | ||
|
|
ad9cc900b8 | ||
|
|
6cd93a7bb0 | ||
|
|
3338a02afb | ||
|
|
31cfe82224 | ||
|
|
70a191336b | ||
|
|
030477454c | ||
|
|
2a078d1572 | ||
|
|
3c1f96bc69 | ||
|
|
7d30bdc148 | ||
|
|
5d42961761 | ||
|
|
f20d5cd8d3 | ||
|
|
f5111c0f41 | ||
|
|
f5473236d0 | ||
|
|
d3cb31f1a7 | ||
|
|
d5f5cdd27a | ||
|
|
656dc8fefc | ||
|
|
a505cd9567 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -56,14 +56,22 @@ module.exports = function (pkg, configs = {}) {
|
||||
}),
|
||||
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
|
||||
targets: (() => {
|
||||
const targets = {}
|
||||
|
||||
if (pkg.browserslist !== undefined) {
|
||||
targets.browsers = pkg.browserslist
|
||||
}
|
||||
|
||||
let node = (pkg.engines || {}).node
|
||||
if (node !== undefined) {
|
||||
const trimChars = '^=>~'
|
||||
while (trimChars.includes(node[0])) {
|
||||
node = node.slice(1)
|
||||
}
|
||||
targets.node = node
|
||||
}
|
||||
return { browsers: pkg.browserslist, node }
|
||||
|
||||
return targets
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
@@ -23,9 +23,9 @@
|
||||
"@vates/coalesce-calls": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"aws-sdk": "^2.686.0",
|
||||
"decorator-synchronized": "^0.5.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"execa": "^5.0.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash": "^4.17.4",
|
||||
@@ -45,7 +45,7 @@
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
"babel-plugin-lodash": "^3.3.2",
|
||||
"cross-env": "^7.0.2",
|
||||
"dotenv": "^8.0.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy'
|
||||
import getStream from 'get-stream'
|
||||
import path, { basename } from 'path'
|
||||
import synchronized from 'decorator-synchronized'
|
||||
import { coalesceCalls } from '@vates/coalesce-calls'
|
||||
import { fromCallback, fromEvent, ignoreErrors, timeout } from 'promise-toolbox'
|
||||
import { limitConcurrency } from 'limit-concurrency-decorator'
|
||||
import { parse } from 'xo-remote-parser'
|
||||
import { pipeline } from 'stream'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { synchronized } from 'decorator-synchronized'
|
||||
|
||||
import normalizePath from './_normalizePath'
|
||||
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
|
||||
|
||||
@@ -27,3 +27,12 @@ export const getHandler = (remote, ...rest) => {
|
||||
}
|
||||
return new Handler(remote, ...rest)
|
||||
}
|
||||
|
||||
export const getSyncedHandler = async (...opts) => {
|
||||
const handler = getHandler(...opts)
|
||||
await handler.sync()
|
||||
return {
|
||||
dispose: () => handler.forget(),
|
||||
value: handler,
|
||||
}
|
||||
}
|
||||
|
||||
6
@xen-orchestra/lite/.babelrc.js
Normal file
6
@xen-orchestra/lite/.babelrc.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'), {
|
||||
'@babel/preset-env': {
|
||||
exclude: ['@babel/plugin-proposal-dynamic-import', '@babel/plugin-transform-regenerator'],
|
||||
modules: false,
|
||||
},
|
||||
})
|
||||
32
@xen-orchestra/lite/.eslintrc.js
Normal file
32
@xen-orchestra/lite/.eslintrc.js
Normal file
@@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
|
||||
sourceType: 'module', // Allows for the use of imports
|
||||
ecmaFeatures: {
|
||||
jsx: true, // Allows for the parsing of JSX
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: '17',
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react
|
||||
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin
|
||||
],
|
||||
rules: {
|
||||
'eslint-comments/disable-enable-pair': 'off',
|
||||
// Necessary to pass empty Effects/State to Reaclette
|
||||
'@typescript-eslint/no-empty-interface': 'off',
|
||||
// https://github.com/typescript-eslint/typescript-eslint/issues/1071
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
// https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
|
||||
'@typescript-eslint/no-use-before-define': ['error'],
|
||||
'no-use-before-define': 'off',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
}
|
||||
24
@xen-orchestra/lite/.gitignore
vendored
Normal file
24
@xen-orchestra/lite/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.eslintcache
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
71
@xen-orchestra/lite/package.json
Normal file
71
@xen-orchestra/lite/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "xo-lite",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.1",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.0",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.13.0",
|
||||
"@babel/preset-env": "^7.13.5",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/lab": "^5.0.0-alpha.48",
|
||||
"@mui/material": "^5.0.1",
|
||||
"@novnc/novnc": "^1.2.0",
|
||||
"@types/immutable": "^3.8.7",
|
||||
"@types/js-cookie": "^2.2.6",
|
||||
"@types/lodash": "^4.14.175",
|
||||
"@types/node": "^14.14.21",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-intl": "^3.0.0",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-syntax-highlighter": "^13.5.0",
|
||||
"@types/styled-components": "^5.1.9",
|
||||
"@types/webpack-env": "^1.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.16.1",
|
||||
"@typescript-eslint/parser": "^4.16.1",
|
||||
"babel-loader": "^8.2.2",
|
||||
"classnames": "^2.3.1",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^10.2.0",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"html-webpack-plugin": "^5.2.0",
|
||||
"human-format": "^0.11.0",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"iterable-backoff": "^0.1.0",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-polyfill-webpack-plugin": "^1.0.3",
|
||||
"process": "^0.11.10",
|
||||
"promise-toolbox": "^0.16.0",
|
||||
"reaclette": "^0.10.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "^5.10.16",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
"styled-components": "^5.2.1",
|
||||
"typescript": "^4.3.1",
|
||||
"webpack": "^5.24.2",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"xen-api": "^0.34.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"styled-components": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"start": "cross-env NODE_ENV=development webpack serve",
|
||||
"start:open": "npm run start -- --open"
|
||||
},
|
||||
"browserslist": "> 0.5%, last 2 versions, Firefox ESR, not dead"
|
||||
}
|
||||
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
13
@xen-orchestra/lite/public/index.html
Normal file
13
@xen-orchestra/lite/public/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Xen Orchestra Lite" />
|
||||
<title>XO Lite</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
@xen-orchestra/lite/public/logo.png
Normal file
BIN
@xen-orchestra/lite/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
90
@xen-orchestra/lite/src/App/Infrastructure.tsx
Normal file
90
@xen-orchestra/lite/src/App/Infrastructure.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Switch, Route, RouteComponentProps } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import Pool from './Pool'
|
||||
import TabConsole from './TabConsole'
|
||||
import TreeView from './TreeView'
|
||||
|
||||
import { ObjectsByType } from '../libs/xapi'
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
`
|
||||
const LeftPanel = styled.div`
|
||||
background: #f5f5f5;
|
||||
min-width: 15em;
|
||||
overflow-y: scroll;
|
||||
width: 20%;
|
||||
`
|
||||
// FIXME: temporary work-around while investigating flew-grow issue:
|
||||
// `overflow: hidden` forces the console to shrink to the max available width
|
||||
// even when the tree component takes more than 20% of the width due to
|
||||
// `min-width`
|
||||
const MainPanel = styled.div`
|
||||
overflow: hidden;
|
||||
width: 80%;
|
||||
`
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
pool?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
selectedObject?: string
|
||||
selectedVm?: string
|
||||
}
|
||||
|
||||
// For compatibility with 'withRouter'
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
initialize: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const selectedNodesToArray = (nodes: Array<string> | string | undefined) =>
|
||||
nodes === undefined ? undefined : Array.isArray(nodes) ? nodes : [nodes]
|
||||
|
||||
const Infrastructure = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: props => ({
|
||||
selectedVm: props.location.pathname.split('/')[3],
|
||||
}),
|
||||
computed: {
|
||||
selectedObject: (state, props) =>
|
||||
props.location.pathname.startsWith('/infrastructure/pool') ? state.pool : state.selectedVm,
|
||||
},
|
||||
},
|
||||
({ state: { pool, selectedObject } }) => (
|
||||
<Container>
|
||||
<LeftPanel>
|
||||
<TreeView defaultSelectedNodes={selectedNodesToArray(selectedObject)} />
|
||||
</LeftPanel>
|
||||
<MainPanel>
|
||||
<Switch>
|
||||
<Route exact path={`/infrastructure/pool/${pool}/dashboard`}>
|
||||
<Pool id={pool} />
|
||||
</Route>
|
||||
<Route
|
||||
path='/infrastructure/vms/:id/console'
|
||||
render={({
|
||||
match: {
|
||||
params: { id },
|
||||
},
|
||||
}) => <TabConsole key={id} vmId={id} />}
|
||||
/>
|
||||
</Switch>
|
||||
</MainPanel>
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default withRouter(Infrastructure)
|
||||
120
@xen-orchestra/lite/src/App/Pool/dashboard/ObjectStatus.tsx
Normal file
120
@xen-orchestra/lite/src/App/Pool/dashboard/ObjectStatus.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import Grid from '@mui/material/Grid'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon from '../../../components/Icon'
|
||||
import IntlMessage from '../../../components/IntlMessage'
|
||||
import ProgressCircle from '../../../components/ProgressCircle'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
nActive?: number
|
||||
nTotal?: number
|
||||
type: 'host' | 'VM'
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
nInactive?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CAPTION_STYLE = { textTransform: 'uppercase', mt: 2 }
|
||||
const TYPOGRAPHY_SX = { mb: 2 }
|
||||
|
||||
const ObjectStatusContainer = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
align-content: space-between;
|
||||
margin-bottom: 1em;
|
||||
`
|
||||
|
||||
const CircularProgressPanel = styled.div`
|
||||
margin-left: 2em;
|
||||
`
|
||||
|
||||
const GridPanel = styled.div`
|
||||
margin-left: 2em;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
// TODO: Add a loading page when data is not loaded as it is in the model(figma).
|
||||
// FIXME: replace the hard-coded colors with the theme colors.
|
||||
const ObjectStatus = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
nInactive: (state, { nTotal = 0, nActive = 0 }) => nTotal - nActive,
|
||||
},
|
||||
},
|
||||
({ state: { nInactive }, nActive = 0, nTotal = 0, type }) => {
|
||||
if (nTotal === 0) {
|
||||
return (
|
||||
<span>
|
||||
<IntlMessage id={type === 'VM' ? 'noVms' : 'noHosts'} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectStatusContainer>
|
||||
<CircularProgressPanel>
|
||||
<ProgressCircle max={nTotal} value={nActive} />
|
||||
</CircularProgressPanel>
|
||||
<GridPanel>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Typography sx={TYPOGRAPHY_SX} variant='h5' component='div'>
|
||||
<IntlMessage id={type === 'VM' ? 'vms' : 'hosts'} />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Icon icon='circle' htmlColor='#00BA34' />
|
||||
|
||||
<Typography variant='body2' component='span'>
|
||||
<IntlMessage id='active' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='body2' component='div'>
|
||||
{nActive}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Icon icon='circle' htmlColor='#E8E8E8' />
|
||||
|
||||
<Typography variant='body2' component='span'>
|
||||
<IntlMessage id='inactive' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='body2' component='div'>
|
||||
{nInactive}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
|
||||
<IntlMessage id='total' />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
|
||||
{nTotal}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</GridPanel>
|
||||
</ObjectStatusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default ObjectStatus
|
||||
79
@xen-orchestra/lite/src/App/Pool/dashboard/index.tsx
Normal file
79
@xen-orchestra/lite/src/App/Pool/dashboard/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import Divider from '@mui/material/Divider'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import ObjectStatus from './ObjectStatus'
|
||||
|
||||
import IntlMessage from '../../../components/IntlMessage'
|
||||
import { Host, ObjectsByType, Vm } from '../../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType?: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {
|
||||
hosts?: Map<string, Host>
|
||||
nRunningHosts?: number
|
||||
nRunningVms?: number
|
||||
vms?: Map<string, Vm>
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const DEFAULT_STYLE = { m: 2 }
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
align-content: space-between;
|
||||
gap: 1.25em;
|
||||
background: '#E8E8E8';
|
||||
`
|
||||
|
||||
const Panel = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0px 1px 1px 0px #00000014, 0px 2px 1px 0px #0000000f, 0px 1px 3px 0px #0000001a;
|
||||
margin: 0.5em;
|
||||
`
|
||||
const getHostPowerState = (host: Host) => {
|
||||
const { $metrics } = host
|
||||
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
|
||||
}
|
||||
|
||||
const Dashboard = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
hosts: state => state.objectsByType?.get('host'),
|
||||
vms: state =>
|
||||
state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
|
||||
nRunningHosts: state => (state.hosts?.filter((host: Host) => getHostPowerState(host) === 'Running')).size,
|
||||
nRunningVms: state => (state.vms?.filter((vm: Vm) => vm.power_state === 'Running')).size,
|
||||
},
|
||||
},
|
||||
({ state: { hosts, nRunningHosts, nRunningVms, vms } }) => (
|
||||
<Container>
|
||||
<Panel>
|
||||
<Typography variant='h4' component='div' sx={DEFAULT_STYLE}>
|
||||
<IntlMessage id='status' />
|
||||
</Typography>
|
||||
<ObjectStatus nActive={nRunningHosts} nTotal={hosts?.size} type='host' />
|
||||
<Divider variant='middle' sx={DEFAULT_STYLE} />
|
||||
<ObjectStatus nActive={nRunningVms} nTotal={vms?.size} type='VM' />
|
||||
</Panel>
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default Dashboard
|
||||
46
@xen-orchestra/lite/src/App/Pool/index.tsx
Normal file
46
@xen-orchestra/lite/src/App/Pool/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Dashboard from './dashboard'
|
||||
import Icon from '../../components/Icon'
|
||||
import PanelHeader from '../../components/PanelHeader'
|
||||
import { ObjectsByType, Pool as PoolType } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
pool?: PoolType
|
||||
}
|
||||
|
||||
// TODO: add tabs when https://github.com/vatesfr/xen-orchestra/pull/6096 is merged.
|
||||
const Pool = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
pool: (state, props) => state.objectsByType?.get('pool')?.get(props.id),
|
||||
},
|
||||
},
|
||||
({ state: { pool } }) => (
|
||||
<>
|
||||
<PanelHeader>
|
||||
<span>
|
||||
<Icon icon='warehouse' color='primary' /> {pool?.name_label}
|
||||
</span>
|
||||
</PanelHeader>
|
||||
<Dashboard />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
export default Pool
|
||||
65
@xen-orchestra/lite/src/App/PoolTab/PoolNetworks.tsx
Normal file
65
@xen-orchestra/lite/src/App/PoolTab/PoolNetworks.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import Table, { Column } from '../../components/Table'
|
||||
import { ObjectsByType, Pif } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
objectsFetched: boolean
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
poolId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
managementPifs?: Pif[]
|
||||
pifs?: Map<string, Pif>
|
||||
}
|
||||
|
||||
const COLUMNS: Column<Pif>[] = [
|
||||
{
|
||||
header: <IntlMessage id='device' />,
|
||||
render: pif => pif.device,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='dns' />,
|
||||
render: pif => pif.DNS,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='gateway' />,
|
||||
render: pif => pif.gateway,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='ip' />,
|
||||
render: pif => pif.IP,
|
||||
},
|
||||
]
|
||||
|
||||
const PoolNetworks = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
managementPifs: state =>
|
||||
state.pifs
|
||||
?.filter(pif => pif.management)
|
||||
.map(pif => ({ ...pif, id: pif.$id }))
|
||||
.valueSeq()
|
||||
.toArray(),
|
||||
pifs: state => state.objectsByType.get('PIF'),
|
||||
},
|
||||
},
|
||||
({ state }) => (
|
||||
<Table collection={state.managementPifs} columns={COLUMNS} placeholder={<IntlMessage id='noManagementPifs' />} />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolNetworks
|
||||
89
@xen-orchestra/lite/src/App/PoolTab/PoolUpdates.tsx
Normal file
89
@xen-orchestra/lite/src/App/PoolTab/PoolUpdates.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react'
|
||||
import humanFormat from 'human-format'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import Table, { Column } from '../../components/Table'
|
||||
import XapiConnection, { ObjectsByType, PoolUpdate } from '../../libs/xapi'
|
||||
|
||||
const COLUMN: Column<PoolUpdate>[] = [
|
||||
{
|
||||
header: <IntlMessage id='name' />,
|
||||
render: update => update.name,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='description' />,
|
||||
render: update => update.description,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='version' />,
|
||||
render: update => update.version,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='release' />,
|
||||
render: update => update.release,
|
||||
},
|
||||
{
|
||||
header: <IntlMessage id='size' />,
|
||||
render: update => humanFormat.bytes(update.size),
|
||||
},
|
||||
]
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
objectsFetched: boolean
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
hostRef: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
availableUpdates?: PoolUpdate[] | JSX.Element
|
||||
}
|
||||
|
||||
const PoolUpdates = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
availableUpdates: async function (state, { hostRef }) {
|
||||
try {
|
||||
const stringifiedPoolUpdates = (await state.xapi.call(
|
||||
'host.call_plugin',
|
||||
hostRef,
|
||||
'updater.py',
|
||||
'check_update',
|
||||
{}
|
||||
)) as string
|
||||
return JSON.parse(stringifiedPoolUpdates)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return <IntlMessage id='errorOccurred' />
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
({ state: { availableUpdates } }) =>
|
||||
availableUpdates !== undefined ? (
|
||||
Array.isArray(availableUpdates) ? (
|
||||
<>
|
||||
{availableUpdates.length !== 0 && (
|
||||
<IntlMessage id='availableUpdates' values={{ nUpdates: availableUpdates.length }} />
|
||||
)}
|
||||
<Table collection={availableUpdates} columns={COLUMN} placeholder={<IntlMessage id='noUpdatesAvailable' />} />
|
||||
</>
|
||||
) : (
|
||||
availableUpdates
|
||||
)
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolUpdates
|
||||
53
@xen-orchestra/lite/src/App/PoolTab/index.tsx
Normal file
53
@xen-orchestra/lite/src/App/PoolTab/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import PoolNetworks from './PoolNetworks'
|
||||
import PoolUpdates from './PoolUpdates'
|
||||
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
import { Host, ObjectsByType, Pool } from '../../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsFetched: boolean
|
||||
}
|
||||
|
||||
interface State {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
hosts?: Map<string, Host>
|
||||
pool?: Pool
|
||||
}
|
||||
|
||||
const PoolTab = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
hosts: state => (state.objectsFetched ? state.objectsByType?.get('host') : undefined),
|
||||
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.first() : undefined),
|
||||
},
|
||||
},
|
||||
({ state }) =>
|
||||
state.pool !== undefined ? (
|
||||
<>
|
||||
<PoolNetworks poolId={state.pool.$id} />
|
||||
{state.hosts?.valueSeq().map(host => (
|
||||
<div key={host.$id}>
|
||||
<p>{host.name_label}</p>
|
||||
<PoolUpdates hostRef={host.$ref} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default PoolTab
|
||||
110
@xen-orchestra/lite/src/App/Signin/index.tsx
Normal file
110
@xen-orchestra/lite/src/App/Signin/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Button from '../../components/Button'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Input from '../../components/Input'
|
||||
import IntlMessage from '../../components/IntlMessage'
|
||||
|
||||
interface ParentState {
|
||||
error: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
password: string
|
||||
rememberMe: boolean
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {
|
||||
connectToXapi: (password: string, rememberMe: boolean) => void
|
||||
}
|
||||
|
||||
interface Effects {
|
||||
setRememberMe: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
setPassword: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
submit: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
`
|
||||
|
||||
const Form = styled.form`
|
||||
width: 20em;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const Fieldset = styled.fieldset`
|
||||
border: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
`
|
||||
|
||||
const RememberMe = styled(Fieldset)`
|
||||
text-align: start;
|
||||
vertical-align: baseline;
|
||||
`
|
||||
|
||||
const Error = styled.p`
|
||||
color: #a33;
|
||||
`
|
||||
|
||||
const Signin = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
}),
|
||||
effects: {
|
||||
setRememberMe: function ({ currentTarget: { checked: rememberMe } }) {
|
||||
this.state.rememberMe = rememberMe
|
||||
},
|
||||
setPassword: function ({ currentTarget: { value: password } }) {
|
||||
this.state.password = password
|
||||
},
|
||||
submit: function () {
|
||||
this.effects.connectToXapi(this.state.password, this.state.rememberMe)
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<Wrapper>
|
||||
<Form onSubmit={e => e.preventDefault()}>
|
||||
<img src='logo.png' />
|
||||
<h1>Xen Orchestra Lite</h1>
|
||||
<Fieldset>
|
||||
<Input disabled label={<IntlMessage id='login' />} value='root' />
|
||||
</Fieldset>
|
||||
<Fieldset>
|
||||
<Input
|
||||
autoFocus
|
||||
label={<IntlMessage id='password' />}
|
||||
onChange={effects.setPassword}
|
||||
type='password'
|
||||
value={state.password}
|
||||
/>
|
||||
</Fieldset>
|
||||
<RememberMe>
|
||||
<label>
|
||||
<Checkbox onChange={effects.setRememberMe} checked={state.rememberMe} />
|
||||
|
||||
<IntlMessage id='rememberMe' />
|
||||
</label>
|
||||
</RememberMe>
|
||||
<Error>{state.error}</Error>
|
||||
<Button type='submit' onClick={effects.submit}>
|
||||
<IntlMessage id='connect' />
|
||||
</Button>
|
||||
</Form>
|
||||
</Wrapper>
|
||||
)
|
||||
)
|
||||
|
||||
export default Signin
|
||||
300
@xen-orchestra/lite/src/App/StyleGuide/index.tsx
Normal file
300
@xen-orchestra/lite/src/App/StyleGuide/index.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
// https://mui.com/components/material-icons/
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { materialDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { toNumber } from 'lodash'
|
||||
import { SelectChangeEvent } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import ActionButton from '../../components/ActionButton'
|
||||
import Button from '../../components/Button'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Icon from '../../components/Icon'
|
||||
import Input from '../../components/Input'
|
||||
import ProgressCircle from '../../components/ProgressCircle'
|
||||
import Select from '../../components/Select'
|
||||
import Tabs from '../../components/Tabs'
|
||||
import { alert, confirm } from '../../components/Modal'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
progressBarValue: number
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
onChangeProgressBarValue: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onChangeSelect: (e: SelectChangeEvent<unknown>) => void
|
||||
sayHello: () => void
|
||||
sendPromise: (data: Record<string, unknown>) => Promise<void>
|
||||
showAlertModal: () => void
|
||||
showConfirmModal: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Page = styled.div`
|
||||
margin: 30px;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
`
|
||||
|
||||
const Render = styled.div`
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: solid 1px gray;
|
||||
border-radius: 3px;
|
||||
`
|
||||
|
||||
const Code = styled(SyntaxHighlighter).attrs(() => ({
|
||||
language: 'jsx',
|
||||
style: codeStyle,
|
||||
}))`
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
margin: 0 !important;
|
||||
`
|
||||
|
||||
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
progressBarValue: 100,
|
||||
value: '',
|
||||
}),
|
||||
effects: {
|
||||
onChangeProgressBarValue: function (e) {
|
||||
this.state.progressBarValue = toNumber(e.target.value)
|
||||
},
|
||||
onChangeSelect: function (e) {
|
||||
this.state.value = e.target.value
|
||||
},
|
||||
sayHello: () => alert('hello'),
|
||||
sendPromise: data =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
window.alert(data.foo)
|
||||
}, 1000)
|
||||
}),
|
||||
showAlertModal: () => alert({ message: 'This is an alert modal', title: 'Alert modal', icon: 'info' }),
|
||||
showConfirmModal: () =>
|
||||
confirm({
|
||||
message: 'This is a confirm modal test',
|
||||
title: 'Confirm modal',
|
||||
icon: 'download',
|
||||
}),
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<Page>
|
||||
<h2>ActionButton</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
|
||||
Send promise
|
||||
</ActionButton>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
|
||||
Send promise
|
||||
</ActionButton>`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Button</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Button color='primary' onClick={effects.sayHello} startIcon={<AccountCircleIcon />}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button color='secondary' endIcon={<DeleteIcon />} onClick={effects.sayHello}>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button color='success' onClick={effects.sayHello}>
|
||||
Success
|
||||
</Button>
|
||||
<Button color='warning' onClick={effects.sayHello}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button color='error' onClick={effects.sayHello}>
|
||||
Error
|
||||
</Button>
|
||||
<Button color='info' onClick={effects.sayHello}>
|
||||
Info
|
||||
</Button>
|
||||
</Render>
|
||||
<Code>{`<Button color='primary' onClick={doSomething} startIcon={<AccountCircleIcon />}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button color='secondary' endIcon={<DeleteIcon />} onClick={doSomething}>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button color='success' onClick={doSomething}>
|
||||
Success
|
||||
</Button>
|
||||
<Button color='warning' onClick={doSomething}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button color='error' onClick={doSomething}>
|
||||
Error
|
||||
</Button>
|
||||
<Button color='info' onClick={doSomething}>
|
||||
Info
|
||||
</Button>`}</Code>
|
||||
</Container>
|
||||
<h2>Icon</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Icon icon='truck' htmlColor='#0085FF' />
|
||||
<Icon icon='truck' color='primary' size='2x' />
|
||||
</Render>
|
||||
<Code>{`// https://fontawesome.com/icons
|
||||
<Icon icon='truck' htmlColor='#0085FF'/>
|
||||
<Icon icon='truck' color='primary' size='2x' />`}</Code>
|
||||
</Container>
|
||||
<h2>Input</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Input label='Input' />
|
||||
<Checkbox />
|
||||
</Render>
|
||||
<Code>{`<TextInput label='Input' />
|
||||
<Checkbox />`}</Code>
|
||||
</Container>
|
||||
<h2>Modal</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={effects.showAlertModal}
|
||||
sx={{
|
||||
marginBottom: 1,
|
||||
}}
|
||||
>
|
||||
Alert
|
||||
</Button>
|
||||
<Button color='primary' onClick={effects.showConfirmModal}>
|
||||
Confirm
|
||||
</Button>
|
||||
</Render>
|
||||
<Code>{`<Button
|
||||
color='primary'
|
||||
onClick={() =>
|
||||
alert({
|
||||
message: 'This is an alert modal',
|
||||
title: 'Alert modal',
|
||||
icon: 'info'
|
||||
})
|
||||
}
|
||||
>
|
||||
Alert
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await confirm({
|
||||
message: 'This is a confirm modal',
|
||||
title: 'Confirm modal',
|
||||
icon: 'download',
|
||||
})
|
||||
// The modal has been confirmed
|
||||
} catch (reason) { // "cancel"
|
||||
// The modal has been closed
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>`}</Code>
|
||||
</Container>
|
||||
<h2>ProgressCircle</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<ProgressCircle max={200} value={state.progressBarValue} />
|
||||
</div>
|
||||
<div>
|
||||
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
defaultValue={state.progressBarValue}
|
||||
max='200'
|
||||
min='0'
|
||||
onChange={effects.onChangeProgressBarValue}
|
||||
step='1'
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: '10px auto',
|
||||
}}
|
||||
type='range'
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<ProgressCircle max={200} value={state.progressBarValue} />
|
||||
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Select</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Select
|
||||
onChange={effects.onChangeSelect}
|
||||
options={[
|
||||
{ name: 'Bar', value: 1 },
|
||||
{ name: 'Foo', value: 2 },
|
||||
]}
|
||||
value={state.value}
|
||||
valueRenderer='value'
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<Select
|
||||
onChange={handleChange}
|
||||
optionRenderer={item => item.name}
|
||||
options={[
|
||||
{ name: 'Bar', value: 1 },
|
||||
{ name: 'Foo', value: 2 },
|
||||
]}
|
||||
value={state.value}
|
||||
valueRenderer='value'
|
||||
/>`}
|
||||
</Code>
|
||||
</Container>
|
||||
<h2>Tabs</h2>
|
||||
<Container>
|
||||
<Render>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
|
||||
{ label: 'FOO', pathname: '/styleguide/foo' },
|
||||
]}
|
||||
useUrl
|
||||
/>
|
||||
</Render>
|
||||
<Code>
|
||||
{`<Tabs
|
||||
tabs={[
|
||||
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
|
||||
{ label: 'FOO', pathname: '/styleguide/foo' },
|
||||
]}
|
||||
useUrl
|
||||
/>`}
|
||||
</Code>
|
||||
</Container>
|
||||
</Page>
|
||||
)
|
||||
)
|
||||
|
||||
export default App
|
||||
102
@xen-orchestra/lite/src/App/TabConsole.tsx
Normal file
102
@xen-orchestra/lite/src/App/TabConsole.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Console from '../components/Console'
|
||||
import IntlMessage, { translate } from '../components/IntlMessage'
|
||||
import { ObjectsByType, Vm } from '../libs/xapi'
|
||||
import PanelHeader from '../components/PanelHeader'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {
|
||||
consoleScale: number
|
||||
sendCtrlAltDel?: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
vmId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
scaleConsole: React.ChangeEventHandler<HTMLInputElement>
|
||||
setCtrlAltDel: (sendCtrlAltDel: State['sendCtrlAltDel']) => void
|
||||
showNotImplemented: () => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
vm?: Vm
|
||||
}
|
||||
|
||||
const TabConsole = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
// Value in percent
|
||||
consoleScale: 100,
|
||||
sendCtrlAltDel: undefined,
|
||||
}),
|
||||
effects: {
|
||||
scaleConsole: function (e) {
|
||||
this.state.consoleScale = +e.currentTarget.value
|
||||
|
||||
// With "scaleViewport", the canvas occupies all available space of its
|
||||
// container. But when the size of the container is changed, the canvas
|
||||
// size isn't updated
|
||||
// Issue https://github.com/novnc/noVNC/issues/1364
|
||||
// PR https://github.com/novnc/noVNC/pull/1365
|
||||
window.dispatchEvent(new UIEvent('resize'))
|
||||
},
|
||||
setCtrlAltDel: function (sendCtrlAltDel) {
|
||||
this.state.sendCtrlAltDel = sendCtrlAltDel
|
||||
},
|
||||
showNotImplemented: function () {
|
||||
alert('Not Implemented')
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
vm: (state, { vmId }) => state.objectsByType.get('VM')?.get(vmId),
|
||||
},
|
||||
},
|
||||
({ effects, state, vmId }) => (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<PanelHeader
|
||||
actions={[
|
||||
{
|
||||
key: 'start',
|
||||
icon: 'play',
|
||||
color: 'primary',
|
||||
title: translate({ id: 'vmStartLabel' }),
|
||||
variant: 'contained',
|
||||
onClick: effects.showNotImplemented,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{state.vm?.name_label ?? 'loading'}{' '}
|
||||
</PanelHeader>
|
||||
|
||||
{/* Hide scaling and Ctrl+Alt+Del button temporarily */}
|
||||
{/* <RangeInput max={100} min={1} onChange={effects.scaleConsole} step={1} value={state.consoleScale} />
|
||||
{state.sendCtrlAltDel !== undefined && (
|
||||
<Button onClick={state.sendCtrlAltDel}>
|
||||
<IntlMessage id='ctrlAltDel' />
|
||||
</Button>
|
||||
)} */}
|
||||
{state.vm?.power_state !== 'Running' ? (
|
||||
<p>
|
||||
<IntlMessage id='consoleNotAvailable' />
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<Console vmId={vmId} scale={state.consoleScale} setCtrlAltDel={effects.setCtrlAltDel} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default TabConsole
|
||||
131
@xen-orchestra/lite/src/App/TreeView.tsx
Normal file
131
@xen-orchestra/lite/src/App/TreeView.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import { Collection, Map } from 'immutable'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon from '../components/Icon'
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
import Tree, { ItemType } from '../components/Tree'
|
||||
import { Host, ObjectsByType, Pool, Vm } from '../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
defaultSelectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
collection?: Array<ItemType>
|
||||
hostsByPool?: Collection.Keyed<string, Collection<string, Host>>
|
||||
pools?: Map<string, Pool>
|
||||
vms?: Map<string, Vm>
|
||||
vmsByContainerRef?: Collection.Keyed<string, Collection<string, Vm>>
|
||||
}
|
||||
|
||||
const getHostPowerState = (host: Host) => {
|
||||
const { $metrics } = host
|
||||
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
|
||||
}
|
||||
|
||||
const getIconColor = (obj: Host | Vm) => {
|
||||
const powerState = obj.power_state ?? getHostPowerState(obj as Host)
|
||||
return powerState === 'Running' ? '#198754' : powerState === 'Halted' ? '#dc3545' : '#6c757d'
|
||||
}
|
||||
|
||||
const TreeView = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
collection: state => {
|
||||
if (state.pools === undefined) {
|
||||
return
|
||||
}
|
||||
const collection: ItemType[] = []
|
||||
state.pools.valueSeq().forEach((pool: Pool) => {
|
||||
const hosts = state.hostsByPool
|
||||
?.get(pool.$id)
|
||||
?.valueSeq()
|
||||
.sortBy(host => host.name_label)
|
||||
.map((host: Host) => ({
|
||||
children: state.vmsByContainerRef
|
||||
?.get(host.$ref)
|
||||
?.valueSeq()
|
||||
.sortBy(vm => vm.name_label)
|
||||
.map((vm: Vm) => ({
|
||||
id: vm.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/vms/${vm.$id}/console`,
|
||||
tooltip: <IntlMessage id={vm.power_state.toLowerCase()} />,
|
||||
}))
|
||||
.toArray(),
|
||||
id: host.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='server' htmlColor={getIconColor(host)} /> {host.name_label}
|
||||
</span>
|
||||
),
|
||||
tooltip: <IntlMessage id={getHostPowerState(host).toLowerCase()} />,
|
||||
}))
|
||||
.toArray()
|
||||
|
||||
const haltedVms = state.vmsByContainerRef
|
||||
?.get(pool.$ref)
|
||||
?.valueSeq()
|
||||
.sortBy((vm: Vm) => vm.name_label)
|
||||
.map((vm: Vm) => ({
|
||||
id: vm.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/vms/${vm.$id}/console`,
|
||||
tooltip: <IntlMessage id='halted' />,
|
||||
}))
|
||||
.toArray()
|
||||
|
||||
collection.push({
|
||||
children: (hosts ?? []).concat(haltedVms ?? []),
|
||||
id: pool.$id,
|
||||
label: (
|
||||
<span>
|
||||
<Icon icon='warehouse' color='primary' /> {pool.name_label}
|
||||
</span>
|
||||
),
|
||||
to: `/infrastructure/pool/${pool.$id}/dashboard`,
|
||||
})
|
||||
})
|
||||
|
||||
return collection
|
||||
},
|
||||
hostsByPool: state => state.objectsByType?.get('host')?.groupBy((host: Host) => host.$pool.$id),
|
||||
pools: state => state.objectsByType?.get('pool'),
|
||||
vms: state =>
|
||||
state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
|
||||
vmsByContainerRef: state =>
|
||||
state.vms?.groupBy(({ power_state: powerState, resident_on: host, $pool }: Vm) =>
|
||||
powerState === 'Running' || powerState === 'Paused' ? host : $pool.$ref
|
||||
),
|
||||
},
|
||||
},
|
||||
({ state, defaultSelectedNodes }) =>
|
||||
state.collection === undefined ? null : (
|
||||
<div style={{ padding: '10px' }}>
|
||||
<Tree collection={state.collection} defaultSelectedNodes={defaultSelectedNodes} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default TreeView
|
||||
506
@xen-orchestra/lite/src/App/index.tsx
Normal file
506
@xen-orchestra/lite/src/App/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
// import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import Container from '@mui/material/Container'
|
||||
import Cookies from 'js-cookie'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'
|
||||
import MuiDrawer from '@mui/material/Drawer'
|
||||
import React from 'react'
|
||||
import styledComponent from 'styled-components'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { HashRouter as Router, Switch, Redirect, Route } from 'react-router-dom'
|
||||
import { IntlProvider } from 'react-intl'
|
||||
import { Map } from 'immutable'
|
||||
import { styled, createTheme, ThemeProvider } from '@mui/material/styles'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
// import Button from '../components/Button'
|
||||
import Icon from '../components/Icon'
|
||||
import Infrastructure from './Infrastructure'
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
import Link from '../components/Link'
|
||||
import messagesEn from '../lang/en.json'
|
||||
import Modal from '../components/Modal'
|
||||
import PoolTab from './PoolTab'
|
||||
import Signin from './Signin/index'
|
||||
import StyleGuide from './StyleGuide/index'
|
||||
import TabConsole from './TabConsole'
|
||||
|
||||
import XapiConnection, { ObjectsByType, Pool, Vm } from '../libs/xapi'
|
||||
|
||||
const drawerWidth = 240
|
||||
const redirectPaths = ['/', '/infrastructure']
|
||||
|
||||
interface AppBarProps extends MuiAppBarProps {
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard
|
||||
|
||||
const AppBar = styled(MuiAppBar, {
|
||||
shouldForwardProp: prop => prop !== 'open',
|
||||
})<AppBarProps>(({ theme, open }) => ({
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
...(open && {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const Drawer = styled(MuiDrawer, { shouldForwardProp: prop => prop !== 'open' })(({ theme, open }) => ({
|
||||
'& .MuiDrawer-paper': {
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
boxSizing: 'border-box',
|
||||
...(!open && {
|
||||
overflowX: 'hidden',
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
width: theme.spacing(7),
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: theme.spacing(9),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const MainListItems = (): JSX.Element => (
|
||||
<div>
|
||||
<ListItemButton component='a' href='#infrastructure'>
|
||||
<ListItemIcon>
|
||||
<Icon icon='project-diagram' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<IntlMessage id='infrastructure' />} />
|
||||
</ListItemButton>
|
||||
<ListItemButton component='a' href='#about'>
|
||||
<ListItemIcon>
|
||||
<Icon icon='info-circle' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary='About' />
|
||||
</ListItemButton>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface SecondaryListItemsParentState {}
|
||||
|
||||
interface SecondaryListItemsState {}
|
||||
|
||||
interface SecondaryListItemsProps {}
|
||||
|
||||
interface SecondaryListItemsParentEffects {}
|
||||
|
||||
interface SecondaryListItemsEffects {
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
interface SecondaryListItemsComputed {}
|
||||
|
||||
const ICON_STYLE = { fontSize: '1.5em' }
|
||||
|
||||
const SecondaryListItems = withState<
|
||||
SecondaryListItemsState,
|
||||
SecondaryListItemsProps,
|
||||
SecondaryListItemsEffects,
|
||||
SecondaryListItemsComputed,
|
||||
SecondaryListItemsParentState,
|
||||
SecondaryListItemsParentEffects
|
||||
>({}, ({ effects }) => (
|
||||
<div>
|
||||
<ListItem button onClick={() => effects.disconnect()}>
|
||||
<ListItemIcon style={ICON_STYLE}>
|
||||
<Icon icon='sign-out-alt' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<IntlMessage id='disconnect' />} />
|
||||
</ListItem>
|
||||
</div>
|
||||
))
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Default bootstrap 4 colors
|
||||
// https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss#L67-L74
|
||||
const mdTheme = createTheme({
|
||||
background: {
|
||||
primary: {
|
||||
dark: '#111111',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
error: {
|
||||
main: '#dc3545',
|
||||
},
|
||||
info: {
|
||||
main: '#17a2b8',
|
||||
},
|
||||
primary: {
|
||||
dark: '#168FFF',
|
||||
light: '#0085FF',
|
||||
main: '#007bff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#6c757d',
|
||||
},
|
||||
success: {
|
||||
main: '#28a745',
|
||||
},
|
||||
warning: {
|
||||
main: '#ffc107',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: '#E8E8E8',
|
||||
fontStyle: 'medium',
|
||||
fontSize: '1.25em',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'inter',
|
||||
h1: {
|
||||
fontWeight: 500,
|
||||
fontSize: '3em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '3.75em',
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 500,
|
||||
fontSize: '2.25em',
|
||||
fontStyle: 'medium',
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1.5em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '2em',
|
||||
},
|
||||
h4: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1.25em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.75em',
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 500,
|
||||
fontSize: '1em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.50em',
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 500,
|
||||
fontSize: '0.8em',
|
||||
fontStyle: 'medium',
|
||||
lineHeight: '1.25em',
|
||||
},
|
||||
caption: {
|
||||
// styleName: Caps / Caps 1 - 14 Semi Bold
|
||||
fontSize: '0.9em',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.25em',
|
||||
verticalAlign: 'top',
|
||||
letterSpacing: '0.04em',
|
||||
textAlign: 'left',
|
||||
},
|
||||
body2: {
|
||||
// styleName: Paragraph / P2 - 16
|
||||
fontSize: '1em',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.5em',
|
||||
letterSpacing: '0em',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const FullPage = styledComponent.div`
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {
|
||||
connected: boolean
|
||||
drawerOpen: boolean
|
||||
error: React.ReactNode
|
||||
xapiHostname: string
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
connectToXapi: (password: string, rememberMe: boolean) => void
|
||||
disconnect: () => void
|
||||
toggleDrawer: () => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
objectsFetched: boolean
|
||||
pool?: Pool
|
||||
url: string
|
||||
vms?: Map<string, Vm>
|
||||
}
|
||||
|
||||
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
connected: Cookies.get('sessionId') !== undefined,
|
||||
drawerOpen: false,
|
||||
error: '',
|
||||
objectsByType: undefined,
|
||||
xapi: undefined,
|
||||
xapiHostname: process.env.XAPI_HOST || window.location.host,
|
||||
}),
|
||||
effects: {
|
||||
initialize: async function () {
|
||||
const xapi = (this.state.xapi = new XapiConnection())
|
||||
|
||||
xapi.on('connected', () => {
|
||||
this.state.connected = true
|
||||
})
|
||||
|
||||
xapi.on('disconnected', () => {
|
||||
this.state.connected = false
|
||||
})
|
||||
|
||||
xapi.on('objects', (objectsByType: ObjectsByType) => {
|
||||
this.state.objectsByType = objectsByType
|
||||
})
|
||||
|
||||
try {
|
||||
await xapi.reattachSession(this.state.url)
|
||||
} catch (err) {
|
||||
if (err?.code !== 'SESSION_INVALID') {
|
||||
throw err
|
||||
}
|
||||
|
||||
console.log('Session ID is invalid. Asking for credentials.')
|
||||
}
|
||||
},
|
||||
toggleDrawer: function () {
|
||||
this.state.drawerOpen = !this.state.drawerOpen
|
||||
},
|
||||
connectToXapi: async function (password, rememberMe = false) {
|
||||
try {
|
||||
await this.state.xapi.connect({
|
||||
url: this.state.url,
|
||||
user: 'root',
|
||||
password,
|
||||
rememberMe,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.code !== 'SESSION_AUTHENTICATION_FAILED') {
|
||||
throw err
|
||||
}
|
||||
|
||||
this.state.error = <IntlMessage id='badCredentials' />
|
||||
}
|
||||
},
|
||||
disconnect: async function () {
|
||||
await this.state.xapi.disconnect()
|
||||
this.state.connected = false
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
objectsFetched: state => state.objectsByType !== undefined,
|
||||
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.keySeq().first() : undefined),
|
||||
vms: state =>
|
||||
state.objectsFetched
|
||||
? state.objectsByType
|
||||
?.get('VM')
|
||||
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template)
|
||||
: undefined,
|
||||
url: state => `${window.location.protocol}//${state.xapiHostname}`,
|
||||
},
|
||||
},
|
||||
({ effects, state }) => (
|
||||
<IntlProvider messages={messagesEn} locale='en'>
|
||||
{/* Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard */}
|
||||
<ThemeProvider theme={mdTheme}>
|
||||
<Modal />
|
||||
{!state.connected ? (
|
||||
<Signin />
|
||||
) : !state.objectsFetched ? (
|
||||
<IntlMessage id='loading' />
|
||||
) : (
|
||||
<>
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route exact path={redirectPaths}>
|
||||
<Redirect to={`/infrastructure/pool/${state.pool.$id}/dashboard`} />
|
||||
</Route>
|
||||
<Route exact path='/vm-list'>
|
||||
{state.vms !== undefined && (
|
||||
<>
|
||||
<p>There are {state.vms.size} VMs!</p>
|
||||
<ul>
|
||||
{state.vms.valueSeq().map((vm: Vm) => (
|
||||
<li key={vm.$id}>
|
||||
<Link to={vm.$id}>
|
||||
{vm.name_label} - {vm.name_description} ({vm.power_state})
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Route>
|
||||
<Route exact path='/styleguide'>
|
||||
<StyleGuide />
|
||||
</Route>
|
||||
<Route exact path='/styleguide/foo'>
|
||||
<StyleGuide />
|
||||
</Route>
|
||||
<Route exact path='/pool'>
|
||||
<PoolTab />
|
||||
</Route>
|
||||
<Route path='/'>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position='absolute' open={state.drawerOpen}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
pr: '24px', // keep right padding when drawer closed
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
edge='start'
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
onClick={effects.toggleDrawer}
|
||||
sx={{
|
||||
marginRight: '36px',
|
||||
...(state.drawerOpen && { display: 'none' }),
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography component='h1' variant='h6' color='inherit' noWrap sx={{ flexGrow: 1 }}>
|
||||
<Switch>
|
||||
<Route path='/infrastructure'>
|
||||
<IntlMessage id='infrastructure' />
|
||||
</Route>
|
||||
<Route path='/about'>
|
||||
<IntlMessage id='about' />
|
||||
</Route>
|
||||
<Route>
|
||||
<IntlMessage id='notFound' />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Typography>
|
||||
{/* <IconButton color='inherit'>
|
||||
<Badge badgeContent={4} color='secondary'>
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton> */}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant='permanent' open={state.drawerOpen}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
px: [1],
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={effects.toggleDrawer}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
<MainListItems />
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<SecondaryListItems />
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
backgroundColor: theme =>
|
||||
theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900],
|
||||
flexGrow: 1,
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route path='/infrastructure'>
|
||||
<FullPage>
|
||||
<Toolbar />
|
||||
<Infrastructure />
|
||||
</FullPage>
|
||||
</Route>
|
||||
<Route path='/about'>
|
||||
<Toolbar />
|
||||
<Container maxWidth='lg' sx={{ mt: 4, mb: 4 }}>
|
||||
<p>
|
||||
Check out{' '}
|
||||
<Link to='https://xen-orchestra.com/blog/xen-orchestra-lite/'>Xen Orchestra Lite</Link>{' '}
|
||||
dev blog.
|
||||
</p>
|
||||
<p>
|
||||
<IntlMessage id='versionValue' values={{ version: process.env.NPM_VERSION }} />
|
||||
</p>
|
||||
</Container>
|
||||
</Route>
|
||||
<Route>
|
||||
<Toolbar />
|
||||
<IntlMessage id='pageNotFound' />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Box>
|
||||
</Box>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
)
|
||||
)
|
||||
|
||||
export default App
|
||||
57
@xen-orchestra/lite/src/components/ActionButton.tsx
Normal file
57
@xen-orchestra/lite/src/components/ActionButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import LoadingButton, { LoadingButtonProps } from '@mui/lab/LoadingButton'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Omit the `onClick` props to rewrite its own one.
|
||||
interface Props extends Omit<LoadingButtonProps, 'onClick'> {
|
||||
onClick: (data: Record<string, unknown>) => Promise<void>
|
||||
// to pass props with the following pattern: "data-something"
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
_onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const ActionButton = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({ isLoading: false }),
|
||||
effects: {
|
||||
_onClick: function () {
|
||||
this.state.isLoading = true
|
||||
const data: Record<string, unknown> = {}
|
||||
Object.keys(this.props).forEach(key => {
|
||||
if (key.startsWith('data-')) {
|
||||
data[key.slice(5)] = this.props[key]
|
||||
}
|
||||
})
|
||||
return this.props.onClick(data).finally(() => (this.state.isLoading = false))
|
||||
},
|
||||
},
|
||||
},
|
||||
({ children, color = 'secondary', effects, onClick, resetState, state, variant = 'contained', ...props }) => (
|
||||
<LoadingButton
|
||||
color={color}
|
||||
disabled={state.isLoading}
|
||||
fullWidth
|
||||
loading={state.isLoading}
|
||||
onClick={effects._onClick}
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LoadingButton>
|
||||
)
|
||||
)
|
||||
|
||||
export default ActionButton
|
||||
26
@xen-orchestra/lite/src/components/Button.tsx
Normal file
26
@xen-orchestra/lite/src/components/Button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { Button as MuiButton, ButtonProps } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends ButtonProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Button = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ children, color = 'secondary', effects, resetState, state, variant = 'contained', ...props }) => (
|
||||
<MuiButton color={color} fullWidth variant={variant} {...props}>
|
||||
{children}
|
||||
</MuiButton>
|
||||
)
|
||||
)
|
||||
|
||||
export default Button
|
||||
22
@xen-orchestra/lite/src/components/Checkbox.tsx
Normal file
22
@xen-orchestra/lite/src/components/Checkbox.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { CheckboxProps, Checkbox as MuiCheckbox } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends CheckboxProps {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Checkbox = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ effects, resetState, state, ...props }) => <MuiCheckbox {...props} />
|
||||
)
|
||||
|
||||
export default Checkbox
|
||||
193
@xen-orchestra/lite/src/components/Console.tsx
Normal file
193
@xen-orchestra/lite/src/components/Console.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from 'react'
|
||||
import RFB from '@novnc/novnc/lib/rfb'
|
||||
import styled from 'styled-components'
|
||||
import { fibonacci } from 'iterable-backoff'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
import { confirm } from './Modal'
|
||||
|
||||
import XapiConnection, { ObjectsByType, Vm } from '../libs/xapi'
|
||||
|
||||
interface ParentState {
|
||||
objectsByType: ObjectsByType
|
||||
xapi: XapiConnection
|
||||
}
|
||||
|
||||
interface State {
|
||||
// Type error with HTMLDivElement.
|
||||
// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/30451
|
||||
container: React.RefObject<any>
|
||||
// See https://github.com/vatesfr/xen-orchestra/pull/5722#discussion_r619296074
|
||||
rfb: any
|
||||
rfbConnected: boolean
|
||||
timeout?: NodeJS.Timeout
|
||||
tryToReconnect: boolean
|
||||
url?: URL
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scale: number
|
||||
setCtrlAltDel: (sendCtrlAltDel: Effects['sendCtrlAltDel']) => void
|
||||
vmId: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
_connect: () => Promise<void>
|
||||
_handleConnect: () => void
|
||||
_handleDisconnect: () => Promise<void>
|
||||
sendCtrlAltDel: () => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
interface PropsStyledConsole {
|
||||
scale: number
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
enum Protocols {
|
||||
http = 'http:',
|
||||
https = 'https:',
|
||||
ws = 'ws:',
|
||||
wss = 'wss:',
|
||||
}
|
||||
|
||||
const StyledConsole = styled.div<PropsStyledConsole>`
|
||||
height: ${props => props.scale}%;
|
||||
margin: auto;
|
||||
visibility: ${props => (props.visible ? 'visible' : 'hidden')};
|
||||
width: ${props => props.scale}%;
|
||||
`
|
||||
|
||||
// https://github.com/novnc/noVNC/blob/master/docs/API.md
|
||||
const Console = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
container: React.createRef(),
|
||||
rfb: undefined,
|
||||
rfbConnected: false,
|
||||
timeout: undefined,
|
||||
tryToReconnect: true,
|
||||
url: undefined,
|
||||
}),
|
||||
effects: {
|
||||
initialize: function () {
|
||||
this.effects._connect()
|
||||
},
|
||||
_handleConnect: function () {
|
||||
this.state.rfbConnected = true
|
||||
},
|
||||
_handleDisconnect: async function () {
|
||||
this.state.rfbConnected = false
|
||||
const {
|
||||
state: { objectsByType, url },
|
||||
effects: { _connect },
|
||||
} = this
|
||||
const { protocol } = window.location
|
||||
if (protocol === Protocols.https) {
|
||||
try {
|
||||
await fetch(`${protocol}//${url?.host}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
try {
|
||||
await confirm({
|
||||
icon: 'exclamation-triangle',
|
||||
message: (
|
||||
<a href={`${protocol}//${url?.host}`} rel='noopener noreferrer' target='_blank'>
|
||||
<IntlMessage
|
||||
id='unreachableHost'
|
||||
values={{
|
||||
name: objectsByType.get('host')?.find(host => host.address === url?.host)?.name_label,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
title: <IntlMessage id='connectionError' />,
|
||||
})
|
||||
} catch {
|
||||
this.state.tryToReconnect = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.tryToReconnect) {
|
||||
_connect()
|
||||
}
|
||||
},
|
||||
_connect: async function () {
|
||||
const { vmId } = this.props
|
||||
const { objectsByType, rfb, xapi } = this.state
|
||||
let lastError: unknown
|
||||
|
||||
// 8 tries mean 54s
|
||||
for (const delay of fibonacci().toMs().take(8)) {
|
||||
try {
|
||||
const consoles = (objectsByType.get('VM')?.get(vmId) as Vm)?.$consoles.filter(
|
||||
vmConsole => vmConsole.protocol === 'rfb'
|
||||
)
|
||||
|
||||
if (rfb !== undefined) {
|
||||
rfb.removeEventListener('connect', this.effects._handleConnect)
|
||||
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
|
||||
}
|
||||
|
||||
if (consoles === undefined || consoles.length === 0) {
|
||||
throw new Error('Could not find VM console')
|
||||
}
|
||||
|
||||
if (xapi.sessionId === undefined) {
|
||||
throw new Error('Not connected to XAPI')
|
||||
}
|
||||
|
||||
this.state.url = new URL(consoles[0].location)
|
||||
this.state.url.protocol = window.location.protocol === Protocols.https ? Protocols.wss : Protocols.ws
|
||||
this.state.url.searchParams.set('session_id', xapi.sessionId)
|
||||
|
||||
this.state.rfb = new RFB(this.state.container.current, this.state.url, {
|
||||
wsProtocols: ['binary'],
|
||||
})
|
||||
this.state.rfb.addEventListener('connect', this.effects._handleConnect)
|
||||
this.state.rfb.addEventListener('disconnect', this.effects._handleDisconnect)
|
||||
this.state.rfb.scaleViewport = true
|
||||
this.props.setCtrlAltDel(this.effects.sendCtrlAltDel)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise(resolve => (this.state.timeout = setTimeout(resolve, delay)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
},
|
||||
finalize: function () {
|
||||
const { rfb, timeout } = this.state
|
||||
rfb.removeEventListener('connect', this.effects._handleConnect)
|
||||
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
},
|
||||
sendCtrlAltDel: async function () {
|
||||
await confirm({
|
||||
message: <IntlMessage id='confirmCtrlAltDel' />,
|
||||
title: <IntlMessage id='ctrlAltDel' />,
|
||||
})
|
||||
this.state.rfb.sendCtrlAltDel()
|
||||
},
|
||||
},
|
||||
},
|
||||
({ scale, state }) => (
|
||||
<>
|
||||
{state.rfb !== undefined && !state.rfbConnected && (
|
||||
<p>
|
||||
<IntlMessage id={state.tryToReconnect ? 'reconnectionAttempt' : 'hostUnreachable'} />
|
||||
</p>
|
||||
)}
|
||||
<StyledConsole ref={state.container} scale={scale} visible={state.rfbConnected} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
export default Console
|
||||
30
@xen-orchestra/lite/src/components/Icon.tsx
Normal file
30
@xen-orchestra/lite/src/components/Icon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconName as _IconName, library, SizeProp } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
library.add(fas)
|
||||
|
||||
const Icon = ({
|
||||
color,
|
||||
htmlColor,
|
||||
icon,
|
||||
size,
|
||||
}: {
|
||||
color?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
|
||||
htmlColor?: string
|
||||
icon: _IconName
|
||||
size?: SizeProp
|
||||
}): JSX.Element => {
|
||||
const { palette } = useTheme()
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
size={size}
|
||||
color={htmlColor ?? (color !== undefined ? palette[color][palette.mode] : undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default Icon
|
||||
export type IconName = _IconName
|
||||
26
@xen-orchestra/lite/src/components/Input.tsx
Normal file
26
@xen-orchestra/lite/src/components/Input.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
// An interface can only extend an object type or intersection
|
||||
// of object types with statically known members.
|
||||
type Props = _Props & TextFieldProps
|
||||
|
||||
interface _Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Input = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ effects, resetState, state, ...props }) => <TextField fullWidth {...props} />
|
||||
)
|
||||
|
||||
export default Input
|
||||
21
@xen-orchestra/lite/src/components/IntlMessage.tsx
Normal file
21
@xen-orchestra/lite/src/components/IntlMessage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { ElementType, ReactElement, ReactNode } from 'react'
|
||||
import { FormattedMessage, MessageDescriptor, useIntl } from 'react-intl'
|
||||
import intlMessage from '../lang/en.json'
|
||||
|
||||
// Extends FormattedMessage not working: "FormattedMessage refers to a value, but is being used as a type here"
|
||||
// https://stackoverflow.com/questions/62059408/reactjs-and-typescript-refers-to-a-value-but-is-being-used-as-a-type-here-ts
|
||||
// InstanceType<typeof FormattedMessage> not working: "Type [...] does not satisfy the constraint abstract new (...args: any) => any."
|
||||
// See https://formatjs.io/docs/react-intl/components/#formattedmessage
|
||||
interface Props extends MessageDescriptor {
|
||||
children?: (chunks: ReactElement) => ReactElement
|
||||
id?: keyof typeof intlMessage
|
||||
tagName?: ElementType
|
||||
values?: Record<string, ReactNode>
|
||||
}
|
||||
const IntlMessage = (props: Props): JSX.Element => <FormattedMessage {...props} />
|
||||
|
||||
export function translate(message: MessageDescriptor){
|
||||
return useIntl().formatMessage(message)
|
||||
}
|
||||
|
||||
export default React.memo(IntlMessage)
|
||||
38
@xen-orchestra/lite/src/components/Link.tsx
Normal file
38
@xen-orchestra/lite/src/components/Link.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import MaterialLink from '@mui/material/Link'
|
||||
import React from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
decorated?: boolean
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const UNDECORATED_LINK = { textDecoration: 'none', color: 'inherit' }
|
||||
|
||||
const Link = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ to, decorated = true, children }) =>
|
||||
to === undefined ? (
|
||||
<>{children}</>
|
||||
) : to.startsWith('http') ? (
|
||||
<MaterialLink style={decorated ? undefined : UNDECORATED_LINK} target='_blank' rel='noopener noreferrer' href={to}>
|
||||
{children}
|
||||
</MaterialLink>
|
||||
) : (
|
||||
<RouterLink style={decorated ? undefined : UNDECORATED_LINK} component={MaterialLink} to={to}>
|
||||
{children}
|
||||
</RouterLink>
|
||||
)
|
||||
)
|
||||
|
||||
export default Link
|
||||
152
@xen-orchestra/lite/src/components/Modal.tsx
Normal file
152
@xen-orchestra/lite/src/components/Modal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react'
|
||||
import { ButtonProps, Dialog, DialogContent, DialogContentText, DialogActions, DialogTitle } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Button from './Button'
|
||||
import Icon, { IconName } from './Icon'
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
type ModalButton = {
|
||||
color?: ButtonProps['color']
|
||||
label: string | React.ReactNode
|
||||
reason?: unknown
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
interface GeneralParamsModal {
|
||||
icon: IconName
|
||||
message: string | React.ReactNode
|
||||
title: string | React.ReactNode
|
||||
}
|
||||
|
||||
interface ModalParams extends GeneralParamsModal {
|
||||
buttonList: ModalButton[]
|
||||
}
|
||||
|
||||
let instance: EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> | undefined
|
||||
const modal = ({ buttonList, icon, message, title }: ModalParams) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (instance === undefined) {
|
||||
throw new Error('No modal instance')
|
||||
}
|
||||
instance.state.buttonList = buttonList
|
||||
instance.state.icon = icon
|
||||
instance.state.message = message
|
||||
instance.state.onReject = reject
|
||||
instance.state.onSuccess = resolve
|
||||
instance.state.showModal = true
|
||||
instance.state.title = title
|
||||
})
|
||||
|
||||
export const alert = (params: GeneralParamsModal): Promise<unknown> => {
|
||||
const buttonList: ModalButton[] = [
|
||||
{
|
||||
label: <IntlMessage id='ok' />,
|
||||
color: 'primary',
|
||||
value: 'success',
|
||||
},
|
||||
]
|
||||
return modal({ ...params, buttonList })
|
||||
}
|
||||
|
||||
export const confirm = (params: GeneralParamsModal): Promise<unknown> => {
|
||||
const buttonList: ModalButton[] = [
|
||||
{
|
||||
label: <IntlMessage id='confirm' />,
|
||||
value: 'confirm',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
label: <IntlMessage id='cancel' />,
|
||||
color: 'secondary',
|
||||
reason: 'cancel',
|
||||
},
|
||||
]
|
||||
return modal({ ...params, buttonList })
|
||||
}
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
buttonList?: ModalButton[]
|
||||
icon?: IconName
|
||||
message?: string | React.ReactNode
|
||||
onReject?: (reason: unknown) => void
|
||||
onSuccess?: (value: unknown) => void
|
||||
showModal: boolean
|
||||
title?: string | React.ReactNode
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
closeModal: () => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const Modal = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: () => ({
|
||||
buttonList: undefined,
|
||||
icon: undefined,
|
||||
message: undefined,
|
||||
onReject: undefined,
|
||||
onSuccess: undefined,
|
||||
showModal: false,
|
||||
title: undefined,
|
||||
}),
|
||||
effects: {
|
||||
initialize: function () {
|
||||
if (instance !== undefined) {
|
||||
throw new Error('Modal is a singelton')
|
||||
}
|
||||
instance = this
|
||||
},
|
||||
closeModal: function () {
|
||||
this.state.showModal = false
|
||||
},
|
||||
reject: function (reason) {
|
||||
this.state.onReject?.(reason)
|
||||
this.effects.closeModal()
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state }) => {
|
||||
const { closeModal, reject } = effects
|
||||
const { buttonList, icon, message, onReject, onSuccess, showModal, title } = state
|
||||
|
||||
return showModal ? (
|
||||
<Dialog open={showModal} onClose={reject}>
|
||||
<DialogTitle>
|
||||
{icon !== undefined && <Icon icon={icon} />} {title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{message}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{buttonList?.map(({ label, reason, value, ...props }, index) => {
|
||||
const onClick = () => {
|
||||
if (value !== undefined) {
|
||||
onSuccess?.(value)
|
||||
} else {
|
||||
onReject?.(reason)
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
return (
|
||||
<Button key={index} onClick={onClick} {...props}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
) : null
|
||||
}
|
||||
)
|
||||
|
||||
export default Modal
|
||||
63
@xen-orchestra/lite/src/components/PanelHeader.tsx
Normal file
63
@xen-orchestra/lite/src/components/PanelHeader.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import Icon, { IconName } from './Icon'
|
||||
|
||||
import Button, { ButtonProps } from '@mui/material/Button'
|
||||
import ButtonGroup, { ButtonGroupClassKey } from '@mui/material/ButtonGroup'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography, { TypographyClassKey } from '@mui/material/Typography'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Action extends ButtonProps {
|
||||
icon: IconName
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const DEFAULT_TITLE_STYLE = { marginLeft: '0.5em', flex: 1, fontSize: '250%' }
|
||||
const DEFAULT_BUTTONGROUP_STYLE = { margin: '0.5em', flex: 0 }
|
||||
const DEFAULT_STACK_STYLE = {
|
||||
backgroundColor: (theme: Theme) => {
|
||||
const { background, palette } = theme
|
||||
return palette.mode === 'light' ? background.primary.light : background.primary.dark
|
||||
},
|
||||
paddingTop: '1em',
|
||||
}
|
||||
|
||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
// Accepts an array of Actions. An action accepts all the props of a Button + an icon
|
||||
actions?: Array<Action>
|
||||
// the props passed to the title, accepts all the keys of Typography
|
||||
titleProps?: TypographyClassKey
|
||||
// the props passed to the button group, accepts all the keys of a ButtonGroup
|
||||
buttonGroupProps?: ButtonGroupClassKey
|
||||
}
|
||||
|
||||
const PanelHeader = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{},
|
||||
({ actions = [], titleProps = {}, buttonGroupProps = {}, children = null }) => (
|
||||
<Stack direction='row' justifyContent='space-between' alignItems='center' sx={DEFAULT_STACK_STYLE}>
|
||||
<Typography variant='h2' sx={DEFAULT_TITLE_STYLE} {...titleProps}>
|
||||
{children}
|
||||
</Typography>
|
||||
<ButtonGroup sx={DEFAULT_BUTTONGROUP_STYLE} {...buttonGroupProps}>
|
||||
{(actions as Array<Action>)?.map(({ icon, ...actionProps }) => (
|
||||
<Button {...actionProps} key={actionProps.key}>
|
||||
<Icon icon={icon} />
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
)
|
||||
)
|
||||
|
||||
export default PanelHeader
|
||||
87
@xen-orchestra/lite/src/components/ProgressCircle.tsx
Normal file
87
@xen-orchestra/lite/src/components/ProgressCircle.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import React from 'react'
|
||||
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import { Typography } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
const BackgroundBox = styled(Box)({
|
||||
position: 'absolute',
|
||||
})
|
||||
|
||||
const BackgroundCircle = styled(CircularProgress)({
|
||||
color: '#e3dede',
|
||||
})
|
||||
|
||||
const Container = styled(Box)({
|
||||
display: 'inline-flex',
|
||||
position: 'relative',
|
||||
})
|
||||
|
||||
const StyledLabel = styled(Typography)(({ color, theme: { palette } }) => ({
|
||||
color: (palette[(color as string) ?? 'primary'] ?? palette.primary).main,
|
||||
textAlign: 'center',
|
||||
}))
|
||||
|
||||
const LabelBox = styled(Box)({
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
height: '80%',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
margin: 'auto',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '80%',
|
||||
})
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
color?: CircularProgressProps['color']
|
||||
label?: string
|
||||
max?: number
|
||||
showLabel?: boolean
|
||||
size?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
label: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
const ProgressCircle = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
label: ({ progress }, { label }) => label ?? `${progress}%`,
|
||||
progress: (_, { max = 100, value }) => Math.round((value / max) * 100),
|
||||
},
|
||||
},
|
||||
({ color = 'success', showLabel = true, size = 100, state: { label, progress } }) => (
|
||||
<Container>
|
||||
<BackgroundBox>
|
||||
<BackgroundCircle variant='determinate' value={100} size={size} />
|
||||
</BackgroundBox>
|
||||
<CircularProgress aria-label={label} color={color} size={size} value={progress} variant='determinate' />
|
||||
{showLabel && (
|
||||
<LabelBox>
|
||||
<StyledLabel variant='h5' color={color}>
|
||||
{label}
|
||||
</StyledLabel>
|
||||
</LabelBox>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
)
|
||||
|
||||
export default ProgressCircle
|
||||
7
@xen-orchestra/lite/src/components/RangeInput.tsx
Normal file
7
@xen-orchestra/lite/src/components/RangeInput.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'>
|
||||
|
||||
const RangeInput = React.memo((props: Props) => <input {...props} type='range' />)
|
||||
|
||||
export default RangeInput
|
||||
97
@xen-orchestra/lite/src/components/Select.tsx
Normal file
97
@xen-orchestra/lite/src/components/Select.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import React from 'react'
|
||||
import SelectMaterialUi, { SelectProps } from '@mui/material/Select'
|
||||
import { iteratee } from 'lodash'
|
||||
import { SelectChangeEvent } from '@mui/material'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
type AdditionalProps = Record<string, any>
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props extends SelectProps {
|
||||
additionalProps?: AdditionalProps
|
||||
onChange: (e: SelectChangeEvent<unknown>) => void
|
||||
optionRenderer?: string | { (item: any): number | string }
|
||||
options: any[] | undefined
|
||||
value: any
|
||||
valueRenderer?: string | { (item: any): number | string }
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {
|
||||
renderOption: (item: any, additionalProps?: AdditionalProps) => React.ReactNode
|
||||
renderValue: (item: any, additionalProps?: AdditionalProps) => number | string
|
||||
options?: JSX.Element[]
|
||||
}
|
||||
|
||||
const Select = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
computed: {
|
||||
// @ts-ignore
|
||||
renderOption: (_, { optionRenderer }) => iteratee(optionRenderer),
|
||||
// @ts-ignore
|
||||
renderValue: (_, { valueRenderer }) => iteratee(valueRenderer),
|
||||
options: (state, { additionalProps, options, optionRenderer, valueRenderer }) =>
|
||||
options?.map(item => {
|
||||
const label =
|
||||
optionRenderer === undefined
|
||||
? item.name ?? item.label ?? item.name_label
|
||||
: state.renderOption(item, additionalProps)
|
||||
const value =
|
||||
valueRenderer === undefined ? item.value ?? item.id ?? item.$id : state.renderValue(item, additionalProps)
|
||||
|
||||
if (value === undefined) {
|
||||
console.error('Computed value is undefined')
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
)
|
||||
}),
|
||||
},
|
||||
},
|
||||
({
|
||||
additionalProps,
|
||||
displayEmpty = true,
|
||||
effects,
|
||||
multiple,
|
||||
options,
|
||||
required,
|
||||
resetState,
|
||||
state,
|
||||
value,
|
||||
...props
|
||||
}) => (
|
||||
<FormControl>
|
||||
<SelectMaterialUi
|
||||
multiple={multiple}
|
||||
required={required}
|
||||
displayEmpty={displayEmpty}
|
||||
value={value ?? (multiple ? [] : '')}
|
||||
{...props}
|
||||
>
|
||||
{!multiple && (
|
||||
<MenuItem value=''>
|
||||
<em>
|
||||
<IntlMessage id='none' />
|
||||
</em>
|
||||
</MenuItem>
|
||||
)}
|
||||
{state.options}
|
||||
</SelectMaterialUi>
|
||||
</FormControl>
|
||||
)
|
||||
)
|
||||
|
||||
export default Select
|
||||
73
@xen-orchestra/lite/src/components/Table.tsx
Normal file
73
@xen-orchestra/lite/src/components/Table.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { withState } from 'reaclette'
|
||||
|
||||
import IntlMessage from './IntlMessage'
|
||||
|
||||
export type Column<Type> = {
|
||||
header: React.ReactNode
|
||||
id?: string
|
||||
render: { (item: Type): React.ReactNode }
|
||||
}
|
||||
|
||||
type Item = {
|
||||
id?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Props {
|
||||
collection: Item[] | undefined
|
||||
columns: Column<any>[]
|
||||
placeholder?: JSX.Element
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
const StyledTable = styled.table`
|
||||
border: 1px solid #333;
|
||||
td {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
thead {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
`
|
||||
const Table = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ collection, columns, placeholder }) =>
|
||||
collection !== undefined ? (
|
||||
collection.length !== 0 ? (
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col, index) => (
|
||||
<td key={col.id ?? index}>{col.header}</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{collection.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
{columns.map((col, index) => (
|
||||
<td key={col.id ?? index}>{col.render(item)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
) : (
|
||||
placeholder ?? <IntlMessage id='noData' />
|
||||
)
|
||||
) : (
|
||||
<IntlMessage id='loading' />
|
||||
)
|
||||
)
|
||||
|
||||
export default Table
|
||||
114
@xen-orchestra/lite/src/components/Tabs.tsx
Normal file
114
@xen-orchestra/lite/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import React from 'react'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import TabContext from '@mui/lab/TabContext'
|
||||
import TabList from '@mui/lab/TabList'
|
||||
import TabPanel from '@mui/lab/TabPanel'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { RouteComponentProps } from 'react-router-dom'
|
||||
import { withState } from 'reaclette'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import IntlMessage from '../components/IntlMessage'
|
||||
|
||||
const BOX_STYLE = { borderBottom: 1, borderColor: 'divider', marginTop: '0.5em' }
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
component?: React.ReactNode
|
||||
disabled?: boolean
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
interface UrlTab extends Tab {
|
||||
pathname: string
|
||||
value?: any
|
||||
}
|
||||
|
||||
interface NoUrlTab extends Tab {
|
||||
value: any
|
||||
}
|
||||
|
||||
// For compatibility with 'withRouter'
|
||||
interface Props extends RouteComponentProps {
|
||||
indicatorColor?: 'primary' | 'secondary'
|
||||
textColor?: 'inherit' | 'primary' | 'secondary'
|
||||
// tabs = [
|
||||
// {
|
||||
// component: <span>BAR</span>,
|
||||
// pathname: '/path',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='cloud' /> {labelA}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// ]
|
||||
tabs: Array<NoUrlTab | UrlTab>
|
||||
useUrl?: boolean
|
||||
value?: any
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
onChange: (event: React.SyntheticEvent, value: string) => void
|
||||
}
|
||||
|
||||
interface Computed {}
|
||||
|
||||
// TODO: improve view as done in the model(figma).
|
||||
const pageUnderConstruction = (
|
||||
<div style={{ color: '#0085FF', textAlign: 'center' }}>
|
||||
<Typography variant='h2'>
|
||||
<IntlMessage id='xoLiteUnderConstruction' />
|
||||
</Typography>
|
||||
<Typography variant='h3'>
|
||||
<IntlMessage id='newFeaturesUnderConstruction' />
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Tabs = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: ({ location: { pathname }, tabs, useUrl = false, value }) => ({
|
||||
value: (useUrl && pathname) || (value ?? tabs[0].value ?? tabs[0].pathname),
|
||||
}),
|
||||
effects: {
|
||||
onChange: function (_, value) {
|
||||
if (this.props.useUrl) {
|
||||
const { history, tabs } = this.props
|
||||
history.push(tabs.find(tab => (tab.value ?? tab.pathname) === value).pathname)
|
||||
}
|
||||
this.state.value = value
|
||||
},
|
||||
},
|
||||
},
|
||||
({ effects, state: { value }, indicatorColor, textColor, tabs }) => (
|
||||
<TabContext value={value}>
|
||||
<Box sx={BOX_STYLE}>
|
||||
<TabList indicatorColor={indicatorColor} onChange={effects.onChange} textColor={textColor}>
|
||||
{tabs.map((tab: UrlTab | NoUrlTab) => {
|
||||
const value = tab.value ?? tab.pathname
|
||||
return <Tab disabled={tab.disabled} key={value} label={tab.label} value={value} />
|
||||
})}
|
||||
</TabList>
|
||||
</Box>
|
||||
{tabs.map((tab: UrlTab | NoUrlTab) => {
|
||||
const value = tab.value ?? tab.pathname
|
||||
return (
|
||||
<TabPanel key={value} value={value}>
|
||||
{tab.component === undefined ? pageUnderConstruction : tab.component}
|
||||
</TabPanel>
|
||||
)
|
||||
})}
|
||||
</TabContext>
|
||||
)
|
||||
)
|
||||
|
||||
export default withRouter(Tabs)
|
||||
196
@xen-orchestra/lite/src/components/Tree.tsx
Normal file
196
@xen-orchestra/lite/src/components/Tree.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import classNames from 'classnames'
|
||||
import React, { useEffect } from 'react'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import TreeView from '@mui/lab/TreeView'
|
||||
import TreeItem, { useTreeItem, TreeItemContentProps } from '@mui/lab/TreeItem'
|
||||
import { withState } from 'reaclette'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import Icon from '../components/Icon'
|
||||
|
||||
interface ParentState {}
|
||||
|
||||
interface State {
|
||||
expandedNodes?: Array<string>
|
||||
selectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
export interface ItemType {
|
||||
children?: Array<ItemType>
|
||||
id: string
|
||||
label: React.ReactElement
|
||||
to?: string
|
||||
tooltip?: React.ReactNode
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// collection = [
|
||||
// {
|
||||
// id: 'idA',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='warehouse' /> {labelA}
|
||||
// </span>
|
||||
// ),
|
||||
// to: '/routeA',
|
||||
// children: [
|
||||
// {
|
||||
// id: 'ida',
|
||||
// label: label: (
|
||||
// <span>
|
||||
// <Icon icon='server' /> {labela}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// id: 'idB',
|
||||
// label: (
|
||||
// <span>
|
||||
// <Icon icon='warehouse' /> {labelB}
|
||||
// </span>
|
||||
// ),
|
||||
// to: '/routeB',
|
||||
// tooltip: <IntlMessage id='tooltipB' />
|
||||
// }
|
||||
// ]
|
||||
collection: Array<ItemType>
|
||||
defaultSelectedNodes?: Array<string>
|
||||
}
|
||||
|
||||
interface CustomContentProps extends TreeItemContentProps {
|
||||
defaultSelectedNode?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface ParentEffects {}
|
||||
|
||||
interface Effects {
|
||||
setExpandedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
|
||||
setSelectedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
|
||||
}
|
||||
|
||||
interface Computed {
|
||||
defaultSelectedNode?: string
|
||||
}
|
||||
|
||||
// Inspired by https://mui.com/components/tree-view/#contentcomponent-prop.
|
||||
const CustomContent = React.forwardRef(function CustomContent(props: CustomContentProps, ref) {
|
||||
const { classes, className, defaultSelectedNode, expansionIcon, label, nodeId, to } = props
|
||||
const { focused, handleExpansion, handleSelection, selected } = useTreeItem(nodeId)
|
||||
const history = useHistory()
|
||||
|
||||
useEffect(() => {
|
||||
// There can only be one node selected at once for now.
|
||||
// Auto-revealing more than one node in the tree would require a different implementation.
|
||||
if (defaultSelectedNode === nodeId) {
|
||||
ref?.current?.scrollIntoView()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
to !== undefined && history.push(to)
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
const handleExpansionClick = (event: React.SyntheticEvent) => {
|
||||
event.stopPropagation()
|
||||
handleExpansion(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(className, { [classes.focused]: focused, [classes.selected]: selected })}
|
||||
onClick={handleSelection}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={classes.iconContainer} onClick={handleExpansionClick}>
|
||||
{expansionIcon}
|
||||
</span>
|
||||
<span className={classes.label}>{label}</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
const renderItem = ({ children, id, label, to, tooltip }: ItemType, defaultSelectedNode?: string) => {
|
||||
return (
|
||||
<TreeItem
|
||||
ContentComponent={CustomContent}
|
||||
// FIXME: ContentProps should only be React.HTMLAttributes<HTMLElement> or undefined, it doesn't support other type.
|
||||
// when https://github.com/mui-org/material-ui/issues/28668 is fixed, remove 'as CustomContentProps'.
|
||||
ContentProps={{ defaultSelectedNode, to } as CustomContentProps}
|
||||
label={tooltip ? <Tooltip title={tooltip}>{label}</Tooltip> : label}
|
||||
key={id}
|
||||
nodeId={id}
|
||||
>
|
||||
{Array.isArray(children) ? children.map(item => renderItem(item, defaultSelectedNode)) : null}
|
||||
</TreeItem>
|
||||
)
|
||||
}
|
||||
|
||||
const Tree = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
{
|
||||
initialState: ({ collection, defaultSelectedNodes }) => {
|
||||
if (defaultSelectedNodes === undefined) {
|
||||
return {
|
||||
expandedNodes: [collection[0].id],
|
||||
selectedNodes: [],
|
||||
}
|
||||
}
|
||||
|
||||
// expandedNodes should contain all nodes up to the defaultSelectedNodes.
|
||||
const expandedNodes = new Set<string>()
|
||||
const pathToNode = new Set<string>()
|
||||
const addExpandedNode = (collection: Array<ItemType> | undefined) => {
|
||||
if (collection === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const node of collection) {
|
||||
if (defaultSelectedNodes.includes(node.id)) {
|
||||
for (const nodeId of pathToNode) {
|
||||
expandedNodes.add(nodeId)
|
||||
}
|
||||
}
|
||||
pathToNode.add(node.id)
|
||||
addExpandedNode(node.children)
|
||||
pathToNode.delete(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
addExpandedNode(collection)
|
||||
|
||||
return { expandedNodes: Array.from(expandedNodes), selectedNodes: defaultSelectedNodes }
|
||||
},
|
||||
effects: {
|
||||
setExpandedNodeIds: function (_, nodeIds) {
|
||||
this.state.expandedNodes = nodeIds
|
||||
},
|
||||
setSelectedNodeIds: function (_, nodeIds) {
|
||||
this.state.selectedNodes = [nodeIds[0]]
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
defaultSelectedNode: (_, { defaultSelectedNodes }) =>
|
||||
defaultSelectedNodes !== undefined ? defaultSelectedNodes[0] : undefined,
|
||||
},
|
||||
},
|
||||
({ effects, state: { defaultSelectedNode, expandedNodes, selectedNodes }, collection }) => (
|
||||
<TreeView
|
||||
defaultCollapseIcon={<Icon icon='chevron-up' />}
|
||||
defaultExpanded={[collection[0].id]}
|
||||
defaultExpandIcon={<Icon icon='chevron-down' />}
|
||||
expanded={expandedNodes}
|
||||
multiSelect
|
||||
onNodeSelect={effects.setSelectedNodeIds}
|
||||
onNodeToggle={effects.setExpandedNodeIds}
|
||||
selected={selectedNodes}
|
||||
>
|
||||
{collection.map(item => renderItem(item, defaultSelectedNode))}
|
||||
</TreeView>
|
||||
)
|
||||
)
|
||||
|
||||
export default Tree
|
||||
26
@xen-orchestra/lite/src/index.tsx
Normal file
26
@xen-orchestra/lite/src/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { createGlobalStyle } from 'styled-components'
|
||||
|
||||
import App from './App/index'
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, Verdana, Helvetica, Ubuntu, sans-serif;
|
||||
box-sizing: border-box;
|
||||
color: #212529;
|
||||
}
|
||||
`
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Helmet>
|
||||
<link rel='shortcut icon' href='favicon.ico' />
|
||||
</Helmet>
|
||||
<GlobalStyle />
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
55
@xen-orchestra/lite/src/lang/en.json
Normal file
55
@xen-orchestra/lite/src/lang/en.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"about": "About",
|
||||
"active": "Active",
|
||||
"availableUpdates": "{nUpdates, number} available update{nUpdates, plural, one {} other {s}}",
|
||||
"badCredentials": "Bad credentials",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmCtrlAltDel": "Send Ctrl+Alt+Del to VM?",
|
||||
"connect": "Connect",
|
||||
"connectionError": "Connection error",
|
||||
"consoleNotAvailable": "Console is only available for running VMs",
|
||||
"ctrlAltDel": "Ctrl+Alt+Del",
|
||||
"description": "Description",
|
||||
"device": "Device",
|
||||
"disconnect": "Disconnect",
|
||||
"dns": "DNS",
|
||||
"errorOccurred": "An error has occurred.",
|
||||
"gateway": "Gateway",
|
||||
"halted": "Halted",
|
||||
"hosts": "Hosts",
|
||||
"hostUnreachable": "Host unreachable",
|
||||
"inactive": "Inactive",
|
||||
"infrastructure": "Infrastructure",
|
||||
"ip": "IP",
|
||||
"loading": "Loading…",
|
||||
"login": "Login",
|
||||
"name": "Name",
|
||||
"newFeaturesUnderConstruction": "New features are coming soon!",
|
||||
"noHosts": "No hosts",
|
||||
"noData": "No data",
|
||||
"noImplemented": "Not implemented",
|
||||
"noManagementPifs": "No management PIFs found",
|
||||
"none": "None",
|
||||
"noVms": "No VMs",
|
||||
"notFound": "Not Found",
|
||||
"pageNotFound": "This page doesn't exist.",
|
||||
"xoLiteUnderConstruction": "XO Lite is under construction",
|
||||
"noUpdatesAvailable": "No updates available",
|
||||
"ok": "OK",
|
||||
"password": "Password",
|
||||
"paused": "Paused",
|
||||
"reconnectionAttempt": "Trying to reconnect…",
|
||||
"release": "Release",
|
||||
"rememberMe": "Remember me",
|
||||
"running": "Running",
|
||||
"size": "Size",
|
||||
"status": "Status",
|
||||
"suspended": "Suspended",
|
||||
"total": "Total",
|
||||
"unreachableHost": "Click here to make sure your host ({name}) is reachable. You may have to allow self-signed SSL certificates in your browser.",
|
||||
"vms": "VMs",
|
||||
"version": "Version",
|
||||
"versionValue": "Version {version}",
|
||||
"vmStartLabel": "Start"
|
||||
}
|
||||
4
@xen-orchestra/lite/src/lang/fr.json
Normal file
4
@xen-orchestra/lite/src/lang/fr.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"connect": "Connexion",
|
||||
"vmStartLabel": "Démarrer"
|
||||
}
|
||||
205
@xen-orchestra/lite/src/libs/xapi.ts
Normal file
205
@xen-orchestra/lite/src/libs/xapi.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { EventEmitter } from 'events'
|
||||
import { Map } from 'immutable'
|
||||
import { Xapi } from 'xen-api'
|
||||
|
||||
export interface XapiObject {
|
||||
$pool: Pool
|
||||
$ref: string
|
||||
$type: keyof types
|
||||
$id: string
|
||||
}
|
||||
|
||||
// Dictionary of XAPI types and their corresponding TypeScript types
|
||||
interface types {
|
||||
PIF: Pif
|
||||
pool: Pool
|
||||
VM: Vm
|
||||
host: Host
|
||||
}
|
||||
|
||||
// XAPI types ---
|
||||
|
||||
export interface Pif extends XapiObject {
|
||||
device: string
|
||||
DNS: string
|
||||
gateway: string
|
||||
IP: string
|
||||
management: boolean
|
||||
network: string
|
||||
}
|
||||
|
||||
export interface Pool extends XapiObject {
|
||||
name_label: string
|
||||
}
|
||||
|
||||
export interface PoolUpdate {
|
||||
changelog: {
|
||||
author: string
|
||||
date: Date
|
||||
description: string
|
||||
}
|
||||
description: string
|
||||
license: string
|
||||
name: string
|
||||
release: string
|
||||
size: number
|
||||
url: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface Vm extends XapiObject {
|
||||
$consoles: Array<{ protocol: string; location: string }>
|
||||
is_a_snapshot: boolean
|
||||
is_a_template: boolean
|
||||
is_control_domain: boolean
|
||||
name_description: string
|
||||
name_label: string
|
||||
power_state: string
|
||||
resident_on: string
|
||||
}
|
||||
|
||||
interface HostMetrics {
|
||||
live: boolean
|
||||
}
|
||||
export interface Host extends XapiObject {
|
||||
$metrics: HostMetrics
|
||||
address: string
|
||||
name_label: string
|
||||
power_state: string
|
||||
}
|
||||
|
||||
// --------
|
||||
|
||||
export interface ObjectsByType extends Map<string, Map<string, XapiObject>> {
|
||||
get<NSV, T extends keyof types>(key: T, notSetValue: NSV): Map<string, types[T]> | NSV
|
||||
get<T extends keyof types>(key: T): Map<string, types[T]> | undefined
|
||||
}
|
||||
|
||||
export default class XapiConnection extends EventEmitter {
|
||||
areObjectsFetched: Promise<void>
|
||||
connected: boolean
|
||||
objectsByType: ObjectsByType
|
||||
sessionId?: string
|
||||
|
||||
_resolveObjectsFetched!: () => void
|
||||
|
||||
_xapi?: {
|
||||
objects: EventEmitter & {
|
||||
all: { [id: string]: XapiObject }
|
||||
}
|
||||
connect(): Promise<void>
|
||||
disconnect(): Promise<void>
|
||||
call: (method: string, ...args: unknown[]) => Promise<unknown>
|
||||
_objectsFetched: Promise<void>
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.objectsByType = Map() as ObjectsByType
|
||||
this.connected = false
|
||||
this.areObjectsFetched = new Promise(resolve => {
|
||||
this._resolveObjectsFetched = resolve
|
||||
})
|
||||
}
|
||||
|
||||
async reattachSession(url: string): Promise<void> {
|
||||
const sessionId = Cookies.get('sessionId')
|
||||
if (sessionId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.connect({ url, sessionId })
|
||||
}
|
||||
|
||||
async connect({
|
||||
url,
|
||||
user = 'root',
|
||||
password,
|
||||
sessionId,
|
||||
rememberMe = Cookies.get('rememberMe') === 'true',
|
||||
}: {
|
||||
url: string
|
||||
user?: string
|
||||
password?: string
|
||||
sessionId?: string
|
||||
rememberMe?: boolean
|
||||
}): Promise<void> {
|
||||
const xapi = (this._xapi = new Xapi({
|
||||
auth: { user, password, sessionId },
|
||||
url,
|
||||
watchEvents: true,
|
||||
readonly: false,
|
||||
}))
|
||||
|
||||
const updateObjects = (objects: { [id: string]: XapiObject }) => {
|
||||
try {
|
||||
this.objectsByType = this.objectsByType.withMutations(objectsByType => {
|
||||
Object.entries(objects).forEach(([id, object]) => {
|
||||
if (object === undefined) {
|
||||
// Remove
|
||||
objectsByType.forEach((objects, type) => {
|
||||
objectsByType.set(type, objects.remove(id))
|
||||
})
|
||||
} else {
|
||||
// Add or update
|
||||
const { $type } = object
|
||||
objectsByType.set($type, objectsByType.get($type, Map<string, XapiObject>()).set(id, object))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.emit('objects', this.objectsByType)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
xapi.on('connected', () => {
|
||||
this.sessionId = xapi.sessionId
|
||||
this.connected = true
|
||||
this.emit('connected')
|
||||
})
|
||||
|
||||
xapi.on('disconnected', () => {
|
||||
Cookies.remove('sessionId')
|
||||
this.emit('disconnected')
|
||||
})
|
||||
|
||||
xapi.on('sessionId', (sessionId: string) => {
|
||||
if (rememberMe) {
|
||||
Cookies.set('rememberMe', 'true', { expires: 7 })
|
||||
}
|
||||
Cookies.set('sessionId', sessionId, rememberMe ? { expires: 7 } : undefined)
|
||||
})
|
||||
|
||||
await xapi.connect()
|
||||
await xapi._objectsFetched
|
||||
|
||||
updateObjects(xapi.objects.all)
|
||||
this._resolveObjectsFetched()
|
||||
|
||||
xapi.objects.on('add', updateObjects)
|
||||
xapi.objects.on('update', updateObjects)
|
||||
xapi.objects.on('remove', updateObjects)
|
||||
}
|
||||
|
||||
disconnect(): Promise<void> | undefined {
|
||||
Cookies.remove('rememberMe')
|
||||
Cookies.remove('sessionId')
|
||||
const { _xapi } = this
|
||||
if (_xapi !== undefined) {
|
||||
return _xapi.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
call(method: string, ...args: unknown[]): Promise<unknown> {
|
||||
const { _xapi, connected } = this
|
||||
if (!connected || _xapi === undefined) {
|
||||
throw new Error('Not connected to XAPI')
|
||||
}
|
||||
|
||||
return _xapi.call(method, ...args)
|
||||
}
|
||||
}
|
||||
63
@xen-orchestra/lite/tsconfig.json
Normal file
63
@xen-orchestra/lite/tsconfig.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
"jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
"declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./dist", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
"noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"resolveJsonModule": true
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
}
|
||||
}
|
||||
6
@xen-orchestra/lite/types/decs.d.ts
vendored
Normal file
6
@xen-orchestra/lite/types/decs.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '@novnc/novnc/lib/rfb'
|
||||
declare module 'human-format'
|
||||
declare module 'iterable-backoff'
|
||||
declare module 'json-rpc-protocol'
|
||||
declare module 'promise-toolbox'
|
||||
declare module 'xen-api'
|
||||
42
@xen-orchestra/lite/types/reaclette.d.ts
vendored
Normal file
42
@xen-orchestra/lite/types/reaclette.d.ts
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
type RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects> = {
|
||||
readonly effects: Effects & ParentEffects
|
||||
readonly state: State & ParentState & Computed
|
||||
readonly resetState: () => void
|
||||
} & Props
|
||||
|
||||
interface EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> {
|
||||
readonly effects: Effects & ParentEffects
|
||||
readonly state: State & ParentState & Computed
|
||||
readonly props: Props
|
||||
}
|
||||
|
||||
interface StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects> {
|
||||
initialState?: State | ((props: Props) => State) // what about Reaclette's state inheritance?
|
||||
effects?: {
|
||||
initialize?: () => void | Promise<void>
|
||||
finalize?: () => void | Promise<void>
|
||||
} & Effects &
|
||||
ThisType<EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
computed?: {
|
||||
[ComputedName in keyof Computed]: (
|
||||
state: State & ParentState & Computed,
|
||||
props: Props
|
||||
) => Computed[ComputedName] | Promise<Computed[ComputedName]>
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'reaclette' {
|
||||
function provideState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>
|
||||
): (component: React.Component<Props>) => React.Component<Props>
|
||||
|
||||
function injectState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
// FIXME: also accept class components
|
||||
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
): React.ElementType<Props>
|
||||
|
||||
function withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
|
||||
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>,
|
||||
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
|
||||
): React.ElementType<Props>
|
||||
}
|
||||
21
@xen-orchestra/lite/types/theme.d.ts
vendored
Normal file
21
@xen-orchestra/lite/types/theme.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Theme as ThemeMui, ThemeOptions as ThemeOptionsMui } from '@mui/material/styles'
|
||||
declare module '@mui/material/styles' {
|
||||
// FIXME: when https://github.com/microsoft/TypeScript/issues/40315 is fixed.
|
||||
// issue: Type 'Theme'/'ThemeOptions' recursively references itself as a base type.
|
||||
interface Theme extends ThemeMui {
|
||||
background: {
|
||||
primary: {
|
||||
dark: string
|
||||
light: string
|
||||
}
|
||||
}
|
||||
}
|
||||
interface ThemeOptions extends ThemeOptionsMui {
|
||||
background?: {
|
||||
primary?: {
|
||||
dark?: string
|
||||
light?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
@xen-orchestra/lite/webpack.config.js
Normal file
72
@xen-orchestra/lite/webpack.config.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
const path = require('path')
|
||||
const webpack = require('webpack')
|
||||
|
||||
const resolveApp = relative => path.resolve(__dirname, relative)
|
||||
|
||||
const { NODE_ENV = 'production' } = process.env
|
||||
const __PROD__ = NODE_ENV === 'production'
|
||||
|
||||
// https://webpack.js.org/configuration/
|
||||
module.exports = {
|
||||
mode: NODE_ENV,
|
||||
target: 'web',
|
||||
devServer: {
|
||||
historyApiFallback: true,
|
||||
},
|
||||
entry: resolveApp('src/index.tsx'),
|
||||
output: {
|
||||
filename: __PROD__ ? '[name].[contenthash:8].js' : '[name].js',
|
||||
path: resolveApp('dist'),
|
||||
},
|
||||
optimization: {
|
||||
moduleIds: __PROD__ ? 'deterministic' : undefined,
|
||||
runtimeChunk: true,
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
dns: false,
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
devtool: __PROD__ ? 'source-map' : 'eval-cheap-module-source-map',
|
||||
plugins: [
|
||||
new (require('clean-webpack-plugin').CleanWebpackPlugin)(),
|
||||
new (require('copy-webpack-plugin'))({
|
||||
patterns: [
|
||||
{
|
||||
from: resolveApp('public'),
|
||||
to: resolveApp('dist'),
|
||||
filter: file => file !== resolveApp('public/index.html'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
new (require('html-webpack-plugin'))({
|
||||
template: resolveApp('public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({ XAPI_HOST: '', NPM_VERSION: require('./package.json').version }),
|
||||
new (require('node-polyfill-webpack-plugin'))(),
|
||||
].filter(Boolean),
|
||||
}
|
||||
@@ -66,6 +66,10 @@ configure([
|
||||
// if filter is a string, then it is pattern
|
||||
// (https://github.com/visionmedia/debug#wildcards) which is
|
||||
// matched against the namespace of the logs
|
||||
//
|
||||
// If it's an array, it will be handled as an array of filters
|
||||
// and the transport will be used if any one of them match the
|
||||
// current log
|
||||
filter: process.env.DEBUG,
|
||||
|
||||
transport: transportConsole(),
|
||||
|
||||
@@ -4,6 +4,42 @@ const { compileGlobPattern } = require('./utils')
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const compileFilter = filter => {
|
||||
if (filter === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = typeof filter
|
||||
if (type === 'function') {
|
||||
return filter
|
||||
}
|
||||
if (type === 'string') {
|
||||
const re = compileGlobPattern(filter)
|
||||
return log => re.test(log.namespace)
|
||||
}
|
||||
|
||||
if (Array.isArray(filter)) {
|
||||
const filters = filter.map(compileFilter).filter(_ => _ !== undefined)
|
||||
const { length } = filters
|
||||
if (length === 0) {
|
||||
return
|
||||
}
|
||||
if (length === 1) {
|
||||
return filters[0]
|
||||
}
|
||||
return log => {
|
||||
for (let i = 0; i < length; ++i) {
|
||||
if (filters[i](log)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
throw new TypeError('unsupported `filter`')
|
||||
}
|
||||
|
||||
const createTransport = config => {
|
||||
if (typeof config === 'function') {
|
||||
return config
|
||||
@@ -19,26 +55,15 @@ const createTransport = config => {
|
||||
}
|
||||
}
|
||||
|
||||
let { filter } = config
|
||||
let transport = createTransport(config.transport)
|
||||
const level = resolve(config.level)
|
||||
const filter = compileFilter([config.filter, level === undefined ? undefined : log => log.level >= level])
|
||||
|
||||
let transport = createTransport(config.transport)
|
||||
|
||||
if (filter !== undefined) {
|
||||
if (typeof filter === 'string') {
|
||||
const re = compileGlobPattern(filter)
|
||||
filter = log => re.test(log.namespace)
|
||||
}
|
||||
|
||||
const orig = transport
|
||||
transport = function (log) {
|
||||
if ((level !== undefined && log.level >= level) || filter(log)) {
|
||||
return orig.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
} else if (level !== undefined) {
|
||||
const orig = transport
|
||||
transport = function (log) {
|
||||
if (log.level >= level) {
|
||||
if (filter(log)) {
|
||||
return orig.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/log",
|
||||
"version": "0.2.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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const get = require('lodash/get')
|
||||
const identity = require('lodash/identity')
|
||||
const isEqual = require('lodash/isEqual')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { parseDuration } = require('@vates/parse-duration')
|
||||
const { watch } = require('app-conf')
|
||||
@@ -48,7 +49,7 @@ module.exports = class Config {
|
||||
const watcher = config => {
|
||||
try {
|
||||
const value = processor(get(config, path))
|
||||
if (value !== prev) {
|
||||
if (!isEqual(value, prev)) {
|
||||
prev = value
|
||||
cb(value)
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/emit-async": "^0.1.0",
|
||||
"@xen-orchestra/log": "^0.2.1",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"app-conf": "^0.9.0",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -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))
|
||||
|
||||
@@ -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).
|
||||
|
||||
[](https://xen-orchestra.com/blog/backup-your-xenserver-vms-with-xen-orchestra/)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
58
docs/xoa.md
58
docs/xoa.md
@@ -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`:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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
|
||||
})()
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user