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. 82
      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),
);
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 {

7
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 },

2
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;

5
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();

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') {

2
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

2
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",

7
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')}

6
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 } },
],
})

1
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";

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

82
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('');
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;
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 });
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');

7
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,

50
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);
}
}

2
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
Loading…
Cancel
Save