diff --git a/lib/cache.js b/lib/cache.js deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js index 047e4c96..9b158ccf 100644 --- a/lib/graphql/resolvers.js +++ b/lib/graphql/resolvers.js @@ -231,6 +231,7 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => { _.set('restartServices', !!pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid), _.set('emptyUnit', !!pid && state.emptyUnit?.[operatorId]?.[deviceId] === pid), _.set('refillUnit', !!pid && state.refillUnit?.[operatorId]?.[deviceId] === pid), + _.set('diagnostics', !!pid && state.diagnostics?.[operatorId]?.[deviceId] === pid), )(pq) // Clean up the state middleware and prevent commands from being issued more than once @@ -242,6 +243,10 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => { delete state.refillUnit?.[operatorId]?.[deviceId] } + if (!_.isNil(state.diagnostics?.[operatorId]?.[deviceId])) { + delete state.diagnostics?.[operatorId]?.[deviceId] + } + return res } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index 6e0ff03b..7977e522 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -206,6 +206,7 @@ type DynamicConfig { restartServices: Boolean! emptyUnit: Boolean! refillUnit: Boolean! + diagnostics: Boolean! } type Configs { diff --git a/lib/machine-loader.js b/lib/machine-loader.js index 578e211e..fd9375b1 100644 --- a/lib/machine-loader.js +++ b/lib/machine-loader.js @@ -1,6 +1,9 @@ +const fsPromises = require('fs').promises +const path = require('path') const _ = require('lodash/fp') const pgp = require('pg-promise')() const uuid = require('uuid') +const makeDir = require('make-dir') const batching = require('./cashbox-batches') const db = require('./db') @@ -13,10 +16,12 @@ const notifierUtils = require('./notifier/utils') const notifierQueries = require('./notifier/queries') const { ApolloError } = require('apollo-server-errors'); const { loadLatestConfig } = require('./new-settings-loader') +const logger = require('./logger') const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' } const unresponsiveStatus = { label: 'Unresponsive', type: 'error' } const stuckStatus = { label: 'Stuck', type: 'error' } +const OPERATOR_DATA_DIR = process.env.OPERATOR_DATA_DIR const MACHINE_WITH_CALCULATED_FIELD_SQL = ` select d.*, COALESCE(emptybills, 0) + COALESCE(regularbills, 0) as cashbox from devices d @@ -53,6 +58,11 @@ function toMachineObject (r) { numberOfRecyclers: r.number_of_recyclers, version: r.version, model: r.model, + diagnostics: { + timestamp: r.diagnostics_timestamp? new Date(r.diagnostics_timestamp) : null, + scanTimestamp: r.diagnostics_scan_timestamp? new Date(r.diagnostics_scan_timestamp) : null, + frontTimestamp: r.diagnostics_front_timestamp? new Date(r.diagnostics_front_timestamp) : null + }, pairedAt: new Date(r.created), lastPing: new Date(r.last_online), name: r.name, @@ -362,6 +372,36 @@ function refillUnit (rec) { )]) } +function diagnostics (rec) { + const directory = `${OPERATOR_DATA_DIR}/diagnostics/${rec.deviceId}/` + const sql = `UPDATE devices + SET diagnostics_timestamp = NULL, + diagnostics_scan_updated_at = NULL, + diagnostics_front_updated_at = NULL + WHERE device_id = $1` + + const scanPath = path.join(directory, 'scan.jpg') + const frontPath = path.join(directory, 'front.jpg') + + const removeFiles = [scanPath, frontPath].map(filePath => { + return fsPromises.unlink(filePath).catch(err => { + if (err.code !== 'ENOENT') { + throw err + } + // File doesn't exist, no problem + }) + }) + + return Promise.all(removeFiles) + .then(() => db.none(sql, [rec.deviceId])) + .then(() => db.none('NOTIFY $1:name, $2', ['machineAction', JSON.stringify( + { + action: 'diagnostics', + value: _.pick(['deviceId', 'operatorId', 'action'], rec) + } + )])) +} + function setMachine (rec, operatorId) { rec.operatorId = operatorId switch (rec.action) { @@ -374,6 +414,7 @@ function setMachine (rec, operatorId) { case 'restartServices': return restartServices(rec) case 'emptyUnit': return emptyUnit(rec) case 'refillUnit': return refillUnit(rec) + case 'diagnostics': return diagnostics(rec) default: throw new Error('No such action: ' + rec.action) } } @@ -436,6 +477,44 @@ function getNetworkHeartbeatByDevice (deviceId) { .then(res => _.mapKeys(_.camelCase, _.find(it => it.device_id === deviceId, res))) } +function updateDiagnostics (deviceId, images) { + const sql = `UPDATE devices + SET diagnostics_timestamp = NOW(), + diagnostics_scan_updated_at = CASE WHEN $2 THEN NOW() ELSE diagnostics_scan_updated_at END, + diagnostics_front_updated_at = CASE WHEN $3 THEN NOW() ELSE diagnostics_front_updated_at END + WHERE device_id = $1` + + const directory = `${OPERATOR_DATA_DIR}/diagnostics/${deviceId}/` + const { scan, front } = images + + return updatePhotos(scan, front, directory) + .then(() => db.none(sql, [deviceId, !!scan, !!front])) + .catch(err => logger.error('while running machine diagnostics: ', err)) +} + +function createPhoto (name, data, dir) { + if (!data) { + logger.error(`Diagnostics error: No data to save for ${name} photo`) + return Promise.resolve() + } + + const decodedImageData = Buffer.from(data, 'base64') + const filename = path.join(dir, name) + return fsPromises.writeFile(filename, decodedImageData) +} + +function updatePhotos (scan, front, dir) { + const dirname = path.join(dir) + _.attempt(() => makeDir.sync(dirname)) + + const promises = [ + createPhoto('scan.jpg', scan, dirname), + createPhoto('front.jpg', front, dirname) + ] + + return Promise.all(promises) +} + module.exports = { getMachineName, getMachines, @@ -450,5 +529,6 @@ module.exports = { getConfig, getMachineIds, emptyMachineUnits, - refillMachineUnits + refillMachineUnits, + updateDiagnostics } diff --git a/lib/middlewares/populateSettings.js b/lib/middlewares/populateSettings.js index 629db235..6bc04dc1 100644 --- a/lib/middlewares/populateSettings.js +++ b/lib/middlewares/populateSettings.js @@ -45,6 +45,9 @@ function machineAction (type, value) { logger.debug(`Refilling recyclers from machine '${deviceId}' from operator ${operatorId}`) state.refillUnit[operatorId] = { [deviceId]: pid } break + case 'diagnostics': + logger.debug(`Running diagnostics on machine '${deviceId}' from operator ${operatorId}`) + state.diagnostics[operatorId] = { [deviceId]: pid } default: break } diff --git a/lib/middlewares/state.js b/lib/middlewares/state.js index f26f082d..ee5da8e6 100644 --- a/lib/middlewares/state.js +++ b/lib/middlewares/state.js @@ -17,6 +17,7 @@ module.exports = (function () { restartServicesMap: {}, emptyUnit: {}, refillUnit: {}, + diagnostics: {}, mnemonic: null } }()) diff --git a/lib/new-admin/graphql/types/machine.type.js b/lib/new-admin/graphql/types/machine.type.js index 53532c86..b23bdae7 100644 --- a/lib/new-admin/graphql/types/machine.type.js +++ b/lib/new-admin/graphql/types/machine.type.js @@ -12,6 +12,7 @@ const typeDef = gql` paired: Boolean! lastPing: Date pairedAt: Date + diagnostics: Diagnostics version: String model: String cashUnits: CashUnits @@ -24,6 +25,12 @@ const typeDef = gql` packetLoss: String } + type Diagnostics { + timestamp: Date + frontTimestamp: Date + scanTimestamp: Date + } + type CashUnits { cashbox: Int cassette1: Int @@ -81,6 +88,7 @@ const typeDef = gql` restartServices emptyUnit refillUnit + diagnostics } type Query { diff --git a/lib/routes.js b/lib/routes.js index d56e5243..4165ea58 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -21,6 +21,7 @@ const cashboxRoutes = require('./routes/cashboxRoutes') const customerRoutes = require('./routes/customerRoutes') const logsRoutes = require('./routes/logsRoutes') const pairingRoutes = require('./routes/pairingRoutes') +const diagnosticsRoutes = require('./routes/diagnosticsRoutes') const performanceRoutes = require('./routes/performanceRoutes') const phoneCodeRoutes = require('./routes/phoneCodeRoutes') const pollingRoutes = require('./routes/pollingRoutes') @@ -73,6 +74,7 @@ app.use('/state', stateRoutes) app.use('/cashbox', cashboxRoutes) app.use('/network', performanceRoutes) +app.use('/diagnostics', diagnosticsRoutes) app.use('/verify_user', verifyUserRoutes) app.use('/verify_transaction', verifyTxRoutes) diff --git a/lib/routes/diagnosticsRoutes.js b/lib/routes/diagnosticsRoutes.js new file mode 100644 index 00000000..41d9aa56 --- /dev/null +++ b/lib/routes/diagnosticsRoutes.js @@ -0,0 +1,14 @@ +const express = require('express') +const router = express.Router() + +const { updateDiagnostics } = require('../machine-loader') + +function diagnostics (req, res, next) { + return updateDiagnostics(req.deviceId, req.body) + .then(() => res.status(200).send({ status: 'OK' })) + .catch(next) +} + +router.post('/', diagnostics) + +module.exports = router diff --git a/migrations/1716561996854-diagnostics.js b/migrations/1716561996854-diagnostics.js new file mode 100644 index 00000000..be747a94 --- /dev/null +++ b/migrations/1716561996854-diagnostics.js @@ -0,0 +1,15 @@ +const db = require('./db') + +exports.up = function (next) { + let sql = [ + 'alter table devices add column diagnostics_timestamp timestampz', + 'alter table devices add column diagnostics_scan_updated_at timestampz', + 'alter table devices add column diagnostics_front_updated_at timestampz' + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/components/machineActions/DiagnosticsModal.js b/new-lamassu-admin/src/components/machineActions/DiagnosticsModal.js new file mode 100644 index 00000000..6cdec7ff --- /dev/null +++ b/new-lamassu-admin/src/components/machineActions/DiagnosticsModal.js @@ -0,0 +1,201 @@ +import { useLazyQuery, useQuery } from '@apollo/react-hooks' +import { makeStyles } from '@material-ui/core/styles' +import FileSaver from 'file-saver' +import gql from 'graphql-tag' +import React, { useState, useEffect } from 'react' + +import Modal from 'src/components/Modal' +import { Button } from 'src/components/buttons' +import { H3, P } from 'src/components/typography' +import { URI } from 'src/utils/apollo' + +import { diagnosticsModal } from './MachineActions.styles' + +const useStyles = makeStyles(diagnosticsModal) + +const STATES = { + INITIAL: 'INITIAL', + EMPTY: 'EMPTY', + RUNNING: 'RUNNING', + FAILURE: 'FAILURE', + FILLED: 'FILLED' +} + +const MACHINE = gql` + query getMachine($deviceId: ID!) { + machine(deviceId: $deviceId) { + diagnostics { + timestamp + frontTimestamp + scanTimestamp + } + } + } +` + +const MACHINE_LOGS = gql` + query machineLogsCsv( + $deviceId: ID! + $limit: Int + $from: Date + $until: Date + $timezone: String + ) { + machineLogsCsv( + deviceId: $deviceId + limit: $limit + from: $from + until: $until + timezone: $timezone + ) + } +` + +const createCsv = async ({ machineLogsCsv }) => { + console.log(machineLogsCsv) + const machineLogs = new Blob([machineLogsCsv], { + type: 'text/plain;charset=utf-8' + }) + + FileSaver.saveAs(machineLogs, 'machineLogs.csv') +} + +const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => { + const classes = useStyles() + const [state, setState] = useState(STATES.INITIAL) + const [timestamp, setTimestamp] = useState(null) + let timeout = null + + const [fetchSummary, { loading }] = useLazyQuery(MACHINE_LOGS, { + onCompleted: data => createCsv(data) + }) + + const { data, stopPolling, startPolling } = useQuery(MACHINE, { + variables: { deviceId } + }) + + useEffect(() => { + if (!data) return + if (!timestamp && !data.machine.diagnostics.timestamp) { + stopPolling() + setState(STATES.EMPTY) + } + if ( + timestamp && + data.machine.diagnostics.timestamp && + data.machine.diagnostics.timestamp !== timestamp + ) { + clearTimeout(timeout) + setTimestamp(data.machine.diagnostics.timestamp) + setState(STATES.FILLED) + stopPolling() + } + if (!timestamp && data.machine.diagnostics.timestamp) { + setTimestamp(data.machine.diagnostics.timestamp) + setState(STATES.FILLED) + } + }, [data, stopPolling, timeout, timestamp]) + + const path = `${URI}/operator-data/diagnostics/${deviceId}/` + + function runDiagnostics() { + startPolling(2000) + + timeout = setTimeout(() => { + setState(STATES.FAILURE) + stopPolling() + }, 60 * 1000) + + setState(STATES.RUNNING) + sendAction() + } + + return ( + + {state === STATES.INITIAL && ( +
+

