Skip to content

Commit 7fca41b

Browse files
bajtosjuliangruber
andauthored
Activity Log backend + WebUI (#110)
Record the following activities and render them in WebUI: - When the Station starts/stops - When the Saturn Module starts/stops - INFO & ERROR logs from Saturn Co-authored-by: Julian Gruber <[email protected]> Signed-off-by: Miroslav Bajtoš <[email protected]>
1 parent 07096ee commit 7fca41b

13 files changed

+356
-19
lines changed

main/activity-log.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict'
2+
3+
/** @typedef {import('./typings').Activity} Activity */
4+
/** @typedef {import('./typings').RecordActivityArgs} RecordActivityArgs */
5+
6+
const Store = require('electron-store')
7+
const crypto = require('node:crypto')
8+
9+
const activityLogStore = new Store({
10+
name: 'activity-log'
11+
})
12+
13+
class ActivityLog {
14+
#entries
15+
16+
constructor () {
17+
this.#entries = loadStoredEntries()
18+
}
19+
20+
/**
21+
* @param {RecordActivityArgs} args
22+
* @returns {Activity}
23+
*/
24+
record ({ source, type, message }) {
25+
/** @type {Activity} */
26+
const activity = {
27+
id: crypto.randomUUID(),
28+
timestamp: Date.now(),
29+
source,
30+
type,
31+
message
32+
}
33+
// Freeze the data to prevent ActivityLog users from accidentally changing our store
34+
Object.freeze(activity)
35+
36+
this.#entries.push(activity)
37+
38+
if (this.#entries.length > 100) {
39+
// Delete the oldest activity to keep ActivityLog at constant size
40+
this.#entries.shift()
41+
}
42+
this.#save()
43+
return activity
44+
}
45+
46+
getAllEntries () {
47+
// Clone the array to prevent the caller from accidentally changing our store
48+
return [...this.#entries]
49+
}
50+
51+
reset () {
52+
this.#entries = []
53+
this.#save()
54+
}
55+
56+
#save () {
57+
activityLogStore.set('activities', this.#entries)
58+
}
59+
}
60+
61+
/**
62+
* @returns {Activity[]}
63+
*/
64+
function loadStoredEntries () {
65+
// A workaround to fix false TypeScript errors
66+
return /** @type {any} */(activityLogStore.get('activities', []))
67+
}
68+
69+
module.exports = {
70+
ActivityLog
71+
}

main/index.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,25 @@ const setupUI = require('./ui')
99
const setupTray = require('./tray')
1010
const setupUpdater = require('./updater')
1111
const saturnNode = require('./saturn-node')
12-
const { setupIpcMain } = require('./ipc')
12+
const { setupIpcMain, ipcMainEvents } = require('./ipc')
1313
const { setupAppMenu } = require('./app-menu')
1414

15+
const { ActivityLog } = require('./activity-log')
16+
const { ipcMain } = require('electron/main')
17+
18+
/** @typedef {import('./typings').Activity} Activity */
19+
/** @typedef {import('./typings').RecordActivityArgs} RecordActivityOptions */
20+
1521
const inTest = (process.env.NODE_ENV === 'test')
22+
const isDev = !app.isPackaged && !inTest
1623

1724
function handleError (/** @type {any} */ err) {
25+
ctx.recordActivity({
26+
source: 'Station',
27+
type: 'error',
28+
message: `Station failed to start: ${err.message || err}`
29+
})
30+
1831
log.error(err)
1932
dialog.showErrorBox('Error occured', err.stack ?? err.message ?? err)
2033
}
@@ -35,13 +48,30 @@ if (!app.requestSingleInstanceLock() && !inTest) {
3548
app.quit()
3649
}
3750

51+
const activityLog = new ActivityLog()
52+
if (isDev) {
53+
// Do not preserve old Activity entries in development mode
54+
activityLog.reset()
55+
}
56+
3857
/** @type {import('./typings').Context} */
3958
const ctx = {
59+
getAllActivities: () => activityLog.getAllEntries(),
60+
61+
recordActivity: (args) => {
62+
activityLog.record(args)
63+
ipcMain.emit(ipcMainEvents.ACTIVITY_LOGGED, activityLog.getAllEntries())
64+
},
65+
4066
manualCheckForUpdates: () => { throw new Error('never get here') },
4167
showUI: () => { throw new Error('never get here') },
4268
loadWebUIFromDist: serve({ directory: path.resolve(__dirname, '../renderer/dist') })
4369
}
4470

71+
process.on('exit', () => {
72+
ctx.recordActivity({ source: 'Station', type: 'info', message: 'Station stopped.' })
73+
})
74+
4575
async function run () {
4676
try {
4777
await app.whenReady()
@@ -56,7 +86,9 @@ async function run () {
5686
await setupAppMenu(ctx)
5787
await setupUI(ctx)
5888
await setupUpdater(ctx)
59-
await setupIpcMain()
89+
await setupIpcMain(ctx)
90+
91+
ctx.recordActivity({ source: 'Station', type: 'info', message: 'Station started.' })
6092

6193
await saturnNode.setup(ctx)
6294
} catch (e) {

main/ipc.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ const { ipcMain } = require('electron')
55
const saturnNode = require('./saturn-node')
66
const stationConfig = require('./station-config')
77

8+
/** @typedef {import('./typings').Context} Context */
9+
810
const ipcMainEvents = Object.freeze({
11+
ACTIVITY_LOGGED: 'station:activity-logged',
12+
913
UPDATE_CHECK_STARTED: 'station:update-check:started',
1014
UPDATE_CHECK_FINISHED: 'station:update-check:finished'
1115
})
1216

13-
function setupIpcMain () {
17+
function setupIpcMain (/** @type {Context} */ ctx) {
1418
ipcMain.handle('saturn:isRunning', saturnNode.isRunning)
1519
ipcMain.handle('saturn:isReady', saturnNode.isReady)
16-
ipcMain.handle('saturn:start', saturnNode.start)
20+
ipcMain.handle('saturn:start', _event => saturnNode.start(ctx))
1721
ipcMain.handle('saturn:stop', saturnNode.stop)
1822
ipcMain.handle('saturn:getLog', saturnNode.getLog)
1923
ipcMain.handle('saturn:getWebUrl', saturnNode.getWebUrl)
@@ -26,6 +30,8 @@ function setupIpcMain () {
2630
ipcMain.handle('station:setOnboardingCompleted', (_event) => stationConfig.setOnboardingCompleted())
2731
ipcMain.handle('station:getUserConsent', stationConfig.getUserConsent)
2832
ipcMain.handle('station:setUserConsent', (_event, consent) => stationConfig.setUserConsent(consent))
33+
34+
ipcMain.handle('station:getAllActivities', (_event, _args) => ctx.getAllActivities())
2935
}
3036

3137
module.exports = {

main/preload.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@
33
const { contextBridge, ipcRenderer } = require('electron')
44

55
contextBridge.exposeInMainWorld('electron', {
6+
getAllActivities: () => ipcRenderer.invoke('station:getAllActivities'),
7+
8+
/**
9+
* @param {(Activity: import('./typings').Activity) => void} callback
10+
*/
11+
onActivityLogged (callback) {
12+
/** @type {(event: import('electron').IpcRendererEvent, ...args: any[]) => void} */
13+
const listener = (_event, activities) => callback(activities)
14+
15+
ipcRenderer.on('station:activity-logged', listener)
16+
17+
return function unsubscribe () {
18+
ipcRenderer.removeListener('station:activity-logged', listener)
19+
}
20+
},
21+
622
saturnNode: {
723
start: () => ipcRenderer.invoke('saturn:start'),
824
stop: () => ipcRenderer.invoke('saturn:stop'),

main/saturn-node.js

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const Store = require('electron-store')
1212
const consts = require('./consts')
1313
const configStore = new Store()
1414

15+
/** @typedef {import('./typings').Context} Context */
16+
1517
const saturnBinaryPath = getSaturnBinaryPath()
1618

1719
/** @type {import('execa').ExecaChildProcess | null} */
@@ -31,7 +33,7 @@ const ConfigKeys = {
3133

3234
let filAddress = /** @type {string | undefined} */ (configStore.get(ConfigKeys.FilAddress))
3335

34-
async function setup (/** @type {import('./typings').Context} */ _ctx) {
36+
async function setup (/** @type {Context} */ ctx) {
3537
console.log('Using Saturn L2 Node binary: %s', saturnBinaryPath)
3638

3739
const stat = await fs.stat(saturnBinaryPath)
@@ -41,7 +43,7 @@ async function setup (/** @type {import('./typings').Context} */ _ctx) {
4143
if (!childProcess) return
4244
stop()
4345
})
44-
await start()
46+
await start(ctx)
4547
}
4648

4749
function getSaturnBinaryPath () {
@@ -55,7 +57,7 @@ function getSaturnBinaryPath () {
5557
: path.resolve(__dirname, '..', 'build', 'saturn', `l2node-${process.platform}-${arch}`, name)
5658
}
5759

58-
async function start () {
60+
async function start (/** @type {Context} */ ctx) {
5961
if (!filAddress) {
6062
console.info('Saturn node requires FIL address. Please configure it in the Station UI.')
6163
return
@@ -88,6 +90,7 @@ async function start () {
8890
stdout.on('data', (/** @type {string} */ data) => {
8991
forwardChunkFromSaturn(data, console.log)
9092
appendToChildLog(data)
93+
handleActivityLogs(ctx, data)
9194
})
9295

9396
stderr.setEncoding('utf-8')
@@ -111,6 +114,8 @@ async function start () {
111114
console.log('Saturn node is up and ready (Web URL: %s)', webUrl)
112115
ready = true
113116
stdout.off('data', readyHandler)
117+
118+
ctx.recordActivity({ source: 'Saturn', type: 'info', message: 'Saturn module started.' })
114119
resolve()
115120
}
116121
}
@@ -131,6 +136,7 @@ async function start () {
131136
const msg = `Saturn node exited ${reason}`
132137
console.log(msg)
133138
appendToChildLog(msg)
139+
ctx.recordActivity({ source: 'Saturn', type: 'info', message: msg })
134140

135141
ready = false
136142
})
@@ -141,9 +147,11 @@ async function start () {
141147
setTimeout(500)
142148
])
143149
} catch (err) {
144-
const msg = err instanceof Error ? err.message : '' + err
145-
appendToChildLog(`Cannot start Saturn node: ${msg}`)
150+
const errorMsg = err instanceof Error ? err.message : '' + err
151+
const message = `Cannot start Saturn node: ${errorMsg}`
152+
appendToChildLog(message)
146153
console.error('Cannot start Saturn node:', err)
154+
ctx.recordActivity({ source: 'Saturn', type: 'error', message })
147155
}
148156
}
149157

@@ -211,6 +219,26 @@ function appendToChildLog (text) {
211219
.join('')
212220
}
213221

222+
/**
223+
* @param {Context} ctx
224+
* @param {string} text
225+
*/
226+
function handleActivityLogs (ctx, text) {
227+
text
228+
.trimEnd()
229+
.split(/\n/g)
230+
.forEach(line => {
231+
const m = line.match(/^(INFO|ERROR): (.*)$/)
232+
if (!m) return
233+
234+
ctx.recordActivity({
235+
source: 'Saturn',
236+
type: /** @type {any} */(m[1].toLowerCase()),
237+
message: m[2]
238+
})
239+
})
240+
}
241+
214242
module.exports = {
215243
setup,
216244
start,

main/test/activity-log.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use strict'
2+
3+
const assert = require('assert').strict
4+
const { ActivityLog } = require('../activity-log')
5+
const { assertTimestampIsCloseToNow, pickProps } = require('./test-helpers')
6+
7+
/** @typedef {import('../typings').RecordActivityArgs} RecordActivityOptions */
8+
9+
describe('ActivityLog', function () {
10+
beforeEach(function () {
11+
return new ActivityLog().reset()
12+
})
13+
14+
it('record activities and assign them timestamp and id ', function () {
15+
const activityLog = new ActivityLog()
16+
const activityCreated = activityLog.record(givenActivity({
17+
source: 'Station',
18+
type: 'info',
19+
message: 'Hello world!'
20+
}))
21+
22+
assert.strictEqual(activityLog.getAllEntries().length, 1)
23+
assert.deepStrictEqual(activityCreated, activityLog.getAllEntries()[0])
24+
25+
const { id, timestamp, ...activity } = activityLog.getAllEntries()[0]
26+
assert.deepStrictEqual(activity, {
27+
source: 'Station',
28+
type: 'info',
29+
message: 'Hello world!'
30+
})
31+
32+
assert.equal(typeof id, 'string')
33+
assertTimestampIsCloseToNow(timestamp, 'activity.timestamp')
34+
})
35+
36+
it('assigns unique ids', function () {
37+
const activityLog = new ActivityLog()
38+
activityLog.record(givenActivity({ message: 'one' }))
39+
activityLog.record(givenActivity({ message: 'two' }))
40+
const [first, second] = activityLog.getAllEntries()
41+
assert(first.id !== second.id, `Expected unique ids. Got the same value: ${first.id}`)
42+
})
43+
44+
it('preserves activities across restarts', function () {
45+
new ActivityLog().record(givenActivity({ message: 'first run' }))
46+
const activityLog = new ActivityLog()
47+
activityLog.record(givenActivity({ message: 'second run' }))
48+
assert.deepStrictEqual(activityLog.getAllEntries().map(it => pickProps(it, 'message')), [
49+
{ message: 'first run' },
50+
{ message: 'second run' }
51+
])
52+
})
53+
54+
it('limits the log to the most recent 50 entries', /** @this {Mocha.Test} */ function () {
55+
this.timeout(10000)
56+
57+
const log = new ActivityLog()
58+
for (let i = 0; i < 110; i++) {
59+
log.record(givenActivity({ message: `activity ${i}` }))
60+
}
61+
const entries = log.getAllEntries()
62+
assert.deepStrictEqual(
63+
[entries.at(0)?.message, entries.at(-1)?.message],
64+
['activity 10', 'activity 109']
65+
)
66+
})
67+
})
68+
69+
/**
70+
* @param {Partial<RecordActivityOptions>} [props]
71+
* @returns {RecordActivityOptions}
72+
*/
73+
function givenActivity (props) {
74+
return {
75+
source: 'Station',
76+
type: 'info',
77+
message: 'some message',
78+
...props
79+
}
80+
}

main/test/smoke.test.js

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)