feat: add machine status page (#344)

* feat: add confirm-dialog component

* feat: add MachineStatus to router

* feat: add machine details to api endpoints

* feat: add machine-status expandabletable

* fix: add missing property to TextInput on story

* style: minor style fixes

* feat: useAxios to unpair and reboot specific machinees

* fix: style fixes
use shutdown instead of reboot
use named colors

* fix: use new ExpTable

* fix: class instead of sttyles, use named colors

* feat: use ConfirmDialog to confirm unpair action

* chore: eslint fix

* refactor: use gql, new ExpTable and ramda on machine-status

* fix: 'fallback' status instead of the 'all good' one

* fix: makeStyles instead of withStyles

* refactor: simplify StatusChip

* fix: css spacing instead of nbsp

* fix: move makeStyles outside component

* refactor: makeStyles instead of withStyles

* refactor: adapting based props for Status

* refactor: moar simple Status chip

* feat: use graphql mutation instead of rest for machine action
feat: use graphql instead of rest on MachineDetailsCard

* fix: Dialog close must be handled outside

* fix: just pass down onDissmissed and onConfirmed to the component
https://github.com/lamassu/lamassu-server/pull/344#discussion_r370136028

* refactor: machineAction on separate file and 404 handling

* feat: basic handling of graphql exceptions on machineAction
This commit is contained in:
Mauricio Navarro Miranda 2020-02-04 14:12:44 -06:00 committed by GitHub
parent f1edea4e8a
commit fdf18b60ad
14 changed files with 609 additions and 3 deletions

View file

@ -15,6 +15,10 @@ function getMachines () {
cashbox: r.cashbox,
cassette1: r.cassette1,
cassette2: r.cassette2,
pairedAt: new Date(r.created).valueOf(),
lastPing: new Date(r.last_online).valueOf(),
// TODO: we shall start using this JSON field at some point
// location: r.location,
paired: r.paired
})))
}
@ -32,8 +36,16 @@ function getMachineNames (config) {
const machineScoped = configManager.machineScoped(r.deviceId, config)
const name = _.defaultTo('', machineScoped.machineName)
const cashOut = machineScoped.cashOutEnabled
const machineModel = _.defaultTo('', machineScoped.machineModel)
const machineLocation = _.defaultTo('', machineScoped.machineLocation)
return _.assign(r, {name, cashOut})
// TODO: obtain next fields from somewhere
const printer = null
const pingTime = null
const statuses = [{label: 'Unknown detailed status', type: 'warning'}]
const softwareVersion = ''
return _.assign(r, {name, cashOut, machineModel, machineLocation, printer, pingTime, statuses, softwareVersion})
}
return _.map(addName, machines)

View file

