Compare commits
55 Commits
pool-autop
...
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 |
@@ -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
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const gitDiffIndex = () => gitDiff('index', ['--cached', 'HEAD'])
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const files = gitDiffIndex().filter(_ => _.endsWith('.cjs') || _.endsWith('.js') || _.endsWith('.mjs'))
|
||||
const files = gitDiffIndex().filter(_ => _.endsWith('.cjs') || _.endsWith('.js') || _.endsWith('.mjs') || _.endsWith('.tsx') || _.endsWith('.ts'))
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user