Compare commits

...

55 Commits

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

View File

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

View File

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

View File

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

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

3391
yarn.lock

File diff suppressed because it is too large Load Diff