The nav link race:
- Every page's header used `<a id="jobLink" href=''>` and the real
destination was only assigned later, after /info returned. An empty
href resolves to the current document URL, so clicking "Monitor Jobs"
on sendMessage before /info completed silently reloaded sendMessage.
- Fixed structurally: nav links now use static relative hrefs baked
into the HTML ("./sendMessage.html", "./monitorJobs.html"), so the
destination is correct the moment the DOM parses.
Shared UI:
- New html/css/app.css: design tokens (palette, radius, shadow, font),
sticky compact top header (bot avatar + label on the left, nav pills
in the middle, current user on the right, active-page highlight via
aria-current), card containers, form styling with focus rings,
DataTables theme overrides, status pills, modal, and responsive
breakpoints.
- New html/js/app.js: shared browser bootstrap. Parses appName from
the URL, redirects to OAuth if the id cookie is missing, fetches
/info once, populates the header, applies aria-current to the
active nav link, and invokes a per-page onReady callback with
{ info, appName }. Also exports getCookie, escapeHtml, and formatDate
helpers so each page stops shipping its own copy.
Per-page rewrites:
- sendMessage.html/.js: form now lives in a card, image preview only
shows when a file is attached, EasyMDE + VirtualSelect styled to
match the theme, submit is a primary button, confirmation modal
redesigned. All bootstrap code deleted (delegated to app.js).
- monitorJobs.html/.js: three cards (Running / Scheduled / Completed)
with themed DataTables. Completed table sorts by start time desc,
paginates, and searches; message column truncates HTML previews to
~120 chars. Empty-state text per table. `jobId` and running-row
"view" links go to jobDetail via safe relative URLs.
- jobDetail.html/.js: same shared header + card layout; summary grid,
message preview, and recipient table styled to match the new
palette.
Sanity checks:
- All 43 helper tests still pass.
- Server boots cleanly on port 3001.
- Curl of sendMessage/monitorJobs/jobDetail all return 200 with the
shared header markup.
- /CollabCentral/:app/css/app.css and /CollabCentral/:app/js/app.js
both serve 200 (shared static mount is per-bot as expected).
- No local href in any page is empty; every nav target resolves at
parse time.
- /info and requireBot 404 gate unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
126 lines
4.6 KiB
JavaScript
126 lines
4.6 KiB
JavaScript
// Page script for monitorJobs.html. Shared bootstrapping lives in js/app.js;
|
|
// this file just wires the three DataTables (running, scheduled, completed)
|
|
// once the user is confirmed authorized.
|
|
|
|
CollabCentral.init(function (ctx) {
|
|
document.getElementById('jobsPanel').classList.remove('hidden');
|
|
initJobTables(ctx.appName);
|
|
});
|
|
|
|
function jobDetailLink(appName, jobId, label) {
|
|
if (jobId === undefined || jobId === null) return CollabCentral.escapeHtml(label);
|
|
return '<a href="./jobDetail.html?jobId=' + encodeURIComponent(jobId) + '">' +
|
|
CollabCentral.escapeHtml(label) + '</a>';
|
|
}
|
|
|
|
function renderDate(val) {
|
|
return val ? CollabCentral.escapeHtml(CollabCentral.formatDate(val)) : '';
|
|
}
|
|
|
|
function renderSuccessRate(pct) {
|
|
if (pct === undefined || pct === null || pct === '') return '';
|
|
var n = typeof pct === 'number' ? pct : parseFloat(pct);
|
|
if (isNaN(n)) return CollabCentral.escapeHtml(pct);
|
|
return n.toFixed(1) + '%';
|
|
}
|
|
|
|
function renderDelivered(row) {
|
|
var s = row.stats || {};
|
|
if (s.succeededMsgs === undefined || s.totalMsgs === undefined) return '';
|
|
return s.succeededMsgs + ' / ' + s.totalMsgs;
|
|
}
|
|
|
|
function initJobTables(appName) {
|
|
$('#runningJobs').dataTable({
|
|
ajax: {
|
|
url: '/CollabCentral/' + appName + '/jobs/list/running',
|
|
dataSrc: '',
|
|
},
|
|
columns: [
|
|
{ data: 'senderDisplayName' },
|
|
{ data: 'appName' },
|
|
{ data: 'startTime', render: function (v) { return renderDate(v); } },
|
|
{ data: 'totalRecipients' },
|
|
{
|
|
data: 'completedRecipients',
|
|
render: function (val, type, row) {
|
|
if (type !== 'display' || row.jobId === undefined || row.jobId === null) return val;
|
|
var text = (val === undefined || val === null) ? 'view' : val + ' · view';
|
|
return jobDetailLink(appName, row.jobId, text);
|
|
},
|
|
},
|
|
],
|
|
searching: false,
|
|
info: false,
|
|
ordering: false,
|
|
paging: false,
|
|
language: { emptyTable: 'No running jobs.' },
|
|
});
|
|
|
|
$('#scheduledJobs').dataTable({
|
|
ajax: {
|
|
url: '/CollabCentral/' + appName + '/jobs/list/scheduled',
|
|
dataSrc: '',
|
|
},
|
|
columns: [
|
|
{ data: 'senderDisplayName' },
|
|
{ data: 'appName' },
|
|
{
|
|
data: 'message',
|
|
render: function (val, type, row) {
|
|
if (type !== 'display') return '';
|
|
// Message is stored per-language; prefer english.html or english.markdown.
|
|
var m = (val && (val.english || val)) || {};
|
|
var html = m.html || m.markdown || m.text || '';
|
|
// Truncate to keep the row compact
|
|
var text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
if (text.length > 120) text = text.slice(0, 117) + '…';
|
|
return CollabCentral.escapeHtml(text);
|
|
},
|
|
},
|
|
{ data: 'totalRecipients' },
|
|
{ data: 'scheduledFor', render: function (v) { return renderDate(v); } },
|
|
],
|
|
searching: false,
|
|
info: false,
|
|
ordering: false,
|
|
paging: false,
|
|
language: { emptyTable: 'No scheduled jobs.' },
|
|
});
|
|
|
|
$('#completedJobs').dataTable({
|
|
ajax: {
|
|
url: '/CollabCentral/' + appName + '/jobs/list/completed',
|
|
dataSrc: '',
|
|
},
|
|
columns: [
|
|
{
|
|
data: 'jobId',
|
|
render: function (jobId, type) {
|
|
if (type !== 'display') return jobId;
|
|
return jobDetailLink(appName, jobId, '#' + jobId);
|
|
},
|
|
},
|
|
{ data: 'senderDisplayName' },
|
|
{ data: 'appName' },
|
|
{ data: 'startTime', render: function (v) { return renderDate(v); } },
|
|
{ data: 'endTime', render: function (v) { return renderDate(v); } },
|
|
{ data: 'stats.totalTime' },
|
|
{
|
|
data: null,
|
|
render: function (row, type) {
|
|
if (type !== 'display') return '';
|
|
return renderDelivered(row);
|
|
},
|
|
},
|
|
{ data: 'stats.succeededMsgPct', render: function (v) { return renderSuccessRate(v); } },
|
|
],
|
|
order: [[3, 'desc']],
|
|
pageLength: 25,
|
|
lengthMenu: [10, 25, 50, 100],
|
|
searching: true,
|
|
info: true,
|
|
paging: true,
|
|
language: { emptyTable: 'No completed jobs in the last 30 days.' },
|
|
});
|
|
}
|