Add tracker pattern detail page with properties and detected trackers sections
Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -2714,6 +2714,31 @@ func (s *Service) CountDetectedTrackersByPatternID(
|
||||
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(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -45,6 +46,21 @@ type (
|
||||
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(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
@@ -183,6 +199,59 @@ LIMIT @limit;
|
||||
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(
|
||||
ctx context.Context,
|
||||
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
|
||||
}
|
||||
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:
|
||||
action = probo.ActionCookieBannerVersionGet
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (r *trackerPatternResolver) Permission(ctx context.Context, obj *types.TrackerPattern, action string) (bool, error) {
|
||||
return r.Resolver.Permission(ctx, obj, action)
|
||||
@@ -1390,6 +1434,11 @@ func (r *Resolver) CookieCategoryConnection() schema.CookieCategoryConnectionRes
|
||||
return &cookieCategoryConnectionResolver{r}
|
||||
}
|
||||
|
||||
// DetectedTrackerConnection returns schema.DetectedTrackerConnectionResolver implementation.
|
||||
func (r *Resolver) DetectedTrackerConnection() schema.DetectedTrackerConnectionResolver {
|
||||
return &detectedTrackerConnectionResolver{r}
|
||||
}
|
||||
|
||||
// TrackerPattern returns schema.TrackerPatternResolver implementation.
|
||||
func (r *Resolver) TrackerPattern() schema.TrackerPatternResolver { return &trackerPatternResolver{r} }
|
||||
|
||||
@@ -1413,6 +1462,7 @@ type cookieBannerConnectionResolver struct{ *Resolver }
|
||||
type cookieBannerVersionResolver struct{ *Resolver }
|
||||
type cookieCategoryResolver struct{ *Resolver }
|
||||
type cookieCategoryConnectionResolver struct{ *Resolver }
|
||||
type detectedTrackerConnectionResolver struct{ *Resolver }
|
||||
type trackerPatternResolver struct{ *Resolver }
|
||||
type trackerPatternConnectionResolver struct{ *Resolver }
|
||||
type trackerResourceResolver struct{ *Resolver }
|
||||
|
||||
@@ -275,6 +275,14 @@ type TrackerPattern implements Node {
|
||||
createdAt: 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)
|
||||
}
|
||||
|
||||
@@ -310,6 +318,52 @@ input TrackerPatternFilter
|
||||
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
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.TrackerResourceType") {
|
||||
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