@ -4,6 +4,7 @@ const { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json')
const got = require('got')
const machineLoader = require('../../machine-loader')
const { machineAction } = require('../machines')
const logs = require('../../logs')
const supportLogs = require('../../support_logs')
const settingsLoader = require('../../new-settings-loader')
@ -42,6 +43,11 @@ const typeDefs = gql`
display: String!
}
type MachineStatus {
label: String!
type: String!
}
type Machine {
name: String!
deviceId: ID!
@ -49,6 +55,7 @@ const typeDefs = gql`
cashbox: Int
cassette1: Int
cassette2: Int
statuses: [MachineStatus]
}
type Account {
@ -154,7 +161,15 @@ const typeDefs = gql`
deviceId: ID
}
enum MachineAction {
resetCashOutBills
unpair
reboot
restartServices
}
type Mutation {
machineAction(deviceId:ID!, action: MachineAction!): Machine
machineSupportLogs(deviceId: ID!): SupportLogsResponse
serverSupportLogs: SupportLogsResponse
saveConfig(config: JSONObject): JSONObject
@ -184,6 +199,7 @@ const resolvers = {
config: () => settingsLoader.getConfig()
},
Mutation: {
machineAction: (...[, { deviceId, action }]) => machineAction({ deviceId, action }),
machineSupportLogs: (...[, { deviceId }]) => supportLogs.insert(deviceId),
serverSupportLogs: () => serverLogs.insert(),
saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config)

20
lib/new-admin/machines.js Normal file
View file

@ -0,0 +1,20 @@
const machineLoader = require('../machine-loader')
const { UserInputError } = require('apollo-server-express')
function getMachine(machineId) {
return machineLoader.getMachines()
.then(machines => machines.find(({ deviceId }) => deviceId === machineId))
}
function machineAction({ deviceId, action }) {
return getMachine(deviceId)
.then(machine => {
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
return machine
})
.then(machineLoader.setMachine({ deviceId, action }))
.then(getMachine(deviceId))
}
module.exports = { machineAction }

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12" height="12" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/status/warning</title>
<rect width="12" height="12" rx="3" ry="3" fill="#ff7311"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12" height="12" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/status/warning</title>
<rect width="12" height="12" rx="3" ry="3" fill="#ff584a"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -0,0 +1,96 @@
import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle as MuiDialogTitle,
IconButton,
makeStyles
} from '@material-ui/core'
import React, { useState, memo } from 'react'
import { Button } from '../components/buttons'
import { ReactComponent as CloseIcon } from '../styling/icons/action/close/zodiac.svg'
import { spacer } from '../styling/variables'
import { TextInput } from './inputs'
import { H4, P } from './typography'
const useStyles = makeStyles({
closeButton: {
position: 'absolute',
right: spacer,
top: spacer
}
})
export const DialogTitle = ({ children, onClose }) => {
const classes = useStyles()
return (
<MuiDialogTitle>
{children}
{onClose && (
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}>
<CloseIcon />
</IconButton>
)}
</MuiDialogTitle>
)
}
export const ConfirmDialog = memo(
({
title = 'Confirm action',
subtitle = 'This action requires confirmation',
open,
toBeConfirmed,
onConfirmed,
onDissmised,
...props
}) => {
const [value, setValue] = useState('')
const handleChange = event => {
setValue(event.target.value)
}
return (
<Dialog open={open} aria-labelledby="form-dialog-title" {...props}>
<DialogTitle id="customized-dialog-title" onClose={onDissmised}>
<H4>{title}</H4>
{subtitle && (
<DialogContentText>
<P>{subtitle}</P>
</DialogContentText>
)}
</DialogTitle>
<DialogContent>
<TextInput
label={`Write '${toBeConfirmed}' to confirm`}
name="confirm-input"
autoFocus
id="confirm-input"
type="text"
large
fullWidth
value={value}
touched={{}}
error={toBeConfirmed !== value}
InputLabelProps={{ shrink: true }}
onChange={handleChange}
/>
</DialogContent>
<DialogActions>
<Button
color="green"
disabled={toBeConfirmed !== value}
onClick={onConfirmed}>
Confirm
</Button>
</DialogActions>
</Dialog>
)
}
)

View file

@ -0,0 +1,69 @@
import React from 'react'
import Chip from '@material-ui/core/Chip'
import { makeStyles } from '@material-ui/core/styles'
import {
tomato,
mistyRose,
pumpkin,
secondaryColorDarker as spring4,
inputFontWeight,
spring3,
smallestFontSize,
inputFontFamily,
spacer
} from '../styling/variables'
const colors = {
error: tomato,
warning: pumpkin,
success: spring4
}
const backgroundColors = {
error: mistyRose,
warning: mistyRose,
success: spring3
}
const useStyles = makeStyles({
root: {
borderRadius: spacer / 2,
marginTop: spacer / 2,
marginRight: spacer / 4,
marginBottom: spacer / 2,
marginLeft: spacer / 4,
height: 18,
backgroundColor: ({ type }) => backgroundColors[type]
},
label: {
fontSize: smallestFontSize,
fontWeight: inputFontWeight,
fontFamily: inputFontFamily,
paddingRight: spacer / 2,
paddingLeft: spacer / 2,
color: ({ type }) => colors[type]
}
})
const Status = ({ status }) => {
const classes = useStyles({ type: status.type })
return <Chip type={status.type} label={status.label} classes={classes} />
}
const MainStatus = ({ statuses }) => {
const mainStatus =
statuses.find(s => s.type === 'error') ||
statuses.find(s => s.type === 'warning') ||
statuses[0]
const plus = { label: `+${statuses.length - 1}`, type: mainStatus.type }
return (
<div>
<Status status={mainStatus} />
{statuses.length > 1 && <Status status={plus} />}
</div>
)
}
export { Status, MainStatus }

View file

@ -0,0 +1,242 @@
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import moment from 'moment'
import React, { useState } from 'react'
import { useMutation } from '@apollo/react-hooks'
import { gql } from 'apollo-boost'
import { Dialog, DialogContent } from '@material-ui/core'
import { H4 } from 'src/components/typography'
import ActionButton from '../../components/buttons/ActionButton'
import { DialogTitle, ConfirmDialog } from '../../components/ConfirmDialog'
import { Status } from '../../components/Status'
import { ReactComponent as DownloadReversedIcon } from '../../styling/icons/button/download/white.svg'
import { ReactComponent as DownloadIcon } from '../../styling/icons/button/download/zodiac.svg'
import { ReactComponent as RebootReversedIcon } from '../../styling/icons/button/reboot/white.svg'
import { ReactComponent as RebootIcon } from '../../styling/icons/button/reboot/zodiac.svg'
import { ReactComponent as ShutdownReversedIcon } from '../../styling/icons/button/shut down/white.svg'
import { ReactComponent as ShutdownIcon } from '../../styling/icons/button/shut down/zodiac.svg'
import { ReactComponent as UnpairReversedIcon } from '../../styling/icons/button/unpair/white.svg'
import { ReactComponent as UnpairIcon } from '../../styling/icons/button/unpair/zodiac.svg'
import {
detailsRowStyles,
labelStyles
} from '../Transactions/Transactions.styles'
import { zircon } from '../../styling/variables'
const MACHINE_ACTION = gql`
mutation MachineAction($deviceId: ID!, $action: MachineAction!) {
machineAction(deviceId: $deviceId, action: $action) {
deviceId
}
}
`
const colDivider = {
background: zircon,
width: 2
}
const inlineChip = {
marginInlineEnd: '0.25em'
}
const useLStyles = makeStyles(labelStyles)
const Label = ({ children }) => {
const classes = useLStyles()
return <div className={classes.label}>{children}</div>
}
const useMDStyles = makeStyles({ ...detailsRowStyles, colDivider, inlineChip })
const MachineDetailsRow = ({ it: machine }) => {
const [errorDialog, setErrorDialog] = useState(false)
const [dialogOpen, setOpen] = useState(false)
const [actionMessage, setActionMessage] = useState(null)
const classes = useMDStyles()
const unpairDialog = () => setOpen(true)
const [machineAction, { loading }] = useMutation(MACHINE_ACTION, {
onError: ({ graphQLErrors, message }) => {
const errorMessage = graphQLErrors[0] ? graphQLErrors[0].message : message
setActionMessage(errorMessage)
setErrorDialog(true)
}
})
return (
<>
<Dialog open={errorDialog} aria-labelledby="form-dialog-title">
<DialogTitle
id="customized-dialog-title"
onClose={() => setErrorDialog(false)}>
<H4>Error</H4>
</DialogTitle>
<DialogContent>{actionMessage}</DialogContent>
</Dialog>
<div className={classes.wrapper}>
<div className={classnames(classes.row)}>
<div className={classnames(classes.col)}>
<div className={classnames(classes.row)}>
<div className={classnames(classes.col, classes.col2)}>
<div className={classes.innerRow}>
<div>
<Label>Statuses</Label>
<div>
{machine.statuses.map((status, index) => (
<Status status={status} key={index} />
))}
</div>
</div>
<div>
<Label>Lamassu Support article</Label>
<div>
{machine.statuses.map((...[, index]) => (
<span key={index} />
))}
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={classnames(
classes.col,
classes.col2,
classes.colDivider
)}
/>
<div className={classnames(classes.col)}>
<div className={classnames(classes.row)}>
<div className={classnames(classes.col, classes.col2)}>
<div className={classes.innerRow}>
<div>
<Label>Machine Model</Label>
<div>{machine.machineModel}</div>
</div>
<div className={classes.commissionWrapper}>
<Label>Address</Label>
<div>{machine.machineLocation}</div>
</div>
</div>
</div>
</div>
<div className={classnames(classes.row)}>
<div className={classnames(classes.col, classes.col2)}>
<div className={classes.innerRow}>
<div>
<Label>Paired at</Label>
<div>
{moment(machine.pairedAt).format('YYYY-MM-DD HH:mm:ss')}
</div>
</div>
<div className={classes.commissionWrapper}>
<Label>Software update</Label>
<div className={classes.innerRow}>
{machine.softwareVersion && (
<span className={classes.inlineChip}>
{machine.softwareVersion}
</span>
)}
<ActionButton
className={classes.inlineChip}
disabled
color="primary"
Icon={DownloadIcon}
InverseIcon={DownloadReversedIcon}>
Update
</ActionButton>
</div>
</div>
</div>
</div>
</div>
<div className={classnames(classes.row)}>
<div className={classnames(classes.col, classes.col2)}>
<div className={classes.innerRow}>
<div>
<Label>Printer</Label>
<div>{machine.printer || 'unknown'}</div>
</div>
<div className={classes.commissionWrapper}>
<Label>Actions</Label>
<div className={classes.innerRow}>
<ActionButton
className={classes.inlineChip}
color="primary"
Icon={UnpairIcon}
InverseIcon={UnpairReversedIcon}
disabled={loading}
onClick={unpairDialog}>
Unpair
</ActionButton>
<ConfirmDialog
open={dialogOpen}
title="Unpair this machine?"
subtitle={false}
toBeConfirmed={machine.name}
onConfirmed={() => {
setOpen(false)
machineAction({
variables: {
deviceId: machine.deviceId,
action: 'unpair'
}
})
}}
onDissmised={() => {
setOpen(false)
}}
/>
<ActionButton
className={classes.inlineChip}
color="primary"
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
machineAction({
variables: {
deviceId: machine.deviceId,
action: 'reboot'
}
})
}}>
Reboot
</ActionButton>
<ActionButton
className={classes.inlineChip}
disabled={loading}
color="primary"
Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon}
onClick={() => {
machineAction({
variables: {
deviceId: machine.deviceId,
action: 'shutdown'
}
})
}}>
Shutdown
</ActionButton>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
export default MachineDetailsRow

View file

@ -0,0 +1,103 @@
import { makeStyles } from '@material-ui/core'
import { useQuery } from '@apollo/react-hooks'
import { gql } from 'apollo-boost'
import moment from 'moment'
import * as R from 'ramda'
import React from 'react'
import ExpTable from '../../components/expandable-table/ExpTable'
import { MainStatus } from '../../components/Status'
import Title from '../../components/Title'
import { ReactComponent as WarningIcon } from '../../styling/icons/status/pumpkin.svg'
import { ReactComponent as ErrorIcon } from '../../styling/icons/status/tomato.svg'
import { mainStyles } from '../Transactions/Transactions.styles'
import MachineDetailsRow from './MachineDetailsCard'
const GET_MACHINES = gql`
{
machines {
name
deviceId
paired
cashbox
cassette1
cassette2
statuses {
label
type
}
}
}
`
const useStyles = makeStyles(mainStyles)
const MachineStatus = () => {
const classes = useStyles()
const { data: machinesResponse } = useQuery(GET_MACHINES)
const elements = [
{
header: 'Machine Name',
size: 232,
textAlign: 'left',
view: m => m.name
},
{
header: 'Status',
size: 349,
textAlign: 'left',
view: m => <MainStatus statuses={m.statuses} />
},
{
header: 'Last ping',
size: 192,
textAlign: 'left',
view: m => moment(m.lastPing).fromNow()
},
{
header: 'Ping Time',
size: 155,
textAlign: 'left',
view: m => m.pingTime || 'unknown'
},
{
header: 'Software Version',
size: 201,
textAlign: 'left',
view: m => m.softwareVersion || 'unknown'
},
{
size: 71
}
]
return (
<>
<div className={classes.titleWrapper}>
<div className={classes.titleAndButtonsContainer}>
<Title>Machine Status</Title>
</div>
<div className={classes.headerLabels}>
<div>
<WarningIcon />
<span>Warning</span>
</div>
<div>
<ErrorIcon />
<span>Error</span>
</div>
</div>
</div>
<ExpTable
elements={elements}
data={R.path(['machines'])(machinesResponse)}
Details={MachineDetailsRow}
/>
</>
)
}
export default MachineStatus

View file

@ -12,6 +12,8 @@ import Services from 'src/pages/Services/Services'
import AuthRegister from 'src/pages/AuthRegister'
import OperatorInfo from 'src/pages/OperatorInfo/OperatorInfo'
import MachineStatus from '../pages/maintenance/MachineStatus'
const tree = [
{ key: 'transactions', label: 'Transactions', route: '/transactions' },
// maintenence: { label: 'Maintenence', children: [{ label: 'Locale', route: '/locale' }] },
@ -27,6 +29,11 @@ const tree = [
key: 'server-logs',
label: 'Server',
route: '/maintenance/server-logs'
},
{
key: 'machine-status',
label: 'Machine Status',
route: '/maintenance/machine-status'
}
]
},
@ -80,6 +87,7 @@ const Routes = () => (
<Route path="/maintenance/server-logs" component={ServerLogs} />
<Route path="/transactions" component={Transactions} />
<Route path="/register" component={AuthRegister} />
<Route path="/maintenance/machine-status" component={MachineStatus} />
</Switch>
)

View file

@ -17,6 +17,7 @@ import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/author
import { ActionButton, Button, Link } from 'src/components/buttons'
import { Radio, TextInput, Switch } from 'src/components/inputs'
import ConfirmDialog from '../components/ConfirmDialog'
import {
H1,
H2,
@ -119,7 +120,11 @@ story.add('Switch', () => (
story.add('Text Input', () => (
<Wrapper>
<TextInput color={select('Color', colors, 'amazonite')} />
<TextInput
name="text-input"
touched={[]}
color={select('Color', colors, 'amazonite')}
/>
</Wrapper>
))
@ -141,6 +146,21 @@ story.add('Checkbox', () => (
</Wrapper>
))
story.add('ConfirmDialog', () => (
<Wrapper>
<ConfirmDialog
open={boolean('open', true)}
onDissmised={() => {
window.alert('dissmised')
}}
onConfirmed={() => {
window.alert('confirmed')
}}
toBeConfirmed="there-is-no-fate"
/>
</Wrapper>
))
story.add('Radio', () => <Radio label="Hehe" />)
const typographyStory = storiesOf('Typography', module)

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12" height="12" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/status/warning</title>
<rect width="12" height="12" rx="3" ry="3" fill="#ff7311"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12" height="12" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/status/warning</title>
<rect width="12" height="12" rx="3" ry="3" fill="#ff584a"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -119,10 +119,10 @@ export {
spring2,
spring3,
tomato,
pumpkin,
mistyRose,
java,
neon,
pumpkin,
linen,
// named colors
primaryColor,