Static frontend with vendored marked/DOMPurify, bounded Markdown pipeline, same-origin mock server with scenario selection, unit, contract and CDP end-to-end tests under frontend/**.
420 lines
13 KiB
JavaScript
420 lines
13 KiB
JavaScript
/**
|
|
* Sources, Request History, and Artifacts rendering module.
|
|
* Provides bounded lazy expansion of huge tool results, sanitized textContent rendering,
|
|
* and distinct display of repeated/cache-hit calls.
|
|
*/
|
|
|
|
const MAX_HISTORY_RESULT_DISPLAY_BYTES = 32 * 1024; // 32 KiB display limit per entry
|
|
|
|
/**
|
|
* Formats byte counts into human-readable strings.
|
|
* @param {number} bytes
|
|
* @returns {string}
|
|
*/
|
|
export function formatBytes(bytes) {
|
|
if (typeof bytes !== 'number' || isNaN(bytes) || bytes < 0) return '0 B';
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
/**
|
|
* Bounded JSON serializer that traverses data structures lazily and terminates
|
|
* immediately when maxBytes is exceeded, avoiding full serialization memory cost.
|
|
* @param {any} value
|
|
* @param {number} [maxBytes]
|
|
* @returns {string}
|
|
*/
|
|
export function serializeBounded(value, maxBytes = MAX_HISTORY_RESULT_DISPLAY_BYTES) {
|
|
if (value === undefined) return 'undefined';
|
|
if (value === null) return 'null';
|
|
if (typeof value !== 'object') {
|
|
const s = String(value);
|
|
if (s.length > maxBytes) {
|
|
return s.slice(0, maxBytes) + `\n\n... [Truncated: ${formatBytes(s.length)} total]`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
let totalChars = 0;
|
|
let truncated = false;
|
|
const parts = [];
|
|
|
|
function append(str) {
|
|
if (!str) return;
|
|
if (totalChars + str.length > maxBytes) {
|
|
const allowed = Math.max(0, maxBytes - totalChars);
|
|
if (allowed > 0) {
|
|
parts.push(str.slice(0, allowed));
|
|
totalChars += allowed;
|
|
}
|
|
truncated = true;
|
|
} else {
|
|
parts.push(str);
|
|
totalChars += str.length;
|
|
}
|
|
}
|
|
|
|
function walk(val, indent) {
|
|
if (totalChars >= maxBytes) {
|
|
truncated = true;
|
|
return;
|
|
}
|
|
|
|
if (val === null) {
|
|
append('null');
|
|
return;
|
|
}
|
|
|
|
const type = typeof val;
|
|
if (type !== 'object') {
|
|
if (type === 'string') {
|
|
const remaining = maxBytes - totalChars;
|
|
if (val.length > remaining) {
|
|
truncated = true;
|
|
const sliced = val.slice(0, Math.max(0, remaining - 15));
|
|
append(JSON.stringify(sliced).slice(0, -1) + '... [truncated]"');
|
|
} else {
|
|
append(JSON.stringify(val));
|
|
}
|
|
} else {
|
|
append(JSON.stringify(val));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const spaces = ' '.repeat(indent);
|
|
const nextSpaces = ' '.repeat(indent + 1);
|
|
|
|
if (Array.isArray(val)) {
|
|
if (val.length === 0) {
|
|
append('[]');
|
|
return;
|
|
}
|
|
append('[\n');
|
|
for (let i = 0; i < val.length; i++) {
|
|
if (totalChars >= maxBytes) {
|
|
truncated = true;
|
|
append(`${nextSpaces}... [${val.length - i} more items truncated]\n`);
|
|
break;
|
|
}
|
|
append(nextSpaces);
|
|
walk(val[i], indent + 1);
|
|
if (i < val.length - 1) append(',');
|
|
append('\n');
|
|
}
|
|
append(`${spaces}]`);
|
|
return;
|
|
}
|
|
|
|
// Object
|
|
const keys = Object.keys(val);
|
|
if (keys.length === 0) {
|
|
append('{}');
|
|
return;
|
|
}
|
|
append('{\n');
|
|
for (let i = 0; i < keys.length; i++) {
|
|
if (totalChars >= maxBytes) {
|
|
truncated = true;
|
|
append(`${nextSpaces}... [${keys.length - i} more properties truncated]\n`);
|
|
break;
|
|
}
|
|
const key = keys[i];
|
|
append(`${nextSpaces}${JSON.stringify(key)}: `);
|
|
walk(val[key], indent + 1);
|
|
if (i < keys.length - 1) append(',');
|
|
append('\n');
|
|
}
|
|
append(`${spaces}}`);
|
|
}
|
|
|
|
walk(value, 0);
|
|
|
|
let result = parts.join('');
|
|
if (truncated) {
|
|
result += `\n\n... [Result display bounded to ${formatBytes(maxBytes)}; click Export to MD for full output]`;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Formats ISO timestamp into readable UTC string.
|
|
* @param {string} isoString
|
|
* @returns {string}
|
|
*/
|
|
export function formatTimestamp(isoString) {
|
|
if (!isoString) return 'Unknown';
|
|
try {
|
|
const d = new Date(isoString);
|
|
if (isNaN(d.getTime())) return isoString;
|
|
return d.toISOString().replace('T', ' ').replace('Z', ' UTC');
|
|
} catch {
|
|
return isoString;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders the list of accessed Confluence pages.
|
|
* @param {HTMLElement} container
|
|
* @param {Array<{ page_id: string, title: string, space: string, url: string, accessed_at: string }>} pages
|
|
*/
|
|
export function renderPagesAccessed(container, pages) {
|
|
container.replaceChildren();
|
|
|
|
if (!Array.isArray(pages) || pages.length === 0) {
|
|
const emptyMsg = document.createElement('p');
|
|
emptyMsg.className = 'page-card-meta';
|
|
emptyMsg.textContent = 'No Confluence pages were read during this query.';
|
|
container.appendChild(emptyMsg);
|
|
return;
|
|
}
|
|
|
|
pages.forEach((page) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'page-card';
|
|
|
|
const left = document.createElement('div');
|
|
left.className = 'page-card-left';
|
|
|
|
const spaceBadge = document.createElement('span');
|
|
spaceBadge.className = 'badge-space';
|
|
spaceBadge.textContent = page.space || 'PAGE';
|
|
|
|
const link = document.createElement('a');
|
|
link.className = 'page-title-link';
|
|
link.textContent = page.title || `Page ${page.page_id}`;
|
|
if (page.url) {
|
|
try {
|
|
const u = new URL(page.url, window.location.href);
|
|
if (['http:', 'https:'].includes(u.protocol)) {
|
|
link.href = u.href;
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
}
|
|
} catch {
|
|
// Leave link without href if invalid
|
|
}
|
|
}
|
|
|
|
left.appendChild(spaceBadge);
|
|
left.appendChild(link);
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'page-card-meta';
|
|
meta.textContent = `ID: ${page.page_id} · Accessed: ${formatTimestamp(page.accessed_at)}`;
|
|
|
|
card.appendChild(left);
|
|
card.appendChild(meta);
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Renders the authoritative tool history with bounded lazy result expansion.
|
|
* @param {HTMLElement} container
|
|
* @param {Array<object>} toolHistory
|
|
*/
|
|
export function renderToolHistory(container, toolHistory) {
|
|
container.replaceChildren();
|
|
|
|
if (!Array.isArray(toolHistory) || toolHistory.length === 0) {
|
|
const emptyMsg = document.createElement('p');
|
|
emptyMsg.className = 'page-card-meta';
|
|
emptyMsg.textContent = 'No remote tool operations recorded.';
|
|
container.appendChild(emptyMsg);
|
|
return;
|
|
}
|
|
|
|
toolHistory.forEach((entry) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'tool-card';
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'tool-card-header';
|
|
|
|
const headerLeft = document.createElement('div');
|
|
headerLeft.className = 'tool-card-header-left';
|
|
|
|
const idSpan = document.createElement('span');
|
|
idSpan.className = 'tool-id';
|
|
idSpan.textContent = `#${entry.tool_call_id || 'call'}`;
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'tool-name';
|
|
nameSpan.textContent = entry.tool || 'unknown_tool';
|
|
|
|
headerLeft.appendChild(idSpan);
|
|
headerLeft.appendChild(nameSpan);
|
|
|
|
const badges = document.createElement('div');
|
|
badges.className = 'tool-badges';
|
|
|
|
// Status badge
|
|
const statusBadge = document.createElement('span');
|
|
const isSuccess = entry.status === 'success';
|
|
statusBadge.className = `badge ${isSuccess ? 'badge-success' : 'badge-error'}`;
|
|
statusBadge.textContent = isSuccess ? 'Success' : 'Error';
|
|
badges.appendChild(statusBadge);
|
|
|
|
// Cache hit badge
|
|
if (entry.cache_hit) {
|
|
const cacheBadge = document.createElement('span');
|
|
cacheBadge.className = 'badge badge-cache';
|
|
cacheBadge.textContent = 'Cache Hit';
|
|
badges.appendChild(cacheBadge);
|
|
}
|
|
|
|
// Truncation badges
|
|
if (entry.parameters_truncated) {
|
|
const paramTrunc = document.createElement('span');
|
|
paramTrunc.className = 'badge badge-truncated';
|
|
paramTrunc.textContent = 'Params Truncated';
|
|
badges.appendChild(paramTrunc);
|
|
}
|
|
|
|
if (entry.result_truncated) {
|
|
const resTrunc = document.createElement('span');
|
|
resTrunc.className = 'badge badge-truncated';
|
|
resTrunc.textContent = 'Result Truncated';
|
|
badges.appendChild(resTrunc);
|
|
}
|
|
|
|
header.appendChild(headerLeft);
|
|
header.appendChild(badges);
|
|
card.appendChild(header);
|
|
|
|
// Body
|
|
const body = document.createElement('div');
|
|
body.className = 'tool-card-body';
|
|
|
|
const metaRow = document.createElement('div');
|
|
metaRow.className = 'tool-meta-row';
|
|
const timeText = `Started: ${formatTimestamp(entry.started_at)} · Completed: ${formatTimestamp(entry.completed_at)}`;
|
|
metaRow.textContent = timeText;
|
|
body.appendChild(metaRow);
|
|
|
|
// Expandable toggle button
|
|
const toggleBtn = document.createElement('button');
|
|
toggleBtn.type = 'button';
|
|
toggleBtn.className = 'tool-expand-btn';
|
|
toggleBtn.textContent = 'Show details';
|
|
toggleBtn.setAttribute('aria-expanded', 'false');
|
|
|
|
let detailsRendered = false;
|
|
let detailsContainer = null;
|
|
|
|
toggleBtn.addEventListener('click', () => {
|
|
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
|
if (isExpanded) {
|
|
toggleBtn.setAttribute('aria-expanded', 'false');
|
|
toggleBtn.textContent = 'Show details';
|
|
if (detailsContainer) {
|
|
detailsContainer.classList.add('hidden');
|
|
}
|
|
} else {
|
|
toggleBtn.setAttribute('aria-expanded', 'true');
|
|
toggleBtn.textContent = 'Hide details';
|
|
|
|
if (!detailsRendered) {
|
|
detailsContainer = document.createElement('div');
|
|
detailsContainer.className = 'tool-details';
|
|
|
|
// Parameters section
|
|
const paramLabel = document.createElement('div');
|
|
paramLabel.className = 'history-subheading';
|
|
paramLabel.textContent = 'Parameters';
|
|
detailsContainer.appendChild(paramLabel);
|
|
|
|
const paramBox = document.createElement('pre');
|
|
paramBox.className = 'tool-result-box';
|
|
paramBox.textContent = serializeBounded(entry.parameters || {}, MAX_HISTORY_RESULT_DISPLAY_BYTES);
|
|
detailsContainer.appendChild(paramBox);
|
|
|
|
// Result or Error section
|
|
const resultLabel = document.createElement('div');
|
|
resultLabel.className = 'history-subheading';
|
|
resultLabel.textContent = entry.error ? 'Error' : 'Result';
|
|
detailsContainer.appendChild(resultLabel);
|
|
|
|
const resultBox = document.createElement('pre');
|
|
resultBox.className = 'tool-result-box';
|
|
|
|
if (entry.error) {
|
|
resultBox.textContent = serializeBounded(entry.error, MAX_HISTORY_RESULT_DISPLAY_BYTES);
|
|
} else {
|
|
// Lazy bounded serialization without paying full in-memory stringification cost
|
|
resultBox.textContent = serializeBounded(entry.result, MAX_HISTORY_RESULT_DISPLAY_BYTES);
|
|
}
|
|
|
|
detailsContainer.appendChild(resultBox);
|
|
body.appendChild(detailsContainer);
|
|
detailsRendered = true;
|
|
} else if (detailsContainer) {
|
|
detailsContainer.classList.remove('hidden');
|
|
}
|
|
}
|
|
});
|
|
|
|
body.appendChild(toggleBtn);
|
|
card.appendChild(body);
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Renders the list of exported artifacts.
|
|
* @param {HTMLElement} container
|
|
* @param {Array<{ id: string, name: string, size_bytes: number, expires_at: string }>} artifacts
|
|
* @param {(id: string, name: string) => Promise<void>} onDownload
|
|
*/
|
|
export function renderArtifacts(container, artifacts, onDownload) {
|
|
container.replaceChildren();
|
|
|
|
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
return;
|
|
}
|
|
|
|
artifacts.forEach((art) => {
|
|
const item = document.createElement('li');
|
|
item.className = 'artifact-item';
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'artifact-info';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'artifact-name';
|
|
nameSpan.textContent = art.name || 'unnamed_artifact';
|
|
|
|
const metaSpan = document.createElement('span');
|
|
metaSpan.className = 'artifact-meta';
|
|
metaSpan.textContent = `${formatBytes(art.size_bytes)} · Expires: ${formatTimestamp(art.expires_at)}`;
|
|
|
|
info.appendChild(nameSpan);
|
|
info.appendChild(metaSpan);
|
|
|
|
const dlBtn = document.createElement('button');
|
|
dlBtn.type = 'button';
|
|
dlBtn.className = 'download-btn';
|
|
dlBtn.textContent = 'Download';
|
|
dlBtn.setAttribute('aria-label', `Download ${art.name}`);
|
|
|
|
dlBtn.addEventListener('click', async () => {
|
|
dlBtn.disabled = true;
|
|
const originalText = dlBtn.textContent;
|
|
dlBtn.textContent = 'Downloading...';
|
|
try {
|
|
await onDownload(art.id, art.name);
|
|
} finally {
|
|
dlBtn.disabled = false;
|
|
dlBtn.textContent = originalText;
|
|
}
|
|
});
|
|
|
|
item.appendChild(info);
|
|
item.appendChild(dlBtn);
|
|
container.appendChild(item);
|
|
});
|
|
}
|