collabcentral/html/jobDetail.js
Joseph B. McQueen 2984e8e851 Phase 9: rebuild frontend on a shared layout + fix Monitor Jobs race
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>
2026-07-01 19:36:36 -04:00

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 + '%)';
}