From f0856ad9f4588e9126292378d16636ab3dfd72c6 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 1 May 2024 13:28:00 -0400 Subject: [PATCH] task session management Task session is no longer closed if the socket.io connection is lost. Instead, in the client, if the socket connection is lost it updates the session to status of reconnecting. When the socket reconnects, the status is changed back to active. This prevents work sessions from simply ending and dying if the socket connection is lost, and lets the client drive all of that directly. --- app/controllers/task.js | 34 ++++++++ app/models/task-session.js | 7 +- app/services/client.js | 2 +- app/services/report.js | 5 +- app/services/task.js | 34 ++++++++ .../report/components/weekly-summary.pug | 2 +- app/views/task/session/view.pug | 2 +- app/views/task/view.pug | 7 +- app/workers/tracker-monitor.js | 6 +- client/css/dtp-site.less | 1 + client/css/site/table.less | 17 ++++ client/js/time-tracker-client.js | 84 ++++++++++++++----- config/limiter.js | 7 +- lib/site-ioserver.js | 50 ++--------- start-local | 2 + 15 files changed, 185 insertions(+), 75 deletions(-) create mode 100644 client/css/site/table.less diff --git a/app/controllers/task.js b/app/controllers/task.js index b2abeae..abd030c 100644 --- a/app/controllers/task.js +++ b/app/controllers/task.js @@ -74,6 +74,13 @@ export default class TaskController extends SiteController { this.postTaskSessionScreenshot.bind(this), ); + router.post( + '/:taskId/session/:sessionId/status', + limiterService.create(limiterConfig.postTaskSessionStatus), + checkSessionOwnership, + this.postTaskSessionStatus.bind(this), + ); + router.post( '/:taskId/session/:sessionId/close', limiterService.create(limiterConfig.postCloseTaskSession), @@ -146,6 +153,33 @@ export default class TaskController extends SiteController { } } + async postTaskSessionStatus (req, res) { + const { task: taskService } = this.dtp.services; + try { + this.log.debug('updating task session status', { + sessionId: res.locals.session._id, + params: req.body, + }); + await taskService.setTaskSessionStatus(res.locals.session, req.body.status); + + const displayList = this.createDisplayList('set-session-status'); + displayList.showNotification( + 'Session status updated', + 'success', + 'bottom-center', + 5000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to update task session status', { error }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postCloseTaskSession (req, res) { const { task: taskService } = this.dtp.services; try { diff --git a/app/models/task-session.js b/app/models/task-session.js index 91ab932..02bb3e2 100644 --- a/app/models/task-session.js +++ b/app/models/task-session.js @@ -7,7 +7,12 @@ import mongoose from 'mongoose'; const Schema = mongoose.Schema; -const STATUS_LIST = ['active', 'finished', 'expired']; +const STATUS_LIST = [ + 'active', + 'reconnecting', + 'finished', + 'expired', +]; const ScreenshotSchema = new Schema({ created: { type: Date, required: true, default: Date.now, index: 1 }, diff --git a/app/services/client.js b/app/services/client.js index f95162c..18c688f 100644 --- a/app/services/client.js +++ b/app/services/client.js @@ -223,8 +223,8 @@ export default class ClientService extends SiteService { this.dtp.emitter .to(client.user._id.toString()) .emit('session-control', { - displayList, cmd: 'end-session', + displayList, }); return; diff --git a/app/services/report.js b/app/services/report.js index e94f329..a1b3c4f 100644 --- a/app/services/report.js +++ b/app/services/report.js @@ -22,13 +22,11 @@ export default class ReportService extends SiteService { async getWeeklyEarnings (user) { const { client: clientService } = this.dtp.services; - const NOW = new Date(); + const dateStart = this.startOfWeek(NOW); const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); - this.log.debug('computing weekly earnings', { dateStart, dateEnd }); - const data = await TaskSession.aggregate([ { $match: { @@ -77,6 +75,7 @@ export default class ReportService extends SiteService { async getDailyHoursWorkedForUser (user) { const NOW = new Date(); + const dateStart = this.startOfWeek(NOW); const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); diff --git a/app/services/task.js b/app/services/task.js index 0f43e2d..7199f4d 100644 --- a/app/services/task.js +++ b/app/services/task.js @@ -224,6 +224,40 @@ export default class TaskService extends SiteService { }, }, ); + + const displayList = this.createDisplayList('screenshot-accepted'); + displayList.showNotification( + 'Screenshot accepted', + 'success', + 'bottom-center', + 3000, + ); + + this.dtp.emitter + .to(session.task._id.toString()) + .emit('session-control', { + displayList, + }); + } + + async setTaskSessionStatus (session, status) { + if (status === session.status) { + return; // do nothing + } + if (!['active', 'reconnecting'].includes(status)) { + throw new SiteError(400, 'Can only set status to active or reconnecting'); + } + + this.log.info('updating task session status', { + user: { + _id: session.user._id, + username: session.user.username, + }, + session: { _id: session._id }, + status, + }); + + await TaskSession.updateOne({ _id: session._id }, { $set: { status } }); } async closeTaskSession (session, status = 'finished') { diff --git a/app/views/report/components/weekly-summary.pug b/app/views/report/components/weekly-summary.pug index 9d0cd36..ba399f3 100644 --- a/app/views/report/components/weekly-summary.pug +++ b/app/views/report/components/weekly-summary.pug @@ -1,5 +1,5 @@ mixin renderWeeklySummaryReport (data) - table.uk-table.uk-table-small.uk-table-justify.no-select + table.uk-table.uk-table-small.no-select thead tr th Project diff --git a/app/views/task/session/view.pug b/app/views/task/session/view.pug index 6ac2f2b..4964a6d 100644 --- a/app/views/task/session/view.pug +++ b/app/views/task/session/view.pug @@ -43,7 +43,7 @@ block view-content .uk-margin-medium if Array.isArray(session.screenshots) && (session.screenshots.length > 0) h3 Screenshots - div(class="uk-child-width-1-1 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small + div(class="uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small each screenshot in session.screenshots a(href=`/image/${screenshot.image._id}`, data-type="image", diff --git a/app/views/task/view.pug b/app/views/task/view.pug index fdafa08..5d6d785 100644 --- a/app/views/task/view.pug +++ b/app/views/task/view.pug @@ -57,9 +57,9 @@ block view-content table.uk-table.uk-table-small.uk-table-divider thead - tr.uk-background-secondary + tr th.uk-table-expand Start Time - th.uk-text-nowrap.uk-table-shrink Tracked Time + th.uk-text-nowrap.uk-table-shrink Tracked th.uk-text-nowrap.uk-table-shrink Billable tbody each session in sessions @@ -73,7 +73,8 @@ block view-content ).uk-link-reset.uk-display-block= dayjs(session.created).format('dddd [at] h:mm a') td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.duration).format('HH:MM:SS') td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.hourlyRate * (session.duration / 3600)).format('$0,00.00') - tr.uk-background-secondary + tfoot + tr td.uk-table-expand TOTALS td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalTimeWorked).format('HH:MM:SS') td.uk-text-right.uk-text-nowrap.uk-table-shrink #{numeral(totalBillable).format('$0,0.00')} diff --git a/app/workers/tracker-monitor.js b/app/workers/tracker-monitor.js index debc170..9cdc996 100644 --- a/app/workers/tracker-monitor.js +++ b/app/workers/tracker-monitor.js @@ -44,6 +44,7 @@ class TrackerMonitorWorker extends SiteRuntime { true, CRON_TIMEZONE, ); + await this.expireTaskSessions(); /* * Bull Queue job processors @@ -52,6 +53,8 @@ class TrackerMonitorWorker extends SiteRuntime { // this.log.info('registering queue job processor', { config: this.config.jobQueues.links }); // this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', this.config.jobQueues.links); // this.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this)); + + this.log.info('Tracker Monitor online'); } async shutdown ( ) { @@ -65,10 +68,11 @@ class TrackerMonitorWorker extends SiteRuntime { const NOW = new Date(); const oldestDate = dayjs(NOW).subtract(10, 'minute'); + this.log.debug('scanning for defunct sessions'); await this.TaskSession .find({ $and: [ - { status: 'active' }, + { status: { $in: ['active', 'reconnecting'] } }, { lastUpdated: { $lt: oldestDate } }, ], }) diff --git a/client/css/dtp-site.less b/client/css/dtp-site.less index 076d03f..4828fe2 100644 --- a/client/css/dtp-site.less +++ b/client/css/dtp-site.less @@ -9,4 +9,5 @@ @import "site/menu.less"; @import "site/navbar.less"; @import "site/stats.less"; +@import "site/table.less"; @import "site/video.less"; \ No newline at end of file diff --git a/client/css/site/table.less b/client/css/site/table.less new file mode 100644 index 0000000..a315618 --- /dev/null +++ b/client/css/site/table.less @@ -0,0 +1,17 @@ +table.uk-table { + font-size: 0.9rem; + + thead, tfoot { + background-color: #2a2a2a; + font-size: 0.9rem; + + th, td { + color: #e8e8e8; + font-size: 0.9rem; + } + } + + tfoot { + font-weight: bold; + } +} \ No newline at end of file diff --git a/client/js/time-tracker-client.js b/client/js/time-tracker-client.js index 6b8d1d0..325bd27 100644 --- a/client/js/time-tracker-client.js +++ b/client/js/time-tracker-client.js @@ -19,7 +19,7 @@ dayjs.extend(dayjsRelativeTime); export class TimeTrackerApp extends DtpApp { - static get SCREENSHOT_INTERVAL ( ) { return 1000 * 60 * 10; } + static get SCREENSHOT_INTERVAL ( ) { return 1000 * 15; } static get SFX_TRACKER_START ( ) { return 'tracker-start'; } static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; } @@ -146,17 +146,33 @@ export class TimeTrackerApp extends DtpApp { async onTrackerSocketConnect (socket) { this.log.debug('onSocketConnect', 'attaching socket events'); - socket.on('system-message', this.onSystemMessage.bind(this)); - socket.on('session-control', this.onSessionControl.bind(this)); + + this.systemMessageHandler = this.onSystemMessage.bind(this); + socket.on('system-message', this.systemMessageHandler); + + this.sessionControlHandler = this.onSessionControl.bind(this); + socket.on('session-control', this.sessionControlHandler); if (dtp.task) { await this.socket.joinChannel(dtp.task._id, 'Task'); } + if (this.taskSession) { + await this.setTaskSessionStatus('active'); + } } async onTrackerSocketDisconnect (socket) { this.log.debug('onSocketDisconnect', 'detaching socket events'); - socket.off('system-message', this.onSystemMessage.bind(this)); + + socket.off('session-control', this.sessionControlHandler); + delete this.sessionControlHandler; + + socket.off('system-message', this.systemMessageHandler); + delete this.systemMessageHandler; + + if (this.taskSession) { + await this.setTaskSessionStatus('reconnecting'); + } } async onSystemMessage (message) { @@ -167,23 +183,23 @@ export class TimeTrackerApp extends DtpApp { async onSessionControl (message) { const activityToggle = document.querySelector(''); - - switch (message.cmd) { - case 'end-session': - try { - await this.closeTaskSession(); - activityToggle.checked = false; - } catch (error) { - this.log.error('onSessionControl', 'failed to close task work session', { error }); + if (message.cmd) { + switch (message.cmd) { + case 'end-session': + try { + await this.closeTaskSession(); + activityToggle.checked = false; + } catch (error) { + this.log.error('onSessionControl', 'failed to close task work session', { error }); + return; + } + break; + + default: + this.log.error('onSessionControl', 'invalid command received', { cmd: message.cmd }); return; - } - break; - - default: - this.log.error('onSessionControl', 'invalid command received', { cmd: message.cmd }); - return; + } } - if (message.displayList) { this.displayEngine.executeDisplayList(message.displayList); } @@ -491,6 +507,30 @@ export class TimeTrackerApp extends DtpApp { } } + async setTaskSessionStatus (status) { + try { + const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/status`; + const body = JSON.stringify({ status }); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': body.length, + }, + body, + }); + await this.processResponse(response); + this.taskSession.status = status; + } catch (error) { + UIkit.notification({ + message: `Failed to update task session status: ${error.message}`, + status: 'danger', + pos: 'bottom-center', + timeout: 5000, + }); + } + } + async closeTaskSession ( ) { try { const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`; @@ -534,6 +574,12 @@ export class TimeTrackerApp extends DtpApp { } async updateSessionDisplay ( ) { + if (this.taskSession.status === 'reconnecting') { + this.currentSessionDuration.textContent = '---'; + this.currentSessionTimeRemaining.textContent = '---'; + return; + } + const NOW = new Date(); const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second'); this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS'); diff --git a/config/limiter.js b/config/limiter.js index b5cf828..8c74001 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -202,10 +202,15 @@ export default { message: 'You are starting task work sessions too quickly', }, postTaskSessionScreenshot: { - total: 12, + total: 20, expire: ONE_HOUR, message: 'You are uploading session screenshots too quickly', }, + postTaskSessionStatus: { + total: 100, + expire: ONE_HOUR, + message: 'You are changing task work session status too quickly', + }, postCloseTaskSession: { total: 12, expire: ONE_HOUR, diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index 64c7858..3fdf185 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -75,8 +75,6 @@ export class SiteIoServer extends SiteCommon { } async onSocketConnect (socket) { - const { channel: channelService } = this.dtp.services; - this.log.debug('socket connection', { sid: socket.id }); try { const token = await ConnectToken @@ -103,6 +101,7 @@ export class SiteIoServer extends SiteCommon { }, ]) .lean(); + if (!token) { this.log.alert('rejecting invalid socket token', { sid: socket.sid, @@ -144,7 +143,6 @@ export class SiteIoServer extends SiteCommon { const session = { socket, joinedChannels: new Set(), - joinedRooms: new Map(), }; this.sessions[socket.id] = session; @@ -180,23 +178,6 @@ export class SiteIoServer extends SiteCommon { user: session.user, }); break; - - case 'Channel': - session.channel = await channelService.getChannelById(token.consumer._id); - if (session.channel.widgetKey) { - delete session.channel.widgetKey; - session.isWidget = true; - } - if (session.channel.streamKey) { - delete session.channel.streamKey; - } - this.log.info('starting Channel session', { channel: session.channel.name }); - - socket.emit('widget-authenticated', { - message: 'token verified', - channel: session.channel, - }); - break; } } catch (error) { this.log.error('failed to process a socket connection', { error }); @@ -204,34 +185,12 @@ export class SiteIoServer extends SiteCommon { } async onSocketDisconnect (session, reason) { - const { task: taskService } = this.dtp.services; - this.log.debug('socket disconnect', { sid: session.socket.id, consumerId: (session.user || session.channel)._id, - joinedRooms: session.joinedRooms.size, reason, }); - try { - this.log.info('closing open task sessions for user', { - user: { - _id: session.user._id, - username: session.user.username, - }, - }); - await taskService.closeTaskSessionForUser(session.user); - } catch (error) { - this.log.error('failed to close active task sessions for user', { - user: { - _id: session.user._id, - username: session.user.username, - }, - error, - }); - // fall through - } - if (session.onSocketDisconnect) { session.socket.off('disconnect', session.onSocketDisconnect); delete session.onSocketDisconnect; @@ -312,9 +271,12 @@ export class SiteIoServer extends SiteCommon { return; } - this.log.debug('socket leaves channel', { sid: session.socket.id, user: (session.user || session.channel)._id, channelId }); + this.log.debug('socket leaves channel', { + sid: session.socket.id, + user: (session.user || session.channel)._id, + channelId, + }); session.socket.leave(channelId); - session.joinedChannels.delete(channelId); } } \ No newline at end of file diff --git a/start-local b/start-local index c5e58a9..51d43f6 100755 --- a/start-local +++ b/start-local @@ -9,7 +9,9 @@ MINIO_ROOT_PASSWORD="06f281ba-a8e4-4d69-8769-3e8f2dd60630" export MINIO_ROOT_USER MINIO_ROOT_PASSWORD forever start --killSignal=SIGINT app/workers/host-services.js +forever start --killSignal=SIGINT app/workers/tracker-monitor.js minio server ./data/minio --address ":9080" --console-address ":9081" +forever stop app/workers/tracker-monitor.js forever stop app/workers/host-services.js \ No newline at end of file