Compare commits
3 Commits
ash/react-
...
zserge/ann
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a64951f9a | ||
|
|
3071482bf4 | ||
|
|
b3589300b3 |
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
28
apps/annotation/pkg/apis/annotation_manifest.go
generated
28
apps/annotation/pkg/apis/annotation_manifest.go
generated
@@ -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",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
20
apps/annotation/pkg/app/authorizer.go
Normal file
20
apps/annotation/pkg/app/authorizer.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
)
|
||||
|
||||
func GetAuthorizer() authorizer.Authorizer {
|
||||
return authorizer.AuthorizerFunc(func(
|
||||
ctx context.Context, attr authorizer.Attributes,
|
||||
) (authorized authorizer.Decision, reason string, err error) {
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
// Any authenticated user can access the API
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package annotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
@@ -82,6 +82,10 @@ func RegisterAppInstaller(
|
||||
return installer, nil
|
||||
}
|
||||
|
||||
func (a *AnnotationAppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
return annotationapp.GetAuthorizer()
|
||||
}
|
||||
|
||||
func (a *AnnotationAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) apiserverrest.Storage {
|
||||
kind := annotationV0.AnnotationKind()
|
||||
gvr := schema.GroupVersionResource{
|
||||
@@ -178,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:
|
||||
@@ -257,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) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { AnnotationEvent, DataFrame, toDataFrame } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { StateHistoryItem } from 'app/types/unified-alerting';
|
||||
|
||||
import { getAPINamespace } from '../../api/utils';
|
||||
import { ScopedResourceClient } from '../apiserver/client';
|
||||
import type { Resource, ResourceForCreate, ListOptions } from '../apiserver/types';
|
||||
|
||||
import { AnnotationTagsResponse } from './types';
|
||||
|
||||
export interface AnnotationServer {
|
||||
@@ -47,11 +51,190 @@ class LegacyAnnotationServer implements AnnotationServer {
|
||||
}
|
||||
}
|
||||
|
||||
// K8s-style annotation spec based on the CUE definition
|
||||
interface K8sAnnotationSpec {
|
||||
text: string;
|
||||
time: number;
|
||||
timeEnd?: number;
|
||||
dashboardUID?: string;
|
||||
panelID?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface K8sAnnotationTagsResponse {
|
||||
tags: Array<{ tag: string; count: number }>;
|
||||
}
|
||||
|
||||
const K8S_ANNOTATION_API_CONFIG = {
|
||||
group: 'annotation.grafana.app',
|
||||
version: 'v0alpha1',
|
||||
resource: 'annotations',
|
||||
};
|
||||
|
||||
class K8sAnnotationServer implements AnnotationServer {
|
||||
private client: ScopedResourceClient<K8sAnnotationSpec>;
|
||||
|
||||
constructor() {
|
||||
this.client = new ScopedResourceClient<K8sAnnotationSpec>(K8S_ANNOTATION_API_CONFIG);
|
||||
}
|
||||
|
||||
async query(params: Record<string, unknown>, requestId: string): Promise<DataFrame> {
|
||||
const listOpts: ListOptions = {};
|
||||
|
||||
if (params.limit) {
|
||||
listOpts.limit = Number(params.limit);
|
||||
}
|
||||
|
||||
const fieldSelectors: string[] = [];
|
||||
|
||||
if (params.dashboardUID) {
|
||||
fieldSelectors.push(`spec.dashboardUID=${params.dashboardUID}`);
|
||||
}
|
||||
|
||||
if (params.panelId) {
|
||||
fieldSelectors.push(`spec.panelID=${params.panelId}`);
|
||||
}
|
||||
|
||||
if (params.from) {
|
||||
fieldSelectors.push(`spec.time=${params.from}`);
|
||||
}
|
||||
|
||||
if (params.to) {
|
||||
fieldSelectors.push(`spec.timeEnd=${params.to}`);
|
||||
}
|
||||
|
||||
if (fieldSelectors.length > 0) {
|
||||
listOpts.fieldSelector = fieldSelectors.join(',');
|
||||
}
|
||||
|
||||
const result = await this.client.list(listOpts);
|
||||
|
||||
let annotations = result.items.map((item: Resource<K8sAnnotationSpec>) => ({
|
||||
id: item.metadata.name,
|
||||
...item.spec,
|
||||
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<StateHistoryItem[]> {
|
||||
// For now, we filter client-side since label selector support for alertUID may not be implemented
|
||||
const result = await this.client.list({
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
// Filter by tags that contain the alertUID
|
||||
// Alert annotations typically have the alert UID in their tags
|
||||
return result.items
|
||||
.filter((item: Resource<K8sAnnotationSpec>) => {
|
||||
// Check if any tag contains the alertUID
|
||||
return item.spec.tags?.some((tag) => tag.includes(alertUID));
|
||||
})
|
||||
.map((item: Resource<K8sAnnotationSpec>) => ({
|
||||
id: item.metadata.name,
|
||||
...item.spec,
|
||||
panelId: item.spec.panelID,
|
||||
})) as any;
|
||||
}
|
||||
|
||||
async save(annotation: AnnotationEvent): Promise<AnnotationEvent> {
|
||||
const resource: ResourceForCreate<K8sAnnotationSpec> = {
|
||||
metadata: {
|
||||
name: '', // Will be auto-generated by the server
|
||||
},
|
||||
spec: {
|
||||
text: annotation.text || '',
|
||||
time: annotation.time || Date.now(),
|
||||
timeEnd: annotation.timeEnd,
|
||||
dashboardUID: annotation.dashboardUID ?? undefined,
|
||||
panelID: annotation.panelId,
|
||||
tags: annotation.tags,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await this.client.create(resource);
|
||||
|
||||
return {
|
||||
...annotation,
|
||||
id: result.metadata.name,
|
||||
...result.spec,
|
||||
panelId: result.spec.panelID,
|
||||
};
|
||||
}
|
||||
|
||||
async update(annotation: AnnotationEvent): Promise<unknown> {
|
||||
if (!annotation.id) {
|
||||
throw new Error('Annotation ID is required for update');
|
||||
}
|
||||
|
||||
// Get the existing resource to preserve metadata (especially resourceVersion)
|
||||
const existing = await this.client.get(String(annotation.id));
|
||||
|
||||
// Update only the spec fields, preserve all metadata
|
||||
const updated: Resource<K8sAnnotationSpec> = {
|
||||
apiVersion: existing.apiVersion,
|
||||
kind: existing.kind,
|
||||
metadata: {
|
||||
...existing.metadata,
|
||||
// Preserve critical metadata fields for update
|
||||
},
|
||||
spec: {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
return this.client.update(updated);
|
||||
}
|
||||
|
||||
async delete(annotation: AnnotationEvent): Promise<unknown> {
|
||||
if (!annotation.id) {
|
||||
throw new Error('Annotation ID is required for delete');
|
||||
}
|
||||
return this.client.delete(String(annotation.id), false);
|
||||
}
|
||||
|
||||
async tags(): Promise<Array<{ term: string; count: number }>> {
|
||||
// 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}/tags`;
|
||||
|
||||
const response = await getBackendSrv().get<K8sAnnotationTagsResponse>(url, { limit: 1000 });
|
||||
|
||||
return response.tags.map(({ tag, count }) => ({
|
||||
term: tag,
|
||||
count,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let instance: AnnotationServer | null = null;
|
||||
|
||||
export function annotationServer(): AnnotationServer {
|
||||
if (!instance) {
|
||||
instance = new LegacyAnnotationServer();
|
||||
if (config.featureToggles.kubernetesAnnotations) {
|
||||
instance = new K8sAnnotationServer();
|
||||
} else {
|
||||
instance = new LegacyAnnotationServer();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -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<Options> {}
|
||||
export interface Props extends PanelProps<Options> { }
|
||||
interface State {
|
||||
annotations: AnnotationEvent[];
|
||||
timeInfo: string;
|
||||
@@ -128,7 +129,22 @@ export class AnnoListPanel extends PureComponent<Props, State> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user