diff --git a/apps/annotation/go.mod b/apps/annotation/go.mod index aa4942ccd60..d5b52b863ca 100644 --- a/apps/annotation/go.mod +++ b/apps/annotation/go.mod @@ -6,6 +6,8 @@ require ( github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 k8s.io/apimachinery v0.34.3 + k8s.io/apiserver v0.34.2 + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e ) diff --git a/apps/annotation/go.sum b/apps/annotation/go.sum index e5233ba82c9..19fadc91f46 100644 --- a/apps/annotation/go.sum +++ b/apps/annotation/go.sum @@ -248,6 +248,8 @@ k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +k8s.io/apiserver v0.34.2 h1:2/yu8suwkmES7IzwlehAovo8dDE07cFRC7KMDb1+MAE= +k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= diff --git a/apps/annotation/kinds/annotation.cue b/apps/annotation/kinds/annotation.cue index 5df0fe9f12a..607688f7cc2 100644 --- a/apps/annotation/kinds/annotation.cue +++ b/apps/annotation/kinds/annotation.cue @@ -1,17 +1,22 @@ package kinds annotationv0alpha1: { - kind: "Annotation" + kind: "Annotation" pluralName: "Annotations" - schema: { - spec: { - text: string + schema: { + spec: { + text: string time: int64 timeEnd?: int64 dashboardUID?: string panelID?: int64 tags?: [...string] - } - } -} - + } + } + selectableFields: [ + "spec.time", + "spec.timeEnd", + "spec.dashboardUID", + "spec.panelID", + ] +} \ No newline at end of file diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go index db99bcffa08..d6ed331fee2 100644 --- a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go @@ -25,6 +25,13 @@ type Annotation struct { Status AnnotationStatus `json:"status" yaml:"status"` } +func NewAnnotation() *Annotation { + return &Annotation{ + Spec: *NewAnnotationSpec(), + Status: *NewAnnotationStatus(), + } +} + func (o *Annotation) GetSpec() any { return o.Spec } diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go index 3127e6a8954..1224173abd5 100644 --- a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go @@ -5,13 +5,69 @@ package v0alpha1 import ( + "errors" + "fmt" + "github.com/grafana/grafana-app-sdk/resource" ) // schema is unexported to prevent accidental overwrites var ( - schemaAnnotation = resource.NewSimpleSchema("annotation.grafana.app", "v0alpha1", &Annotation{}, &AnnotationList{}, resource.WithKind("Annotation"), - resource.WithPlural("annotations"), resource.WithScope(resource.NamespacedScope)) + schemaAnnotation = resource.NewSimpleSchema("annotation.grafana.app", "v0alpha1", NewAnnotation(), &AnnotationList{}, resource.WithKind("Annotation"), + resource.WithPlural("annotations"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ + FieldSelector: "spec.time", + FieldValueFunc: func(o resource.Object) (string, error) { + cast, ok := o.(*Annotation) + if !ok { + return "", errors.New("provided object must be of type *Annotation") + } + + return fmt.Sprintf("%d", cast.Spec.Time), nil + }, + }, + resource.SelectableField{ + FieldSelector: "spec.timeEnd", + FieldValueFunc: func(o resource.Object) (string, error) { + cast, ok := o.(*Annotation) + if !ok { + return "", errors.New("provided object must be of type *Annotation") + } + if cast.Spec.TimeEnd == nil { + return "", nil + } + + return fmt.Sprintf("%d", *cast.Spec.TimeEnd), nil + }, + }, + resource.SelectableField{ + FieldSelector: "spec.dashboardUID", + FieldValueFunc: func(o resource.Object) (string, error) { + cast, ok := o.(*Annotation) + if !ok { + return "", errors.New("provided object must be of type *Annotation") + } + if cast.Spec.DashboardUID == nil { + return "", nil + } + + return *cast.Spec.DashboardUID, nil + }, + }, + resource.SelectableField{ + FieldSelector: "spec.panelID", + FieldValueFunc: func(o resource.Object) (string, error) { + cast, ok := o.(*Annotation) + if !ok { + return "", errors.New("provided object must be of type *Annotation") + } + if cast.Spec.PanelID == nil { + return "", nil + } + + return fmt.Sprintf("%d", *cast.Spec.PanelID), nil + }, + }, + })) kindAnnotation = resource.Kind{ Schema: schemaAnnotation, Codecs: map[resource.KindEncoding]resource.Codec{ diff --git a/apps/annotation/pkg/apis/annotation_manifest.go b/apps/annotation/pkg/apis/annotation_manifest.go index 004529ee84d..4410d623eee 100644 --- a/apps/annotation/pkg/apis/annotation_manifest.go +++ b/apps/annotation/pkg/apis/annotation_manifest.go @@ -40,6 +40,12 @@ var appManifestData = app.ManifestData{ Scope: "Namespaced", Conversion: false, Schema: &versionSchemaAnnotationv0alpha1, + SelectableFields: []string{ + "spec.time", + "spec.timeEnd", + "spec.dashboardUID", + "spec.panelID", + }, }, }, Routes: app.ManifestVersionRoutes{ @@ -77,6 +83,28 @@ var appManifestData = app.ManifestData{ "tags": { SchemaProps: spec.SchemaProps{ Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "count": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "tag": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + Required: []string{ + "tag", + "count", + }, + }}, + }, }, }, }, diff --git a/pkg/registry/apps/annotation/register.go b/pkg/registry/apps/annotation/register.go index e49c1611234..b5d6db6214b 100644 --- a/pkg/registry/apps/annotation/register.go +++ b/pkg/registry/apps/annotation/register.go @@ -2,7 +2,6 @@ package annotation import ( "context" - "errors" "fmt" "strconv" "strings" @@ -183,39 +182,25 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO return nil, fmt.Errorf("unsupported operator %s for spec.panelID (only = supported)", r.Operator) } case "spec.time": - switch r.Operator { - case selection.GreaterThan: + if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals { from, err := strconv.ParseInt(r.Value, 10, 64) if err != nil { - return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err) + return nil, fmt.Errorf("invalid from value %q: %w", r.Value, err) } opts.From = from - case selection.LessThan: - to, err := strconv.ParseInt(r.Value, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err) - } - opts.To = to - default: - return nil, fmt.Errorf("unsupported operator %s for spec.time (only >, < supported for ranges)", r.Operator) + } else { + return nil, fmt.Errorf("unsupported operator %s for spec.from (only = supported)", r.Operator) } case "spec.timeEnd": - switch r.Operator { - case selection.GreaterThan: - from, err := strconv.ParseInt(r.Value, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err) - } - opts.From = from - case selection.LessThan: + if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals { to, err := strconv.ParseInt(r.Value, 10, 64) if err != nil { - return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err) + return nil, fmt.Errorf("invalid to value %q: %w", r.Value, err) } opts.To = to - default: - return nil, fmt.Errorf("unsupported operator %s for spec.timeEnd (only >, < supported for ranges)", r.Operator) + } else { + return nil, fmt.Errorf("unsupported operator %s for spec.to (only = supported)", r.Operator) } default: @@ -262,7 +247,32 @@ func (s *legacyStorage) Update(ctx context.Context, forceAllowCreate bool, options *metav1.UpdateOptions, ) (runtime.Object, bool, error) { - return nil, false, errors.New("not implemented") + namespace := request.NamespaceValue(ctx) + + obj, err := objInfo.UpdatedObject(ctx, nil) + if err != nil { + return nil, false, err + } + + resource, ok := obj.(*annotationV0.Annotation) + if !ok { + return nil, false, fmt.Errorf("expected annotation") + } + + if resource.Name != name { + return nil, false, fmt.Errorf("name in URL does not match name in body") + } + + if resource.Namespace != namespace { + return nil, false, fmt.Errorf("namespace in URL does not match namespace in body") + } + + updated, err := s.store.Update(ctx, resource) + if err != nil { + return nil, false, err + } + + return updated, false, nil } func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { diff --git a/public/app/features/annotations/api.ts b/public/app/features/annotations/api.ts index 209a03a4951..493ed4f9e0b 100644 --- a/public/app/features/annotations/api.ts +++ b/public/app/features/annotations/api.ts @@ -79,15 +79,12 @@ class K8sAnnotationServer implements AnnotationServer { } async query(params: Record, requestId: string): Promise { - // Convert legacy query params to k8s list options const listOpts: ListOptions = {}; - // Map limit parameter if (params.limit) { listOpts.limit = Number(params.limit); } - // Build field selectors for dashboard/panel filtering and time ranges const fieldSelectors: string[] = []; if (params.dashboardUID) { @@ -98,54 +95,57 @@ class K8sAnnotationServer implements AnnotationServer { fieldSelectors.push(`spec.panelID=${params.panelId}`); } - // Handle time range filters using spec.time field - // Note: < and > operators need URL encoding when passed as query parameters - // < becomes %3C and > becomes %3E if (params.from) { - // Use URL-encoded > operator - fieldSelectors.push(`spec.time%3E${params.from}`); + fieldSelectors.push(`spec.time=${params.from}`); } if (params.to) { - // Use URL-encoded < operator - fieldSelectors.push(`spec.time%3C${params.to}`); + fieldSelectors.push(`spec.timeEnd=${params.to}`); } if (fieldSelectors.length > 0) { listOpts.fieldSelector = fieldSelectors.join(','); } - // Handle tags using label selectors - if (params.tags && Array.isArray(params.tags) && params.tags.length > 0) { - // Tags should be converted to label selectors - // Format: tag1,tag2 means annotations that have both tags - listOpts.labelSelector = params.tags.map((tag) => `tag=${tag}`).join(','); - } - const result = await this.client.list(listOpts); - // Convert k8s resources to legacy annotation format - const annotations = result.items.map((item: Resource) => ({ + let annotations = result.items.map((item: Resource) => ({ id: item.metadata.name, ...item.spec, - panelId: item.spec.panelID, // Map panelID back to panelId + panelId: item.spec.panelID, })); + // Client-side tag filtering (tags are not in SelectableFields since they're in an array) + if (params.tags && Array.isArray(params.tags) && params.tags.length > 0) { + const tags = params.tags; + annotations = annotations.filter((anno) => { + if (!anno.tags || anno.tags.length === 0) { + return false; + } + return tags.every((tag) => anno.tags!.includes(tag)); + }); + } return toDataFrame(annotations); } async forAlert(alertUID: string): Promise { + // For now, we filter client-side since label selector support for alertUID may not be implemented const result = await this.client.list({ - labelSelector: `alertUID=${alertUID}`, + limit: 1000, }); - // Return as any[] since the k8s annotation format doesn't match StateHistoryItem exactly - // This is a limitation of the current type definitions - return result.items.map((item: Resource) => ({ - id: item.metadata.name, - ...item.spec, - panelId: item.spec.panelID, - })) as any; + // Filter by tags that contain the alertUID + // Alert annotations typically have the alert UID in their tags + return result.items + .filter((item: Resource) => { + // Check if any tag contains the alertUID + return item.spec.tags?.some((tag) => tag.includes(alertUID)); + }) + .map((item: Resource) => ({ + id: item.metadata.name, + ...item.spec, + panelId: item.spec.panelID, + })) as any; } async save(annotation: AnnotationEvent): Promise { @@ -178,19 +178,27 @@ class K8sAnnotationServer implements AnnotationServer { throw new Error('Annotation ID is required for update'); } - // Get the existing resource to preserve metadata + // Get the existing resource to preserve metadata (especially resourceVersion) const existing = await this.client.get(String(annotation.id)); - // Update only the spec fields + // Update only the spec fields, preserve all metadata const updated: Resource = { - ...existing, + apiVersion: existing.apiVersion, + kind: existing.kind, + metadata: { + ...existing.metadata, + // Preserve critical metadata fields for update + }, spec: { - text: annotation.text || existing.spec.text, - time: annotation.time || existing.spec.time, - timeEnd: annotation.timeEnd ?? existing.spec.timeEnd, - dashboardUID: annotation.dashboardUID ? annotation.dashboardUID : existing.spec.dashboardUID, - panelID: annotation.panelId ?? existing.spec.panelID, - tags: annotation.tags ?? existing.spec.tags, + text: annotation.text !== undefined ? annotation.text : existing.spec.text, + time: annotation.time !== undefined ? annotation.time : existing.spec.time, + timeEnd: annotation.timeEnd !== undefined ? annotation.timeEnd : existing.spec.timeEnd, + dashboardUID: + annotation.dashboardUID !== undefined && annotation.dashboardUID !== null + ? annotation.dashboardUID + : existing.spec.dashboardUID, + panelID: annotation.panelId !== undefined ? annotation.panelId : existing.spec.panelID, + tags: annotation.tags !== undefined ? annotation.tags : existing.spec.tags, }, }; @@ -207,7 +215,7 @@ class K8sAnnotationServer implements AnnotationServer { async tags(): Promise> { // Use the custom /tags route defined in the CUE manifest const namespace = getAPINamespace(); - const url = `/apis/${K8S_ANNOTATION_API_CONFIG.group}/${K8S_ANNOTATION_API_CONFIG.version}/namespaces/${namespace}/${K8S_ANNOTATION_API_CONFIG.resource}/tags`; + const url = `/apis/${K8S_ANNOTATION_API_CONFIG.group}/${K8S_ANNOTATION_API_CONFIG.version}/namespaces/${namespace}/tags`; const response = await getBackendSrv().get(url, { limit: 1000 }); diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx index 77dbc76feb3..aa045ef0792 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -14,6 +14,7 @@ import { } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { config, getBackendSrv, locationService } from '@grafana/runtime'; +import { annotationServer } from 'app/features/annotations/api'; import { Button, ScrollContainer, stylesFactory, TagList } from '@grafana/ui'; import { AbstractList } from '@grafana/ui/internal'; import { appEvents } from 'app/core/app_events'; @@ -28,7 +29,7 @@ interface UserInfo { email?: string; } -export interface Props extends PanelProps {} +export interface Props extends PanelProps { } interface State { annotations: AnnotationEvent[]; timeInfo: string; @@ -128,7 +129,22 @@ export class AnnoListPanel extends PureComponent { params.tags = params.tags ? [...params.tags, ...queryTags] : queryTags; } - const annotations = await getBackendSrv().get('/api/annotations', params, this.state.requestId); + // Use annotationServer() to support both legacy and k8s APIs + const df = await annotationServer().query(params, this.state.requestId); + + // Convert DataFrame to array of annotations + // The DataFrame will have fields that correspond to annotation properties + const annotations: AnnotationEvent[] = []; + if (df.length > 0) { + const length = df.fields[0]?.values.length || 0; + for (let i = 0; i < length; i++) { + const annotation: any = {}; + df.fields.forEach((field) => { + annotation[field.name] = field.values[i]; + }); + annotations.push(annotation as AnnotationEvent); + } + } this.setState({ annotations,