From 3061b2d0894a9df8501b6a922158ab6ed1288020 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:35:57 +0000 Subject: [PATCH] feat(graphql): add beaconTimeline to metrics query Adds a new beaconTimeline field to the Metrics GraphQL query, mirroring the existing questTimeline functionality but utilizing the BeaconHistory ent to expose beacon check-in timeline statistics. Updates gqlgen config and implements the generated resolver. Co-authored-by: KCarretto <16250309+KCarretto@users.noreply.github.com> --- .../graphql/generated/ent.generated.go | 2 + .../graphql/generated/metrics.generated.go | 238 ++++++++++++++++++ .../graphql/generated/root_.generated.go | 44 ++++ tavern/internal/graphql/gqlgen.yml | 2 + tavern/internal/graphql/metrics.resolvers.go | 70 ++++++ .../internal/graphql/models/gqlgen_models.go | 8 +- tavern/internal/graphql/schema.graphql | 12 + .../internal/graphql/schema/metrics.graphql | 12 + tavern/internal/www/schema.graphql | 12 + 9 files changed, 399 insertions(+), 1 deletion(-) diff --git a/tavern/internal/graphql/generated/ent.generated.go b/tavern/internal/graphql/generated/ent.generated.go index 80f656815..eea88198e 100644 --- a/tavern/internal/graphql/generated/ent.generated.go +++ b/tavern/internal/graphql/generated/ent.generated.go @@ -11317,6 +11317,8 @@ func (ec *executionContext) fieldContext_Query_metrics(_ context.Context, field switch field.Name { case "questTimelineChart": return ec.fieldContext_Metrics_questTimelineChart(ctx, field) + case "beaconTimeline": + return ec.fieldContext_Metrics_beaconTimeline(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Metrics", field.Name) }, diff --git a/tavern/internal/graphql/generated/metrics.generated.go b/tavern/internal/graphql/generated/metrics.generated.go index 12757599b..4bed647c3 100644 --- a/tavern/internal/graphql/generated/metrics.generated.go +++ b/tavern/internal/graphql/generated/metrics.generated.go @@ -20,12 +20,39 @@ import ( type MetricsResolver interface { QuestTimelineChart(ctx context.Context, obj *models.Metrics, start time.Time, end *time.Time, granularitySeconds int, where *ent.QuestWhereInput) ([]*models.QuestTimelineBucket, error) + BeaconTimeline(ctx context.Context, obj *models.Metrics, start time.Time, end *time.Time, granularitySeconds int, where *ent.BeaconHistoryWhereInput) ([]*models.BeaconTimelineBucket, error) } // endregion ************************** generated!.gotpl ************************** // region ***************************** args.gotpl ***************************** +func (ec *executionContext) field_Metrics_beaconTimeline_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "start", ec.unmarshalNTime2timeᚐTime) + if err != nil { + return nil, err + } + args["start"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "end", ec.unmarshalOTime2ᚖtimeᚐTime) + if err != nil { + return nil, err + } + args["end"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "granularity_seconds", ec.unmarshalNInt2int) + if err != nil { + return nil, err + } + args["granularity_seconds"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "where", ec.unmarshalOBeaconHistoryWhereInput2ᚖrealmᚗpubᚋtavernᚋinternalᚋentᚐBeaconHistoryWhereInput) + if err != nil { + return nil, err + } + args["where"] = arg3 + return args, nil +} + func (ec *executionContext) field_Metrics_questTimelineChart_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -60,6 +87,64 @@ func (ec *executionContext) field_Metrics_questTimelineChart_args(ctx context.Co // region **************************** field.gotpl ***************************** +func (ec *executionContext) _BeaconTimelineBucket_count(ctx context.Context, field graphql.CollectedField, obj *models.BeaconTimelineBucket) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_BeaconTimelineBucket_count, + func(ctx context.Context) (any, error) { + return obj.Count, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_BeaconTimelineBucket_count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BeaconTimelineBucket", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _BeaconTimelineBucket_startTimestamp(ctx context.Context, field graphql.CollectedField, obj *models.BeaconTimelineBucket) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_BeaconTimelineBucket_startTimestamp, + func(ctx context.Context) (any, error) { + return obj.StartTimestamp, nil + }, + nil, + ec.marshalNTime2timeᚐTime, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_BeaconTimelineBucket_startTimestamp(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BeaconTimelineBucket", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Metrics_questTimelineChart(ctx context.Context, field graphql.CollectedField, obj *models.Metrics) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -109,6 +194,53 @@ func (ec *executionContext) fieldContext_Metrics_questTimelineChart(ctx context. return fc, nil } +func (ec *executionContext) _Metrics_beaconTimeline(ctx context.Context, field graphql.CollectedField, obj *models.Metrics) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Metrics_beaconTimeline, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Metrics().BeaconTimeline(ctx, obj, fc.Args["start"].(time.Time), fc.Args["end"].(*time.Time), fc.Args["granularity_seconds"].(int), fc.Args["where"].(*ent.BeaconHistoryWhereInput)) + }, + nil, + ec.marshalNBeaconTimelineBucket2ᚕᚖrealmᚗpubᚋtavernᚋinternalᚋgraphqlᚋmodelsᚐBeaconTimelineBucketᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Metrics_beaconTimeline(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Metrics", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "count": + return ec.fieldContext_BeaconTimelineBucket_count(ctx, field) + case "startTimestamp": + return ec.fieldContext_BeaconTimelineBucket_startTimestamp(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type BeaconTimelineBucket", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Metrics_beaconTimeline_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _QuestTimelineBucket_count(ctx context.Context, field graphql.CollectedField, obj *models.QuestTimelineBucket) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -272,6 +404,50 @@ func (ec *executionContext) fieldContext_QuestTimelineTacticBucket_count(_ conte // region **************************** object.gotpl **************************** +var beaconTimelineBucketImplementors = []string{"BeaconTimelineBucket"} + +func (ec *executionContext) _BeaconTimelineBucket(ctx context.Context, sel ast.SelectionSet, obj *models.BeaconTimelineBucket) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, beaconTimelineBucketImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("BeaconTimelineBucket") + case "count": + out.Values[i] = ec._BeaconTimelineBucket_count(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "startTimestamp": + out.Values[i] = ec._BeaconTimelineBucket_startTimestamp(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var metricsImplementors = []string{"Metrics"} func (ec *executionContext) _Metrics(ctx context.Context, sel ast.SelectionSet, obj *models.Metrics) graphql.Marshaler { @@ -318,6 +494,42 @@ func (ec *executionContext) _Metrics(ctx context.Context, sel ast.SelectionSet, continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "beaconTimeline": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Metrics_beaconTimeline(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) default: panic("unknown field " + strconv.Quote(field.Name)) @@ -439,6 +651,32 @@ func (ec *executionContext) _QuestTimelineTacticBucket(ctx context.Context, sel // region ***************************** type.gotpl ***************************** +func (ec *executionContext) marshalNBeaconTimelineBucket2ᚕᚖrealmᚗpubᚋtavernᚋinternalᚋgraphqlᚋmodelsᚐBeaconTimelineBucketᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.BeaconTimelineBucket) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNBeaconTimelineBucket2ᚖrealmᚗpubᚋtavernᚋinternalᚋgraphqlᚋmodelsᚐBeaconTimelineBucket(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNBeaconTimelineBucket2ᚖrealmᚗpubᚋtavernᚋinternalᚋgraphqlᚋmodelsᚐBeaconTimelineBucket(ctx context.Context, sel ast.SelectionSet, v *models.BeaconTimelineBucket) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._BeaconTimelineBucket(ctx, sel, v) +} + func (ec *executionContext) marshalNMetrics2realmᚗpubᚋtavernᚋinternalᚋgraphqlᚋmodelsᚐMetrics(ctx context.Context, sel ast.SelectionSet, v models.Metrics) graphql.Marshaler { return ec._Metrics(ctx, sel, &v) } diff --git a/tavern/internal/graphql/generated/root_.generated.go b/tavern/internal/graphql/generated/root_.generated.go index f100fdbcc..97ecce163 100644 --- a/tavern/internal/graphql/generated/root_.generated.go +++ b/tavern/internal/graphql/generated/root_.generated.go @@ -129,6 +129,11 @@ type ComplexityRoot struct { Node func(childComplexity int) int } + BeaconTimelineBucket struct { + Count func(childComplexity int) int + StartTimestamp func(childComplexity int) int + } + BuildProfile struct { BuildImage func(childComplexity int) int Buildtasks func(childComplexity int) int @@ -379,6 +384,7 @@ type ComplexityRoot struct { } Metrics struct { + BeaconTimeline func(childComplexity int, start time.Time, end *time.Time, granularitySeconds int, where *ent.BeaconHistoryWhereInput) int QuestTimelineChart func(childComplexity int, start time.Time, end *time.Time, granularitySeconds int, where *ent.QuestWhereInput) int } @@ -1166,6 +1172,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.BeaconHistoryEdge.Node(childComplexity), true + case "BeaconTimelineBucket.count": + if e.ComplexityRoot.BeaconTimelineBucket.Count == nil { + break + } + + return e.ComplexityRoot.BeaconTimelineBucket.Count(childComplexity), true + + case "BeaconTimelineBucket.startTimestamp": + if e.ComplexityRoot.BeaconTimelineBucket.StartTimestamp == nil { + break + } + + return e.ComplexityRoot.BeaconTimelineBucket.StartTimestamp(childComplexity), true + case "BuildProfile.buildImage": if e.ComplexityRoot.BuildProfile.BuildImage == nil { break @@ -2340,6 +2360,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.LinkEdge.Node(childComplexity), true + case "Metrics.beaconTimeline": + if e.ComplexityRoot.Metrics.BeaconTimeline == nil { + break + } + + args, err := ec.field_Metrics_beaconTimeline_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Metrics.BeaconTimeline(childComplexity, args["start"].(time.Time), args["end"].(*time.Time), args["granularity_seconds"].(int), args["where"].(*ent.BeaconHistoryWhereInput)), true + case "Metrics.questTimelineChart": if e.ComplexityRoot.Metrics.QuestTimelineChart == nil { break @@ -11891,6 +11923,18 @@ type Metrics { granularity_seconds: Int! where: QuestWhereInput ): [QuestTimelineBucket!]! + + beaconTimeline( + start: Time! + end: Time + granularity_seconds: Int! + where: BeaconHistoryWhereInput + ): [BeaconTimelineBucket!]! +} + +type BeaconTimelineBucket { + count: Int! + startTimestamp: Time! } type QuestTimelineBucket { diff --git a/tavern/internal/graphql/gqlgen.yml b/tavern/internal/graphql/gqlgen.yml index 66e726378..547b10406 100644 --- a/tavern/internal/graphql/gqlgen.yml +++ b/tavern/internal/graphql/gqlgen.yml @@ -74,3 +74,5 @@ models: fields: questTimelineChart: resolver: true + beaconTimeline: + resolver: true diff --git a/tavern/internal/graphql/metrics.resolvers.go b/tavern/internal/graphql/metrics.resolvers.go index c3073e40e..29fe9172f 100644 --- a/tavern/internal/graphql/metrics.resolvers.go +++ b/tavern/internal/graphql/metrics.resolvers.go @@ -11,6 +11,7 @@ import ( "time" "realm.pub/tavern/internal/ent" + "realm.pub/tavern/internal/ent/beaconhistory" "realm.pub/tavern/internal/ent/quest" "realm.pub/tavern/internal/ent/tome" "realm.pub/tavern/internal/graphql/generated" @@ -112,6 +113,75 @@ func (r *metricsResolver) QuestTimelineChart(ctx context.Context, obj *models.Me return buckets, nil } +// BeaconTimeline is the resolver for the beaconTimeline field. +func (r *metricsResolver) BeaconTimeline(ctx context.Context, obj *models.Metrics, start time.Time, end *time.Time, granularitySeconds int, where *ent.BeaconHistoryWhereInput) ([]*models.BeaconTimelineBucket, error) { + endTime := time.Now() + if end != nil { + endTime = *end + } + + if granularitySeconds <= 0 { + return nil, fmt.Errorf("granularity_seconds must be > 0") + } + + granularity := time.Duration(granularitySeconds) * time.Second + + // Ensure start is before or equal to endTime + if start.After(endTime) { + return []*models.BeaconTimelineBucket{}, nil + } + + // Create buckets from start to endTime + var buckets []*models.BeaconTimelineBucket + bucketMap := make(map[int64]*models.BeaconTimelineBucket) + + for t := start; t.Before(endTime) || t.Equal(endTime); t = t.Add(granularity) { + bucket := &models.BeaconTimelineBucket{ + StartTimestamp: t, + Count: 0, + } + buckets = append(buckets, bucket) + bucketMap[t.Unix()] = bucket + } + + query := r.client.BeaconHistory.Query() + if where != nil { + var err error + query, err = where.Filter(query) + if err != nil { + return nil, fmt.Errorf("failed to apply filter: %w", err) + } + } + + // Filter histories by time range + query = query.Where( + beaconhistory.CreatedAtGTE(start), + beaconhistory.CreatedAtLTE(endTime), + ) + + // Fetch beacon histories + histories, err := query.All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query beacon histories: %w", err) + } + + // Group histories into buckets + for _, h := range histories { + diff := h.CreatedAt.Sub(start) + if diff < 0 { + continue + } + + bucketTime := start.Add(diff.Truncate(granularity)) + + if bucket, exists := bucketMap[bucketTime.Unix()]; exists { + bucket.Count++ + } + } + + return buckets, nil +} + // Metrics is the resolver for the metrics field. func (r *queryResolver) Metrics(ctx context.Context) (*models.Metrics, error) { return &models.Metrics{}, nil diff --git a/tavern/internal/graphql/models/gqlgen_models.go b/tavern/internal/graphql/models/gqlgen_models.go index f8efdcae3..e72fc12da 100644 --- a/tavern/internal/graphql/models/gqlgen_models.go +++ b/tavern/internal/graphql/models/gqlgen_models.go @@ -15,6 +15,11 @@ import ( "realm.pub/tavern/internal/ent/tome" ) +type BeaconTimelineBucket struct { + Count int `json:"count"` + StartTimestamp time.Time `json:"startTimestamp"` +} + // Input for a tome configuration in a build profile. type BuildProfileTomeInput struct { // The ID of the tome to include. @@ -105,7 +110,8 @@ type ImportRepositoryInput struct { } type Metrics struct { - QuestTimelineChart []*QuestTimelineBucket `json:"questTimelineChart"` + QuestTimelineChart []*QuestTimelineBucket `json:"questTimelineChart"` + BeaconTimeline []*BeaconTimelineBucket `json:"beaconTimeline"` } type QuestTimelineBucket struct { diff --git a/tavern/internal/graphql/schema.graphql b/tavern/internal/graphql/schema.graphql index 4aa639cff..5b2d4f5d2 100644 --- a/tavern/internal/graphql/schema.graphql +++ b/tavern/internal/graphql/schema.graphql @@ -6991,6 +6991,18 @@ type Metrics { granularity_seconds: Int! where: QuestWhereInput ): [QuestTimelineBucket!]! + + beaconTimeline( + start: Time! + end: Time + granularity_seconds: Int! + where: BeaconHistoryWhereInput + ): [BeaconTimelineBucket!]! +} + +type BeaconTimelineBucket { + count: Int! + startTimestamp: Time! } type QuestTimelineBucket { diff --git a/tavern/internal/graphql/schema/metrics.graphql b/tavern/internal/graphql/schema/metrics.graphql index 3dd5b2494..fda517e97 100644 --- a/tavern/internal/graphql/schema/metrics.graphql +++ b/tavern/internal/graphql/schema/metrics.graphql @@ -9,6 +9,18 @@ type Metrics { granularity_seconds: Int! where: QuestWhereInput ): [QuestTimelineBucket!]! + + beaconTimeline( + start: Time! + end: Time + granularity_seconds: Int! + where: BeaconHistoryWhereInput + ): [BeaconTimelineBucket!]! +} + +type BeaconTimelineBucket { + count: Int! + startTimestamp: Time! } type QuestTimelineBucket { diff --git a/tavern/internal/www/schema.graphql b/tavern/internal/www/schema.graphql index 4aa639cff..5b2d4f5d2 100644 --- a/tavern/internal/www/schema.graphql +++ b/tavern/internal/www/schema.graphql @@ -6991,6 +6991,18 @@ type Metrics { granularity_seconds: Int! where: QuestWhereInput ): [QuestTimelineBucket!]! + + beaconTimeline( + start: Time! + end: Time + granularity_seconds: Int! + where: BeaconHistoryWhereInput + ): [BeaconTimelineBucket!]! +} + +type BeaconTimelineBucket { + count: Int! + startTimestamp: Time! } type QuestTimelineBucket {