Add tracker pattern detail page with properties and detected trackers sections
Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
72
.cursor/rules/relay-connection-item-components.mdc
Normal file
72
.cursor/rules/relay-connection-item-components.mdc
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
description: Extract connection list items (table rows, list entries) into their own fragment component
|
||||||
|
globs: "**/*.tsx"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Extract connection items into fragment components
|
||||||
|
|
||||||
|
When rendering items from a Relay connection (e.g. `edges.map(…)`), each item
|
||||||
|
MUST be rendered by a dedicated component that owns its own fragment — never
|
||||||
|
inline the rendering of node fields directly in the parent's `.map()` body.
|
||||||
|
|
||||||
|
This ensures:
|
||||||
|
- Data requirements are colocated with the rendering component
|
||||||
|
- Adding/removing fields in a row doesn't bloat the parent's fragment
|
||||||
|
- The row component is independently testable and reusable
|
||||||
|
|
||||||
|
## Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// _components/ThingRow.tsx — owns its fragment
|
||||||
|
const thingRowFragment = graphql`
|
||||||
|
fragment ThingRow_thing on Thing {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
status
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface ThingRowProps {
|
||||||
|
thingKey: ThingRow_thing$key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThingRow({ thingKey }: ThingRowProps) {
|
||||||
|
const thing = useFragment(thingRowFragment, thingKey);
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td>{thing.name}</Td>
|
||||||
|
<Td>{thing.status}</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Parent — spreads the row fragment in its connection and renders the component
|
||||||
|
const parentFragment = graphql`
|
||||||
|
fragment ParentPage_things on Query
|
||||||
|
@refetchable(queryName: "ParentPageRefetchQuery") {
|
||||||
|
things(first: $first, after: $after) @connection(key: "ParentPage_things") {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
...ThingRow_thing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// In JSX:
|
||||||
|
{things.map(thing => (
|
||||||
|
<ThingRow key={thing.id} thingKey={thing} />
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
- File: `_components/<NodeType>Row.tsx` (for table rows) or
|
||||||
|
`_components/<NodeType>Card.tsx` (for card lists)
|
||||||
|
- Fragment: `<ComponentName>_<typeName>` (e.g. `ThingRow_thing`)
|
||||||
|
- Prop: `<typeName>Key` (e.g. `thingKey`)
|
||||||
@@ -39,3 +39,6 @@ interface EditCookieRowProps {
|
|||||||
|
|
||||||
Callback props (`onSave`, `onCancel`) and configuration props (`isUpdating`,
|
Callback props (`onSave`, `onCancel`) and configuration props (`isUpdating`,
|
||||||
`variant`) are fine — only **domain data** must come from fragments.
|
`variant`) are fine — only **domain data** must come from fragments.
|
||||||
|
|
||||||
|
For connection items (table rows, list entries rendered in `.map()`), see the
|
||||||
|
companion rule `relay-connection-item-components.mdc`.
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc
|
|||||||
</div>
|
</div>
|
||||||
</Td>
|
</Td>
|
||||||
<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>
|
||||||
<Td>
|
<Td>
|
||||||
{resource.lastDetectedAt
|
{resource.lastDetectedAt
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -300,9 +300,9 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
|
|||||||
const srcBadge = pattern.source ? sourceBadge(pattern.source, __) : null;
|
const srcBadge = pattern.source ? sourceBadge(pattern.source, __) : null;
|
||||||
|
|
||||||
return (
|
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>
|
<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>
|
<span className={pattern.excluded ? undefined : "font-medium"}>{pattern.displayName}</span>
|
||||||
{pattern.description && (
|
{pattern.description && (
|
||||||
<span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1">
|
<span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1">
|
||||||
|
|||||||
@@ -81,4 +81,9 @@ export const cookieBannerRoutes = [
|
|||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordPageLoader")),
|
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[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
@@ -264,6 +264,51 @@ pages/organizations/third-parties/ThirdPartiesPage.tsx
|
|||||||
pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
|
pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Page sections
|
||||||
|
|
||||||
|
When a detail page has visually distinct sections (e.g. a properties card and a paginated list), extract each section into its own component in `_components/`. Each section owns a colocated Relay fragment (or pagination fragment) so that field additions never modify the parent page's query.
|
||||||
|
|
||||||
|
The page query spreads the section fragments and passes the fragment key to each section component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// TrackerPatternDetailPage.tsx (page — spreads section fragments)
|
||||||
|
export const trackerPatternDetailPageQuery = graphql`
|
||||||
|
query TrackerPatternDetailPageQuery($trackerPatternId: ID!) {
|
||||||
|
node(id: $trackerPatternId) {
|
||||||
|
... on TrackerPattern {
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
...TrackerPatternPropertiesSection_trackerPattern
|
||||||
|
...TrackerPatternDetectedTrackersSection_trackerPattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// In JSX:
|
||||||
|
<TrackerPatternPropertiesSection trackerPatternKey={pattern} />
|
||||||
|
<TrackerPatternDetectedTrackersSection trackerPatternKey={pattern} />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// _components/TrackerPatternPropertiesSection.tsx — owns its fragment
|
||||||
|
const fragment = graphql`
|
||||||
|
fragment TrackerPatternPropertiesSection_trackerPattern on TrackerPattern {
|
||||||
|
pattern
|
||||||
|
matchType
|
||||||
|
trackerType
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function TrackerPatternPropertiesSection({ trackerPatternKey }: Props) {
|
||||||
|
const pattern = useFragment(fragment, trackerPatternKey);
|
||||||
|
return <Card padded>{/* PropertyRows */}</Card>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Section components follow the same naming and fragment conventions as connection item components (see below), but represent a **logical section** of a page rather than a single list item.
|
||||||
|
|
||||||
## `_components` folder
|
## `_components` folder
|
||||||
|
|
||||||
Sub-components that are used **only** by a single page live in a `_components/` folder next to that page. The underscore prefix visually distinguishes them from route-segment folders.
|
Sub-components that are used **only** by a single page live in a `_components/` folder next to that page. The underscore prefix visually distinguishes them from route-segment folders.
|
||||||
|
|||||||
@@ -400,3 +400,68 @@ export function MoveToCategoryMenu({ queryRef, onMove }: Props) {
|
|||||||
See also the "Interaction-triggered queries" section in [`contrib/claude/relay.md`](relay.md).
|
See also the "Interaction-triggered queries" section in [`contrib/claude/relay.md`](relay.md).
|
||||||
|
|
||||||
(Snippet names and GraphQL types are illustrative; align with real schema and fragment names in the app.)
|
(Snippet names and GraphQL types are illustrative; align with real schema and fragment names in the app.)
|
||||||
|
|
||||||
|
## Page sections are components
|
||||||
|
|
||||||
|
When a detail page has multiple distinct sections (e.g. a properties card and a paginated list), extract each section into its own component in `_components/`. Each section owns a colocated Relay fragment so that field additions never modify the parent page's query.
|
||||||
|
|
||||||
|
The page spreads the section fragments on the shared node and passes the fragment key:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Page query — spreads section fragments
|
||||||
|
export const detailPageQuery = graphql`
|
||||||
|
query DetailPageQuery($nodeId: ID!) {
|
||||||
|
node(id: $nodeId) {
|
||||||
|
... on MyType {
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
...MyTypePropertiesSection_myType
|
||||||
|
...MyTypeListSection_myType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Page JSX:
|
||||||
|
<MyTypePropertiesSection myTypeKey={node} />
|
||||||
|
<MyTypeListSection myTypeKey={node} />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// _components/MyTypePropertiesSection.tsx
|
||||||
|
const fragment = graphql`
|
||||||
|
fragment MyTypePropertiesSection_myType on MyType {
|
||||||
|
field1
|
||||||
|
field2
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface MyTypePropertiesSectionProps {
|
||||||
|
myTypeKey: MyTypePropertiesSection_myType$key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyTypePropertiesSection({ myTypeKey }: MyTypePropertiesSectionProps) {
|
||||||
|
const data = useFragment(fragment, myTypeKey);
|
||||||
|
return <Card padded>{/* PropertyRows */}</Card>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For sections that own a paginated connection, use `usePaginationFragment` with a `@refetchable` fragment — the same pattern as a standalone page, but scoped to a section component.
|
||||||
|
|
||||||
|
## Connection items are components
|
||||||
|
|
||||||
|
When rendering items from a Relay connection (e.g. table rows via `edges.map(…)`), each item **must** be a dedicated component with its own colocated fragment — never inline the rendering of node fields directly in the parent's `.map()` body.
|
||||||
|
|
||||||
|
Place the item component in `_components/` adjacent to the page. Name it after the GraphQL type it renders (e.g. `DetectedTrackerRow.tsx`, `ThirdPartyCard.tsx`). The component receives a single fragment key prop (e.g. `detectedTrackerKey: DetectedTrackerRow_detectedTracker$key`) and calls `useFragment` internally.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Parent (page) — spreads the child fragment in the connection:
|
||||||
|
edges { node { id ...DetectedTrackerRow_detectedTracker } }
|
||||||
|
|
||||||
|
// Parent JSX:
|
||||||
|
{trackers.map(tracker => (
|
||||||
|
<DetectedTrackerRow key={tracker.id} detectedTrackerKey={tracker} />
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures field additions/removals in the row never modify the parent's fragment, and keeps the item independently testable.
|
||||||
|
|||||||
@@ -2714,6 +2714,31 @@ func (s *Service) CountDetectedTrackersByPatternID(
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListDetectedTrackersForPattern(
|
||||||
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
|
trackerPatternID gid.GID,
|
||||||
|
cursor *page.Cursor[coredata.DetectedTrackerOrderField],
|
||||||
|
) (coredata.DetectedTrackers, error) {
|
||||||
|
var trackers coredata.DetectedTrackers
|
||||||
|
|
||||||
|
err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
if err := trackers.LoadByTrackerPatternID(ctx, conn, scope, trackerPatternID, cursor); err != nil {
|
||||||
|
return fmt.Errorf("cannot list detected trackers for pattern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackers, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateTrackerResource(
|
func (s *Service) CreateTrackerResource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -45,6 +46,21 @@ type (
|
|||||||
DetectedTrackers []*DetectedTracker
|
DetectedTrackers []*DetectedTracker
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (dt *DetectedTracker) CursorKey(field DetectedTrackerOrderField) page.CursorKey {
|
||||||
|
switch field {
|
||||||
|
case DetectedTrackerOrderFieldInitiatorURL:
|
||||||
|
if dt.InitiatorURL == nil {
|
||||||
|
return page.NewCursorKey(dt.ID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return page.NewCursorKey(dt.ID, *dt.InitiatorURL)
|
||||||
|
case DetectedTrackerOrderFieldLastDetectedAt:
|
||||||
|
return page.NewCursorKey(dt.ID, dt.LastDetectedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(fmt.Sprintf("unsupported order by: %s", field))
|
||||||
|
}
|
||||||
|
|
||||||
func (dt *DetectedTracker) Upsert(
|
func (dt *DetectedTracker) Upsert(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
@@ -183,6 +199,59 @@ LIMIT @limit;
|
|||||||
return domains, nil
|
return domains, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (dts *DetectedTrackers) LoadByTrackerPatternID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
trackerPatternID gid.GID,
|
||||||
|
cursor *page.Cursor[DetectedTrackerOrderField],
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
cookie_banner_id,
|
||||||
|
tracker_pattern_id,
|
||||||
|
tracker_type,
|
||||||
|
identifier,
|
||||||
|
max_age_seconds,
|
||||||
|
source,
|
||||||
|
value_size,
|
||||||
|
initiator_url,
|
||||||
|
initiator_domain,
|
||||||
|
last_detected_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
detected_trackers
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND tracker_pattern_id = @tracker_pattern_id
|
||||||
|
AND %s
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"tracker_pattern_id": trackerPatternID,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query detected trackers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DetectedTracker])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect detected trackers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*dts = trackers
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (dts *DetectedTrackers) RelinkByTrackerPatternID(
|
func (dts *DetectedTrackers) RelinkByTrackerPatternID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
|
|||||||
84
pkg/coredata/detected_tracker_order_field.go
Normal file
84
pkg/coredata/detected_tracker_order_field.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package coredata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.probo.inc/probo/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DetectedTrackerOrderField string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DetectedTrackerOrderFieldInitiatorURL DetectedTrackerOrderField = "INITIATOR_URL"
|
||||||
|
DetectedTrackerOrderFieldLastDetectedAt DetectedTrackerOrderField = "LAST_DETECTED_AT"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ page.OrderField = DetectedTrackerOrderField("")
|
||||||
|
_ fmt.Stringer = DetectedTrackerOrderField("")
|
||||||
|
_ encoding.TextMarshaler = DetectedTrackerOrderField("")
|
||||||
|
_ encoding.TextUnmarshaler = (*DetectedTrackerOrderField)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func DetectedTrackerOrderFields() []DetectedTrackerOrderField {
|
||||||
|
return []DetectedTrackerOrderField{
|
||||||
|
DetectedTrackerOrderFieldInitiatorURL,
|
||||||
|
DetectedTrackerOrderFieldLastDetectedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v DetectedTrackerOrderField) IsValid() bool {
|
||||||
|
switch v {
|
||||||
|
case
|
||||||
|
DetectedTrackerOrderFieldInitiatorURL,
|
||||||
|
DetectedTrackerOrderFieldLastDetectedAt:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v DetectedTrackerOrderField) String() string {
|
||||||
|
return string(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v DetectedTrackerOrderField) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(v.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *DetectedTrackerOrderField) UnmarshalText(text []byte) error {
|
||||||
|
val := DetectedTrackerOrderField(text)
|
||||||
|
if !val.IsValid() {
|
||||||
|
return fmt.Errorf("invalid DetectedTrackerOrderField value: %q", string(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
*v = val
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p DetectedTrackerOrderField) Column() string {
|
||||||
|
switch p {
|
||||||
|
case DetectedTrackerOrderFieldInitiatorURL:
|
||||||
|
return "COALESCE(initiator_url, '')"
|
||||||
|
case DetectedTrackerOrderFieldLastDetectedAt:
|
||||||
|
return "last_detected_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(fmt.Sprintf("unsupported order by: %s", p))
|
||||||
|
}
|
||||||
@@ -465,6 +465,18 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
|||||||
|
|
||||||
return types.NewCookieConsentRecord(record), nil
|
return types.NewCookieConsentRecord(record), nil
|
||||||
}
|
}
|
||||||
|
case coredata.TrackerPatternEntityType:
|
||||||
|
action = probo.ActionTrackerPatternGet
|
||||||
|
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
||||||
|
scope := coredata.NewScopeFromObjectID(id)
|
||||||
|
|
||||||
|
pattern, err := r.cookieBanner.GetTrackerPattern(ctx, scope, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewTrackerPatternNode(pattern), nil
|
||||||
|
}
|
||||||
case coredata.CookieBannerVersionEntityType:
|
case coredata.CookieBannerVersionEntityType:
|
||||||
action = probo.ActionCookieBannerVersionGet
|
action = probo.ActionCookieBannerVersionGet
|
||||||
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
||||||
|
|||||||
@@ -434,6 +434,19 @@ func (r *cookieCategoryConnectionResolver) TotalCount(ctx context.Context, obj *
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TotalCount is the resolver for the totalCount field.
|
||||||
|
func (r *detectedTrackerConnectionResolver) TotalCount(ctx context.Context, obj *types.DetectedTrackerConnection) (int, error) {
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ParentID)
|
||||||
|
|
||||||
|
count, err := r.cookieBanner.CountDetectedTrackersByPatternID(ctx, scope, obj.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot count detected trackers", log.Error(err))
|
||||||
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateCookieBanner is the resolver for the createCookieBanner field.
|
// CreateCookieBanner is the resolver for the createCookieBanner field.
|
||||||
func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) {
|
func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) {
|
||||||
if err := r.authorize(ctx, input.OrganizationID, probo.ActionCookieBannerCreate); err != nil {
|
if err := r.authorize(ctx, input.OrganizationID, probo.ActionCookieBannerCreate); err != nil {
|
||||||
@@ -1278,6 +1291,37 @@ func (r *trackerPatternResolver) DetectedCount(ctx context.Context, obj *types.T
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DetectedTrackers is the resolver for the detectedTrackers field.
|
||||||
|
func (r *trackerPatternResolver) DetectedTrackers(ctx context.Context, obj *types.TrackerPattern, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DetectedTrackerOrderBy) (*types.DetectedTrackerConnection, error) {
|
||||||
|
if err := r.authorize(ctx, obj.ID, probo.ActionTrackerPatternGet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageOrderBy := page.OrderBy[coredata.DetectedTrackerOrderField]{
|
||||||
|
Field: coredata.DetectedTrackerOrderFieldLastDetectedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
}
|
||||||
|
if orderBy != nil {
|
||||||
|
pageOrderBy = page.OrderBy[coredata.DetectedTrackerOrderField]{
|
||||||
|
Field: orderBy.Field,
|
||||||
|
Direction: orderBy.Direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ID)
|
||||||
|
|
||||||
|
trackers, err := r.cookieBanner.ListDetectedTrackersForPattern(ctx, scope, obj.ID, cursor)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot list detected trackers", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := page.NewPage(trackers, cursor)
|
||||||
|
|
||||||
|
return types.NewDetectedTrackerConnection(p, r, obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Permission is the resolver for the permission field.
|
// Permission is the resolver for the permission field.
|
||||||
func (r *trackerPatternResolver) Permission(ctx context.Context, obj *types.TrackerPattern, action string) (bool, error) {
|
func (r *trackerPatternResolver) Permission(ctx context.Context, obj *types.TrackerPattern, action string) (bool, error) {
|
||||||
return r.Resolver.Permission(ctx, obj, action)
|
return r.Resolver.Permission(ctx, obj, action)
|
||||||
@@ -1390,6 +1434,11 @@ func (r *Resolver) CookieCategoryConnection() schema.CookieCategoryConnectionRes
|
|||||||
return &cookieCategoryConnectionResolver{r}
|
return &cookieCategoryConnectionResolver{r}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DetectedTrackerConnection returns schema.DetectedTrackerConnectionResolver implementation.
|
||||||
|
func (r *Resolver) DetectedTrackerConnection() schema.DetectedTrackerConnectionResolver {
|
||||||
|
return &detectedTrackerConnectionResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
// TrackerPattern returns schema.TrackerPatternResolver implementation.
|
// TrackerPattern returns schema.TrackerPatternResolver implementation.
|
||||||
func (r *Resolver) TrackerPattern() schema.TrackerPatternResolver { return &trackerPatternResolver{r} }
|
func (r *Resolver) TrackerPattern() schema.TrackerPatternResolver { return &trackerPatternResolver{r} }
|
||||||
|
|
||||||
@@ -1413,6 +1462,7 @@ type cookieBannerConnectionResolver struct{ *Resolver }
|
|||||||
type cookieBannerVersionResolver struct{ *Resolver }
|
type cookieBannerVersionResolver struct{ *Resolver }
|
||||||
type cookieCategoryResolver struct{ *Resolver }
|
type cookieCategoryResolver struct{ *Resolver }
|
||||||
type cookieCategoryConnectionResolver struct{ *Resolver }
|
type cookieCategoryConnectionResolver struct{ *Resolver }
|
||||||
|
type detectedTrackerConnectionResolver struct{ *Resolver }
|
||||||
type trackerPatternResolver struct{ *Resolver }
|
type trackerPatternResolver struct{ *Resolver }
|
||||||
type trackerPatternConnectionResolver struct{ *Resolver }
|
type trackerPatternConnectionResolver struct{ *Resolver }
|
||||||
type trackerResourceResolver struct{ *Resolver }
|
type trackerResourceResolver struct{ *Resolver }
|
||||||
|
|||||||
@@ -275,6 +275,14 @@ type TrackerPattern implements Node {
|
|||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
|
|
||||||
|
detectedTrackers(
|
||||||
|
first: Int
|
||||||
|
after: CursorKey
|
||||||
|
last: Int
|
||||||
|
before: CursorKey
|
||||||
|
orderBy: DetectedTrackerOrder
|
||||||
|
): DetectedTrackerConnection @goField(forceResolver: true)
|
||||||
|
|
||||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +318,52 @@ input TrackerPatternFilter
|
|||||||
cookieCategoryId: ID
|
cookieCategoryId: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DetectedTracker implements Node {
|
||||||
|
id: ID!
|
||||||
|
identifier: String!
|
||||||
|
initiatorUrl: String
|
||||||
|
maxAgeSeconds: Int
|
||||||
|
source: CookieSource
|
||||||
|
lastDetectedAt: Datetime!
|
||||||
|
createdAt: Datetime!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DetectedTrackerOrderField
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/coredata.DetectedTrackerOrderField"
|
||||||
|
) {
|
||||||
|
INITIATOR_URL
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DetectedTrackerOrderFieldInitiatorURL"
|
||||||
|
)
|
||||||
|
LAST_DETECTED_AT
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DetectedTrackerOrderFieldLastDetectedAt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
input DetectedTrackerOrder
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DetectedTrackerOrderBy"
|
||||||
|
) {
|
||||||
|
direction: OrderDirection!
|
||||||
|
field: DetectedTrackerOrderField!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetectedTrackerConnection
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DetectedTrackerConnection"
|
||||||
|
) {
|
||||||
|
totalCount: Int! @goField(forceResolver: true)
|
||||||
|
edges: [DetectedTrackerEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetectedTrackerEdge {
|
||||||
|
cursor: CursorKey!
|
||||||
|
node: DetectedTracker!
|
||||||
|
}
|
||||||
|
|
||||||
enum TrackerResourceType
|
enum TrackerResourceType
|
||||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.TrackerResourceType") {
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.TrackerResourceType") {
|
||||||
SCRIPT
|
SCRIPT
|
||||||
|
|||||||
76
pkg/server/api/console/v1/types/detected_tracker.go
Normal file
76
pkg/server/api/console/v1/types/detected_tracker.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
DetectedTrackerOrderBy OrderBy[coredata.DetectedTrackerOrderField]
|
||||||
|
|
||||||
|
DetectedTrackerConnection struct {
|
||||||
|
TotalCount int
|
||||||
|
Edges []*DetectedTrackerEdge
|
||||||
|
PageInfo PageInfo
|
||||||
|
|
||||||
|
Resolver any
|
||||||
|
ParentID gid.GID
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDetectedTrackerConnection(
|
||||||
|
p *page.Page[*coredata.DetectedTracker, coredata.DetectedTrackerOrderField],
|
||||||
|
parentType any,
|
||||||
|
parentID gid.GID,
|
||||||
|
) *DetectedTrackerConnection {
|
||||||
|
edges := make([]*DetectedTrackerEdge, len(p.Data))
|
||||||
|
|
||||||
|
for i := range edges {
|
||||||
|
edges[i] = NewDetectedTrackerEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DetectedTrackerConnection{
|
||||||
|
Edges: edges,
|
||||||
|
PageInfo: *NewPageInfo(p),
|
||||||
|
|
||||||
|
Resolver: parentType,
|
||||||
|
ParentID: parentID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDetectedTrackerEdge(
|
||||||
|
dt *coredata.DetectedTracker,
|
||||||
|
orderBy coredata.DetectedTrackerOrderField,
|
||||||
|
) *DetectedTrackerEdge {
|
||||||
|
return &DetectedTrackerEdge{
|
||||||
|
Cursor: dt.CursorKey(orderBy),
|
||||||
|
Node: NewDetectedTrackerNode(dt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDetectedTrackerNode(dt *coredata.DetectedTracker) *DetectedTracker {
|
||||||
|
return &DetectedTracker{
|
||||||
|
ID: dt.ID,
|
||||||
|
Identifier: dt.Identifier,
|
||||||
|
InitiatorURL: dt.InitiatorURL,
|
||||||
|
MaxAgeSeconds: dt.MaxAgeSeconds,
|
||||||
|
Source: dt.Source,
|
||||||
|
LastDetectedAt: dt.LastDetectedAt,
|
||||||
|
CreatedAt: dt.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user