diff --git a/src/background.js b/src/background.js
index cdb2b8e..39108da 100644
--- a/src/background.js
+++ b/src/background.js
@@ -81,6 +81,10 @@ async function handleVendorGetMessage(message, sender) {
return handleListRecentConsentStatesMessage();
}
+ if (message.type === "list_recent_observed_requests") {
+ return handleListRecentObservedRequestsMessage();
+ }
+
if (message.type === "purge_unlocked_evidence_records") {
return handlePurgeUnlockedEvidenceRecordsMessage();
}
@@ -316,6 +320,16 @@ async function handleListRecentConsentStatesMessage() {
};
}
+async function handleListRecentObservedRequestsMessage() {
+ const db = await openVendorGetDb();
+ const observedRequests = await listRecentObservedRequests(db, 50);
+
+ return {
+ success: true,
+ observedRequests
+ };
+}
+
function getLatestConsentState(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(["consent_states"], "readonly");
@@ -368,6 +382,38 @@ function listRecentConsentStates(db, limit) {
});
}
+function listRecentObservedRequests(db, limit) {
+ return new Promise((resolve, reject) => {
+ const observedRequests = [];
+ const tx = db.transaction(
+ [VENDORGET_STORE_NAMES.observedRequests],
+ "readonly"
+ );
+ const requestsStore = tx.objectStore(VENDORGET_STORE_NAMES.observedRequests);
+ const lastSeenAtIndex = requestsStore.index("lastSeenAt");
+ const cursorRequest = lastSeenAtIndex.openCursor(null, "prev");
+
+ cursorRequest.onerror = () => reject(cursorRequest.error);
+ cursorRequest.onsuccess = () => {
+ const cursor = cursorRequest.result;
+
+ if (!cursor || observedRequests.length >= limit) {
+ return;
+ }
+
+ observedRequests.push(cursor.value);
+
+ if (observedRequests.length < limit) {
+ cursor.continue();
+ }
+ };
+
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error);
+ tx.oncomplete = () => resolve(observedRequests);
+ });
+}
+
function listRecentGvlSnapshots(db, limit) {
return new Promise((resolve, reject) => {
const snapshots = [];
diff --git a/src/dashboard/dashboard.html b/src/dashboard/dashboard.html
index 07beca2..30c0e1b 100644
--- a/src/dashboard/dashboard.html
+++ b/src/dashboard/dashboard.html
@@ -102,6 +102,9 @@
GVL-Explorer öffnen
+
+ Request-Explorer öffnen
+
Request-/Empfänger-Analyse öffnen
diff --git a/src/request-explorer/request-explorer.css b/src/request-explorer/request-explorer.css
new file mode 100644
index 0000000..39e588f
--- /dev/null
+++ b/src/request-explorer/request-explorer.css
@@ -0,0 +1,228 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ font-family: Arial, sans-serif;
+ color: #e5edf5;
+ background: #111827;
+}
+
+.explorer {
+ width: min(1180px, 100%);
+ margin: 0 auto;
+ padding: 24px;
+}
+
+.explorer-header {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #334155;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: 24px;
+ font-weight: 700;
+}
+
+h2 {
+ margin-bottom: 12px;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+h3 {
+ margin-top: 14px;
+ margin-bottom: 8px;
+ font-size: 13px;
+ color: #cbd5e1;
+}
+
+p {
+ max-width: 760px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: #cbd5e1;
+}
+
+.back-link {
+ width: fit-content;
+ color: #bfdbfe;
+ font-size: 13px;
+}
+
+.panel {
+ margin-bottom: 22px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid #334155;
+}
+
+.section-help {
+ margin-bottom: 4px;
+}
+
+.request-overview {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 220px));
+ gap: 10px;
+ margin: 8px 0 0;
+}
+
+.request-overview div {
+ min-width: 0;
+ padding: 10px 12px;
+ border: 1px solid #334155;
+ border-radius: 4px;
+ background: #1f2937;
+}
+
+.request-overview dt {
+ margin: 0 0 6px;
+ font-size: 12px;
+ color: #cbd5e1;
+}
+
+.request-overview dd {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+ background: #1f2937;
+}
+
+th,
+td {
+ padding: 10px 12px;
+ border: 1px solid #334155;
+ text-align: left;
+}
+
+th {
+ color: #cbd5e1;
+ font-weight: 700;
+ background: #182231;
+}
+
+.request-list-wrap {
+ width: 100%;
+ overflow-x: auto;
+ border: 1px solid #334155;
+ border-radius: 4px;
+ background: #1f2937;
+}
+
+.request-list {
+ min-width: 980px;
+ border: 0;
+}
+
+.request-list th,
+.request-list td {
+ padding: 8px 10px;
+ vertical-align: top;
+}
+
+.request-list tbody tr {
+ cursor: pointer;
+}
+
+.request-list tbody tr:hover,
+.request-list tbody tr:focus {
+ outline: 0;
+ background: #263449;
+}
+
+.request-list tbody tr.is-selected {
+ background: #1e3a5f;
+ box-shadow: inset 3px 0 0 #60a5fa;
+}
+
+.request-list .numeric {
+ text-align: right;
+}
+
+.request-list .path-cell,
+.request-list .origin-cell {
+ max-width: 260px;
+ overflow-wrap: anywhere;
+}
+
+.request-detail {
+ margin-top: 18px;
+}
+
+.inspector-table th,
+.inspector-table td {
+ vertical-align: top;
+}
+
+.inspector-table th {
+ width: 230px;
+}
+
+.inspector-table .inspector-value {
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ user-select: text;
+}
+
+.empty-state {
+ padding: 10px 12px;
+ border: 1px solid #334155;
+ border-radius: 4px;
+ background: #1f2937;
+}
+
+.inspector-details {
+ margin-top: 12px;
+ font-size: 13px;
+ color: #cbd5e1;
+}
+
+.inspector-details summary {
+ cursor: pointer;
+}
+
+.inspector-details pre {
+ max-height: 260px;
+ margin: 10px 0 0;
+ padding: 10px 12px;
+ overflow: auto;
+ border: 1px solid #334155;
+ border-radius: 4px;
+ color: #e5edf5;
+ background: #0f172a;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 640px) {
+ .explorer {
+ padding: 16px;
+ }
+
+ .request-overview {
+ grid-template-columns: 1fr;
+ }
+
+ .inspector-table th {
+ width: 150px;
+ }
+}
diff --git a/src/request-explorer/request-explorer.html b/src/request-explorer/request-explorer.html
new file mode 100644
index 0000000..6368260
--- /dev/null
+++ b/src/request-explorer/request-explorer.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+ VG-Observe Request-Explorer
+
+
+
+
+
+
+
+ Zuletzt beobachtete Requests
+
+ Keine beobachteten Requests vorhanden.
+
+
+
+
+
+
+ | Letzte Beobachtung |
+ Empfänger-Origin |
+ Pfad |
+ Third-Party |
+ Consent-Parameter |
+ Consent-Korrelation |
+ SeenCount |
+
+
+
+
+
+
+
+ Ausgewählter Request
+
+
+
+
+ Vollständiger JSON-Rohdatenauszug
+
+
+
+ correlation
+
+
+
+ requestFingerprintSource
+
+
+
+ Technische Rohfelder
+
+
+
+
+
+
+
+
+
diff --git a/src/request-explorer/request-explorer.js b/src/request-explorer/request-explorer.js
new file mode 100644
index 0000000..bbc8c8f
--- /dev/null
+++ b/src/request-explorer/request-explorer.js
@@ -0,0 +1,285 @@
+"use strict";
+
+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 requestDetailSummary = document.getElementById("request-detail-summary");
+const requestDetailJson = document.getElementById("request-detail-json");
+const requestDetailCorrelation = document.getElementById(
+ "request-detail-correlation"
+);
+const requestDetailFingerprintSource = document.getElementById(
+ "request-detail-fingerprint-source"
+);
+const requestDetailTechnicalFields = document.getElementById(
+ "request-detail-technical-fields"
+);
+
+let observedRequests = [];
+let selectedRequestFingerprint = null;
+
+document.addEventListener("DOMContentLoaded", async () => {
+ await renderObservedRequests();
+});
+
+async function renderObservedRequests() {
+ try {
+ const result = await browser.runtime.sendMessage({
+ type: "list_recent_observed_requests"
+ });
+
+ if (!result?.success) {
+ throw new Error(result?.error ?? "list_recent_observed_requests_failed");
+ }
+
+ observedRequests = result.observedRequests ?? [];
+ updateOverview();
+
+ if (observedRequests.length === 0) {
+ renderNoObservedRequests();
+ return;
+ }
+
+ requestEmpty.hidden = true;
+ requestContent.hidden = false;
+
+ if (!findObservedRequest(selectedRequestFingerprint)) {
+ selectedRequestFingerprint =
+ observedRequests[0]?.requestFingerprint ?? null;
+ }
+
+ renderRequestList();
+ renderSelectedRequest();
+ } catch (error) {
+ observedRequests = [];
+ updateOverview();
+ requestEmpty.hidden = false;
+ requestContent.hidden = true;
+ requestEmpty.textContent =
+ "Beobachtete Requests konnten nicht geladen werden.";
+ console.warn("VG-Observe request list failed", error);
+ }
+}
+
+function updateOverview() {
+ requestLoadedCount.textContent = String(observedRequests.length);
+ requestLatestSeen.textContent = formatNullable(
+ observedRequests[0]?.lastSeenAt ?? null
+ );
+}
+
+function renderNoObservedRequests() {
+ selectedRequestFingerprint = null;
+ requestList.textContent = "";
+ clearRequestDetails();
+ requestEmpty.hidden = false;
+ requestContent.hidden = true;
+ requestEmpty.textContent = "Keine beobachteten Requests vorhanden.";
+}
+
+function renderRequestList() {
+ requestList.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);
+ }
+}
+
+function appendListCell(row, value, className) {
+ const cell = document.createElement("td");
+
+ if (className) {
+ cell.className = className;
+ }
+
+ cell.textContent = value;
+ row.append(cell);
+}
+
+function selectObservedRequest(requestFingerprint) {
+ selectedRequestFingerprint = requestFingerprint;
+ renderRequestList();
+ renderSelectedRequest();
+}
+
+function renderSelectedRequest() {
+ const observedRequest = findObservedRequest(selectedRequestFingerprint);
+
+ clearRequestDetails();
+
+ if (!observedRequest) {
+ return;
+ }
+
+ renderSummaryTable([
+ ["Letzte Beobachtung", formatNullable(observedRequest?.lastSeenAt)],
+ ["Erste Beobachtung", formatNullable(observedRequest?.firstSeenAt)],
+ ["Empfaenger-Origin", formatNullable(observedRequest?.request?.origin)],
+ ["Pfad", formatNullable(observedRequest?.request?.pathname)],
+ ["Methode", formatNullable(observedRequest?.request?.method)],
+ ["Request-Typ", formatNullable(observedRequest?.request?.type)],
+ ["Third-Party", formatThirdParty(observedRequest?.request?.thirdParty)],
+ ["Consent-Parameter vorhanden", formatYesNo(hasConsentParams(observedRequest))],
+ [
+ "Consent-Korrelation vorhanden",
+ formatYesNo(hasConsentCorrelation(observedRequest))
+ ],
+ ["SeenCount", formatNullable(observedRequest?.seenCount)],
+ [
+ "Request-Fingerprint",
+ formatNullable(observedRequest?.requestFingerprint)
+ ]
+ ]);
+
+ requestDetailJson.textContent = JSON.stringify(observedRequest, null, 2);
+ requestDetailCorrelation.textContent = JSON.stringify(
+ observedRequest?.correlation ?? null,
+ null,
+ 2
+ );
+ requestDetailFingerprintSource.textContent = JSON.stringify(
+ observedRequest?.requestFingerprintSource ?? null,
+ null,
+ 2
+ );
+ requestDetailTechnicalFields.textContent = JSON.stringify(
+ buildTechnicalFieldExtract(observedRequest),
+ null,
+ 2
+ );
+}
+
+function renderSummaryTable(rows) {
+ const table = document.createElement("table");
+ const body = document.createElement("tbody");
+
+ table.className = "inspector-table";
+
+ for (const [label, value] of rows) {
+ const row = document.createElement("tr");
+ const labelCell = document.createElement("th");
+ const valueCell = document.createElement("td");
+
+ labelCell.scope = "row";
+ labelCell.textContent = label;
+ valueCell.className = "inspector-value";
+ valueCell.textContent = value;
+ row.append(labelCell, valueCell);
+ body.append(row);
+ }
+
+ table.append(body);
+ requestDetailSummary.append(table);
+}
+
+function clearRequestDetails() {
+ requestDetailSummary.textContent = "";
+ requestDetailJson.textContent = "";
+ requestDetailCorrelation.textContent = "";
+ requestDetailFingerprintSource.textContent = "";
+ requestDetailTechnicalFields.textContent = "";
+}
+
+function findObservedRequest(requestFingerprint) {
+ if (!requestFingerprint) {
+ return null;
+ }
+
+ return (
+ observedRequests.find((observedRequest) => {
+ return observedRequest?.requestFingerprint === requestFingerprint;
+ }) ?? null
+ );
+}
+
+function hasConsentParams(observedRequest) {
+ const consentParams = observedRequest?.consentParams;
+
+ if (!consentParams || typeof consentParams !== "object") {
+ return false;
+ }
+
+ return Object.values(consentParams).some((value) => {
+ return value !== null && value !== undefined && value !== "";
+ });
+}
+
+function hasConsentCorrelation(observedRequest) {
+ return Boolean(observedRequest?.correlation?.stateFingerprint);
+}
+
+function buildTechnicalFieldExtract(observedRequest) {
+ return {
+ schemaVersion: observedRequest?.schemaVersion ?? null,
+ recordedAt: observedRequest?.recordedAt ?? null,
+ recordingSource: observedRequest?.recordingSource ?? null,
+ firstSeenAt: observedRequest?.firstSeenAt ?? null,
+ lastSeenAt: observedRequest?.lastSeenAt ?? null,
+ seenCount: observedRequest?.seenCount ?? null,
+ context: observedRequest?.context ?? null,
+ request: observedRequest?.request ?? null,
+ consentParams: observedRequest?.consentParams ?? null
+ };
+}
+
+function formatNullable(value) {
+ if (value === null || value === undefined || value === "") {
+ return "-";
+ }
+
+ return String(value);
+}
+
+function formatThirdParty(value) {
+ if (value === true) {
+ return "ja";
+ }
+
+ if (value === false) {
+ return "nein";
+ }
+
+ return "unbekannt";
+}
+
+function formatYesNo(value) {
+ return value ? "ja" : "nein";
+}