From 08679f6e0091753db221202ba4f5412d4b94419d Mon Sep 17 00:00:00 2001 From: jensmohr Date: Wed, 27 May 2026 16:09:21 +0200 Subject: [PATCH] Restructure VG-Observe into focused explorer views --- src/analysis-dashboard/analysis-dashboard.css | 123 +++ .../analysis-dashboard.html | 76 ++ src/analysis-dashboard/analysis-dashboard.js | 76 ++ src/background.js | 876 +++++++++++++++++- src/consent-explorer/consent-explorer.css | 230 +++++ src/consent-explorer/consent-explorer.html | 77 ++ src/consent-explorer/consent-explorer.js | 517 +++++++++++ src/dashboard/dashboard.css | 95 +- src/dashboard/dashboard.html | 121 +-- src/dashboard/dashboard.js | 434 ++------- src/gvl-explorer/gvl-explorer.css | 201 ++++ src/gvl-explorer/gvl-explorer.html | 68 ++ src/gvl-explorer/gvl-explorer.js | 273 ++++++ 13 files changed, 2653 insertions(+), 514 deletions(-) create mode 100644 src/analysis-dashboard/analysis-dashboard.css create mode 100644 src/analysis-dashboard/analysis-dashboard.html create mode 100644 src/analysis-dashboard/analysis-dashboard.js create mode 100644 src/consent-explorer/consent-explorer.css create mode 100644 src/consent-explorer/consent-explorer.html create mode 100644 src/consent-explorer/consent-explorer.js create mode 100644 src/gvl-explorer/gvl-explorer.css create mode 100644 src/gvl-explorer/gvl-explorer.html create mode 100644 src/gvl-explorer/gvl-explorer.js diff --git a/src/analysis-dashboard/analysis-dashboard.css b/src/analysis-dashboard/analysis-dashboard.css new file mode 100644 index 0000000..400a278 --- /dev/null +++ b/src/analysis-dashboard/analysis-dashboard.css @@ -0,0 +1,123 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + font-family: Arial, sans-serif; + color: #e5edf5; + background: #111827; +} + +.analysis-dashboard { + width: min(1040px, 100%); + margin: 0 auto; + padding: 24px; +} + +.analysis-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-bottom: 8px; + font-size: 13px; + color: #e5edf5; +} + +p { + max-width: 760px; + font-size: 13px; + line-height: 1.5; + color: #cbd5e1; +} + +.back-link { + width: fit-content; + color: #bfdbfe; + font-size: 13px; +} + +.analysis-status { + min-height: 18px; + font-size: 13px; + color: #cbd5e1; +} + +.panel { + margin-bottom: 22px; + padding-bottom: 20px; + border-bottom: 1px solid #334155; +} + +.status-grid, +.area-grid { + display: grid; + gap: 12px; + margin: 0; +} + +.status-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.area-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.status-grid div, +.area-grid article { + min-width: 0; + padding: 12px; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.status-grid dt { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.35; + color: #cbd5e1; +} + +.status-grid dd { + margin: 0; + font-size: 18px; + font-weight: 700; + overflow-wrap: anywhere; +} + +@media (max-width: 760px) { + .analysis-dashboard { + padding: 16px; + } + + .status-grid, + .area-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/analysis-dashboard/analysis-dashboard.html b/src/analysis-dashboard/analysis-dashboard.html new file mode 100644 index 0000000..3c1fcc0 --- /dev/null +++ b/src/analysis-dashboard/analysis-dashboard.html @@ -0,0 +1,76 @@ + + + + + + VG-Observe Analyse-Dashboard + + + +
+
+ Zurück zum Dashboard +

Analyse-Dashboard

+

+ Diese Ansicht bereitet technische Prüfungen zwischen + Consent-Zuständen, Vendorlisten und beobachteten Requests vor. + Aktuell werden nur vorhandene Datenbestände und vorbereitete + Analysebereiche angezeigt. +

+
+ Lade Datenbestände +
+
+ +
+

Datenbestände

+
+
+
Anzahl Consent States
+ +
+
+
Anzahl Observed Requests
+
-
+
+
+
Lokal gespeicherte Vendorlisten
+
-
+
+
+
Lokal aktuelle Vendorlisten-Version
+
-
+
+
+
Analyse-Engine
+
noch nicht aktiv
+
+
+
+ +
+

Vorbereitete Analysebereiche

+
+
+

Consent ↔ Vendorliste

+

Analyse noch nicht ausgeführt.

+
+
+

Consent ↔ beobachtete Requests

+

Analyse noch nicht ausgeführt.

+
+
+

Request-Hosts ↔ bekannte Vendoren

+

Keine erkennbare Zuordnung berechnet. Analyse noch nicht ausgeführt.

+
+
+

Potenziell erklärungsbedürftige technische Diskrepanzen

+

Keine Bewertung vorgenommen. Analyse noch nicht ausgeführt.