Loading...

+
+ )} + + {state === STATES.EMPTY && ( +
+

No diagnostics available

+

Run diagnostics to generate a report

+
+ )} + + {state === STATES.RUNNING && ( +
+

Running Diagnostics...

+

This page should refresh automatically

+
+ )} + + {state === STATES.FAILURE && ( +
+

Failed to run diagnostics

+

Please try again. If the problem persists, contact support.

+
+ )} + + {state === STATES.FILLED && ( +
+
+
+

Scan

+ Failure getting photo +
+
+

Front

+ Failure getting photo +

+
+
+
+

Diagnostics executed at: {timestamp}

+
+
+ )} +
+ + +
+
+ ) +} + +export default DiagnosticsModal diff --git a/new-lamassu-admin/src/components/machineActions/MachineActions.js b/new-lamassu-admin/src/components/machineActions/MachineActions.js index 65cc73a1..d9ede4f1 100644 --- a/new-lamassu-admin/src/components/machineActions/MachineActions.js +++ b/new-lamassu-admin/src/components/machineActions/MachineActions.js @@ -15,6 +15,7 @@ import { ReactComponent as ShutdownIcon } from 'src/styling/icons/button/shut do import { ReactComponent as UnpairReversedIcon } from 'src/styling/icons/button/unpair/white.svg' import { ReactComponent as UnpairIcon } from 'src/styling/icons/button/unpair/zodiac.svg' +import DiagnosticsModal from './DiagnosticsModal' import { machineActionsStyles } from './MachineActions.styles' const useStyles = makeStyles(machineActionsStyles) @@ -66,6 +67,7 @@ const getState = machineEventsLazy => const MachineActions = memo(({ machine, onActionSuccess }) => { const [action, setAction] = useState({ command: null }) const [preflightOptions, setPreflightOptions] = useState({}) + const [showModal, setShowModal] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const classes = useStyles() @@ -81,6 +83,8 @@ const MachineActions = memo(({ machine, onActionSuccess }) => { preflightOptions ) + const [simpleMachineAction] = useMutation(MACHINE_ACTION) + const [machineAction, { loading }] = useMutation(MACHINE_ACTION, { onError: ({ message }) => { const errorMessage = message ?? 'An error ocurred' @@ -188,7 +192,7 @@ const MachineActions = memo(({ machine, onActionSuccess }) => { {machine.model === 'aveiro' && ( { Refill Unit )} + { + setShowModal(true) + }}> + Diagnostics + + {showModal && ( + + simpleMachineAction({ + variables: { + deviceId: machine.deviceId, + action: 'diagnostics' + } + }) + } + deviceId={machine.deviceId} + onClose={() => { + setShowModal(false) + }} + /> + )}