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>
143 lines
5 KiB
JavaScript
143 lines
5 KiB
JavaScript
// Page script for jobDetail.html. Shared bootstrapping lives in js/app.js;
|
|
// this file just fetches the requested job and renders the detail view.
|
|
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var jobId = urlParams.get('jobId');
|
|
|
|
CollabCentral.init(function (ctx) {
|
|
loadJobDetail(ctx.appName);
|
|
});
|
|
|
|
function loadJobDetail(appName) {
|
|
if (!jobId) {
|
|
document.getElementById('notFoundPanel').classList.remove('hidden');
|
|
return;
|
|
}
|
|
fetch('/CollabCentral/' + appName + '/jobs/detail/' + encodeURIComponent(jobId))
|
|
.then(function (res) {
|
|
if (res.status === 404) {
|
|
document.getElementById('notFoundPanel').classList.remove('hidden');
|
|
return null;
|
|
}
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.json();
|
|
})
|
|
.then(function (job) {
|
|
if (!job) return;
|
|
renderJob(job);
|
|
document.getElementById('detailPanel').classList.remove('hidden');
|
|
})
|
|
.catch(function (err) {
|
|
console.error('Failed to load job detail:', err);
|
|
document.getElementById('notFoundPanel').classList.remove('hidden');
|
|
});
|
|
}
|
|
|
|
function renderJob(job) {
|
|
document.getElementById('jobTitle').textContent = 'Job #' + (job.jobId || '?');
|
|
|
|
var subtitle = 'Sent by ' + (job.senderDisplayName || 'unknown');
|
|
if (job.startTime) subtitle += ' · started ' + CollabCentral.formatDate(job.startTime);
|
|
document.getElementById('subtitle').textContent = subtitle;
|
|
|
|
var stats = job.stats || {};
|
|
var rows = [
|
|
['Job ID', job.jobId],
|
|
['Submitted by', job.senderDisplayName],
|
|
['Scheduled for', job.scheduledFor ? CollabCentral.formatDate(job.scheduledFor) : 'Sent immediately'],
|
|
['Started', CollabCentral.formatDate(job.startTime)],
|
|
['Completed', job.endTime ? CollabCentral.formatDate(job.endTime) : 'In progress'],
|
|
['Total duration', stats.totalTime],
|
|
['Webex time', stats.webexTime],
|
|
['Average per message', stats.averageTime],
|
|
['Recipients', (job.memberList || []).length],
|
|
['Delivered', formatSuccess(stats)],
|
|
];
|
|
|
|
var dl = document.getElementById('summaryGrid');
|
|
dl.innerHTML = '';
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var key = rows[i][0], val = rows[i][1];
|
|
if (val === undefined || val === null || val === '') continue;
|
|
var dt = document.createElement('dt');
|
|
dt.textContent = key;
|
|
var dd = document.createElement('dd');
|
|
dd.textContent = val;
|
|
dl.appendChild(dt);
|
|
dl.appendChild(dd);
|
|
}
|
|
|
|
// Message preview: prefer HTML, fall back to markdown/text.
|
|
var preview = document.getElementById('messagePreview');
|
|
var msg = (job.message && job.message.english) || job.message || {};
|
|
if (msg.html) {
|
|
preview.innerHTML = msg.html;
|
|
} else if (msg.markdown) {
|
|
preview.textContent = msg.markdown;
|
|
} else if (msg.text) {
|
|
preview.textContent = msg.text;
|
|
} else if (typeof msg.raw === 'string') {
|
|
preview.textContent = msg.raw;
|
|
} else {
|
|
preview.innerHTML = '<em>No message content available.</em>';
|
|
}
|
|
|
|
var members = job.memberList || [];
|
|
document.getElementById('recipientCount').textContent = members.length;
|
|
|
|
$('#recipients').dataTable({
|
|
data: members.map(formatRecipientRow),
|
|
columns: [
|
|
{ data: 'displayName' },
|
|
{ data: 'email' },
|
|
{ data: 'status' },
|
|
{ data: 'time' },
|
|
{ data: 'error' },
|
|
],
|
|
pageLength: 50,
|
|
lengthMenu: [25, 50, 100, 250],
|
|
order: [[2, 'asc']],
|
|
searching: true,
|
|
info: true,
|
|
paging: true,
|
|
});
|
|
}
|
|
|
|
function formatRecipientRow(m) {
|
|
var result = m.results || null;
|
|
var status;
|
|
var email = '—';
|
|
var err = '';
|
|
|
|
if (!result) {
|
|
status = '<span class="pill pending">Pending</span>';
|
|
} else if (result.id && !result.message && !result.errors) {
|
|
status = '<span class="pill ok">Delivered</span>';
|
|
email = result.toPersonEmail || email;
|
|
} else {
|
|
status = '<span class="pill err">Failed</span>';
|
|
email = result.toPersonEmail || email;
|
|
if (result.message) err = result.message;
|
|
else if (result.errors && result.errors.length) {
|
|
err = result.errors.map(function (e) {
|
|
return e.description || e.code || JSON.stringify(e);
|
|
}).join('; ');
|
|
}
|
|
}
|
|
|
|
return {
|
|
displayName: m.displayName || '—',
|
|
email: email,
|
|
status: status,
|
|
time: m.msgTime ? (m.msgTime / 1000).toFixed(2) + ' s' : '—',
|
|
error: err,
|
|
};
|
|
}
|
|
|
|
function formatSuccess(stats) {
|
|
if (!stats || stats.succeededMsgs === undefined) return null;
|
|
var pct = (typeof stats.succeededMsgPct === 'number')
|
|
? stats.succeededMsgPct.toFixed(1)
|
|
: stats.succeededMsgPct;
|
|
return stats.succeededMsgs + ' / ' + stats.totalMsgs + ' (' + pct + '%)';
|
|
}
|