Browse Source

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.
develop
Rob Colbert 12 months ago
parent
commit
f0856ad9f4
  1. 34
      app/controllers/task.js
  2. 7
      app/models/task-session.js
  3. 2
      app/services/client.js
  4. 5
      app/services/report.js
  5. 34
      app/services/task.js
  6. 2
      app/views/report/components/weekly-summary.pug
  7. 2
      app/views/task/session/view.pug
  8. 7
      app/views/task/view.pug
  9. 6
      app/workers/tracker-monitor.js
  10. 1
      client/css/dtp-site.less
  11. 17
      client/css/site/table.less
  12. 84
      client/js/time-tracker-client.js
  13. 7
      config/limiter.js
  14. 50
      lib/site-ioserver.js
  15. 2
      start-local

34
app/controllers/task.js

@ -74,6 +74,13 @@ export default class TaskController extends SiteController {
this.postTaskSessionScreenshot.bind(this), this.postTaskSessionScreenshot.bind(this),
); );
router.post(
'/:taskId/session/:sessionId/status',
limiterService.create(limiterConfig.postTaskSessionStatus),
checkSessionOwnership,
this.postTaskSessionStatus.bind(this),
);
router.post( router.post(
'/:taskId/session/:sessionId/close', '/:taskId/session/:sessionId/close',
limiterService.create(limiterConfig.postCloseTaskSession), 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) { async postCloseTaskSession (req, res) {
const { task: taskService } = this.dtp.services; const { task: taskService } = this.dtp.services;
try { try {

7
app/models/task-session.js

@ -7,7 +7,12 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
const STATUS_LIST = ['active', 'finished', 'expired']; const STATUS_LIST = [
'active',
'reconnecting',
'finished',
'expired',
];
const ScreenshotSchema = new Schema({ const ScreenshotSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: 1 }, created: { type: Date, required: true, default: Date.now, index: 1 },

2
app/services/client.js

@ -223,8 +223,8 @@ export default class ClientService extends SiteService {
this.dtp.emitter this.dtp.emitter
.to(client.user._id.toString()) .to(client.user._id.toString())
.emit('session-control', { .emit('session-control', {
displayList,
cmd: 'end-session', cmd: 'end-session',
displayList,
}); });
return; return;

5
app/services/report.js

@ -22,13 +22,11 @@ export default class ReportService extends SiteService {
async getWeeklyEarnings (user) { async getWeeklyEarnings (user) {
const { client: clientService } = this.dtp.services; const { client: clientService } = this.dtp.services;
const NOW = new Date(); const NOW = new Date();
const dateStart = this.startOfWeek(NOW); const dateStart = this.startOfWeek(NOW);
const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); const dateEnd = dayjs(dateStart).add(1, 'week').toDate();
this.log.debug('computing weekly earnings', { dateStart, dateEnd });
const data = await TaskSession.aggregate([ const data = await TaskSession.aggregate([
{ {
$match: { $match: {
@ -77,6 +75,7 @@ export default class ReportService extends SiteService {
async getDailyHoursWorkedForUser (user) { async getDailyHoursWorkedForUser (user) {
const NOW = new Date(); const NOW = new Date();
const dateStart = this.startOfWeek(NOW); const dateStart = this.startOfWeek(NOW);
const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); const dateEnd = dayjs(dateStart).add(1, 'week').toDate();

34
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') { async closeTaskSession (session, status = 'finished') {

2
app/views/report/components/weekly-summary.pug

@ -1,5 +1,5 @@
mixin renderWeeklySummaryReport (data) mixin renderWeeklySummaryReport (data)
table.uk-table.uk-table-small.uk-table-justify.no-select table.uk-table.uk-table-small.no-select
thead thead
tr tr
th Project th Project

2
app/views/task/session/view.pug

@ -43,7 +43,7 @@ block view-content
.uk-margin-medium .uk-margin-medium
if Array.isArray(session.screenshots) && (session.screenshots.length > 0) if Array.isArray(session.screenshots) && (session.screenshots.length > 0)
h3 Screenshots 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 each screenshot in session.screenshots
a(href=`/image/${screenshot.image._id}`, a(href=`/image/${screenshot.image._id}`,
data-type="image", data-type="image",

7
app/views/task/view.pug

@ -57,9 +57,9 @@ block view-content
table.uk-table.uk-table-small.uk-table-divider table.uk-table.uk-table-small.uk-table-divider
thead thead
tr.uk-background-secondary tr
th.uk-table-expand Start Time 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 th.uk-text-nowrap.uk-table-shrink Billable
tbody tbody
each session in sessions 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') ).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.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') 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-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(totalTimeWorked).format('HH:MM:SS')
td.uk-text-right.uk-text-nowrap.uk-table-shrink #{numeral(totalBillable).format('$0,0.00')} td.uk-text-right.uk-text-nowrap.uk-table-shrink #{numeral(totalBillable).format('$0,0.00')}

6
app/workers/tracker-monitor.js

@ -44,6 +44,7 @@ class TrackerMonitorWorker extends SiteRuntime {
true, true,
CRON_TIMEZONE, CRON_TIMEZONE,
); );
await this.expireTaskSessions();
/* /*
* Bull Queue job processors * 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.log.info('registering queue job processor', { config: this.config.jobQueues.links });
// this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', 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.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this));
this.log.info('Tracker Monitor online');
} }
async shutdown ( ) { async shutdown ( ) {
@ -65,10 +68,11 @@ class TrackerMonitorWorker extends SiteRuntime {
const NOW = new Date(); const NOW = new Date();
const oldestDate = dayjs(NOW).subtract(10, 'minute'); const oldestDate = dayjs(NOW).subtract(10, 'minute');
this.log.debug('scanning for defunct sessions');
await this.TaskSession await this.TaskSession
.find({ .find({
$and: [ $and: [
{ status: 'active' }, { status: { $in: ['active', 'reconnecting'] } },
{ lastUpdated: { $lt: oldestDate } }, { lastUpdated: { $lt: oldestDate } },
], ],
}) })

1
client/css/dtp-site.less

@ -9,4 +9,5 @@
@import "site/menu.less"; @import "site/menu.less";
@import "site/navbar.less"; @import "site/navbar.less";
@import "site/stats.less"; @import "site/stats.less";
@import "site/table.less";
@import "site/video.less"; @import "site/video.less";

17
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;
}
}

84
client/js/time-tracker-client.js

@ -19,7 +19,7 @@ dayjs.extend(dayjsRelativeTime);
export class TimeTrackerApp extends DtpApp { 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_START ( ) { return 'tracker-start'; }
static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; } static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; }
@ -146,17 +146,33 @@ export class TimeTrackerApp extends DtpApp {
async onTrackerSocketConnect (socket) { async onTrackerSocketConnect (socket) {
this.log.debug('onSocketConnect', 'attaching socket events'); 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) { if (dtp.task) {
await this.socket.joinChannel(dtp.task._id, 'Task'); await this.socket.joinChannel(dtp.task._id, 'Task');
} }
if (this.taskSession) {
await this.setTaskSessionStatus('active');
}
} }
async onTrackerSocketDisconnect (socket) { async onTrackerSocketDisconnect (socket) {
this.log.debug('onSocketDisconnect', 'detaching socket events'); 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) { async onSystemMessage (message) {
@ -167,23 +183,23 @@ export class TimeTrackerApp extends DtpApp {
async onSessionControl (message) { async onSessionControl (message) {
const activityToggle = document.querySelector(''); const activityToggle = document.querySelector('');
if (message.cmd) {
switch (message.cmd) { switch (message.cmd) {
case 'end-session': case 'end-session':
try { try {
await this.closeTaskSession(); await this.closeTaskSession();
activityToggle.checked = false; activityToggle.checked = false;
} catch (error) { } catch (error) {
this.log.error('onSessionControl', 'failed to close task work session', { 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; return;
} }
break;
default:
this.log.error('onSessionControl', 'invalid command received', { cmd: message.cmd });
return;
} }
if (message.displayList) { if (message.displayList) {
this.displayEngine.executeDisplayList(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 ( ) { async closeTaskSession ( ) {
try { try {
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`; const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`;
@ -534,6 +574,12 @@ export class TimeTrackerApp extends DtpApp {
} }
async updateSessionDisplay ( ) { async updateSessionDisplay ( ) {
if (this.taskSession.status === 'reconnecting') {
this.currentSessionDuration.textContent = '---';
this.currentSessionTimeRemaining.textContent = '---';
return;
}
const NOW = new Date(); const NOW = new Date();
const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second'); const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second');
this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS'); this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS');

7
config/limiter.js

@ -202,10 +202,15 @@ export default {
message: 'You are starting task work sessions too quickly', message: 'You are starting task work sessions too quickly',
}, },
postTaskSessionScreenshot: { postTaskSessionScreenshot: {
total: 12, total: 20,
expire: ONE_HOUR, expire: ONE_HOUR,
message: 'You are uploading session screenshots too quickly', 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: { postCloseTaskSession: {
total: 12, total: 12,
expire: ONE_HOUR, expire: ONE_HOUR,

50
lib/site-ioserver.js

@ -75,8 +75,6 @@ export class SiteIoServer extends SiteCommon {
} }
async onSocketConnect (socket) { async onSocketConnect (socket) {
const { channel: channelService } = this.dtp.services;
this.log.debug('socket connection', { sid: socket.id }); this.log.debug('socket connection', { sid: socket.id });
try { try {
const token = await ConnectToken const token = await ConnectToken
@ -103,6 +101,7 @@ export class SiteIoServer extends SiteCommon {
}, },
]) ])
.lean(); .lean();
if (!token) { if (!token) {
this.log.alert('rejecting invalid socket token', { this.log.alert('rejecting invalid socket token', {
sid: socket.sid, sid: socket.sid,
@ -144,7 +143,6 @@ export class SiteIoServer extends SiteCommon {
const session = { const session = {
socket, socket,
joinedChannels: new Set(), joinedChannels: new Set(),
joinedRooms: new Map(),
}; };
this.sessions[socket.id] = session; this.sessions[socket.id] = session;
@ -180,23 +178,6 @@ export class SiteIoServer extends SiteCommon {
user: session.user, user: session.user,
}); });
break; 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) { } catch (error) {
this.log.error('failed to process a socket connection', { error }); this.log.error('failed to process a socket connection', { error });
@ -204,34 +185,12 @@ export class SiteIoServer extends SiteCommon {
} }
async onSocketDisconnect (session, reason) { async onSocketDisconnect (session, reason) {
const { task: taskService } = this.dtp.services;
this.log.debug('socket disconnect', { this.log.debug('socket disconnect', {
sid: session.socket.id, sid: session.socket.id,
consumerId: (session.user || session.channel)._id, consumerId: (session.user || session.channel)._id,
joinedRooms: session.joinedRooms.size,
reason, 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) { if (session.onSocketDisconnect) {
session.socket.off('disconnect', session.onSocketDisconnect); session.socket.off('disconnect', session.onSocketDisconnect);
delete session.onSocketDisconnect; delete session.onSocketDisconnect;
@ -312,9 +271,12 @@ export class SiteIoServer extends SiteCommon {
return; 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.socket.leave(channelId);
session.joinedChannels.delete(channelId); session.joinedChannels.delete(channelId);
} }
} }

2
start-local

@ -9,7 +9,9 @@ MINIO_ROOT_PASSWORD="06f281ba-a8e4-4d69-8769-3e8f2dd60630"
export MINIO_ROOT_USER MINIO_ROOT_PASSWORD export MINIO_ROOT_USER MINIO_ROOT_PASSWORD
forever start --killSignal=SIGINT app/workers/host-services.js 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" minio server ./data/minio --address ":9080" --console-address ":9081"
forever stop app/workers/tracker-monitor.js
forever stop app/workers/host-services.js forever stop app/workers/host-services.js
Loading…
Cancel
Save