diff --git a/lib/db.js b/lib/db.js
index bed67ddf..4a190214 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -1,6 +1,9 @@
const Pgp = require('pg-promise')
+const uuid = require('uuid')
+const _ = require('lodash/fp')
const psqlUrl = require('../lib/options').postgresql
const logger = require('./logger')
+const eventBus = require('./event-bus')
const pgp = Pgp({
pgNative: true,
@@ -15,4 +18,15 @@ const pgp = Pgp({
})
const db = pgp(psqlUrl)
+
+eventBus.subscribe('log', args => {
+ const { level, message, meta } = args
+
+ const sql = `insert into server_logs
+ (id, device_id, message, log_level, meta) values ($1, $2, $3, $4, $5) returning *`
+
+ db.one(sql, [uuid.v4(), '', message, level, meta])
+ .then(_.mapKeys(_.camelCase))
+})
+
module.exports = db
diff --git a/lib/event-bus.js b/lib/event-bus.js
new file mode 100644
index 00000000..0c481327
--- /dev/null
+++ b/lib/event-bus.js
@@ -0,0 +1,29 @@
+// Adapted from https://medium.com/@soffritti.pierfrancesco/create-a-simple-event-bus-in-javascript-8aa0370b3969
+
+const uuid = require('uuid')
+const _ = require('lodash/fp')
+
+const subscriptions = {}
+
+function subscribe (eventType, callback) {
+ const id = uuid.v1()
+
+ if (!subscriptions[eventType]) subscriptions[eventType] = {}
+
+ subscriptions[eventType][id] = callback
+
+ return {
+ unsubscribe: () => {
+ delete subscriptions[eventType][id]
+ if (_.keys(subscriptions[eventType]).length === 0) delete subscriptions[eventType]
+ }
+ }
+}
+
+function publish (eventType, arg) {
+ if (!subscriptions[eventType]) return
+
+ _.keys(subscriptions[eventType]).forEach(key => subscriptions[eventType][key](arg))
+}
+
+module.exports = { subscribe, publish }
diff --git a/lib/logger.js b/lib/logger.js
index 0531717b..d8c1a56e 100644
--- a/lib/logger.js
+++ b/lib/logger.js
@@ -1,11 +1,15 @@
const winston = require('winston')
+const Postgres = require('./pg-transport')
const options = require('./options')
-const _ = require('lodash/fp')
const logger = new winston.Logger({
level: options.logLevel,
transports: [
- new (winston.transports.Console)({ timestamp: true, colorize: true })
+ new (winston.transports.Console)({ timestamp: true, colorize: true }),
+ new Postgres({
+ connectionString: options.postgresql,
+ tableName: 'server_logs'
+ })
],
rewriters: [
(...[,, meta]) => meta instanceof Error ? { message: meta.message, stack: meta.stack } : meta
diff --git a/lib/new-admin/admin-server.js b/lib/new-admin/admin-server.js
index ff112fb9..beb5aa50 100644
--- a/lib/new-admin/admin-server.js
+++ b/lib/new-admin/admin-server.js
@@ -7,7 +7,9 @@ const got = require('got')
const supportLogs = require('../support_logs')
const machineLoader = require('../machine-loader')
const logs = require('../logs')
+const serverLogs = require('./server-logs')
+const supervisor = require('./supervisor')
const funding = require('./funding')
const config = require('./config')
@@ -56,6 +58,28 @@ app.post('/api/support_logs', (req, res, next) => {
.catch(next)
})
+app.get('/api/version', (req, res, next) => {
+ res.send(require('../../package.json').version)
+})
+
+app.get('/api/uptimes', (req, res, next) => {
+ return supervisor.getAllProcessInfo()
+ .then(r => res.send(r))
+ .catch(next)
+})
+
+app.post('/api/server_support_logs', (req, res, next) => {
+ return serverLogs.insert()
+ .then(r => res.send(r))
+ .catch(next)
+})
+
+app.get('/api/server_logs', (req, res, next) => {
+ return serverLogs.getServerLogs()
+ .then(r => res.send(r))
+ .catch(next)
+})
+
function dbNotify () {
return got.post('http://localhost:3030/dbChange')
.catch(e => console.error('Error: lamassu-server not responding'))
diff --git a/lib/new-admin/server-logs.js b/lib/new-admin/server-logs.js
new file mode 100644
index 00000000..9a25412b
--- /dev/null
+++ b/lib/new-admin/server-logs.js
@@ -0,0 +1,26 @@
+const _ = require('lodash/fp')
+const uuid = require('uuid')
+
+const db = require('../db')
+
+const NUM_RESULTS = 500
+
+function getServerLogs (until = new Date().toISOString()) {
+ const sql = `select id, log_level, timestamp, message from server_logs
+ order by timestamp desc
+ limit $1`
+
+ return Promise.all([db.any(sql, [ NUM_RESULTS ])])
+ .then(([logs]) => ({
+ logs: _.map(_.mapKeys(_.camelCase), logs)
+ }))
+}
+
+function insert () {
+ const sql = `insert into server_support_logs
+ (id) values ($1) returning *`
+ return db.one(sql, [uuid.v4()])
+ .then(_.mapKeys(_.camelCase))
+}
+
+module.exports = { getServerLogs, insert }
diff --git a/lib/new-admin/supervisor.js b/lib/new-admin/supervisor.js
new file mode 100644
index 00000000..4fb806c4
--- /dev/null
+++ b/lib/new-admin/supervisor.js
@@ -0,0 +1,58 @@
+const xmlrpc = require('xmlrpc')
+const logger = require('../logger')
+const { promisify } = require('util')
+
+function getAllProcessInfo () {
+ const convertStates = (state) => {
+ // From http://supervisord.org/subprocess.html#process-states
+ switch (state) {
+ case 'STOPPED':
+ return 'STOPPED'
+ case 'STARTING':
+ return 'RUNNING'
+ case 'RUNNING':
+ return 'RUNNING'
+ case 'BACKOFF':
+ return 'FATAL'
+ case 'STOPPING':
+ return 'STOPPED'
+ case 'EXITED':
+ return 'STOPPED'
+ case 'UNKNOWN':
+ return 'FATAL'
+ default:
+ logger.error(`Supervisord returned an unsupported state: ${state}`)
+ return 'FATAL'
+ }
+ }
+
+ const client = xmlrpc.createClient({
+ host: 'localhost',
+ port: '9001',
+ path: '/RPC2'
+ })
+
+ client.methodCall[promisify.custom] = (method, params) => {
+ return new Promise((resolve, reject) => client.methodCall(method, params, (err, value) => {
+ if (err) reject(err)
+ else resolve(value)
+ }))
+ }
+
+ return promisify(client.methodCall)('supervisor.getAllProcessInfo', [])
+ .then((value) => {
+ return value.map(process => (
+ {
+ name: process.name,
+ state: convertStates(process.statename),
+ uptime: (process.statename === 'RUNNING') ? new Date(process.now) - new Date(process.start) : 0
+ }
+ ))
+ })
+ .catch((error) => {
+ if (error.code === 'ECONNREFUSED') logger.error('Failed to connect to supervisord HTTP server.')
+ else logger.error(error)
+ })
+}
+
+module.exports = { getAllProcessInfo }
diff --git a/lib/pg-transport.js b/lib/pg-transport.js
new file mode 100644
index 00000000..65f39494
--- /dev/null
+++ b/lib/pg-transport.js
@@ -0,0 +1,39 @@
+const Transport = require('winston-transport')
+const eventBus = require('./event-bus')
+
+//
+// Inherit from `winston-transport` so you can take advantage
+// of the base functionality and `.exceptions.handle()`.
+//
+module.exports = class CustomTransport extends Transport {
+ constructor (opts) {
+ super(opts)
+
+ //
+ // Consume any custom options here. e.g.:
+ // - Connection information for databases
+ // - Authentication information for APIs (e.g. loggly, papertrail,
+ // logentries, etc.).
+ //
+ this.tableName = opts.tableName || 'winston_logs'
+
+ if (!opts.connectionString) {
+ throw new Error('You have to define connectionString')
+ }
+
+ this.connectionString = opts.connectionString
+ }
+
+ log (level, message, meta, callback) {
+ if (!callback) callback = () => {}
+
+ setImmediate(() => {
+ this.emit('logged', level, message, meta)
+ })
+
+ // Perform the writing to the remote service
+ eventBus.publish('log', { level, message, meta })
+
+ callback()
+ }
+}
diff --git a/migrations/001-initial.js b/migrations/001-initial.js
index c4ac59ce..f49b331b 100644
--- a/migrations/001-initial.js
+++ b/migrations/001-initial.js
@@ -2,6 +2,14 @@ const db = require('./db')
exports.up = function (next) {
var sqls = [
+ 'create table server_logs ( ' +
+ 'id uuid PRIMARY KEY, ' +
+ 'device_id text, ' +
+ 'log_level text, ' +
+ 'timestamp timestamptz DEFAULT now(), ' +
+ 'message text, ' +
+ 'meta json)',
+
'CREATE TABLE IF NOT EXISTS user_config ( ' +
'id serial PRIMARY KEY, ' +
'type text NOT NULL, ' +
diff --git a/migrations/1572524820075-server-support-logs.js b/migrations/1572524820075-server-support-logs.js
new file mode 100644
index 00000000..6edaadff
--- /dev/null
+++ b/migrations/1572524820075-server-support-logs.js
@@ -0,0 +1,15 @@
+var db = require('./db')
+
+exports.up = function (next) {
+ const sql =
+ [`create table server_support_logs (
+ id uuid PRIMARY KEY,
+ timestamp timestamptz not null default now() )`
+ ]
+
+ db.multi(sql, next)
+}
+
+exports.down = function (next) {
+ next()
+}
diff --git a/new-lamassu-admin/src/components/Uptime.js b/new-lamassu-admin/src/components/Uptime.js
new file mode 100644
index 00000000..aaa23f8f
--- /dev/null
+++ b/new-lamassu-admin/src/components/Uptime.js
@@ -0,0 +1,63 @@
+import React from 'react'
+import { floor, lowerCase, startCase } from 'lodash/fp'
+import { makeStyles } from '@material-ui/core'
+import classnames from 'classnames'
+
+import { spring3, spring2, mistyRose, tomato, zircon, comet, white, fontSecondary } from '../styling/variables'
+import typographyStyles from './typography/styles'
+const { label } = typographyStyles
+
+const styles = {
+ uptimeContainer: {
+ display: 'inline-block',
+ minWidth: 120,
+ margin: '0 20px 0 20px'
+ },
+ name: {
+ paddingLeft: 8,
+ color: comet
+ },
+ uptime: {
+ extend: label,
+ textAlign: 'center',
+ padding: 4
+ },
+ running: {
+ backgroundColor: spring3,
+ color: spring2
+ },
+ notRunning: {
+ backgroundColor: mistyRose,
+ color: tomato
+ }
+}
+
+const useStyles = makeStyles(styles)
+
+const Uptime = ({ process, ...props }) => {
+ const classes = useStyles()
+
+ const uptimeClassNames = {
+ [classes.uptime]: true,
+ [classes.running]: process.state === 'RUNNING',
+ [classes.notRunning]: process.state !== 'RUNNING'
+ }
+
+ const uptime = (time) => {
+ if (time < 60) return `${time}s`
+ if (time < 3600) return `${floor(time / 60, 0)}m`
+ if (time < 86400) return `${floor(time / 60 / 60, 0)}h`
+ return `${floor(time / 60 / 60 / 24, 0)}d`
+ }
+
+ return (
+
+
{lowerCase(process.name)}
+
+ {process.state === 'RUNNING' ? `Running for ${uptime(process.uptime)}` : startCase(lowerCase(process.state))}
+
+
+ )
+}
+
+export default Uptime
diff --git a/new-lamassu-admin/src/components/buttons/ActionButton.js b/new-lamassu-admin/src/components/buttons/ActionButton.js
index 14f4f5c5..0f4caf6b 100644
--- a/new-lamassu-admin/src/components/buttons/ActionButton.js
+++ b/new-lamassu-admin/src/components/buttons/ActionButton.js
@@ -21,7 +21,7 @@ const ActionButton = memo(({ className, Icon, InverseIcon, color, children, ...p
}
- {children}
+ {children && {children}
}
)
})
diff --git a/new-lamassu-admin/src/components/buttons/BaseButton.styles.js b/new-lamassu-admin/src/components/buttons/BaseButton.styles.js
new file mode 100644
index 00000000..4630fd77
--- /dev/null
+++ b/new-lamassu-admin/src/components/buttons/BaseButton.styles.js
@@ -0,0 +1,70 @@
+import {
+ white,
+ fontColor,
+ subheaderColor,
+ subheaderDarkColor,
+ offColor,
+ offDarkColor
+} from '../../styling/variables'
+
+const colors = (color1, color2, color3) => {
+ return {
+ backgroundColor: color1,
+ '&:hover': {
+ backgroundColor: color2
+ },
+ '&:active': {
+ backgroundColor: color3
+ }
+ }
+}
+
+const buttonHeight = 45
+
+export default {
+ baseButton: {
+ extend: colors(subheaderColor, subheaderDarkColor, offColor),
+ cursor: 'pointer',
+ border: 'none',
+ outline: 0,
+ height: buttonHeight,
+ color: fontColor,
+ '&:active': {
+ color: white
+ }
+ },
+ primary: {
+ extend: colors(subheaderColor, subheaderDarkColor, offColor),
+ '&:active': {
+ color: white,
+ '& $buttonIcon': {
+ display: 'none'
+ },
+ '& $buttonIconActive': {
+ display: 'block'
+ }
+ },
+ '& $buttonIconActive': {
+ display: 'none'
+ }
+ },
+ secondary: {
+ extend: colors(offColor, offDarkColor, white),
+ color: white,
+ '&:active': {
+ color: fontColor,
+ '& $buttonIcon': {
+ display: 'flex'
+ },
+ '& $buttonIconActive': {
+ display: 'none'
+ }
+ },
+ '& $buttonIcon': {
+ display: 'none'
+ },
+ '& $buttonIconActive': {
+ display: 'flex'
+ }
+ }
+}
diff --git a/new-lamassu-admin/src/components/buttons/FeatureButton.js b/new-lamassu-admin/src/components/buttons/FeatureButton.js
new file mode 100644
index 00000000..9d46ce43
--- /dev/null
+++ b/new-lamassu-admin/src/components/buttons/FeatureButton.js
@@ -0,0 +1,50 @@
+import React, { memo } from 'react'
+import classnames from 'classnames'
+import { makeStyles } from '@material-ui/core/styles'
+
+import baseButtonStyles from './BaseButton.styles'
+
+const { baseButton, primary } = baseButtonStyles
+
+const svgSize = 25
+
+const styles = {
+ featureButton: {
+ extend: baseButton,
+ width: baseButton.height,
+ borderRadius: baseButton.height / 2,
+ display: 'flex'
+ },
+ primary,
+ buttonIcon: {
+ margin: 'auto',
+ '& svg': {
+ width: svgSize,
+ height: svgSize
+ }
+ },
+ buttonIconActive: {} // required to extend primary
+}
+
+const useStyles = makeStyles(styles)
+
+const FeatureButton = memo(({ className, Icon, InverseIcon, ...props }) => {
+ const classes = useStyles()
+
+ const classNames = {
+ [classes.featureButton]: true,
+ [classes.primary]: true
+ }
+
+ return (
+
+ )
+})
+
+export default FeatureButton
diff --git a/new-lamassu-admin/src/components/buttons/SimpleButton.js b/new-lamassu-admin/src/components/buttons/SimpleButton.js
index 7b3f0bad..bc7e037d 100644
--- a/new-lamassu-admin/src/components/buttons/SimpleButton.js
+++ b/new-lamassu-admin/src/components/buttons/SimpleButton.js
@@ -1,9 +1,27 @@
import React, { memo } from 'react'
import classnames from 'classnames'
+import baseButtonStyles from './BaseButton.styles'
+import { makeStyles } from '@material-ui/core/styles'
+
+const { baseButton } = baseButtonStyles
+
+const styles = {
+ button: {
+ extend: baseButton,
+ borderRadius: baseButton.height / 2,
+ outline: 0,
+ padding: '0 20px'
+ }
+}
+
+const useStyles = makeStyles(styles)
+
const SimpleButton = memo(({ className, children, color, size, ...props }) => {
+ const classes = useStyles()
+
return (
-