Add lightweight observed request explorer
Dieser Commit ist enthalten in:
@@ -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 = [];
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
<a class="button-link" href="../gvl-explorer/gvl-explorer.html">
|
||||
GVL-Explorer öffnen
|
||||
</a>
|
||||
<a class="button-link" href="../request-explorer/request-explorer.html">
|
||||
Request-Explorer öffnen
|
||||
</a>
|
||||
<a class="button-link" href="../analysis-dashboard/analysis-dashboard.html">
|
||||
Request-/Empfänger-Analyse öffnen
|
||||
</a>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VG-Observe Request-Explorer</title>
|
||||
<link rel="stylesheet" href="request-explorer.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="explorer">
|
||||
<header class="explorer-header">
|
||||
<a class="back-link" href="../dashboard/dashboard.html">Zurück zum Dashboard</a>
|
||||
<h1>Request-Explorer</h1>
|
||||
<p class="section-help">
|
||||
Diese Ansicht zeigt zuletzt technisch beobachtete Requests.
|
||||
</p>
|
||||
<dl class="request-overview" aria-label="Geladene Request-Uebersicht">
|
||||
<div>
|
||||
<dt>Geladene Einträge</dt>
|
||||
<dd id="request-loaded-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Zuletzt beobachtet</dt>
|
||||
<dd id="request-latest-seen">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section class="panel" aria-labelledby="request-list-title">
|
||||
<h2 id="request-list-title">Zuletzt beobachtete Requests</h2>
|
||||
<p id="request-empty" class="empty-state" hidden>
|
||||
Keine beobachteten Requests vorhanden.
|
||||
</p>
|
||||
<div id="request-content" hidden>
|
||||
<div class="request-list-wrap">
|
||||
<table class="request-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Letzte Beobachtung</th>
|
||||
<th scope="col">Empfänger-Origin</th>
|
||||
<th scope="col">Pfad</th>
|
||||
<th scope="col">Third-Party</th>
|
||||
<th scope="col">Consent-Parameter</th>
|
||||
<th scope="col">Consent-Korrelation</th>
|
||||
<th scope="col">SeenCount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="request-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section class="request-detail" aria-labelledby="request-detail-title">
|
||||
<h2 id="request-detail-title">Ausgewählter Request</h2>
|
||||
<div id="request-detail-summary"></div>
|
||||
</section>
|
||||
|
||||
<details class="inspector-details" open>
|
||||
<summary>Vollständiger JSON-Rohdatenauszug</summary>
|
||||
<pre id="request-detail-json"></pre>
|
||||
</details>
|
||||
<details class="inspector-details">
|
||||
<summary>correlation</summary>
|
||||
<pre id="request-detail-correlation"></pre>
|
||||
</details>
|
||||
<details class="inspector-details">
|
||||
<summary>requestFingerprintSource</summary>
|
||||
<pre id="request-detail-fingerprint-source"></pre>
|
||||
</details>
|
||||
<details class="inspector-details">
|
||||
<summary>Technische Rohfelder</summary>
|
||||
<pre id="request-detail-technical-fields"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="request-explorer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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";
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren