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