diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index 1e32879b390..6996422fba6 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -197,6 +197,7 @@ type WebhookStatus struct { Secret string `json:"secret,omitempty"` EncryptedSecret []byte `json:"encryptedSecret,omitempty"` SubscribedEvents []string `json:"subscribedEvents,omitempty"` + LastEvent int64 `json:"lastEvent,omitempty"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 6a121136822..cf3a050475b 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -2127,6 +2127,12 @@ func schema_pkg_apis_provisioning_v0alpha1_WebhookStatus(ref common.ReferenceCal }, }, }, + "lastEvent": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + Format: "int64", + }, + }, }, }, }, diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go index d550e4fc753..23eed891b36 100644 --- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go +++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go @@ -12,6 +12,7 @@ type WebhookStatusApplyConfiguration struct { Secret *string `json:"secret,omitempty"` EncryptedSecret []byte `json:"encryptedSecret,omitempty"` SubscribedEvents []string `json:"subscribedEvents,omitempty"` + LastEvent *int64 `json:"lastEvent,omitempty"` } // WebhookStatusApplyConfiguration constructs a declarative configuration of the WebhookStatus type for use with @@ -63,3 +64,11 @@ func (b *WebhookStatusApplyConfiguration) WithSubscribedEvents(values ...string) } return b } + +// WithLastEvent sets the LastEvent field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the LastEvent field is set to the value of the last call. +func (b *WebhookStatusApplyConfiguration) WithLastEvent(value int64) *WebhookStatusApplyConfiguration { + b.LastEvent = &value + return b +} diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 25b5bd6126d..99f6632ead4 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -33,6 +33,7 @@ import ( "github.com/grafana/grafana/pkg/apiserver/readonly" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" clientset "github.com/grafana/grafana/pkg/generated/clientset/versioned" + client "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" informers "github.com/grafana/grafana/pkg/generated/informers/externalversions" listers "github.com/grafana/grafana/pkg/generated/listers/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -93,6 +94,7 @@ type APIBuilder struct { storageStatus dualwrite.Service unified resource.ResourceClient secrets secrets.Service + client client.ProvisioningV0alpha1Interface } // NewAPIBuilder creates an API builder. @@ -301,6 +303,10 @@ func (b *APIBuilder) GetGroupVersion() schema.GroupVersion { return provisioning.SchemeGroupVersion } +func (b *APIBuilder) GetClient() client.ProvisioningV0alpha1Interface { + return b.client +} + func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { err := provisioning.AddToScheme(scheme) if err != nil { @@ -356,11 +362,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place // TODO: Do not set private fields directly, use factory methods. - storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = &webhookConnector{ - getter: b, - jobs: b.jobs, - webhooksEnabled: b.isPublic, - } + storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = NewWebhookConnector(b, b, b.jobs, b.isPublic) storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = &testConnector{ getter: b, } @@ -549,10 +551,13 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH repoInformer := sharedInformerFactory.Provisioning().V0alpha1().Repositories() go repoInformer.Informer().Run(postStartHookCtx.Context.Done()) + b.client = c.ProvisioningV0alpha1() + // We do not have a local client until *GetPostStartHooks*, so we can delay init for some b.tester = &RepositoryTester{ - client: c.ProvisioningV0alpha1(), + client: b.GetClient(), } + b.repositoryLister = repoInformer.Lister() exportWorker := export.NewExportWorker( @@ -561,7 +566,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH b.parsers, ) syncWorker := sync.NewSyncWorker( - c.ProvisioningV0alpha1(), + b.GetClient(), b.parsers, b.resourceLister, b.storageStatus, @@ -588,7 +593,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH go driver.Run(postStartHookCtx.Context) repoController, err := controller.NewRepositoryController( - c.ProvisioningV0alpha1(), + b.GetClient(), repoInformer, b, // repoGetter b.resourceLister, diff --git a/pkg/registry/apis/provisioning/types.go b/pkg/registry/apis/provisioning/types.go index b2d3acdf3fa..d804643cd21 100644 --- a/pkg/registry/apis/provisioning/types.go +++ b/pkg/registry/apis/provisioning/types.go @@ -4,6 +4,7 @@ import ( "context" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" ) @@ -19,3 +20,7 @@ type RepoGetter interface { // the repository instance may or may not be valid/healthy AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error) } + +type ClientGetter interface { + GetClient() client.ProvisioningV0alpha1Interface +} diff --git a/pkg/registry/apis/provisioning/webhook.go b/pkg/registry/apis/provisioning/webhook.go index f9509013362..c9dc77e1db8 100644 --- a/pkg/registry/apis/provisioning/webhook.go +++ b/pkg/registry/apis/provisioning/webhook.go @@ -2,12 +2,15 @@ package provisioning import ( "context" + "encoding/json" "fmt" "net/http" "time" "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" @@ -24,11 +27,21 @@ const webhookMaxBodySize = 25 * 1024 * 1024 // This only works for github right now type webhookConnector struct { + client ClientGetter getter RepoGetter jobs jobs.Queue webhooksEnabled bool } +func NewWebhookConnector(client ClientGetter, getter RepoGetter, jobs jobs.Queue, webhooksEnabled bool) *webhookConnector { + return &webhookConnector{ + client: client, + getter: getter, + jobs: jobs, + webhooksEnabled: webhooksEnabled, + } +} + func (*webhookConnector) New() runtime.Object { return &provisioning.WebhookResponse{} } @@ -89,10 +102,17 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim responder.Error(err) return } + if rsp == nil { responder.Error(fmt.Errorf("expecting a response")) return } + + if err := s.updateLastEvent(ctx, repo, name, namespace); err != nil { + // Continue processing as this is non-critical; the update is purely informational + logger.Error("failed to update last event", "error", err) + } + if rsp.Job != nil { rsp.Job.Repository = name job, err := s.jobs.Insert(ctx, namespace, *rsp.Job) @@ -103,10 +123,45 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim responder.Object(rsp.Code, job) return } + responder.Object(rsp.Code, rsp) }), 30*time.Second), nil } +// updateLastEvent updates the last event time for the webhook +// This is to provide some visibility that the webhook is still active and working +// It's not a good idea to update the webhook status too often, so we only update it if it's been a while +func (s *webhookConnector) updateLastEvent(ctx context.Context, repo repository.Repository, name, namespace string) error { + client := s.client.GetClient() + if client == nil { + // This would only happen if we wired things up incorrectly + return fmt.Errorf("client is nil") + } + + lastEvent := time.UnixMilli(repo.Config().Status.Webhook.LastEvent) + eventAge := time.Since(lastEvent) + + if repo.Config().Status.Webhook != nil && (eventAge > time.Minute) { + patchOp := map[string]interface{}{ + "op": "replace", + "path": "/status/webhook/lastEvent", + "value": time.Now().UnixMilli(), + } + + patch, err := json.Marshal([]map[string]interface{}{patchOp}) + if err != nil { + return fmt.Errorf("marshal patch: %w", err) + } + + if _, err = client.Repositories(namespace). + Patch(ctx, name, types.JSONPatchType, patch, metav1.PatchOptions{}, "status"); err != nil { + return fmt.Errorf("patch status: %w", err) + } + } + + return nil +} + var ( _ rest.Storage = (*webhookConnector)(nil) _ rest.Connecter = (*webhookConnector)(nil) diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 99cfe4de8f1..a48c9e80902 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -3903,6 +3903,10 @@ "type": "integer", "format": "int64" }, + "lastEvent": { + "type": "integer", + "format": "int64" + }, "secret": { "type": "string" }, diff --git a/public/app/api/clients/provisioning/endpoints.gen.ts b/public/app/api/clients/provisioning/endpoints.gen.ts index ba6ed801f09..d198a13454d 100644 --- a/public/app/api/clients/provisioning/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/endpoints.gen.ts @@ -920,6 +920,7 @@ export type SyncStatus = { export type WebhookStatus = { encryptedSecret?: string; id?: number; + lastEvent?: number; secret?: string; subscribedEvents?: string[]; url?: string; diff --git a/public/app/features/provisioning/Repository/RepositoryOverview.tsx b/public/app/features/provisioning/Repository/RepositoryOverview.tsx index 3a547c61d1b..4704728a0cd 100644 --- a/public/app/features/provisioning/Repository/RepositoryOverview.tsx +++ b/public/app/features/provisioning/Repository/RepositoryOverview.tsx @@ -1,18 +1,7 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; -import { - CellProps, - Stack, - Box, - Text, - LinkButton, - Card, - TextLink, - InteractiveTable, - Grid, - useStyles2, -} from '@grafana/ui'; +import { Box, Card, CellProps, Grid, InteractiveTable, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; import { Repository, ResourceCount } from 'app/api/clients/provisioning'; import { Trans } from 'app/core/internationalization'; @@ -25,10 +14,15 @@ import { SyncRepository } from './SyncRepository'; type StatCell = CellProps; +function getColumnCount(hasWebhook: boolean): 3 | 4 { + return hasWebhook ? 4 : 3; +} + export function RepositoryOverview({ repo }: { repo: Repository }) { const styles = useStyles2(getStyles); const status = repo.status; const webhookURL = getWebhookURL(repo); + const columns = getColumnCount(Boolean(repo.status?.webhook)); const resourceColumns = useMemo( () => [ @@ -54,7 +48,7 @@ export function RepositoryOverview({ repo }: { repo: Repository }) { return ( - +
@@ -203,14 +197,53 @@ export function RepositoryOverview({ repo }: { repo: Repository }) { - {webhookURL && ( - - Webhook - - )}
+ {repo.status?.webhook && ( +
+ + + Webhook + + + +
+ + ID: + +
+
+ {status?.webhook?.id ?? 'N/A'} +
+
+ + Events: + +
+
+ {status?.webhook?.subscribedEvents?.join(', ') ?? 'N/A'} +
+
+ + Last Event: + +
+
+ {formatTimestamp(status?.webhook?.lastEvent)} +
+
+
+ {webhookURL && ( + + + View Webhook + + + )} +
+
+ )}
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4c2ac7b8db9..c3446f01670 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -4752,7 +4752,11 @@ "started": "Started:", "status": "Status:", "view-folder": "View Folder", - "webhook": "Webhook" + "webhook": "Webhook", + "webhook-events": "Events:", + "webhook-id": "ID:", + "webhook-last-event": "Last Event:", + "webhook-url": "View Webhook" }, "repository-resources": { "columns": {