+
+
+
+
+ + + + diff --git a/src/analysis-dashboard/analysis-dashboard.js b/src/analysis-dashboard/analysis-dashboard.js new file mode 100644 index 0000000..3185c70 --- /dev/null +++ b/src/analysis-dashboard/analysis-dashboard.js @@ -0,0 +1,76 @@ +"use strict"; + +const analysisStatus = document.getElementById("analysis-status"); +const summaryConsentStates = document.getElementById("summary-consent-states"); +const summaryObservedRequests = document.getElementById( + "summary-observed-requests" +); +const summaryGvlSnapshots = document.getElementById("summary-gvl-snapshots"); +const summaryCurrentGvlVersion = document.getElementById( + "summary-current-gvl-version" +); + +document.addEventListener("DOMContentLoaded", async () => { + await renderAnalysisSummary(); +}); + +async function renderAnalysisSummary() { + try { + const [evidenceStatus, gvlStatus] = await Promise.all([ + getEvidenceRetentionStatus(), + getLatestGvlUpdateStatus() + ]); + + const storeCounts = evidenceStatus.storeCounts ?? {}; + const status = gvlStatus.status ?? {}; + + summaryConsentStates.textContent = String(storeCounts.consent_states ?? 0); + summaryObservedRequests.textContent = String( + storeCounts.observed_requests ?? 0 + ); + summaryGvlSnapshots.textContent = String(storeCounts.gvl_snapshots ?? 0); + summaryCurrentGvlVersion.textContent = formatNullable( + status.latestLocalVendorListVersion ?? status.currentVendorListVersion + ); + analysisStatus.textContent = "Datenbestände geladen"; + } catch (error) { + summaryConsentStates.textContent = "-"; + summaryObservedRequests.textContent = "-"; + summaryGvlSnapshots.textContent = "-"; + summaryCurrentGvlVersion.textContent = "-"; + analysisStatus.textContent = "Datenbestände konnten nicht geladen werden"; + console.warn("VG-Observe analysis dashboard summary failed", error); + } +} + +async function getEvidenceRetentionStatus() { + const status = await browser.runtime.sendMessage({ + type: "get_evidence_retention_status" + }); + + if (!status?.success) { + throw new Error(status?.error ?? "get_evidence_retention_status_failed"); + } + + return status; +} + +async function getLatestGvlUpdateStatus() { + const status = await browser.runtime.sendMessage({ + type: "get_latest_gvl_update_status" + }); + + if (!status?.success) { + throw new Error(status?.error ?? "get_latest_gvl_update_status_failed"); + } + + return status; +} + +function formatNullable(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + return String(value); +} diff --git a/src/background.js b/src/background.js index b1cad83..b30b01c 100644 --- a/src/background.js +++ b/src/background.js @@ -5,6 +5,13 @@ console.log("VG-Observe background loaded"); const OFFICIAL_IAB_GVL_URL = "https://vendor-list.consensu.org/v3/vendor-list.json"; const EVIDENCE_RECORDING_SOURCE = "vendorget_background_mirror"; +const GVL_AUTO_UPDATE_SOURCE = "extension_startup"; +const AUTO_GVL_CHECK_THROTTLE_MS = 24 * 60 * 60 * 1000; +const AUTO_GVL_CHECK_STORAGE_KEY = "vendorgetAutoGvlUpdateStatus"; + +let isAutoGvlCheckRunning = false; +let lastAutoGvlCheckStartedAt = null; +let latestGvlUpdateStatus = null; browser.runtime.onMessage.addListener((message, sender) => handleVendorGetMessage(message, sender) @@ -15,6 +22,8 @@ browser.webRequest.onBeforeRequest.addListener( { urls: [""] } ); +void runStartupGvlAutoUpdateCheck(); + async function handleVendorGetMessage(message, sender) { if (!message) { return null; @@ -48,6 +57,26 @@ async function handleVendorGetMessage(message, sender) { return handleGetEvidenceRetentionStatusMessage(); } + if (message.type === "get_latest_gvl_update_status") { + return handleGetLatestGvlUpdateStatusMessage(); + } + + if (message.type === "list_gvl_snapshots") { + return handleListGvlSnapshotsMessage(); + } + + if (message.type === "get_gvl_snapshot_summary") { + return handleGetGvlSnapshotSummaryMessage(message); + } + + if (message.type === "get_latest_consent_state") { + return handleGetLatestConsentStateMessage(); + } + + if (message.type === "list_recent_consent_states") { + return handleListRecentConsentStatesMessage(); + } + if (message.type === "purge_unlocked_evidence_records") { return handlePurgeUnlockedEvidenceRecordsMessage(); } @@ -140,6 +169,349 @@ async function handleGetEvidenceRetentionStatusMessage() { }; } +async function handleGetLatestGvlUpdateStatusMessage() { + const db = await openVendorGetDb(); + const latestSnapshot = await getLatestGvlSnapshotByVendorListVersion(db); + + if (latestGvlUpdateStatus) { + return { + success: true, + status: { + ...latestGvlUpdateStatus, + latestLocalVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + latestLocalSnapshotSha256: latestSnapshot?.sha256 ?? null, + latestLocalFetchedAt: latestSnapshot?.fetchedAt ?? null + } + }; + } + + const throttleState = await getAutoGvlCheckThrottleState(); + const throttleDecision = shouldThrottleAutoGvlCheck( + throttleState?.lastAutoGvlCheckAt ?? null + ); + + return { + success: true, + status: { + checkedAt: null, + previousVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + currentVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + previousSnapshotSha256: latestSnapshot?.sha256 ?? null, + currentSnapshotSha256: latestSnapshot?.sha256 ?? null, + latestLocalVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + latestLocalSnapshotSha256: latestSnapshot?.sha256 ?? null, + latestLocalFetchedAt: latestSnapshot?.fetchedAt ?? null, + lastAutoGvlCheckAt: throttleState?.lastAutoGvlCheckAt ?? null, + nextAllowedAutoCheckAt: throttleDecision.nextAllowedAutoCheckAt, + result: "not_checked_since_background_start", + message: "Noch kein automatischer GVL-Update-Check seit Background-Start." + } + }; +} + +async function handleListGvlSnapshotsMessage() { + const db = await openVendorGetDb(); + const snapshots = await listRecentGvlSnapshots(db, 25); + const snapshotsWithEvents = await Promise.all( + snapshots.map(async (snapshot) => { + const event = await getAnyGvlSnapshotEventBySha256(db, snapshot.sha256); + + return { + vendorListVersion: snapshot.vendorListVersion ?? null, + sha256: snapshot.sha256 ?? null, + fetchedAt: snapshot.fetchedAt ?? null, + sourceUrl: snapshot.sourceUrl ?? null, + eventType: event?.eventType ?? null, + eventCapturedAt: event?.capturedAt ?? null + }; + }) + ); + + return { + success: true, + gvlSnapshots: snapshotsWithEvents + }; +} + +async function handleGetGvlSnapshotSummaryMessage(message) { + const db = await openVendorGetDb(); + const payload = message?.payload ?? {}; + const snapshot = await getGvlSnapshotByIdentifier(db, { + sha256: payload.sha256 ?? null, + vendorListVersion: payload.vendorListVersion ?? null + }); + + if (!snapshot) { + return { + success: false, + error: "gvl_snapshot_not_found" + }; + } + + const vendorListVersion = snapshot.vendorListVersion ?? null; + const event = await getAnyGvlSnapshotEventBySha256(db, snapshot.sha256); + const counts = await countGvlNormalizedRecordsForVersion( + db, + vendorListVersion + ); + + return { + success: true, + summary: { + vendorListVersion, + sha256: snapshot.sha256 ?? null, + fetchedAt: snapshot.fetchedAt ?? null, + sourceUrl: snapshot.sourceUrl ?? null, + eventType: event?.eventType ?? null, + eventCapturedAt: event?.capturedAt ?? null, + vendorCount: snapshot.vendorCount ?? counts.vendorCount, + purposeCount: snapshot.purposeCount ?? counts.purposeCount, + specialPurposeCount: counts.specialPurposeCount, + featureCount: counts.featureCount, + specialFeatureCount: counts.specialFeatureCount, + dataCategoryCount: counts.dataCategoryCount, + vendorRelationshipCount: counts.vendorRelationshipCount, + technicalFields: { + snapshotStore: "gvl_snapshots", + vendorListVersion: "vendorListVersion", + sha256: "sha256", + fetchedAt: "fetchedAt", + sourceUrl: "sourceUrl" + }, + diagnostics: { + eventDiagnostics: event?.diagnostics ?? null + } + } + }; +} + +async function handleGetLatestConsentStateMessage() { + const db = await openVendorGetDb(); + const latestStateOrNull = await getLatestConsentState(db); + + return { + success: true, + consentState: latestStateOrNull + }; +} + +async function handleListRecentConsentStatesMessage() { + const db = await openVendorGetDb(); + const consentStates = await listRecentConsentStates(db, 25); + + return { + success: true, + consentStates + }; +} + +function getLatestConsentState(db) { + return new Promise((resolve, reject) => { + const tx = db.transaction(["consent_states"], "readonly"); + const statesStore = tx.objectStore("consent_states"); + const lastSeenAtIndex = statesStore.index("lastSeenAt"); + const cursorRequest = lastSeenAtIndex.openCursor(null, "prev"); + let latestStateOrNull = null; + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (cursor) { + latestStateOrNull = cursor.value; + } + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(latestStateOrNull); + }); +} + +function listRecentConsentStates(db, limit) { + return new Promise((resolve, reject) => { + const consentStates = []; + const tx = db.transaction(["consent_states"], "readonly"); + const statesStore = tx.objectStore("consent_states"); + const lastSeenAtIndex = statesStore.index("lastSeenAt"); + const cursorRequest = lastSeenAtIndex.openCursor(null, "prev"); + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor || consentStates.length >= limit) { + return; + } + + consentStates.push(cursor.value); + + if (consentStates.length < limit) { + cursor.continue(); + } + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(consentStates); + }); +} + +function listRecentGvlSnapshots(db, limit) { + return new Promise((resolve, reject) => { + const snapshots = []; + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); + const snapshotsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshots); + const vendorListVersionIndex = snapshotsStore.index("vendorListVersion"); + const cursorRequest = vendorListVersionIndex.openCursor(null, "prev"); + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor || snapshots.length >= limit) { + return; + } + + snapshots.push(cursor.value); + + if (snapshots.length < limit) { + cursor.continue(); + } + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(snapshots); + }); +} + +function getAnyGvlSnapshotEventBySha256(db, sha256) { + if (!sha256) { + return Promise.resolve(null); + } + + return new Promise((resolve, reject) => { + const tx = db.transaction( + [VENDORGET_STORE_NAMES.gvlSnapshotEvents], + "readonly" + ); + const eventsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshotEvents); + const sha256Index = eventsStore.index("sha256"); + const getRequest = sha256Index.get(sha256); + let eventOrNull = null; + + getRequest.onerror = () => reject(getRequest.error); + getRequest.onsuccess = () => { + eventOrNull = getRequest.result ?? null; + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(eventOrNull); + }); +} + +async function getGvlSnapshotByIdentifier(db, { sha256, vendorListVersion }) { + if (sha256) { + return getGvlSnapshotBySha256(db, sha256); + } + + if (vendorListVersion !== null && vendorListVersion !== undefined) { + return getGvlSnapshotByVendorListVersion(db, vendorListVersion); + } + + return null; +} + +function getGvlSnapshotBySha256(db, sha256) { + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); + const snapshotsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshots); + const getRequest = snapshotsStore.get(sha256); + let snapshotOrNull = null; + + getRequest.onerror = () => reject(getRequest.error); + getRequest.onsuccess = () => { + snapshotOrNull = getRequest.result ?? null; + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(snapshotOrNull); + }); +} + +function getGvlSnapshotByVendorListVersion(db, vendorListVersion) { + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); + const snapshotsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshots); + const vendorListVersionIndex = snapshotsStore.index("vendorListVersion"); + const getRequest = vendorListVersionIndex.get(vendorListVersion); + let snapshotOrNull = null; + + getRequest.onerror = () => reject(getRequest.error); + getRequest.onsuccess = () => { + snapshotOrNull = getRequest.result ?? null; + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(snapshotOrNull); + }); +} + +function countGvlNormalizedRecordsForVersion(db, vendorListVersion) { + if (vendorListVersion === null || vendorListVersion === undefined) { + return Promise.resolve({ + vendorCount: 0, + purposeCount: 0, + specialPurposeCount: 0, + featureCount: 0, + specialFeatureCount: 0, + dataCategoryCount: 0, + vendorRelationshipCount: 0 + }); + } + + const countDefinitions = [ + ["vendorCount", VENDORGET_STORE_NAMES.gvlVendors], + ["purposeCount", VENDORGET_STORE_NAMES.gvlPurposes], + ["specialPurposeCount", VENDORGET_STORE_NAMES.gvlSpecialPurposes], + ["featureCount", VENDORGET_STORE_NAMES.gvlFeatures], + ["specialFeatureCount", VENDORGET_STORE_NAMES.gvlSpecialFeatures], + ["dataCategoryCount", VENDORGET_STORE_NAMES.gvlDataCategories], + [ + "vendorRelationshipCount", + VENDORGET_STORE_NAMES.gvlVendorRelationships + ] + ]; + + return new Promise((resolve, reject) => { + const counts = {}; + const tx = db.transaction( + countDefinitions.map((definition) => definition[1]), + "readonly" + ); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(counts); + + for (const [countName, storeName] of countDefinitions) { + const store = tx.objectStore(storeName); + const vendorListVersionIndex = store.index("vendorListVersion"); + const countRequest = vendorListVersionIndex.count( + IDBKeyRange.only(vendorListVersion) + ); + + countRequest.onsuccess = () => { + counts[countName] = countRequest.result; + }; + } + }); +} + async function handlePurgeUnlockedEvidenceRecordsMessage() { const db = await openVendorGetDb(); @@ -190,26 +562,13 @@ function isGvlImportCandidate(value) { async function handleFetchOfficialGvlMessage() { try { - const response = await fetch(OFFICIAL_IAB_GVL_URL, { - method: "GET", - cache: "no-store" - }); - - if (!response.ok) { - return { - success: false, - error: "official_gvl_fetch_failed", - responseStatus: response.status - }; - } - - const rawJson = await response.json(); + const { rawJson, responseStatus } = await fetchOfficialGvlJson(); if (!isGvlImportCandidate(rawJson)) { return { success: false, error: "invalid_gvl_json", - responseStatus: response.status + responseStatus: responseStatus }; } @@ -219,7 +578,7 @@ async function handleFetchOfficialGvlMessage() { fetchedAt: new Date().toISOString(), diagnostics: { ingestionSource: "official_iab_fetch", - responseStatus: response.status + responseStatus: responseStatus } }); @@ -239,6 +598,491 @@ async function handleFetchOfficialGvlMessage() { } } +async function fetchOfficialGvlJson() { + const response = await fetch(OFFICIAL_IAB_GVL_URL, { + method: "GET", + cache: "no-store", + headers: { + "Cache-Control": "no-cache", + Pragma: "no-cache" + } + }); + + if (!response.ok) { + const error = new Error("official_gvl_fetch_failed"); + + error.responseStatus = response.status; + throw error; + } + + return { + rawJson: await response.json(), + responseStatus: response.status + }; +} + +async function runStartupGvlAutoUpdateCheck() { + if (isAutoGvlCheckRunning) { + return; + } + + isAutoGvlCheckRunning = true; + + const throttleState = await getAutoGvlCheckThrottleState(); + const throttleDecision = shouldThrottleAutoGvlCheck( + throttleState?.lastAutoGvlCheckAt ?? null + ); + + if (throttleDecision.throttled) { + await handleThrottledStartupGvlAutoUpdateCheck( + throttleState, + throttleDecision + ); + isAutoGvlCheckRunning = false; + return; + } + + lastAutoGvlCheckStartedAt = new Date().toISOString(); + await storeAutoGvlCheckThrottleState({ + lastAutoGvlCheckAt: lastAutoGvlCheckStartedAt, + lastAutoGvlCheckResult: "started" + }); + + const startedStatus = { + checkedAt: lastAutoGvlCheckStartedAt, + previousVendorListVersion: null, + currentVendorListVersion: null, + previousSnapshotSha256: null, + currentSnapshotSha256: null, + latestLocalVendorListVersion: null, + latestLocalSnapshotSha256: null, + latestLocalFetchedAt: null, + lastAutoGvlCheckAt: lastAutoGvlCheckStartedAt, + nextAllowedAutoCheckAt: getNextAllowedAutoGvlCheckAt( + lastAutoGvlCheckStartedAt + ), + result: "started", + message: "Automatischer GVL-Update-Check gestartet." + }; + + latestGvlUpdateStatus = startedStatus; + + try { + const db = await openVendorGetDb(); + const previousSnapshot = await getLatestGvlSnapshotByVendorListVersion(db); + const previousVendorListVersion = + previousSnapshot?.vendorListVersion ?? null; + const previousSnapshotSha256 = previousSnapshot?.sha256 ?? null; + const previousFetchedAt = previousSnapshot?.fetchedAt ?? null; + + latestGvlUpdateStatus = { + checkedAt: lastAutoGvlCheckStartedAt, + previousVendorListVersion, + currentVendorListVersion: null, + previousSnapshotSha256, + currentSnapshotSha256: null, + latestLocalVendorListVersion: previousVendorListVersion, + latestLocalSnapshotSha256: previousSnapshotSha256, + latestLocalFetchedAt: previousFetchedAt, + lastAutoGvlCheckAt: lastAutoGvlCheckStartedAt, + nextAllowedAutoCheckAt: getNextAllowedAutoGvlCheckAt( + lastAutoGvlCheckStartedAt + ), + result: "started", + message: "Automatischer GVL-Update-Check gestartet." + }; + + await recordGvlAutoUpdateEvent(db, { + eventType: "gvl_auto_update_check_started", + checkedAt: lastAutoGvlCheckStartedAt, + previousSnapshot, + currentVendorListVersion: null, + currentSnapshotSha256: null, + result: "started" + }); + + const { rawJson, responseStatus } = await fetchOfficialGvlJson(); + + if (!isGvlImportCandidate(rawJson)) { + throw new Error("invalid_gvl_json"); + } + + const currentVendorListVersion = rawJson.vendorListVersion ?? null; + const currentSnapshotSha256 = + await VendorGetGvlService.calculateGvlSnapshotSha256(rawJson); + const newVersionDetected = isNewerGvlVendorListVersion( + currentVendorListVersion, + previousVendorListVersion + ); + + const ingestResult = await VendorGetGvlService.ingestGvlSnapshot( + db, + rawJson, + { + sourceUrl: OFFICIAL_IAB_GVL_URL, + fetchedAt: new Date().toISOString(), + diagnostics: { + ingestionSource: "official_iab_auto_update", + responseStatus: responseStatus, + updateCheckSource: GVL_AUTO_UPDATE_SOURCE, + checkedAt: lastAutoGvlCheckStartedAt, + previousVendorListVersion: previousVendorListVersion, + previousSnapshotSha256: previousSnapshotSha256, + previousFetchedAt: previousFetchedAt, + currentVendorListVersion: currentVendorListVersion, + currentSnapshotSha256: currentSnapshotSha256, + result: newVersionDetected ? "new_version_detected" : "no_change" + } + } + ); + + let normalizationSummary = null; + + if (!ingestResult.alreadyKnown && newVersionDetected) { + normalizationSummary = await normalizeGvlSnapshotWithExistingPipeline( + ingestResult.snapshot + ); + } + + const result = buildGvlAutoUpdateResult({ + newVersionDetected, + alreadyKnown: ingestResult.alreadyKnown + }); + + const status = { + checkedAt: lastAutoGvlCheckStartedAt, + previousVendorListVersion, + currentVendorListVersion, + previousSnapshotSha256, + currentSnapshotSha256, + latestLocalVendorListVersion: ingestResult.snapshot.vendorListVersion, + latestLocalSnapshotSha256: ingestResult.snapshot.sha256, + latestLocalFetchedAt: ingestResult.snapshot.fetchedAt ?? null, + lastAutoGvlCheckAt: lastAutoGvlCheckStartedAt, + nextAllowedAutoCheckAt: getNextAllowedAutoGvlCheckAt( + lastAutoGvlCheckStartedAt + ), + result, + message: buildGvlAutoUpdateMessage(result), + normalizationSummary + }; + + latestGvlUpdateStatus = status; + await storeAutoGvlCheckThrottleState({ + lastAutoGvlCheckAt: lastAutoGvlCheckStartedAt, + lastAutoGvlCheckResult: result + }); + + await recordGvlAutoUpdateEvent(db, { + eventType: buildGvlAutoUpdateEventType(result), + checkedAt: lastAutoGvlCheckStartedAt, + previousSnapshot, + currentVendorListVersion, + currentSnapshotSha256, + currentSnapshot: ingestResult.snapshot, + result, + normalizationSummary + }); + } catch (error) { + console.warn("VG-Observe automatic official GVL update check failed", error); + + const checkedAt = lastAutoGvlCheckStartedAt ?? new Date().toISOString(); + const errorMessage = error?.message ?? String(error); + + latestGvlUpdateStatus = { + checkedAt, + previousVendorListVersion: latestGvlUpdateStatus?.previousVendorListVersion ?? null, + currentVendorListVersion: latestGvlUpdateStatus?.currentVendorListVersion ?? null, + previousSnapshotSha256: latestGvlUpdateStatus?.previousSnapshotSha256 ?? null, + currentSnapshotSha256: latestGvlUpdateStatus?.currentSnapshotSha256 ?? null, + latestLocalVendorListVersion: + latestGvlUpdateStatus?.latestLocalVendorListVersion ?? null, + latestLocalSnapshotSha256: + latestGvlUpdateStatus?.latestLocalSnapshotSha256 ?? null, + latestLocalFetchedAt: latestGvlUpdateStatus?.latestLocalFetchedAt ?? null, + lastAutoGvlCheckAt: lastAutoGvlCheckStartedAt, + nextAllowedAutoCheckAt: getNextAllowedAutoGvlCheckAt( + lastAutoGvlCheckStartedAt + ), + result: "error", + message: "Auto-Check fehlgeschlagen", + error: errorMessage + }; + await storeAutoGvlCheckThrottleState({ + lastAutoGvlCheckAt: lastAutoGvlCheckStartedAt, + lastAutoGvlCheckResult: "error" + }); + + try { + const db = await openVendorGetDb(); + const previousSnapshot = await getLatestGvlSnapshotByVendorListVersion(db); + + await recordGvlAutoUpdateEvent(db, { + eventType: "gvl_auto_update_error", + checkedAt, + previousSnapshot, + currentVendorListVersion: null, + currentSnapshotSha256: null, + result: "error", + error: errorMessage + }); + } catch (eventError) { + console.warn("VG-Observe automatic GVL error event failed", eventError); + } + } finally { + isAutoGvlCheckRunning = false; + } +} + +async function handleThrottledStartupGvlAutoUpdateCheck( + throttleState, + throttleDecision +) { + const checkedAt = new Date().toISOString(); + + try { + const db = await openVendorGetDb(); + const latestSnapshot = await getLatestGvlSnapshotByVendorListVersion(db); + + latestGvlUpdateStatus = { + checkedAt, + previousVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + currentVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + previousSnapshotSha256: latestSnapshot?.sha256 ?? null, + currentSnapshotSha256: latestSnapshot?.sha256 ?? null, + latestLocalVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + latestLocalSnapshotSha256: latestSnapshot?.sha256 ?? null, + latestLocalFetchedAt: latestSnapshot?.fetchedAt ?? null, + lastAutoGvlCheckAt: throttleState?.lastAutoGvlCheckAt ?? null, + nextAllowedAutoCheckAt: throttleDecision.nextAllowedAutoCheckAt, + result: "throttled", + message: "Auto-Check wegen 24h-Throttling übersprungen." + }; + + await recordGvlAutoUpdateEvent(db, { + eventType: "gvl_auto_update_throttled", + checkedAt, + previousSnapshot: latestSnapshot, + currentVendorListVersion: latestSnapshot?.vendorListVersion ?? null, + currentSnapshotSha256: latestSnapshot?.sha256 ?? null, + result: "throttled", + throttleDiagnostics: { + lastAutoGvlCheckAt: throttleState?.lastAutoGvlCheckAt ?? null, + throttleMs: AUTO_GVL_CHECK_THROTTLE_MS, + nextAllowedAutoCheckAt: throttleDecision.nextAllowedAutoCheckAt + } + }); + } catch (error) { + console.warn("VG-Observe automatic GVL throttled event failed", error); + + latestGvlUpdateStatus = { + checkedAt, + previousVendorListVersion: null, + currentVendorListVersion: null, + previousSnapshotSha256: null, + currentSnapshotSha256: null, + latestLocalVendorListVersion: null, + latestLocalSnapshotSha256: null, + latestLocalFetchedAt: null, + lastAutoGvlCheckAt: throttleState?.lastAutoGvlCheckAt ?? null, + nextAllowedAutoCheckAt: throttleDecision.nextAllowedAutoCheckAt, + result: "throttled", + message: + "Auto-Check wegen 24h-Throttling übersprungen; Status-Event konnte nicht geschrieben werden." + }; + } +} + +async function getAutoGvlCheckThrottleState() { + try { + const storedValue = await browser.storage.local.get( + AUTO_GVL_CHECK_STORAGE_KEY + ); + + return storedValue[AUTO_GVL_CHECK_STORAGE_KEY] ?? {}; + } catch (error) { + console.warn("VG-Observe automatic GVL throttle state unavailable", error); + return {}; + } +} + +async function storeAutoGvlCheckThrottleState(state) { + try { + await browser.storage.local.set({ + [AUTO_GVL_CHECK_STORAGE_KEY]: state + }); + } catch (error) { + console.warn("VG-Observe automatic GVL throttle state write failed", error); + } +} + +function shouldThrottleAutoGvlCheck(lastAutoGvlCheckAt) { + if (!lastAutoGvlCheckAt) { + return { + throttled: false, + nextAllowedAutoCheckAt: null + }; + } + + const lastCheckTime = Date.parse(lastAutoGvlCheckAt); + + if (Number.isNaN(lastCheckTime)) { + return { + throttled: false, + nextAllowedAutoCheckAt: null + }; + } + + const nextAllowedTime = lastCheckTime + AUTO_GVL_CHECK_THROTTLE_MS; + const now = Date.now(); + + return { + throttled: now < nextAllowedTime, + nextAllowedAutoCheckAt: new Date(nextAllowedTime).toISOString() + }; +} + +function getNextAllowedAutoGvlCheckAt(lastAutoGvlCheckAt) { + if (!lastAutoGvlCheckAt) { + return null; + } + + const lastCheckTime = Date.parse(lastAutoGvlCheckAt); + + if (Number.isNaN(lastCheckTime)) { + return null; + } + + return new Date(lastCheckTime + AUTO_GVL_CHECK_THROTTLE_MS).toISOString(); +} + +function getLatestGvlSnapshotByVendorListVersion(db) { + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); + const snapshotsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshots); + const vendorListVersionIndex = snapshotsStore.index("vendorListVersion"); + const cursorRequest = vendorListVersionIndex.openCursor(null, "prev"); + let latestSnapshotOrNull = null; + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (cursor) { + latestSnapshotOrNull = cursor.value; + } + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(latestSnapshotOrNull); + }); +} + +function isNewerGvlVendorListVersion(currentVersion, previousVersion) { + if (currentVersion === null || currentVersion === undefined) { + return false; + } + + if (previousVersion === null || previousVersion === undefined) { + return true; + } + + return Number(currentVersion) > Number(previousVersion); +} + +async function normalizeGvlSnapshotWithExistingPipeline(snapshot) { + return { + vendors: await normalizeGvlVendorsFromSnapshot(snapshot), + catalogs: await normalizeGvlCatalogsFromSnapshot(snapshot), + vendorRelationships: + await normalizeGvlVendorRelationshipsFromSnapshot(snapshot) + }; +} + +function buildGvlAutoUpdateResult({ newVersionDetected, alreadyKnown }) { + if (newVersionDetected && !alreadyKnown) { + return "stored"; + } + + if (newVersionDetected && alreadyKnown) { + return "already_known"; + } + + if (!newVersionDetected && !alreadyKnown) { + return "stored"; + } + + return "no_change"; +} + +function buildGvlAutoUpdateEventType(result) { + if (result === "stored") { + return "gvl_auto_update_stored"; + } + + if (result === "already_known") { + return "gvl_auto_update_detected"; + } + + return "gvl_auto_update_no_change"; +} + +function buildGvlAutoUpdateMessage(result) { + if (result === "stored") { + return "Neue offizielle IAB-Europe-Vendorliste gespeichert."; + } + + if (result === "already_known") { + return "Offizielle IAB-Europe-Vendorliste war bereits lokal bekannt."; + } + + return "Keine neuere offizielle IAB-Europe-Vendorliste gefunden."; +} + +function recordGvlAutoUpdateEvent( + db, + { + eventType, + checkedAt, + previousSnapshot, + currentVendorListVersion, + currentSnapshotSha256, + currentSnapshot, + result, + normalizationSummary, + error, + throttleDiagnostics + } +) { + return VendorGetGvlService.recordGvlSnapshotEvent(db, { + eventType, + capturedAt: checkedAt, + vendorListVersion: + currentVendorListVersion ?? previousSnapshot?.vendorListVersion ?? null, + sha256: currentSnapshotSha256 ?? previousSnapshot?.sha256 ?? null, + sourceUrl: OFFICIAL_IAB_GVL_URL, + diagnostics: { + updateCheckSource: GVL_AUTO_UPDATE_SOURCE, + checkedAt, + previousVendorListVersion: previousSnapshot?.vendorListVersion ?? null, + currentVendorListVersion: currentVendorListVersion ?? null, + previousSnapshotSha256: previousSnapshot?.sha256 ?? null, + currentSnapshotSha256: currentSnapshotSha256 ?? null, + previousFetchedAt: previousSnapshot?.fetchedAt ?? null, + currentFetchedAt: currentSnapshot?.fetchedAt ?? null, + result, + vendorCountBefore: previousSnapshot?.vendorCount ?? null, + vendorCountAfter: currentSnapshot?.vendorCount ?? null, + purposeCountBefore: previousSnapshot?.purposeCount ?? null, + purposeCountAfter: currentSnapshot?.purposeCount ?? null, + normalizationSummary: normalizationSummary ?? null, + error: error ?? null, + ...(throttleDiagnostics ?? {}) + } + }); +} + function buildConsentStateV1(rawCapture, sender, latestPingData) { const decodedTcString = decodeTcStringCoreMetadata(rawCapture?.tcString ?? null); diff --git a/src/consent-explorer/consent-explorer.css b/src/consent-explorer/consent-explorer.css new file mode 100644 index 0000000..7c94b2f --- /dev/null +++ b/src/consent-explorer/consent-explorer.css @@ -0,0 +1,230 @@ +* { + 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: 12px; +} + +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; +} + +.consent-state-list-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.consent-state-list { + min-width: 1180px; + border: 0; +} + +.consent-state-list th, +.consent-state-list td { + padding: 8px 10px; + vertical-align: top; +} + +.consent-state-list tbody tr { + cursor: pointer; +} + +.consent-state-list tbody tr:hover, +.consent-state-list tbody tr:focus { + outline: 0; + background: #263449; +} + +.consent-state-list tbody tr.is-selected { + background: #1e3a5f; + box-shadow: inset 3px 0 0 #60a5fa; +} + +.consent-state-list .numeric { + text-align: right; +} + +.consent-state-list .url-cell { + max-width: 220px; + overflow-wrap: anywhere; +} + +.consent-detail { + margin-top: 18px; +} + +.inspector-table th, +.inspector-table td { + vertical-align: top; +} + +.inspector-table th { + width: 230px; +} + +.inspector-table th span, +.inspector-table th small { + display: block; +} + +.inspector-table th small { + margin-top: 4px; + font-size: 11px; + font-weight: 400; + line-height: 1.35; + color: #94a3b8; +} + +.inspector-table th .inspector-help { + color: #cbd5e1; +} + +.inspector-table .inspector-value { + width: 190px; + min-width: 120px; + max-width: 260px; + font-weight: 700; + color: #e5edf5; +} + +.inspector-table .inspector-explanation { + width: auto; + color: #cbd5e1; +} + +.inspector-table td { + 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: 220px; + 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; + } + + .inspector-table th { + width: 150px; + } + + .inspector-table .inspector-value, + .inspector-table .inspector-explanation { + width: auto; + } +} diff --git a/src/consent-explorer/consent-explorer.html b/src/consent-explorer/consent-explorer.html new file mode 100644 index 0000000..1d0b5fb --- /dev/null +++ b/src/consent-explorer/consent-explorer.html @@ -0,0 +1,77 @@ + + + + + + VG-Observe Consent-Explorer + + + +
+
+ Zurück zum Dashboard +

Dokumentierte Consent-Zustände

+

+ Diese Ansicht zeigt gespeicherte Consent-Zustände aus der lokalen + Beobachtungsdatenbank. Jeder Eintrag ist ein dokumentierter Zustand, + den VG-Observe während der Browser-Laufzeit beobachtet hat. +

+
+ +
+ + + +
+
+ + + + diff --git a/src/consent-explorer/consent-explorer.js b/src/consent-explorer/consent-explorer.js new file mode 100644 index 0000000..5718bc5 --- /dev/null +++ b/src/consent-explorer/consent-explorer.js @@ -0,0 +1,517 @@ +"use strict"; + +const documentedConsentEmpty = document.getElementById( + "documented-consent-empty" +); +const documentedConsentContent = document.getElementById( + "documented-consent-content" +); +const documentedConsentList = document.getElementById("documented-consent-list"); +const consentDetailObservation = document.getElementById( + "consent-detail-observation" +); +const consentDetailBasics = document.getElementById("consent-detail-basics"); +const consentDetailSummary = document.getElementById("consent-detail-summary"); +const consentDetailPublisher = document.getElementById( + "consent-detail-publisher" +); +const consentDetailFieldPaths = document.getElementById( + "consent-detail-field-paths" +); +const consentDetailRawStrings = document.getElementById( + "consent-detail-raw-strings" +); +const consentDetailDiagnostics = document.getElementById( + "consent-detail-diagnostics" +); +const consentDetailJson = document.getElementById("consent-detail-json"); + +let documentedConsentStates = []; +let selectedConsentStateFingerprint = null; + +const FIELD_EXPLANATIONS = { + firstSeenAt: + "Zeitpunkt, zu dem dieser Consent-Zustand erstmals lokal beobachtet wurde.", + lastSeenAt: + "Zeitpunkt, zu dem derselbe Consent-Zustand zuletzt wieder beobachtet wurde.", + seenCount: "Wie oft VG-Observe diesen identischen Zustand erkannt hat.", + "page.origin": "Webseite, auf der dieser Consent-Zustand beobachtet wurde.", + "page.url": "Vollständige beobachtete Seitenadresse, soweit verfügbar.", + stateFingerprint: "Technischer Wiedererkennungswert dieses Consent-Zustands.", + "consent.tcString": + "Technischer Consent-Nachweis. Für normale Nutzer meist nur als Rohbeleg relevant.", + "consent.addtlConsent": + "Zusätzlicher Google-Consent-String außerhalb des normalen TCF-Consent-Strings.", + "gvl.vendorListVersion": + "Vendorlisten-Version, die im beobachteten Consent-Kontext gemeldet wurde. Nicht automatisch die aktuellste IAB-Europe-Version.", + "cmp.tcfPolicyVersion": + "Version der TCF-Regelgrundlage, die im beobachteten Consent-Kontext gemeldet wurde.", + "cmp.gdprApplies": + "Angabe, ob das beobachtete Consent-System DSGVO-Anwendbarkeit gemeldet hat.", + "observation.eventStatus": + "Vom beobachteten TCF-Ereignis gemeldeter Status der Consent-Erfassung.", + "observation.cmpStatus": + "Vom beobachteten Consent-System gemeldeter Laufzeitstatus.", + "purposes.consents": + "Anzahl der Zwecke, für die Zustimmung gemeldet wurde.", + "purposes.legitimateInterests": + "Anzahl der Zwecke, für die berechtigtes Interesse gemeldet wurde.", + "vendors.consents": + "Anzahl der Firmen, für die Zustimmung gemeldet wurde.", + "vendors.legitimateInterests": + "Anzahl der Firmen, die sich laut beobachtetem Kontext auf berechtigtes Interesse stützen.", + specialFeatureOptins: + "Anzahl besonderer Funktionen, für die eine aktive Auswahl beobachtet wurde.", + "vendors.disclosedVendors": + "Anzahl der im beobachteten tcData-Kontext offengelegten Vendoren/Firmen.", + "publisher.consents": + "Anzahl der vom Webseitenbetreiber gemeldeten Publisher-Zustimmungen.", + "publisher.legitimateInterests": + "Anzahl der vom Webseitenbetreiber gemeldeten Publisher-Angaben zu berechtigtem Interesse.", + "publisher.restrictions": + "Technische Einschränkungen/Vorgaben des Webseitenbetreibers im TCF-Kontext. Für Nutzer nur eingeschränkt aussagekräftig." +}; + +document.addEventListener("DOMContentLoaded", async () => { + await renderDocumentedConsentStates(); +}); + +async function renderDocumentedConsentStates() { + try { + const result = await browser.runtime.sendMessage({ + type: "list_recent_consent_states" + }); + + if (!result?.success) { + throw new Error(result?.error ?? "list_recent_consent_states_failed"); + } + + documentedConsentStates = result.consentStates ?? []; + + if (documentedConsentStates.length === 0) { + selectedConsentStateFingerprint = null; + renderNoDocumentedConsentStates(); + return; + } + + documentedConsentEmpty.hidden = true; + documentedConsentContent.hidden = false; + + if (!findDocumentedConsentState(selectedConsentStateFingerprint)) { + selectedConsentStateFingerprint = + documentedConsentStates[0]?.stateFingerprint ?? null; + } + + renderDocumentedConsentStateList(); + renderSelectedConsentState(); + } catch (error) { + documentedConsentEmpty.hidden = false; + documentedConsentContent.hidden = true; + documentedConsentEmpty.textContent = + "Dokumentierte Consent-Zustände konnten nicht geladen werden."; + console.warn("VendorGet-IV documented consent states failed", error); + } +} + +function renderNoDocumentedConsentStates() { + documentedConsentList.textContent = ""; + clearConsentStateDetails(); + documentedConsentEmpty.hidden = false; + documentedConsentContent.hidden = true; + documentedConsentEmpty.textContent = + "Keine dokumentierten Consent-Zustände vorhanden."; +} + +function renderDocumentedConsentStateList() { + documentedConsentList.textContent = ""; + + for (const consentState of documentedConsentStates) { + const row = document.createElement("tr"); + const isSelected = + consentState?.stateFingerprint === selectedConsentStateFingerprint; + + row.className = isSelected ? "is-selected" : ""; + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.setAttribute("aria-pressed", isSelected ? "true" : "false"); + + row.addEventListener("click", () => { + selectDocumentedConsentState(consentState?.stateFingerprint ?? null); + }); + + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectDocumentedConsentState(consentState?.stateFingerprint ?? null); + } + }); + + appendListCell(row, formatNullable(consentState?.lastSeenAt)); + appendListCell(row, formatNullable(consentState?.firstSeenAt)); + appendListCell(row, formatNullable(consentState?.seenCount), "numeric"); + appendListCell(row, formatNullable(consentState?.page?.origin)); + appendListCell(row, shortenLongString(consentState?.page?.url, 80), "url-cell"); + appendListCell( + row, + String(countTruthyObjectValues(consentState?.purposes?.consents)), + "numeric" + ); + appendListCell( + row, + String(countTruthyObjectValues(consentState?.vendors?.consents)), + "numeric" + ); + appendListCell( + row, + String(countTruthyObjectValues(consentState?.vendors?.legitimateInterests)), + "numeric" + ); + appendListCell(row, formatNullable(consentState?.gvl?.vendorListVersion)); + appendListCell(row, shortenFingerprint(consentState?.stateFingerprint)); + + documentedConsentList.append(row); + } +} + +function appendListCell(row, value, className) { + const cell = document.createElement("td"); + + if (className) { + cell.className = className; + } + + cell.textContent = value; + row.append(cell); +} + +function selectDocumentedConsentState(stateFingerprint) { + selectedConsentStateFingerprint = stateFingerprint; + renderDocumentedConsentStateList(); + renderSelectedConsentState(); +} + +function renderSelectedConsentState() { + const consentState = findDocumentedConsentState(selectedConsentStateFingerprint); + + clearConsentStateDetails(); + + if (!consentState) { + return; + } + + renderDetailSection(consentDetailObservation, "Beobachtung", [ + ["Erste Beobachtung", formatNullable(consentState?.firstSeenAt), "firstSeenAt"], + ["Letzte Beobachtung", formatNullable(consentState?.lastSeenAt), "lastSeenAt"], + ["Anzahl Beobachtungen", formatNullable(consentState?.seenCount), "seenCount"], + ["Seite / Origin", formatNullable(consentState?.page?.origin), "page.origin"], + ["URL", formatLongString(consentState?.page?.url), "page.url"], + [ + "State-Fingerprint", + formatLongString(consentState?.stateFingerprint), + "stateFingerprint" + ] + ]); + + renderDetailSection( + consentDetailBasics, + "Gemeldete Consent-Grunddaten", + [ + [ + "Beobachteter TC-String / euconsent-v2", + summarizeRawString(consentState?.consent?.tcString), + "consent.tcString" + ], + [ + "Beobachteter Google Additional Consent String", + summarizeRawString(consentState?.consent?.addtlConsent), + "consent.addtlConsent" + ], + [ + "Vom Consent-System gemeldete Vendorlisten-Version", + formatNullable(consentState?.gvl?.vendorListVersion), + "gvl.vendorListVersion" + ], + [ + "Vom Consent-System gemeldete TCF-Policy-Version", + formatNullable(consentState?.cmp?.tcfPolicyVersion), + "cmp.tcfPolicyVersion" + ], + [ + "Vom Consent-System gemeldete DSGVO-Anwendbarkeit", + formatBoolean(consentState?.cmp?.gdprApplies), + "cmp.gdprApplies" + ], + [ + "Beobachteter TCF Event Status", + formatNullable(consentState?.observation?.eventStatus), + "observation.eventStatus" + ], + [ + "Beobachteter Consent-System-Status", + formatNullable(consentState?.observation?.cmpStatus), + "observation.cmpStatus" + ] + ] + ); + + renderDetailSection( + consentDetailSummary, + "Zusammenfassung der Zwecke und Firmen", + [ + [ + "Anzahl Purposes mit aktivem Consent", + String(countTruthyObjectValues(consentState?.purposes?.consents)), + "purposes.consents" + ], + [ + "Anzahl Purposes mit aktivem Legitimate Interest", + String(countTruthyObjectValues(consentState?.purposes?.legitimateInterests)), + "purposes.legitimateInterests" + ], + [ + "Anzahl Vendoren/Firmen mit aktivem Consent", + String(countTruthyObjectValues(consentState?.vendors?.consents)), + "vendors.consents" + ], + [ + "Anzahl Vendoren/Firmen mit aktivem Legitimate Interest", + String(countTruthyObjectValues(consentState?.vendors?.legitimateInterests)), + "vendors.legitimateInterests" + ], + [ + "Anzahl aktivierter Special Features", + String(countTruthyObjectValues(consentState?.specialFeatureOptins)), + "specialFeatureOptins" + ], + [ + "Anzahl offengelegter Vendoren/Firmen laut beobachtetem tcData-Kontext", + String(countTruthyObjectValues(consentState?.vendors?.disclosedVendors)), + "vendors.disclosedVendors" + ] + ] + ); + + renderDetailSection(consentDetailPublisher, "Publisher-Angaben", [ + [ + "Publisher Consents: Anzahl aktiv", + String(countTruthyObjectValues(consentState?.publisher?.consents)), + "publisher.consents" + ], + [ + "Publisher Legitimate Interests: Anzahl aktiv", + String(countTruthyObjectValues(consentState?.publisher?.legitimateInterests)), + "publisher.legitimateInterests" + ], + [ + "Publisher Restrictions: Anzahl Einträge", + String(countObjectKeys(consentState?.publisher?.restrictions)), + "publisher.restrictions" + ] + ]); + + consentDetailFieldPaths.textContent = JSON.stringify( + buildConsentStateFieldPathOverview(), + null, + 2 + ); + consentDetailRawStrings.textContent = JSON.stringify( + { + tcString: consentState?.consent?.tcString ?? null, + addtlConsent: consentState?.consent?.addtlConsent ?? null + }, + null, + 2 + ); + consentDetailDiagnostics.textContent = JSON.stringify( + { + rawTcData: consentState?.rawTcData ?? null, + diagnostics: consentState?.diagnostics ?? null + }, + null, + 2 + ); + consentDetailJson.textContent = JSON.stringify(consentState, null, 2); +} + +function renderDetailSection(container, title, rows) { + const heading = document.createElement("h3"); + const table = document.createElement("table"); + const body = document.createElement("tbody"); + + heading.textContent = title; + table.className = "inspector-table"; + + for (const [label, value, technicalField, helpText] of rows) { + body.append(createInspectorRow(label, value, technicalField, helpText)); + } + + table.append(body); + container.append(heading, table); +} + +function createInspectorRow(label, value, technicalField, helpText) { + const row = document.createElement("tr"); + const labelCell = document.createElement("th"); + const valueCell = document.createElement("td"); + const explanationCell = document.createElement("td"); + const mainLabel = document.createElement("span"); + const explanation = helpText ?? FIELD_EXPLANATIONS[technicalField] ?? "-"; + + labelCell.scope = "row"; + mainLabel.textContent = label; + labelCell.append(mainLabel); + + if (technicalField) { + const technicalFieldElement = document.createElement("small"); + + technicalFieldElement.className = "technical-field"; + technicalFieldElement.textContent = `Technisches Feld: ${technicalField}`; + labelCell.append(technicalFieldElement); + } + + if (helpText) { + const helpTextElement = document.createElement("small"); + + helpTextElement.className = "inspector-help"; + helpTextElement.textContent = helpText; + labelCell.append(helpTextElement); + } + + valueCell.textContent = value; + valueCell.className = "inspector-value"; + explanationCell.textContent = explanation; + explanationCell.className = "inspector-explanation"; + + row.append(labelCell, valueCell, explanationCell); + + return row; +} + +function clearConsentStateDetails() { + consentDetailObservation.textContent = ""; + consentDetailBasics.textContent = ""; + consentDetailSummary.textContent = ""; + consentDetailPublisher.textContent = ""; + consentDetailFieldPaths.textContent = ""; + consentDetailRawStrings.textContent = ""; + consentDetailDiagnostics.textContent = ""; + consentDetailJson.textContent = ""; +} + +function findDocumentedConsentState(stateFingerprint) { + if (!stateFingerprint) { + return null; + } + + return ( + documentedConsentStates.find((consentState) => { + return consentState?.stateFingerprint === stateFingerprint; + }) ?? null + ); +} + +function buildConsentStateFieldPathOverview() { + return { + beobachtung: { + "Erste Beobachtung": "firstSeenAt", + "Letzte Beobachtung": "lastSeenAt", + "Anzahl Beobachtungen": "seenCount", + "Seite / Origin": "page.origin", + URL: "page.url", + "State-Fingerprint": "stateFingerprint" + }, + grunddaten: { + "TC-String / euconsent-v2": "consent.tcString", + "Google Additional Consent String": "consent.addtlConsent", + Vendorliste: "gvl.vendorListVersion", + "TCF-Policy-Version": "cmp.tcfPolicyVersion", + "DSGVO-Anwendbarkeit": "cmp.gdprApplies", + "TCF Event Status": "observation.eventStatus", + "Consent-System-Status": "observation.cmpStatus" + }, + zusammenfassung: { + "Purposes mit Consent": "purposes.consents", + "Purposes mit Legitimate Interest": "purposes.legitimateInterests", + "Vendoren/Firmen mit Consent": "vendors.consents", + "Vendoren/Firmen mit Legitimate Interest": "vendors.legitimateInterests", + "Special Features": "specialFeatureOptins", + "Offengelegte Vendoren/Firmen": "vendors.disclosedVendors" + }, + publisher: { + "Publisher Consents": "publisher.consents", + "Publisher Legitimate Interests": "publisher.legitimateInterests", + "Publisher Restrictions": "publisher.restrictions" + } + }; +} + +function shortenFingerprint(value) { + if (!value) { + return "-"; + } + + return shortenLongString(value, 16); +} + +function shortenLongString(value, maxLength) { + const formattedValue = formatNullable(value); + + if (formattedValue === "-" || formattedValue.length <= maxLength) { + return formattedValue; + } + + return `${formattedValue.slice(0, maxLength)}...`; +} + +function summarizeRawString(value) { + if (value === null || value === undefined || value === "") { + return "nicht vorhanden"; + } + + const stringValue = String(value); + const prefix = stringValue.slice(0, 32); + + return `vorhanden, Länge ${stringValue.length}, Anfang: ${prefix}${ + stringValue.length > prefix.length ? "..." : "" + }`; +} + +function countTruthyObjectValues(obj) { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + return 0; + } + + return Object.values(obj).filter(Boolean).length; +} + +function countObjectKeys(obj) { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + return 0; + } + + return Object.keys(obj).length; +} + +function formatNullable(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + return String(value); +} + +function formatBoolean(value) { + if (value === true) { + return "true"; + } + + if (value === false) { + return "false"; + } + + return "-"; +} + +function formatLongString(value) { + return formatNullable(value); +} diff --git a/src/dashboard/dashboard.css b/src/dashboard/dashboard.css index 92ad67e..de60759 100644 --- a/src/dashboard/dashboard.css +++ b/src/dashboard/dashboard.css @@ -41,6 +41,13 @@ h2 { font-weight: 700; } +p { + max-width: 720px; + font-size: 13px; + line-height: 1.5; + color: #cbd5e1; +} + .dashboard-status { min-height: 18px; font-size: 13px; @@ -55,55 +62,24 @@ h2 { background: #182231; } -.maintenance-status { - display: grid; - gap: 10px; - max-width: 760px; - padding: 12px; - border: 1px solid #3f6f56; - border-radius: 4px; - background: #14251d; -} - -.maintenance-status strong { - font-size: 14px; - color: #bbf7d0; -} - -.maintenance-status dl { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - margin: 0; -} - -.maintenance-status dt { - margin: 0 0 4px; - font-size: 11px; - color: #cbd5e1; -} - -.maintenance-status dd { - margin: 0; - font-size: 12px; - font-weight: 700; - word-break: break-word; -} - .panel { margin-bottom: 22px; padding-bottom: 20px; border-bottom: 1px solid #334155; } -.metric-grid { +.section-help { + margin-bottom: 12px; +} + +.gvl-status-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + gap: 10px; margin: 0; } -.metric-grid div { +.gvl-status-grid div { min-width: 0; padding: 12px; border: 1px solid #334155; @@ -111,17 +87,18 @@ h2 { background: #1f2937; } -.metric-grid dt { +.gvl-status-grid dt { margin: 0 0 8px; font-size: 12px; + line-height: 1.35; color: #cbd5e1; } -.metric-grid dd { +.gvl-status-grid dd { margin: 0; - font-size: 24px; + font-size: 13px; font-weight: 700; - word-break: break-word; + overflow-wrap: anywhere; } table { @@ -150,29 +127,14 @@ th:last-child { text-align: right; } -p { - max-width: 720px; - font-size: 13px; - line-height: 1.5; - color: #cbd5e1; -} - -.retention-actions, -.admin-actions { +.explorer-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; } -.admin-status { - min-height: 18px; - margin-top: 12px; - font-size: 13px; - line-height: 1.4; - color: #cbd5e1; -} - +.button-link, button { padding: 8px 10px; border: 1px solid #475569; @@ -183,6 +145,12 @@ button { background: #1f2937; } +.button-link { + display: inline-flex; + align-items: center; + text-decoration: none; +} + button:disabled { cursor: default; opacity: 0.65; @@ -193,16 +161,11 @@ button:disabled { padding: 16px; } - .metric-grid { + .gvl-status-grid { grid-template-columns: 1fr; } - .maintenance-status dl { - grid-template-columns: 1fr 1fr; - } - - .retention-actions, - .admin-actions { + .explorer-actions { display: grid; } } diff --git a/src/dashboard/dashboard.html b/src/dashboard/dashboard.html index c4b8947..07beca2 100644 --- a/src/dashboard/dashboard.html +++ b/src/dashboard/dashboard.html @@ -14,51 +14,11 @@ Loading evidence status

- Verwaltungsbereich: Lesen und manuelle Aktionen bleiben verfügbar. - VG-IV dokumentiert browserseitige Consent-/TCF-Zustände als - evidenzielle Spiegelung. + Übersicht und Einstieg für VG-Observe. Detailansichten liegen in + eigenen Explorern.

-
- Verwaltungsmodus aktiv: Hintergrundaufzeichnung ist pausiert. -
-
-
Write Suspend
-
-
-
-
-
Quelle
-
-
-
-
-
Heartbeat
-
-
-
-
-
Ablauf
-
-
-
-
-
-
-

Overview

-
-
-
Total Evidence Records
-
-
-
-
-
Locked Records
-
-
-
-
-
Unlocked Records
-
-
-
-
-
-

Evidence Stores

@@ -93,37 +53,58 @@
-
-

Retention Status

-

- Locked records are protected from partial purge. Full deletion still - requires explicit confirmation. +

+

Offizielle Vendorliste

+

+ Die aktuell offiziell abgerufene IAB-Europe-Vendorliste ist die + Version, die VG-Observe direkt von der offiziellen IAB-Europe-Quelle + geladen hat. Sie ist getrennt von der Vendorliste, die in einem + konkreten Consent-Kontext gemeldet wurde.

-
- - -
+
+
+
Lokal neueste gespeicherte Vendorlisten-Version
+
-
+
+
+
Letzter automatischer Update-Check
+
-
+
+
+
Letzter echter automatischer GVL-Check
+
-
+
+
+
Nächster erlaubter automatischer GVL-Check
+
-
+
+
+
Ergebnis des letzten Checks
+
-
+
+
+
Vorherige Version -> aktuelle Version
+
-
+
+
-
-

Administration

-
- - - -
-
- Bereit +
+

Explorer

+

+ Historische Consent-Zustände und technische Belege werden in einer + eigenen Ansicht geöffnet. +

+
diff --git a/src/dashboard/dashboard.js b/src/dashboard/dashboard.js index 38c9a36..89d403e 100644 --- a/src/dashboard/dashboard.js +++ b/src/dashboard/dashboard.js @@ -1,33 +1,20 @@ "use strict"; -const EVIDENCE_MAINTENANCE_SOURCE = "dashboard"; -const EVIDENCE_MAINTENANCE_HEARTBEAT_MS = 5 * 1000; - const dashboardStatus = document.getElementById("dashboard-status"); -const totalCount = document.getElementById("total-count"); -const lockedCount = document.getElementById("locked-count"); -const unlockedCount = document.getElementById("unlocked-count"); -const maintenanceWriteSuspend = document.getElementById( - "maintenance-write-suspend" +const officialGvlLocalVersion = document.getElementById( + "official-gvl-local-version" ); -const maintenanceSource = document.getElementById("maintenance-source"); -const maintenanceHeartbeat = document.getElementById("maintenance-heartbeat"); -const maintenanceExpires = document.getElementById("maintenance-expires"); -const lockAllButton = document.getElementById("lock-all-button"); -const unlockAllButton = document.getElementById("unlock-all-button"); -const gvlFetchOfficialButton = document.getElementById( - "gvl-fetch-official-button" +const officialGvlLastCheck = document.getElementById("official-gvl-last-check"); +const officialGvlLastRealCheck = document.getElementById( + "official-gvl-last-real-check" +); +const officialGvlNextAllowedCheck = document.getElementById( + "official-gvl-next-allowed-check" +); +const officialGvlResult = document.getElementById("official-gvl-result"); +const officialGvlVersionChange = document.getElementById( + "official-gvl-version-change" ); -const gvlImportButton = document.getElementById("gvl-import-button"); -const evidenceDeleteButton = document.getElementById("evidence-delete-button"); -const adminStatus = document.getElementById("admin-status"); -const gvlImportFileInput = document.createElement("input"); - -gvlImportFileInput.type = "file"; -gvlImportFileInput.accept = ".json,application/json"; -gvlImportFileInput.hidden = true; - -let evidenceMaintenanceHeartbeatId = null; const storeCells = { consent_states: document.getElementById("store-consent-states"), @@ -38,127 +25,10 @@ const storeCells = { }; document.addEventListener("DOMContentLoaded", async () => { - document.body.appendChild(gvlImportFileInput); - - await startEvidenceMaintenanceMode(); - evidenceMaintenanceHeartbeatId = setInterval(() => { - void refreshEvidenceMaintenanceMode(); - }, EVIDENCE_MAINTENANCE_HEARTBEAT_MS); - - lockAllButton.addEventListener("click", async () => { - await handleLockAllClick(); - }); - - unlockAllButton.addEventListener("click", async () => { - await handleUnlockAllClick(); - }); - - gvlFetchOfficialButton.addEventListener("click", async () => { - await fetchOfficialGvl(); - }); - - gvlImportButton.addEventListener("click", () => { - gvlImportFileInput.value = ""; - gvlImportFileInput.click(); - }); - - gvlImportFileInput.addEventListener("change", async () => { - const file = gvlImportFileInput.files?.[0] ?? null; - - if (!file) { - return; - } - - await importGvlFile(file); - }); - - evidenceDeleteButton.addEventListener("click", async () => { - await handleEvidenceDeleteClick(); - }); - await renderEvidenceStatus(); + await renderOfficialGvlStatus(); }); -window.addEventListener("beforeunload", () => { - endEvidenceMaintenanceMode(); -}); - -window.addEventListener("pagehide", () => { - endEvidenceMaintenanceMode(); -}); - -async function startEvidenceMaintenanceMode() { - try { - const status = await sendEvidenceMaintenanceMessage( - "start_evidence_maintenance_session" - ); - - renderEvidenceMaintenanceStatus(status); - } catch (error) { - renderEvidenceMaintenanceUnavailable(); - console.warn("VendorGet-IV maintenance start failed", error); - } -} - -async function refreshEvidenceMaintenanceMode() { - try { - const status = await sendEvidenceMaintenanceMessage( - "refresh_evidence_maintenance_session" - ); - - renderEvidenceMaintenanceStatus(status); - } catch (error) { - renderEvidenceMaintenanceUnavailable(); - console.warn("VendorGet-IV maintenance heartbeat failed", error); - } -} - -function endEvidenceMaintenanceMode() { - if (evidenceMaintenanceHeartbeatId !== null) { - clearInterval(evidenceMaintenanceHeartbeatId); - evidenceMaintenanceHeartbeatId = null; - } - - void sendEvidenceMaintenanceMessage("end_evidence_maintenance_session").catch( - (error) => { - console.warn("VendorGet-IV maintenance end failed", error); - } - ); -} - -async function sendEvidenceMaintenanceMessage(type) { - const status = await browser.runtime.sendMessage({ - type: type, - payload: { - source: EVIDENCE_MAINTENANCE_SOURCE - } - }); - - if (!status?.success) { - throw new Error(status?.error ?? `${type}_failed`); - } - - return status; -} - -function renderEvidenceMaintenanceStatus(status) { - maintenanceWriteSuspend.textContent = status.evidenceWriteSuspended - ? "aktiv" - : "inaktiv"; - maintenanceSource.textContent = status.source ?? "-"; - maintenanceHeartbeat.textContent = formatMaintenanceTimestamp( - status.lastHeartbeatAt - ); - maintenanceExpires.textContent = formatMaintenanceTimestamp(status.expiresAt); -} - -function renderEvidenceMaintenanceUnavailable() { - maintenanceWriteSuspend.textContent = "unbekannt"; - maintenanceSource.textContent = "-"; - maintenanceHeartbeat.textContent = "-"; - maintenanceExpires.textContent = "-"; -} - async function renderEvidenceStatus() { try { const status = await browser.runtime.sendMessage({ @@ -169,10 +39,6 @@ async function renderEvidenceStatus() { throw new Error(status?.error ?? "get_evidence_retention_status_failed"); } - totalCount.textContent = String(status.totalCount); - lockedCount.textContent = String(status.lockedCount); - unlockedCount.textContent = String(status.unlockedCount); - renderStoreCounts(status.storeCounts ?? {}); renderStatusMessage("Evidence status loaded"); } catch (error) { @@ -187,250 +53,94 @@ function renderStoreCounts(storeCounts) { } } -async function fetchOfficialGvl() { - gvlFetchOfficialButton.disabled = true; - renderAdminStatus("Fetching official IAB GVL..."); - +async function renderOfficialGvlStatus() { try { const result = await browser.runtime.sendMessage({ - type: "fetch_official_gvl" + type: "get_latest_gvl_update_status" }); if (!result?.success) { - throw new Error(result?.error ?? "official_gvl_fetch_failed"); + throw new Error(result?.error ?? "get_latest_gvl_update_status_failed"); } - await renderEvidenceStatus(); - renderAdminStatus( - "Fetched successfully - " + - `${result.alreadyKnown ? "already known" : "newly stored"} - ` + - `vendorListVersion ${result.vendorListVersion ?? "n/a"} - ` + - `sha256 ${shortenSha256(result.sha256)}` + const status = result.status ?? {}; + + officialGvlLocalVersion.textContent = formatNullable( + status.latestLocalVendorListVersion ?? status.currentVendorListVersion ); + officialGvlLastCheck.textContent = formatNullable(status.checkedAt); + officialGvlLastRealCheck.textContent = formatNullable( + status.lastAutoGvlCheckAt + ); + officialGvlNextAllowedCheck.textContent = formatNullable( + status.nextAllowedAutoCheckAt + ); + officialGvlResult.textContent = formatGvlUpdateResult(status); + officialGvlVersionChange.textContent = formatGvlVersionChange(status); } catch (error) { - renderAdminStatus("Official GVL fetch failed"); - console.warn("VendorGet-IV official GVL fetch failed", error); - } finally { - gvlFetchOfficialButton.disabled = false; + officialGvlLocalVersion.textContent = "-"; + officialGvlLastCheck.textContent = "-"; + officialGvlLastRealCheck.textContent = "-"; + officialGvlNextAllowedCheck.textContent = "-"; + officialGvlResult.textContent = "Auto-Check fehlgeschlagen"; + officialGvlVersionChange.textContent = "-"; + console.warn("VendorGet-IV official GVL status failed", error); } } -async function importGvlFile(file) { - gvlImportButton.disabled = true; - renderAdminStatus("Import läuft..."); +function formatGvlUpdateResult(status) { + const result = status?.result ?? null; - try { - const fileContent = await readFileAsText(file); - const rawJson = JSON.parse(fileContent); - - if (!isGvlImportCandidate(rawJson)) { - throw new Error("invalid_gvl_json"); - } - - const result = await browser.runtime.sendMessage({ - type: "gvl_import_json", - payload: { - rawJson: rawJson, - sourceUrl: "local-file-import" - } - }); - - if (!result?.success) { - throw new Error(result?.error ?? "gvl_import_failed"); - } - - await renderEvidenceStatus(); - renderAdminStatus( - `${result.alreadyKnown ? "already known" : "imported"} - ` + - `vendorListVersion ${result.vendorListVersion ?? "n/a"} - ` + - `sha256 ${shortenSha256(result.sha256)}` - ); - } catch (error) { - renderAdminStatus("Import fehlgeschlagen"); - console.warn("VendorGet-IV GVL import failed", error); - } finally { - gvlImportButton.disabled = false; - } -} - -function readFileAsText(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onerror = () => reject(reader.error); - reader.onload = () => resolve(reader.result); - reader.readAsText(file); - }); -} - -function isGvlImportCandidate(value) { - return ( - value && - typeof value === "object" && - !Array.isArray(value) && - value.vendorListVersion !== undefined && - value.vendors && - typeof value.vendors === "object" && - !Array.isArray(value.vendors) - ); -} - -async function handleEvidenceDeleteClick() { - evidenceDeleteButton.disabled = true; - - try { - const status = await getEvidenceRetentionStatus(); - - const confirmed = confirm( - "Alle lokal gespeicherten VG-IV-Evidenzdaten wirklich löschen?" - ); - - if (!confirmed) { - renderAdminStatus("Löschung abgebrochen"); - return; - } - - if (status.lockedCount === 0) { - await deleteAllEvidenceDatabase(); - await renderEvidenceStatus(); - renderAdminStatus("Evidenzdaten gelöscht"); - return; - } - - const deleteLockedRecords = confirm( - `Achtung: ${status.lockedCount} Datensätze wurden als ` + - "DSGVO-/DSAR-relevant markiert. Sollen auch diese Datensätze " + - "wirklich gelöscht werden?" - ); - - if (deleteLockedRecords) { - await deleteAllEvidenceDatabase(); - await renderEvidenceStatus(); - renderAdminStatus("Evidenzdaten gelöscht"); - return; - } - - const result = await browser.runtime.sendMessage({ - type: "purge_unlocked_evidence_records" - }); - - if (!result?.success) { - throw new Error(result?.error ?? "purge_unlocked_evidence_records_failed"); - } - - await renderEvidenceStatus(); - renderAdminStatus( - `${result.deletedCount} Datensätze gelöscht, ` + - `${result.keptLockedCount} gesperrte Datensätze behalten` - ); - } catch (error) { - renderAdminStatus("Löschung fehlgeschlagen"); - console.warn("VendorGet-IV evidence delete failed", error); - } finally { - evidenceDeleteButton.disabled = false; - } -} - -async function getEvidenceRetentionStatus() { - const status = await browser.runtime.sendMessage({ - type: "get_evidence_retention_status" - }); - - if (!status?.success) { - throw new Error(status?.error ?? "get_evidence_retention_status_failed"); + if (result === "stored") { + return "Neue offizielle Vendorliste gespeichert"; } - return status; -} - -async function deleteAllEvidenceDatabase() { - const result = await browser.runtime.sendMessage({ - type: "delete_all_evidence_database" - }); - - if (!result?.success) { - throw new Error(result?.error ?? "delete_all_evidence_database_failed"); - } -} - -async function handleLockAllClick() { - const confirmed = confirm( - "Alle vorhandenen VG-IV-Evidenzdatensätze als DSGVO-/DSAR-relevant markieren?" - ); - - if (!confirmed) { - renderStatusMessage("Record lock update cancelled"); - return; + if (result === "no_change") { + return "Keine neuere offizielle Vendorliste gefunden"; } - await runRecordLockAction({ - type: "lock_all_evidence_records", - payload: { - reason: "dsar_used", - note: null - } - }); -} - -async function handleUnlockAllClick() { - const confirmed = confirm( - "Alle VG-IV-Evidenzsperren wirklich entfernen?" - ); - - if (!confirmed) { - renderStatusMessage("Record lock update cancelled"); - return; + if (result === "already_known") { + return "Offizielle Vendorliste war bereits lokal bekannt"; } - await runRecordLockAction({ - type: "unlock_all_evidence_records" - }); -} - -async function runRecordLockAction(message) { - setRecordLockButtonsDisabled(true); - - try { - const result = await browser.runtime.sendMessage(message); - - if (!result?.success) { - throw new Error(result?.error ?? `${message.type}_failed`); - } - - await renderEvidenceStatus(); - } catch (error) { - renderStatusMessage("Record lock update failed"); - console.warn("VendorGet-IV dashboard record lock update failed", error); - } finally { - setRecordLockButtonsDisabled(false); + if (result === "error") { + return "Auto-Check fehlgeschlagen"; } + + if (result === "throttled") { + return "Übersprungen wegen 24h-Throttling"; + } + + if (result === "started") { + return "Auto-Check läuft"; + } + + if (result === "not_checked_since_background_start") { + return "Noch kein Auto-Check seit Background-Start"; + } + + return formatNullable(status?.message ?? result); } -function setRecordLockButtonsDisabled(disabled) { - lockAllButton.disabled = disabled; - unlockAllButton.disabled = disabled; +function formatGvlVersionChange(status) { + const previousVersion = formatNullable(status?.previousVendorListVersion); + const currentVersion = formatNullable(status?.currentVendorListVersion); + + if (previousVersion === "-" && currentVersion === "-") { + return "-"; + } + + return `${previousVersion} -> ${currentVersion}`; } function renderStatusMessage(message) { dashboardStatus.textContent = message; } -function renderAdminStatus(message) { - adminStatus.textContent = message; -} - -function shortenSha256(value) { - if (!value) { - return "n/a"; - } - - return `${value.slice(0, 12)}...`; -} - -function formatMaintenanceTimestamp(value) { - if (!value) { +function formatNullable(value) { + if (value === null || value === undefined || value === "") { return "-"; } - return value; + return String(value); } diff --git a/src/gvl-explorer/gvl-explorer.css b/src/gvl-explorer/gvl-explorer.css new file mode 100644 index 0000000..bb0650b --- /dev/null +++ b/src/gvl-explorer/gvl-explorer.css @@ -0,0 +1,201 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + font-family: Arial, sans-serif; + color: #e5edf5; + background: #111827; +} + +.explorer { + width: min(1080px, 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, +p { + margin: 0; +} + +h1 { + font-size: 24px; + font-weight: 700; +} + +h2 { + margin-bottom: 12px; + font-size: 15px; + font-weight: 700; +} + +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; +} + +.snapshot-list-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.fetch-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.fetch-status { + min-height: 18px; + font-size: 13px; + color: #cbd5e1; +} + +button { + padding: 8px 10px; + border: 1px solid #475569; + border-radius: 4px; + font: inherit; + font-size: 13px; + color: #e5edf5; + background: #1f2937; +} + +button:disabled { + cursor: default; + opacity: 0.65; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + background: #1f2937; +} + +th, +td { + padding: 10px 12px; + border: 1px solid #334155; + text-align: left; + vertical-align: top; +} + +th { + color: #cbd5e1; + font-weight: 700; + background: #182231; +} + +.snapshot-list { + min-width: 820px; + border: 0; +} + +.snapshot-list tbody tr { + cursor: pointer; +} + +.snapshot-list tbody tr:hover, +.snapshot-list tbody tr:focus { + outline: 0; + background: #263449; +} + +.snapshot-list tbody tr.is-selected { + background: #1e3a5f; + box-shadow: inset 3px 0 0 #60a5fa; +} + +.snapshot-list .numeric { + text-align: right; +} + +.snapshot-list .sha-cell, +.snapshot-list .url-cell { + overflow-wrap: anywhere; +} + +.snapshot-summary { + margin-top: 18px; +} + +.summary-table th { + width: 260px; +} + +.summary-table td { + font-weight: 700; + overflow-wrap: anywhere; +} + +.empty-state { + padding: 10px 12px; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.technical-details { + margin-top: 12px; + font-size: 13px; + color: #cbd5e1; +} + +.technical-details summary { + cursor: pointer; +} + +.technical-details pre { + max-height: 220px; + 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; + } + + .summary-table th { + width: 160px; + } +} diff --git a/src/gvl-explorer/gvl-explorer.html b/src/gvl-explorer/gvl-explorer.html new file mode 100644 index 0000000..b275d2b --- /dev/null +++ b/src/gvl-explorer/gvl-explorer.html @@ -0,0 +1,68 @@ + + + + + + VG-Observe GVL-Explorer + + + +
+
+ Zurück zum Dashboard +

GVL-Explorer

+

+ Diese Ansicht zeigt lokal gespeicherte offizielle + IAB-Europe-Vendorlisten. Sie dient dazu, historische + Vendorlisten-Versionen nachvollziehbar zu machen. +

+
+ +
+

Gespeicherte Vendorlisten

+
+ + + Bereit + +
+ + +
+
+ + + + diff --git a/src/gvl-explorer/gvl-explorer.js b/src/gvl-explorer/gvl-explorer.js new file mode 100644 index 0000000..e930e81 --- /dev/null +++ b/src/gvl-explorer/gvl-explorer.js @@ -0,0 +1,273 @@ +"use strict"; + +const gvlSnapshotEmpty = document.getElementById("gvl-snapshot-empty"); +const gvlSnapshotContent = document.getElementById("gvl-snapshot-content"); +const gvlSnapshotList = document.getElementById("gvl-snapshot-list"); +const gvlSnapshotSummary = document.getElementById("gvl-snapshot-summary"); +const gvlTechnicalFields = document.getElementById("gvl-technical-fields"); +const gvlDebugData = document.getElementById("gvl-debug-data"); +const gvlFetchOfficialButton = document.getElementById( + "gvl-fetch-official-button" +); +const gvlFetchStatus = document.getElementById("gvl-fetch-status"); + +let gvlSnapshots = []; +let selectedSnapshotSha256 = null; + +document.addEventListener("DOMContentLoaded", async () => { + gvlFetchOfficialButton.addEventListener("click", async () => { + await fetchOfficialGvl(); + }); + + await renderGvlSnapshots(); +}); + +async function fetchOfficialGvl() { + gvlFetchOfficialButton.disabled = true; + renderFetchStatus("Vendorliste wird abgerufen..."); + + try { + const result = await browser.runtime.sendMessage({ + type: "fetch_official_gvl" + }); + + if (!result?.success) { + throw new Error(result?.error ?? "official_gvl_fetch_failed"); + } + + renderFetchStatus( + result.alreadyKnown + ? "Vendorliste bereits bekannt." + : "Vendorliste abgerufen." + ); + + await renderGvlSnapshots(); + await renderSelectedGvlSnapshotSummary(); + } catch (error) { + renderFetchStatus("Vendorliste konnte nicht abgerufen werden."); + console.warn("VG-Observe manual official GVL fetch failed", error); + } finally { + gvlFetchOfficialButton.disabled = false; + } +} + +function renderFetchStatus(message) { + gvlFetchStatus.textContent = message; +} + +async function renderGvlSnapshots() { + try { + const result = await browser.runtime.sendMessage({ + type: "list_gvl_snapshots" + }); + + if (!result?.success) { + throw new Error(result?.error ?? "list_gvl_snapshots_failed"); + } + + gvlSnapshots = result.gvlSnapshots ?? []; + + if (gvlSnapshots.length === 0) { + renderNoGvlSnapshots(); + return; + } + + gvlSnapshotEmpty.hidden = true; + gvlSnapshotContent.hidden = false; + + if (!findGvlSnapshot(selectedSnapshotSha256)) { + selectedSnapshotSha256 = gvlSnapshots[0]?.sha256 ?? null; + } + + renderGvlSnapshotList(); + await renderSelectedGvlSnapshotSummary(); + } catch (error) { + gvlSnapshotEmpty.hidden = false; + gvlSnapshotContent.hidden = true; + gvlSnapshotEmpty.textContent = + "Gespeicherte Vendorlisten konnten nicht geladen werden."; + console.warn("VG-Observe GVL snapshot list failed", error); + } +} + +function renderNoGvlSnapshots() { + gvlSnapshotList.textContent = ""; + clearGvlSnapshotSummary(); + gvlSnapshotEmpty.hidden = false; + gvlSnapshotContent.hidden = true; + gvlSnapshotEmpty.textContent = + "Keine gespeicherten offiziellen Vendorlisten vorhanden."; +} + +function renderGvlSnapshotList() { + gvlSnapshotList.textContent = ""; + + for (const snapshot of gvlSnapshots) { + const row = document.createElement("tr"); + const isSelected = snapshot?.sha256 === selectedSnapshotSha256; + + row.className = isSelected ? "is-selected" : ""; + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.setAttribute("aria-pressed", isSelected ? "true" : "false"); + + row.addEventListener("click", async () => { + await selectGvlSnapshot(snapshot?.sha256 ?? null); + }); + + row.addEventListener("keydown", async (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + await selectGvlSnapshot(snapshot?.sha256 ?? null); + } + }); + + appendListCell(row, formatNullable(snapshot?.vendorListVersion), "numeric"); + appendListCell(row, formatNullable(snapshot?.fetchedAt)); + appendListCell(row, shortenSha256(snapshot?.sha256), "sha-cell"); + appendListCell(row, formatNullable(snapshot?.sourceUrl), "url-cell"); + + gvlSnapshotList.append(row); + } +} + +function appendListCell(row, value, className) { + const cell = document.createElement("td"); + + if (className) { + cell.className = className; + } + + cell.textContent = value; + row.append(cell); +} + +async function selectGvlSnapshot(sha256) { + selectedSnapshotSha256 = sha256; + renderGvlSnapshotList(); + await renderSelectedGvlSnapshotSummary(); +} + +async function renderSelectedGvlSnapshotSummary() { + const snapshot = findGvlSnapshot(selectedSnapshotSha256); + + clearGvlSnapshotSummary(); + + if (!snapshot) { + return; + } + + try { + const result = await browser.runtime.sendMessage({ + type: "get_gvl_snapshot_summary", + payload: { + sha256: snapshot.sha256 + } + }); + + if (!result?.success) { + throw new Error(result?.error ?? "get_gvl_snapshot_summary_failed"); + } + + renderSummaryTable(result.summary ?? {}); + } catch (error) { + gvlSnapshotSummary.textContent = + "Zusammenfassung dieser Vendorliste konnte nicht geladen werden."; + console.warn("VG-Observe GVL snapshot summary failed", error); + } +} + +function renderSummaryTable(summary) { + const table = document.createElement("table"); + const body = document.createElement("tbody"); + const rows = [ + ["Vendorlisten-Version", formatNullable(summary.vendorListVersion)], + ["Abrufzeitpunkt", formatNullable(summary.fetchedAt)], + ["Quelle", formatNullable(summary.sourceUrl)], + ["Anzahl Firmen/Vendoren", formatCount(summary.vendorCount)], + ["Anzahl Zwecke/Purposes", formatCount(summary.purposeCount)], + ["Anzahl Special Purposes", formatCount(summary.specialPurposeCount)], + ["Anzahl Features", formatCount(summary.featureCount)], + ["Anzahl Special Features", formatCount(summary.specialFeatureCount)], + ["Anzahl Datenkategorien", formatCount(summary.dataCategoryCount)], + [ + "Anzahl Vendor-Beziehungen", + formatCount(summary.vendorRelationshipCount) + ] + ]; + + table.className = "summary-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.textContent = value; + row.append(labelCell, valueCell); + body.append(row); + } + + table.append(body); + gvlSnapshotSummary.append(table); + + gvlTechnicalFields.textContent = JSON.stringify( + summary.technicalFields ?? {}, + null, + 2 + ); + gvlDebugData.textContent = JSON.stringify( + { + sha256: summary.sha256 ?? null, + eventType: summary.eventType ?? null, + eventCapturedAt: summary.eventCapturedAt ?? null, + diagnostics: summary.diagnostics ?? null + }, + null, + 2 + ); +} + +function clearGvlSnapshotSummary() { + gvlSnapshotSummary.textContent = ""; + gvlTechnicalFields.textContent = ""; + gvlDebugData.textContent = ""; +} + +function findGvlSnapshot(sha256) { + if (!sha256) { + return null; + } + + return ( + gvlSnapshots.find((snapshot) => { + return snapshot?.sha256 === sha256; + }) ?? null + ); +} + +function formatNullable(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + return String(value); +} + +function formatCount(value) { + if (value === null || value === undefined) { + return "0"; + } + + return String(value); +} + +function shortenSha256(value) { + if (!value) { + return "-"; + } + + return `${String(value).slice(0, 12)}...`; +}