Files
opportunities-extension/src/features/debug/content-view.js

361 lines
17 KiB
JavaScript

function createDebugContentView({
DEBUG_PANEL_ID,
MODAL_ID,
debugState,
formatDebugBody,
formatHeaderBlock,
formatMetadataBlock,
createCloseIcon,
environment
}) {
const { document } = environment;
const categories = [
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
let returnFocus = null;
function getRequestCategory(entry) {
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other";
}
function getChronologicalRequests() {
return [...debugState.requestLog].sort((a, b) =>
(new Date(a.startedAt).getTime() || 0) - (new Date(b.startedAt).getTime() || 0) || a.id - b.id);
}
function toggleDebugPanel() {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.hidden) {
return;
}
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) {
return;
}
if (!panel.hidden) {
closeDebugPanel();
return;
}
returnFocus = document.activeElement;
panel.hidden = false;
renderDebugPanel();
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
}
function closeDebugPanel() {
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) return;
panel.hidden = true;
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
returnFocus = null;
}
function renderDebugPanel() {
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel || panel.hidden) {
return;
}
for (const item of panel.querySelectorAll("details[data-request-id]")) {
const id = item.getAttribute("data-request-id");
if (item.open) expandedIds.add(id);
else expandedIds.delete(id);
}
const focusedCategory = document.activeElement && document.activeElement.getAttribute("data-debug-category");
const focusedControl = document.activeElement && document.activeElement.getAttribute("data-debug-focus");
const listScrollTop = panel.querySelectorAll(".opportunities-extension-debug-list")[0]?.scrollTop || 0;
const scrollTop = panel.scrollTop;
panel.textContent = "";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-title`);
const focusTargets = new Map();
const panelHeader = document.createElement("header");
panelHeader.className = "opportunities-extension-debug-panel-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.id = `${DEBUG_PANEL_ID}-title`;
title.textContent = "Debug Requests";
const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
if (running) summary.textContent += ` ${running} running.`;
heading.append(title, summary);
const visibilityButton = document.createElement("button");
visibilityButton.type = "button";
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
visibilityButton.setAttribute("data-debug-focus", "visibility");
focusTargets.set("visibility", visibilityButton);
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible));
visibilityButton.addEventListener("click", () => {
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
renderDebugPanel();
});
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button opportunities-extension-debug-close-button";
closeButton.title = "Close debug";
closeButton.setAttribute("aria-label", "Close debug");
closeButton.setAttribute("data-debug-focus", "close");
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closeDebugPanel);
focusTargets.set("close", closeButton);
const actions = document.createElement("div");
actions.className = "opportunities-extension-debug-actions";
actions.append(visibilityButton, closeButton);
panelHeader.append(heading, actions);
const entries = getChronologicalRequests();
const availableCategories = categories.filter(([key]) => key === "all" || entries.some(entry => getRequestCategory(entry) === key));
if (!availableCategories.some(([key]) => key === selectedCategory)) selectedCategory = "all";
const tabs = document.createElement("div");
tabs.className = "opportunities-extension-debug-tabs";
tabs.setAttribute("role", "tablist");
tabs.setAttribute("aria-label", "Request categories");
const tabButtons = [];
availableCategories.forEach(([key, label], index) => {
const categoryEntries = key === "all" ? entries : entries.filter(entry => getRequestCategory(entry) === key);
const pending = categoryEntries.filter(entry => entry.status === "pending").length;
const button = document.createElement("button");
button.type = "button";
button.id = `${DEBUG_PANEL_ID}-tab-${key}`;
button.setAttribute("role", "tab");
button.setAttribute("data-debug-category", key);
button.setAttribute("aria-selected", String(key === selectedCategory));
button.setAttribute("aria-controls", `${DEBUG_PANEL_ID}-requests`);
button.tabIndex = key === selectedCategory ? 0 : -1;
button.textContent = `${label} (${categoryEntries.length})${pending ? ` / ${pending} running` : ""}`;
button.addEventListener("click", () => {
selectedCategory = key;
renderDebugPanel();
});
button.addEventListener("keydown", event => {
let nextIndex;
if (event.key === "ArrowRight") nextIndex = (index + 1) % availableCategories.length;
else if (event.key === "ArrowLeft") nextIndex = (index + availableCategories.length - 1) % availableCategories.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = availableCategories.length - 1;
else return;
event.preventDefault();
selectedCategory = availableCategories[nextIndex][0];
renderDebugPanel();
});
tabs.append(button);
tabButtons.push(button);
});
const list = document.createElement("div");
list.className = "opportunities-extension-debug-list";
list.id = `${DEBUG_PANEL_ID}-requests`;
list.setAttribute("role", "tabpanel");
list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`);
list.tabIndex = 0;
list.setAttribute("data-debug-focus", "list");
focusTargets.set("list", list);
const summaries = [];
if (debugState.requestLog.length === 0) {
const empty = document.createElement("div");
empty.className = "opportunities-extension-debug-empty";
empty.textContent = "No requests recorded yet.";
list.append(empty);
}
entries.filter(entry => selectedCategory === "all" || getRequestCategory(entry) === selectedCategory).forEach((entry) => {
const item = document.createElement("details");
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
item.setAttribute("data-request-id", String(entry.id));
item.open = expandedIds.has(String(entry.id));
const heading = document.createElement("summary");
heading.className = "opportunities-extension-debug-heading";
heading.setAttribute("data-debug-focus", `request-${entry.id}`);
focusTargets.set(`request-${entry.id}`, heading);
summaries.push(heading);
const name = document.createElement("strong");
name.textContent = `${entry.id}. ${entry.name}`;
if (entry.name === "requestOpptyProducts" && entry.metadata.optyNumber) {
name.textContent += ` (${entry.metadata.optyNumber}, offset ${entry.metadata.offset})`;
}
const httpStatus = document.createElement("span");
httpStatus.className = "opportunities-extension-debug-http-status";
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
httpStatus.setAttribute("data-http-class", entry.httpStatus ? String(Math.floor(entry.httpStatus / 100)) : "pending");
const status = document.createElement("span");
status.className = "opportunities-extension-debug-status";
status.textContent = entry.status === "pending" ? "running" : entry.status;
const summaryMeta = document.createElement("span");
summaryMeta.className = "opportunities-extension-debug-summary-meta";
const startedTime = document.createElement("time");
startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
const method = document.createElement("span");
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
method.setAttribute("data-method", method.textContent.toUpperCase());
const duration = document.createElement("span");
duration.className = "opportunities-extension-debug-duration";
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration);
heading.append(name, summaryMeta);
const lines = [
`${entry.method} ${entry.url}`,
`Started: ${formatDebugTime(entry.startedAt)}`,
entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending",
entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "",
entry.error ? `Error: ${entry.error}` : "",
`Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}`
].filter(Boolean);
const details = document.createElement("div");
details.className = "opportunities-extension-debug-details";
appendDebugBlock(details, "Request", lines.join("\n"));
appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata));
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
item.append(heading, details);
list.append(item);
});
panel.append(panelHeader, tabs, list);
panel.onkeydown = event => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeDebugPanel();
} else if (event.key === "Tab") {
const controls = [visibilityButton, closeButton, ...tabButtons.filter(button => button.tabIndex === 0), list, ...summaries];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus({ preventScroll: true });
}
}
};
if (focusedCategory) tabButtons.find(button => button.getAttribute("data-debug-category") === selectedCategory).focus({ preventScroll: true });
else if (focusedControl) focusTargets.get(focusedControl)?.focus({ preventScroll: true });
list.scrollTop = listScrollTop;
panel.scrollTop = scrollTop;
}
function createDebugVisibilityIcon(secretsVisible) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z");
pupil.setAttribute("cx", "12");
pupil.setAttribute("cy", "12");
pupil.setAttribute("r", "2.75");
[eye, pupil].forEach((shape) => {
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", "currentColor");
shape.setAttribute("stroke-linecap", "round");
shape.setAttribute("stroke-linejoin", "round");
shape.setAttribute("stroke-width", "1.7");
});
svg.append(eye, pupil);
if (secretsVisible) {
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
slash.setAttribute("d", "M4 4l16 16");
slash.setAttribute("fill", "none");
slash.setAttribute("stroke", "currentColor");
slash.setAttribute("stroke-linecap", "round");
slash.setAttribute("stroke-width", "1.9");
svg.append(slash);
}
return svg;
}
function appendDebugBlock(container, label, value) {
const block = document.createElement("section");
block.className = "opportunities-extension-debug-block";
block.setAttribute("data-direction", label.startsWith("Response") ? "response" : "request");
const heading = document.createElement("h3");
const content = document.createElement("pre");
heading.textContent = label;
appendJsonTokens(content, value);
block.append(heading, content);
container.append(block);
}
function appendJsonTokens(content, value) {
const text = String(value ?? "");
try { JSON.parse(text); } catch { content.textContent = text; return; }
// Highlight only already-masked display text; never interpret response content as HTML.
const tokens = [...text.matchAll(/"(?:[^"\\]|\\.)*"\s*:|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g)];
let offset = 0;
const append = (textValue, kind) => {
if (!textValue) return;
const span = document.createElement("span");
span.textContent = textValue;
if (kind) span.className = `opportunities-extension-debug-json-${kind}`;
content.append(span);
};
tokens.forEach(token => {
append(text.slice(offset, token.index));
const value = token[0];
const kind = value.startsWith('"') ? value.endsWith(":") ? "key" : "string"
: /^(true|false|null)$/.test(value) ? "literal" : "number";
append(value, kind);
offset = token.index + value.length;
});
append(text.slice(offset));
}
function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
return { toggleDebugPanel, closeDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime, getRequestCategory, getChronologicalRequests };
}