diff --git a/src/request-explorer/request-explorer.css b/src/request-explorer/request-explorer.css
index 39e588f..f72909e 100644
--- a/src/request-explorer/request-explorer.css
+++ b/src/request-explorer/request-explorer.css
@@ -164,6 +164,46 @@ th {
overflow-wrap: anywhere;
}
+.request-groups {
+ display: grid;
+ gap: 16px;
+}
+
+.request-context-card {
+ padding: 14px;
+ border: 1px solid #334155;
+ border-radius: 4px;
+ background: #172033;
+}
+
+.request-context-summary {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ margin: 0 0 12px;
+}
+
+.request-context-summary div {
+ min-width: 0;
+ padding: 9px 10px;
+ border: 1px solid #334155;
+ border-radius: 4px;
+ background: #1f2937;
+}
+
+.request-context-summary dt {
+ margin: 0 0 5px;
+ font-size: 12px;
+ color: #cbd5e1;
+}
+
+.request-context-summary dd {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+
.request-detail {
margin-top: 18px;
}
@@ -222,7 +262,17 @@ th {
grid-template-columns: 1fr;
}
+ .request-context-summary {
+ grid-template-columns: 1fr;
+ }
+
.inspector-table th {
width: 150px;
}
}
+
+@media (min-width: 641px) and (max-width: 980px) {
+ .request-context-summary {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/src/request-explorer/request-explorer.html b/src/request-explorer/request-explorer.html
index 6368260..227c504 100644
--- a/src/request-explorer/request-explorer.html
+++ b/src/request-explorer/request-explorer.html
@@ -32,22 +32,7 @@
Keine beobachteten Requests vorhanden.
-
-
-
-
- | Letzte Beobachtung |
- Empfänger-Origin |
- Pfad |
- Third-Party |
- Consent-Parameter |
- Consent-Korrelation |
- SeenCount |
-
-
-
-
-
+
Ausgewählter Request
diff --git a/src/request-explorer/request-explorer.js b/src/request-explorer/request-explorer.js
index bbc8c8f..5be45cd 100644
--- a/src/request-explorer/request-explorer.js
+++ b/src/request-explorer/request-explorer.js
@@ -4,7 +4,7 @@ const requestLoadedCount = document.getElementById("request-loaded-count");
const requestLatestSeen = document.getElementById("request-latest-seen");
const requestEmpty = document.getElementById("request-empty");
const requestContent = document.getElementById("request-content");
-const requestList = document.getElementById("request-list");
+const requestGroups = document.getElementById("request-groups");
const requestDetailSummary = document.getElementById("request-detail-summary");
const requestDetailJson = document.getElementById("request-detail-json");
const requestDetailCorrelation = document.getElementById(
@@ -18,6 +18,7 @@ const requestDetailTechnicalFields = document.getElementById(
);
let observedRequests = [];
+let observedRequestGroups = [];
let selectedRequestFingerprint = null;
document.addEventListener("DOMContentLoaded", async () => {
@@ -35,6 +36,7 @@ async function renderObservedRequests() {
}
observedRequests = result.observedRequests ?? [];
+ observedRequestGroups = buildObservedRequestGroups(observedRequests);
updateOverview();
if (observedRequests.length === 0) {
@@ -50,10 +52,11 @@ async function renderObservedRequests() {
observedRequests[0]?.requestFingerprint ?? null;
}
- renderRequestList();
+ renderRequestGroups();
renderSelectedRequest();
} catch (error) {
observedRequests = [];
+ observedRequestGroups = [];
updateOverview();
requestEmpty.hidden = false;
requestContent.hidden = true;
@@ -72,57 +75,141 @@ function updateOverview() {
function renderNoObservedRequests() {
selectedRequestFingerprint = null;
- requestList.textContent = "";
+ requestGroups.textContent = "";
clearRequestDetails();
requestEmpty.hidden = false;
requestContent.hidden = true;
requestEmpty.textContent = "Keine beobachteten Requests vorhanden.";
}
-function renderRequestList() {
- requestList.textContent = "";
+function renderRequestGroups() {
+ requestGroups.textContent = "";
- for (const observedRequest of observedRequests) {
- const row = document.createElement("tr");
- const isSelected =
- observedRequest?.requestFingerprint === selectedRequestFingerprint;
-
- row.className = isSelected ? "is-selected" : "";
- row.tabIndex = 0;
- row.setAttribute("role", "button");
- row.setAttribute("aria-pressed", isSelected ? "true" : "false");
-
- row.addEventListener("click", () => {
- selectObservedRequest(observedRequest?.requestFingerprint ?? null);
- });
-
- row.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- selectObservedRequest(observedRequest?.requestFingerprint ?? null);
- }
- });
-
- appendListCell(row, formatNullable(observedRequest?.lastSeenAt));
- appendListCell(
- row,
- formatNullable(observedRequest?.request?.origin),
- "origin-cell"
- );
- appendListCell(
- row,
- formatNullable(observedRequest?.request?.pathname),
- "path-cell"
- );
- appendListCell(row, formatThirdParty(observedRequest?.request?.thirdParty));
- appendListCell(row, formatYesNo(hasConsentParams(observedRequest)));
- appendListCell(row, formatYesNo(hasConsentCorrelation(observedRequest)));
- appendListCell(row, formatNullable(observedRequest?.seenCount), "numeric");
-
- requestList.append(row);
+ for (const group of observedRequestGroups) {
+ requestGroups.append(buildRequestGroupElement(group));
}
}
+function buildRequestGroupElement(group) {
+ const section = document.createElement("section");
+ const title = document.createElement("h3");
+ const context = document.createElement("dl");
+ const tableWrap = document.createElement("div");
+ const table = document.createElement("table");
+ const head = document.createElement("thead");
+ const headRow = document.createElement("tr");
+ const body = document.createElement("tbody");
+
+ section.className = "request-context-card";
+ title.textContent = "Technisch beobachteter Vorgang";
+ context.className = "request-context-summary";
+ tableWrap.className = "request-list-wrap";
+ table.className = "request-list";
+
+ appendDefinition(context, "Seiten-/Dokumentkontext", group.contextLabel);
+ appendDefinition(context, "Tab / Frame", group.tabFrameLabel);
+ appendDefinition(context, "Zeitraum", group.periodLabel);
+ appendDefinition(context, "Requests", String(group.requests.length));
+ appendDefinition(
+ context,
+ "Empfaenger-Origins",
+ String(group.recipientOriginCount)
+ );
+ appendDefinition(
+ context,
+ "Third-Party-Requests",
+ String(group.thirdPartyCount)
+ );
+ appendDefinition(
+ context,
+ "Requests mit Consent-Parametern",
+ String(group.consentParamCount)
+ );
+ appendDefinition(
+ context,
+ "Requests mit Consent-Korrelation",
+ String(group.consentCorrelationCount)
+ );
+
+ [
+ "Letzte Beobachtung",
+ "Empfaenger-Origin",
+ "Pfad",
+ "Third-Party",
+ "Consent-Parameter",
+ "Consent-Korrelation",
+ "SeenCount"
+ ].forEach((label) => {
+ const cell = document.createElement("th");
+
+ cell.scope = "col";
+ cell.textContent = label;
+ headRow.append(cell);
+ });
+
+ for (const observedRequest of group.requests) {
+ body.append(buildRequestRow(observedRequest));
+ }
+
+ head.append(headRow);
+ table.append(head, body);
+ tableWrap.append(table);
+ section.append(title, context, tableWrap);
+
+ return section;
+}
+
+function appendDefinition(list, label, value) {
+ const item = document.createElement("div");
+ const term = document.createElement("dt");
+ const description = document.createElement("dd");
+
+ term.textContent = label;
+ description.textContent = value;
+ item.append(term, description);
+ list.append(item);
+}
+
+function buildRequestRow(observedRequest) {
+ const row = document.createElement("tr");
+ const isSelected =
+ observedRequest?.requestFingerprint === selectedRequestFingerprint;
+
+ row.className = isSelected ? "is-selected" : "";
+ row.tabIndex = 0;
+ row.setAttribute("role", "button");
+ row.setAttribute("aria-pressed", isSelected ? "true" : "false");
+
+ row.addEventListener("click", () => {
+ selectObservedRequest(observedRequest?.requestFingerprint ?? null);
+ });
+
+ row.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ selectObservedRequest(observedRequest?.requestFingerprint ?? null);
+ }
+ });
+
+ appendListCell(row, formatNullable(observedRequest?.lastSeenAt));
+ appendListCell(
+ row,
+ formatNullable(observedRequest?.request?.origin),
+ "origin-cell"
+ );
+ appendListCell(
+ row,
+ formatNullable(observedRequest?.request?.pathname),
+ "path-cell"
+ );
+ appendListCell(row, formatThirdParty(observedRequest?.request?.thirdParty));
+ appendListCell(row, formatYesNo(hasConsentParams(observedRequest)));
+ appendListCell(row, formatYesNo(hasConsentCorrelation(observedRequest)));
+ appendListCell(row, formatNullable(observedRequest?.seenCount), "numeric");
+
+ return row;
+}
+
function appendListCell(row, value, className) {
const cell = document.createElement("td");
@@ -136,7 +223,7 @@ function appendListCell(row, value, className) {
function selectObservedRequest(requestFingerprint) {
selectedRequestFingerprint = requestFingerprint;
- renderRequestList();
+ renderRequestGroups();
renderSelectedRequest();
}
@@ -152,6 +239,10 @@ function renderSelectedRequest() {
renderSummaryTable([
["Letzte Beobachtung", formatNullable(observedRequest?.lastSeenAt)],
["Erste Beobachtung", formatNullable(observedRequest?.firstSeenAt)],
+ ["Beobachtet um", formatNullable(getObservedAt(observedRequest))],
+ ["Seiten-/Dokumentkontext", getPageContextLabel(observedRequest)],
+ ["Tab-ID", formatUnknown(getTabId(observedRequest))],
+ ["Frame-ID", formatUnknown(getFrameId(observedRequest))],
["Empfaenger-Origin", formatNullable(observedRequest?.request?.origin)],
["Pfad", formatNullable(observedRequest?.request?.pathname)],
["Methode", formatNullable(observedRequest?.request?.method)],
@@ -251,18 +342,226 @@ function buildTechnicalFieldExtract(observedRequest) {
schemaVersion: observedRequest?.schemaVersion ?? null,
recordedAt: observedRequest?.recordedAt ?? null,
recordingSource: observedRequest?.recordingSource ?? null,
+ observedAt: observedRequest?.observedAt ?? null,
firstSeenAt: observedRequest?.firstSeenAt ?? null,
lastSeenAt: observedRequest?.lastSeenAt ?? null,
seenCount: observedRequest?.seenCount ?? null,
+ requestFingerprint: observedRequest?.requestFingerprint ?? null,
+ requestFingerprintSource: observedRequest?.requestFingerprintSource ?? null,
context: observedRequest?.context ?? null,
request: observedRequest?.request ?? null,
- consentParams: observedRequest?.consentParams ?? null
+ consentParams: observedRequest?.consentParams ?? null,
+ correlation: observedRequest?.correlation ?? null
};
}
+function buildObservedRequestGroups(requests) {
+ const groupsByKey = new Map();
+
+ for (const observedRequest of requests) {
+ const key = buildObservedRequestGroupKey(observedRequest);
+ let group = groupsByKey.get(key);
+
+ if (!group) {
+ group = {
+ key,
+ contextLabel: getPageContextLabel(observedRequest),
+ tabFrameLabel: buildTabFrameLabel(observedRequest),
+ requests: []
+ };
+ groupsByKey.set(key, group);
+ }
+
+ group.requests.push(observedRequest);
+ }
+
+ return Array.from(groupsByKey.values())
+ .map(enrichObservedRequestGroup)
+ .sort((left, right) => {
+ return compareTimestampValues(right.lastSeenAt, left.lastSeenAt);
+ });
+}
+
+function buildObservedRequestGroupKey(observedRequest) {
+ return [
+ `tab:${formatKeyValue(getTabId(observedRequest))}`,
+ `frame:${formatKeyValue(getFrameId(observedRequest))}`,
+ `page:${getPageContextValue(observedRequest) ?? "unknown"}`
+ ].join("|");
+}
+
+function enrichObservedRequestGroup(group) {
+ const sortedRequests = [...group.requests].sort((left, right) => {
+ return compareTimestampValues(getObservedAt(left), getObservedAt(right));
+ });
+ const recipientOrigins = new Set(
+ sortedRequests
+ .map((observedRequest) => observedRequest?.request?.origin ?? null)
+ .filter((origin) => origin !== null && origin !== undefined && origin !== "")
+ );
+ let firstSeenAt = null;
+ let lastSeenAt = null;
+
+ for (const observedRequest of sortedRequests) {
+ firstSeenAt = pickTimestamp(firstSeenAt, observedRequest?.firstSeenAt, "min");
+ lastSeenAt = pickTimestamp(lastSeenAt, observedRequest?.lastSeenAt, "max");
+ }
+
+ return {
+ ...group,
+ requests: sortedRequests,
+ firstSeenAt,
+ lastSeenAt,
+ periodLabel: buildPeriodLabel(firstSeenAt, lastSeenAt),
+ recipientOriginCount: recipientOrigins.size,
+ thirdPartyCount: sortedRequests.filter((observedRequest) => {
+ return observedRequest?.request?.thirdParty === true;
+ }).length,
+ consentParamCount: sortedRequests.filter(hasConsentParams).length,
+ consentCorrelationCount: sortedRequests.filter(hasConsentCorrelation).length
+ };
+}
+
+function getPageContextLabel(observedRequest) {
+ return formatUnknown(getPageContextValue(observedRequest));
+}
+
+function getPageContextValue(observedRequest) {
+ return (
+ getFirstPresentValue(
+ observedRequest,
+ [
+ "pageUrl",
+ "documentUrl",
+ "initiator",
+ "sourceUrl",
+ "context.pageUrl",
+ "context.documentUrl",
+ "context.initiator",
+ "context.sourceUrl",
+ "request.pageUrl",
+ "request.documentUrl",
+ "request.initiator",
+ "request.sourceUrl"
+ ]
+ ) ?? null
+ );
+}
+
+function getTabId(observedRequest) {
+ return getFirstPresentValue(observedRequest, ["tabId", "context.tabId"]);
+}
+
+function getFrameId(observedRequest) {
+ return getFirstPresentValue(observedRequest, ["frameId", "context.frameId"]);
+}
+
+function getObservedAt(observedRequest) {
+ return getFirstPresentValue(observedRequest, [
+ "observedAt",
+ "firstSeenAt",
+ "lastSeenAt"
+ ]);
+}
+
+function getFirstPresentValue(source, paths) {
+ for (const path of paths) {
+ const value = getPathValue(source, path);
+
+ if (value !== null && value !== undefined && value !== "") {
+ return value;
+ }
+ }
+
+ return null;
+}
+
+function getPathValue(source, path) {
+ return path.split(".").reduce((value, key) => {
+ if (value === null || value === undefined || typeof value !== "object") {
+ return undefined;
+ }
+
+ return value[key];
+ }, source);
+}
+
+function buildTabFrameLabel(observedRequest) {
+ return `Tab ${formatUnknown(getTabId(observedRequest))} / Frame ${formatUnknown(
+ getFrameId(observedRequest)
+ )}`;
+}
+
+function pickTimestamp(current, candidate, direction) {
+ if (!current) {
+ return candidate ?? null;
+ }
+
+ if (!candidate) {
+ return current;
+ }
+
+ const comparison = compareTimestampValues(candidate, current);
+
+ if (direction === "min") {
+ return comparison < 0 ? candidate : current;
+ }
+
+ return comparison > 0 ? candidate : current;
+}
+
+function compareTimestampValues(left, right) {
+ const leftTime = Date.parse(left ?? "");
+ const rightTime = Date.parse(right ?? "");
+
+ if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) {
+ return 0;
+ }
+
+ if (Number.isNaN(leftTime)) {
+ return -1;
+ }
+
+ if (Number.isNaN(rightTime)) {
+ return 1;
+ }
+
+ return leftTime - rightTime;
+}
+
+function buildPeriodLabel(firstSeenAt, lastSeenAt) {
+ if (!firstSeenAt && !lastSeenAt) {
+ return "unbekannt";
+ }
+
+ if (firstSeenAt === lastSeenAt || !lastSeenAt) {
+ return formatUnknown(firstSeenAt);
+ }
+
+ if (!firstSeenAt) {
+ return formatUnknown(lastSeenAt);
+ }
+
+ return `${firstSeenAt} bis ${lastSeenAt}`;
+}
+
+function formatKeyValue(value) {
+ return value === null || value === undefined || value === ""
+ ? "unknown"
+ : value;
+}
+
+function formatUnknown(value) {
+ if (value === null || value === undefined || value === "") {
+ return "unbekannt";
+ }
+
+ return String(value);
+}
+
function formatNullable(value) {
if (value === null || value === undefined || value === "") {
- return "-";
+ return "unbekannt";
}
return String(value);
@@ -281,5 +580,5 @@ function formatThirdParty(value) {
}
function formatYesNo(value) {
- return value ? "ja" : "nein";
+ return value ? "vorhanden" : "nicht vorhanden";
}