Add tracker pattern detail page with properties and detected trackers sections

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-22 11:54:44 +02:00
parent f8debf5406
commit b46f2656f5
20 changed files with 1077 additions and 3 deletions

View File

@@ -307,7 +307,7 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc
</div>
</Td>
<Td>
<span className="font-mono text-sm">{resource.path}</span>
<span className="font-mono text-xs break-all max-w-xs inline-block">{resource.path}</span>
</Td>
<Td>
{resource.lastDetectedAt

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Breadcrumb, PageHeader } from "@probo/ui";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { TrackerPatternDetailPageQuery } from "#/__generated__/core/TrackerPatternDetailPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { TrackerPatternDetectedTrackersSection } from "./_components/TrackerPatternDetectedTrackersSection";
import { TrackerPatternPropertiesSection } from "./_components/TrackerPatternPropertiesSection";
export const trackerPatternDetailPageQuery = graphql`
query TrackerPatternDetailPageQuery(
$cookieBannerId: ID!
$trackerPatternId: ID!
) {
cookieBanner: node(id: $cookieBannerId) @required(action: THROW) {
__typename
... on CookieBanner {
id
name
}
}
node(id: $trackerPatternId) @required(action: THROW) {
__typename
... on TrackerPattern {
id
displayName
...TrackerPatternPropertiesSection_trackerPattern
...TrackerPatternDetectedTrackersSection_trackerPattern
}
}
}
`;
interface TrackerPatternDetailPageProps {
queryRef: PreloadedQuery<TrackerPatternDetailPageQuery>;
}
export default function TrackerPatternDetailPage({
queryRef,
}: TrackerPatternDetailPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const data = usePreloadedQuery(trackerPatternDetailPageQuery, queryRef);
if (data.cookieBanner.__typename !== "CookieBanner") {
throw new Error("invalid type for cookieBanner node");
}
if (data.node.__typename !== "TrackerPattern") {
throw new Error("invalid type for node");
}
const cookieBanner = data.cookieBanner;
const pattern = data.node;
return (
<div className="space-y-6">
<Breadcrumb
items={[
{
label: __("Cookie Banners"),
to: `/organizations/${organizationId}/cookie-banners`,
},
{
label: cookieBanner.name,
to: `/organizations/${organizationId}/cookie-banners/${cookieBanner.id}/settings`,
},
{
label: __("Trackers"),
to: `/organizations/${organizationId}/cookie-banners/${cookieBanner.id}/trackers`,
},
{
label: pattern.displayName,
},
]}
/>
<PageHeader title={pattern.displayName} />
<TrackerPatternPropertiesSection trackerPatternKey={pattern} />
<TrackerPatternDetectedTrackersSection trackerPatternKey={pattern} />
</div>
);
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { TrackerPatternDetailPageQuery } from "#/__generated__/core/TrackerPatternDetailPageQuery.graphql";
import TrackerPatternDetailPage, { trackerPatternDetailPageQuery } from "./TrackerPatternDetailPage";
import { TrackerPatternDetailPageSkeleton } from "./TrackerPatternDetailPageSkeleton";
export default function TrackerPatternDetailPageLoader() {
const { cookieBannerId, trackerPatternId } = useParams<{
cookieBannerId: string;
trackerPatternId: string;
}>();
if (typeof cookieBannerId !== "string") {
throw new Error("Missing cookieBannerId parameter");
}
if (typeof trackerPatternId !== "string") {
throw new Error("Missing trackerPatternId parameter");
}
const [queryRef, loadQuery] = useQueryLoader<TrackerPatternDetailPageQuery>(
trackerPatternDetailPageQuery,
);
useEffect(() => {
loadQuery({ cookieBannerId, trackerPatternId });
}, [loadQuery, cookieBannerId, trackerPatternId]);
if (!queryRef) {
return <TrackerPatternDetailPageSkeleton />;
}
return (
<Suspense fallback={<TrackerPatternDetailPageSkeleton />}>
<TrackerPatternDetailPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
export function TrackerPatternDetailPageSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="rounded-2xl border border-border-low p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 border-b border-border-low last:border-b-0">
<div className="h-4 w-28 rounded bg-bg-subtle" />
<div className="h-4 w-48 rounded bg-bg-subtle" />
</div>
))}
</div>
<div className="rounded-2xl border border-border-low p-6 space-y-4">
<div className="h-5 w-48 rounded bg-bg-subtle" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-border-low last:border-b-0">
<div className="h-4 w-32 rounded bg-bg-subtle" />
<div className="h-4 w-48 rounded bg-bg-subtle" />
<div className="h-4 w-16 rounded bg-bg-subtle" />
<div className="h-4 w-20 rounded bg-bg-subtle" />
<div className="h-4 w-28 rounded bg-bg-subtle" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Badge, Td, Tr } from "@probo/ui";
import { graphql, useFragment } from "react-relay";
import type { DetectedTrackerRow_detectedTracker$key } from "#/__generated__/core/DetectedTrackerRow_detectedTracker.graphql";
const detectedTrackerFragment = graphql`
fragment DetectedTrackerRow_detectedTracker on DetectedTracker {
id
identifier
initiatorUrl
maxAgeSeconds
source
lastDetectedAt
}
`;
function sourceBadge(source: string, __: (s: string) => string) {
switch (source) {
case "SCRIPT": return { label: __("Script"), variant: "info" as const };
case "PRE_EXISTING": return { label: __("Pre-existing"), variant: "outline" as const };
default: return { label: source, variant: "neutral" as const };
}
}
interface DetectedTrackerRowProps {
detectedTrackerKey: DetectedTrackerRow_detectedTracker$key;
}
export function DetectedTrackerRow({ detectedTrackerKey }: DetectedTrackerRowProps) {
const { __ } = useTranslate();
const tracker = useFragment(detectedTrackerFragment, detectedTrackerKey);
return (
<Tr>
<Td>
<span className="font-mono text-xs break-all max-w-xs inline-block">{tracker.identifier}</span>
</Td>
<Td>
{tracker.initiatorUrl
? <span className="font-mono text-xs break-all max-w-xs inline-block">{tracker.initiatorUrl}</span>
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
{tracker.maxAgeSeconds != null
? <span className="text-sm">{tracker.maxAgeSeconds}</span>
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
{tracker.source
? (
<Badge variant={sourceBadge(tracker.source, __).variant}>
{sourceBadge(tracker.source, __).label}
</Badge>
)
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
<time dateTime={tracker.lastDetectedAt}>
{new Date(tracker.lastDetectedAt).toLocaleString()}
</time>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
import { type ComponentProps } from "react";
import { graphql, usePaginationFragment } from "react-relay";
import type { TrackerPatternDetectedTrackersSection_trackerPattern$key } from "#/__generated__/core/TrackerPatternDetectedTrackersSection_trackerPattern.graphql";
import type {
DetectedTrackerOrderField,
TrackerPatternDetectedTrackersSectionRefetchQuery,
} from "#/__generated__/core/TrackerPatternDetectedTrackersSectionRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { DetectedTrackerRow } from "./DetectedTrackerRow";
export const trackerPatternDetectedTrackersSectionFragment = graphql`
fragment TrackerPatternDetectedTrackersSection_trackerPattern on TrackerPattern
@refetchable(queryName: "TrackerPatternDetectedTrackersSectionRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "DetectedTrackerOrder", defaultValue: { field: LAST_DETECTED_AT, direction: DESC } }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
detectedTrackers(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "TrackerPatternDetectedTrackersSection_detectedTrackers", filters: ["orderBy"]) {
__id
edges {
node {
id
...DetectedTrackerRow_detectedTracker
}
}
}
}
`;
interface TrackerPatternDetectedTrackersSectionProps {
trackerPatternKey: TrackerPatternDetectedTrackersSection_trackerPattern$key;
}
export function TrackerPatternDetectedTrackersSection({
trackerPatternKey,
}: TrackerPatternDetectedTrackersSectionProps) {
const { __ } = useTranslate();
const { data, ...pagination } = usePaginationFragment<
TrackerPatternDetectedTrackersSectionRefetchQuery,
TrackerPatternDetectedTrackersSection_trackerPattern$key
>(trackerPatternDetectedTrackersSectionFragment, trackerPatternKey);
const trackers = data.detectedTrackers?.edges.map(edge => edge.node) ?? [];
const refetchWithOrder: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
pagination.refetch({
order: { direction: order.direction, field: order.field as DetectedTrackerOrderField },
});
};
return (
<>
<h3 className="text-lg font-semibold">{__("Detected Trackers")}</h3>
{trackers.length > 0
? (
<SortableTable
{...pagination}
refetch={refetchWithOrder}
pageSize={50}
>
<Thead>
<Tr>
<Th>{__("Identifier")}</Th>
<SortableTh field="INITIATOR_URL">{__("Initiator URL")}</SortableTh>
<Th>{__("Max Age (s)")}</Th>
<Th>{__("Source")}</Th>
<SortableTh field="LAST_DETECTED_AT">{__("Detection Time")}</SortableTh>
</Tr>
</Thead>
<Tbody>
{trackers.map(tracker => (
<DetectedTrackerRow
key={tracker.id}
detectedTrackerKey={tracker}
/>
))}
</Tbody>
</SortableTable>
)
: (
<Card padded>
<div className="text-center py-12">
<p className="text-txt-tertiary">
{__("No detected trackers for this pattern yet.")}
</p>
</div>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { humanizeSeconds } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Badge, Card, PropertyRow } from "@probo/ui";
import { graphql, useFragment } from "react-relay";
import type { TrackerPatternPropertiesSection_trackerPattern$key } from "#/__generated__/core/TrackerPatternPropertiesSection_trackerPattern.graphql";
const trackerPatternPropertiesSectionFragment = graphql`
fragment TrackerPatternPropertiesSection_trackerPattern on TrackerPattern {
pattern
matchType
trackerType
source
maxAgeSeconds
description
excluded
detectedCount
lastMatchedAt
cookieCategory {
name
}
}
`;
function trackerTypeBadge(type: string, __: (s: string) => string) {
switch (type) {
case "COOKIE": return { label: __("Cookie"), variant: "warning" as const };
case "LOCAL_STORAGE": return { label: __("localStorage"), variant: "info" as const };
case "SESSION_STORAGE": return { label: __("sessionStorage"), variant: "highlight" as const };
case "INDEXED_DB": return { label: __("IndexedDB"), variant: "success" as const };
case "CACHE_STORAGE": return { label: __("Cache Storage"), variant: "outline" as const };
default: return { label: type, variant: "neutral" as const };
}
}
function sourceBadge(source: string, __: (s: string) => string) {
switch (source) {
case "SCRIPT": return { label: __("Script"), variant: "info" as const };
case "PRE_EXISTING": return { label: __("Pre-existing"), variant: "outline" as const };
default: return { label: source, variant: "neutral" as const };
}
}
interface TrackerPatternPropertiesSectionProps {
trackerPatternKey: TrackerPatternPropertiesSection_trackerPattern$key;
}
export function TrackerPatternPropertiesSection({
trackerPatternKey,
}: TrackerPatternPropertiesSectionProps) {
const { __ } = useTranslate();
const pattern = useFragment(
trackerPatternPropertiesSectionFragment,
trackerPatternKey,
);
const typeBadge = trackerTypeBadge(pattern.trackerType, __);
return (
<Card padded>
<PropertyRow label={__("Pattern")}>
<span className="font-mono text-sm">{pattern.pattern}</span>
</PropertyRow>
<PropertyRow label={__("Match Type")}>
<span className="text-sm">{pattern.matchType === "EXACT" ? __("Exact") : __("Glob")}</span>
</PropertyRow>
<PropertyRow label={__("Type")}>
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
</PropertyRow>
{pattern.source && (
<PropertyRow label={__("Source")}>
<Badge variant={sourceBadge(pattern.source, __).variant}>
{sourceBadge(pattern.source, __).label}
</Badge>
</PropertyRow>
)}
<PropertyRow label={__("Category")}>
<span className="text-sm">
{pattern.cookieCategory?.name ?? "-"}
</span>
</PropertyRow>
<PropertyRow label={__("Max Age")}>
<span className="text-sm">
{humanizeSeconds(pattern.maxAgeSeconds ?? null)}
</span>
</PropertyRow>
{pattern.description && (
<PropertyRow label={__("Description")}>
<span className="text-sm">{pattern.description}</span>
</PropertyRow>
)}
<PropertyRow label={__("Excluded")}>
<span className="text-sm">{pattern.excluded ? __("Yes") : __("No")}</span>
</PropertyRow>
<PropertyRow label={__("Detected Count")}>
<span className="text-sm">{pattern.detectedCount}</span>
</PropertyRow>
<PropertyRow label={__("Last Matched")}>
{pattern.lastMatchedAt
? (
<time dateTime={pattern.lastMatchedAt}>
{new Date(pattern.lastMatchedAt).toLocaleString()}
</time>
)
: <span className="text-txt-tertiary">-</span>}
</PropertyRow>
</Card>
);
}

View File

@@ -300,9 +300,9 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
const srcBadge = pattern.source ? sourceBadge(pattern.source, __) : null;
return (
<Tr className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}>
<Tr to={pattern.id} className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}>
<Td>
<div className="flex flex-col min-w-0">
<div className="flex flex-col min-w-0 max-w-xs">
<span className={pattern.excluded ? undefined : "font-medium"}>{pattern.displayName}</span>
{pattern.description && (
<span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1">

View File

@@ -81,4 +81,9 @@ export const cookieBannerRoutes = [
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordPageLoader")),
},
{
path: "cookie-banners/:cookieBannerId/trackers/:trackerPatternId",
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageLoader")),
},
] satisfies AppRoute[];