a07a8d0ba2
* Fix listing and getting dashboard versions across different API versions What This commit updates dashboard version service to use API version aware API client. The service now also supports parsing different API version representation of dashboards. The API version aware client is also updated to support listing across versions. Why Currently listing or getting specific versions is broken for all v2 versions of the dashboard API, especially if the dashboard being checked is still saved using v1 APIs. Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com> * Remove superfluous tracing spans Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com> --------- Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>
2236 lines
72 KiB
Go
2236 lines
72 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/exp/slices"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/selection"
|
|
|
|
claims "github.com/grafana/authlib/types"
|
|
"github.com/grafana/grafana-app-sdk/logging"
|
|
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
|
|
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
|
|
dashboardv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
|
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
"github.com/grafana/grafana/pkg/infra/kvstore"
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
|
"github.com/grafana/grafana/pkg/infra/slugify"
|
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
|
"github.com/grafana/grafana/pkg/registry"
|
|
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
|
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
|
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
|
dashboardclient "github.com/grafana/grafana/pkg/services/dashboards/service/client"
|
|
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/folder"
|
|
"github.com/grafana/grafana/pkg/services/org"
|
|
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
|
"github.com/grafana/grafana/pkg/services/quota"
|
|
"github.com/grafana/grafana/pkg/services/search/model"
|
|
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
|
"github.com/grafana/grafana/pkg/services/store/entity"
|
|
"github.com/grafana/grafana/pkg/services/user"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
|
"github.com/grafana/grafana/pkg/storage/unified/search"
|
|
"github.com/grafana/grafana/pkg/util"
|
|
"github.com/grafana/grafana/pkg/util/retryer"
|
|
)
|
|
|
|
var (
|
|
// DashboardServiceImpl implements the DashboardService interface
|
|
_ dashboards.DashboardService = (*DashboardServiceImpl)(nil)
|
|
_ dashboards.DashboardProvisioningService = (*DashboardServiceImpl)(nil)
|
|
_ dashboards.PluginService = (*DashboardServiceImpl)(nil)
|
|
|
|
daysInTrash = 24 * 30 * time.Hour
|
|
tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboards/service")
|
|
)
|
|
|
|
const (
|
|
k8sDashboardKvNamespace = "dashboard-cleanup"
|
|
k8sDashboardKvLastResourceVersionKey = "last-resource-version"
|
|
provisioningConcurrencyLimit = 10
|
|
listAllDashboardsLimit = 100000
|
|
)
|
|
|
|
type DashboardServiceImpl struct {
|
|
cfg *setting.Cfg
|
|
log log.Logger
|
|
dashboardStore dashboards.Store
|
|
folderStore folder.FolderStore
|
|
folderService folder.Service
|
|
orgService org.Service
|
|
features featuremgmt.FeatureToggles
|
|
folderPermissions accesscontrol.FolderPermissionsService
|
|
dashboardPermissions accesscontrol.DashboardPermissionsService
|
|
ac accesscontrol.AccessControl
|
|
acService accesscontrol.Service
|
|
k8sclient dashboardclient.K8sHandlerWithFallback
|
|
metrics *dashboardsMetrics
|
|
publicDashboardService publicdashboards.ServiceWrapper
|
|
serverLockService *serverlock.ServerLockService
|
|
kvstore kvstore.KVStore
|
|
dual dualwrite.Service
|
|
|
|
dashboardPermissionsReady chan struct{}
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) startK8sDeletedDashboardsCleanupJob(ctx context.Context) chan struct{} {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
|
|
ticker := time.NewTicker(dr.cfg.K8sDashboardCleanup.Interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := dr.executeCleanupWithLock(ctx); err != nil {
|
|
dr.log.Error("Failed to execute k8s dashboard cleanup", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return done
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) executeCleanupWithLock(ctx context.Context) error {
|
|
// We're taking a leader-like locking approach here. By locking and executing, but never releasing the lock,
|
|
// we ensure that other instances of this service can't run in parallel and hence the cleanup will only happen once
|
|
// per cleanup interval by setting the maxInterval and having the time between executions be the cleanup interval as well.
|
|
return dr.serverLockService.LockAndExecute(
|
|
ctx,
|
|
k8sDashboardKvNamespace,
|
|
dr.cfg.K8sDashboardCleanup.Interval,
|
|
func(ctx context.Context) {
|
|
if err := dr.cleanupK8sDashboardResources(ctx, dr.cfg.K8sDashboardCleanup.BatchSize, dr.cfg.K8sDashboardCleanup.Timeout); err != nil {
|
|
dr.log.Error("Failed to cleanup k8s dashboard resources", "error", err)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
// cleanupK8sDashboardResources cleans up resources marked for deletion in the k8s API.
|
|
// It processes all organizations, finds dashboards with the trash label, and cleans them up.
|
|
// batchSize specifies how many dashboards to process in a single batch.
|
|
// timeout specifies the timeout duration for the cleanup operation.
|
|
func (dr *DashboardServiceImpl) cleanupK8sDashboardResources(ctx context.Context, batchSize int64, timeout time.Duration) error {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.cleanupK8sDashboardResources")
|
|
defer span.End()
|
|
|
|
readingFromLegacy := dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, dr.dual)
|
|
if readingFromLegacy {
|
|
// Legacy does its own cleanup
|
|
return nil
|
|
}
|
|
|
|
// Create a timeout context to ensure we complete before the lock expires
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dr.log.Debug("Running k8s dashboard resource cleanup for all orgs", "numOrgs", len(orgs))
|
|
|
|
var errs []error
|
|
for _, org := range orgs {
|
|
// Check if we're approaching the timeout
|
|
if ctx.Err() != nil {
|
|
dr.log.Info("Timeout reached during cleanup, stopping processing", "timeout", timeout)
|
|
break
|
|
}
|
|
|
|
orgErr := dr.cleanupOrganizationK8sDashboards(ctx, org.ID, batchSize)
|
|
if orgErr != nil {
|
|
errs = append(errs, fmt.Errorf("org %d: %w", org.ID, orgErr))
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cleanupOrganizationK8sDashboards handles cleanup for a single organization's Kubernetes dashboards
|
|
func (dr *DashboardServiceImpl) cleanupOrganizationK8sDashboards(ctx context.Context, orgID int64, batchSize int64) error {
|
|
dr.log.Debug("Running k8s dashboard resource cleanup for org", "orgID", orgID)
|
|
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.cleanupK8sDashboardResources.org")
|
|
defer span.End()
|
|
span.SetAttributes(attribute.Int64("org_id", orgID))
|
|
|
|
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
|
|
|
|
// Get the last processed resource version
|
|
lastResourceVersion, err := dr.getLastResourceVersion(ctx, orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
continueToken := ""
|
|
itemsProcessed := 0
|
|
|
|
for {
|
|
// Check if we're approaching the timeout
|
|
if ctx.Err() != nil {
|
|
dr.log.Info("Timeout reached during org cleanup, stopping processing", "orgID", orgID)
|
|
break
|
|
}
|
|
|
|
// List resources to be cleaned up
|
|
data, listErr, shouldContinue := dr.listResourcesToCleanup(ctx, orgID, lastResourceVersion, continueToken, batchSize)
|
|
if listErr != nil {
|
|
errs = append(errs, fmt.Errorf("failed to list resources: %w", listErr))
|
|
break
|
|
}
|
|
if shouldContinue {
|
|
// Reset and try again with updated resource version
|
|
lastResourceVersion = "0"
|
|
continueToken = ""
|
|
continue
|
|
}
|
|
|
|
// Skip the first item if it matches our last resource version (due to NotOlderThan behavior)
|
|
if len(data.Items) > 0 && data.Items[0].GetResourceVersion() == lastResourceVersion {
|
|
data.Items = data.Items[1:]
|
|
}
|
|
|
|
if len(data.Items) == 0 {
|
|
dr.log.Debug("No items to clean up in this batch", "orgID", orgID)
|
|
break
|
|
}
|
|
|
|
dr.log.Info("Processing dashboard cleanup batch", "orgID", orgID, "count", len(data.Items))
|
|
|
|
// Process the batch
|
|
processedItems, processingErrs := dr.processDashboardBatch(ctx, orgID, data.Items)
|
|
if len(processingErrs) > 0 {
|
|
errs = append(errs, processingErrs...)
|
|
}
|
|
itemsProcessed += processedItems
|
|
|
|
// Update resource version after the batch
|
|
if len(data.Items) > 0 {
|
|
maxBatchResourceVersion := data.Items[len(data.Items)-1].GetResourceVersion()
|
|
if lastResourceVersion != maxBatchResourceVersion {
|
|
dr.log.Info("Updating resource version after batch", "orgID", orgID,
|
|
"newResourceVersion", maxBatchResourceVersion, "oldResourceVersion", lastResourceVersion)
|
|
|
|
if updateErr := dr.kvstore.Set(ctx, orgID, k8sDashboardKvNamespace,
|
|
k8sDashboardKvLastResourceVersionKey, maxBatchResourceVersion); updateErr != nil {
|
|
errs = append(errs, fmt.Errorf("failed to update resource version: %w", updateErr))
|
|
}
|
|
}
|
|
}
|
|
|
|
meta, _ := data.Object["metadata"].(map[string]interface{})
|
|
continueToken, _ = meta["continue"].(string)
|
|
if continueToken == "" {
|
|
break
|
|
}
|
|
}
|
|
|
|
if itemsProcessed > 0 {
|
|
dr.log.Info("Finished k8s dashboard resources cleanup", "orgID", orgID, "itemsProcessed", itemsProcessed)
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return errors.Join(errs...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getLastResourceVersion retrieves the last processed resource version from kvstore
|
|
func (dr *DashboardServiceImpl) getLastResourceVersion(ctx context.Context, orgID int64) (string, error) {
|
|
lastResourceVersion, ok, err := dr.kvstore.Get(ctx, orgID, k8sDashboardKvNamespace, k8sDashboardKvLastResourceVersionKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get last resource version: %w", err)
|
|
}
|
|
|
|
if !ok {
|
|
dr.log.Info("No last resource version found, starting from scratch", "orgID", orgID)
|
|
return "0", nil
|
|
}
|
|
|
|
return lastResourceVersion, nil
|
|
}
|
|
|
|
// listResourcesToCleanup lists resources that need to be cleaned up
|
|
func (dr *DashboardServiceImpl) listResourcesToCleanup(ctx context.Context, orgID int64, resourceVersion, continueToken string, batchSize int64) (*unstructured.UnstructuredList, error, bool) {
|
|
var listOptions v1.ListOptions
|
|
if continueToken != "" {
|
|
listOptions = v1.ListOptions{
|
|
LabelSelector: utils.LabelKeyGetTrash + "=true",
|
|
Continue: continueToken,
|
|
Limit: batchSize,
|
|
}
|
|
} else {
|
|
listOptions = v1.ListOptions{
|
|
LabelSelector: utils.LabelKeyGetTrash + "=true",
|
|
ResourceVersionMatch: v1.ResourceVersionMatchNotOlderThan,
|
|
ResourceVersion: resourceVersion,
|
|
Limit: batchSize,
|
|
}
|
|
}
|
|
|
|
data, err := dr.k8sclient.List(ctx, orgID, listOptions)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "too old resource version") {
|
|
// If the resource version is too old, start from the current version
|
|
dr.log.Info("Resource version too old, starting from current version", "orgID", orgID)
|
|
return nil, nil, true // Signal to continue with reset version
|
|
}
|
|
return nil, err, false
|
|
}
|
|
|
|
return data, nil, false
|
|
}
|
|
|
|
// processDashboardBatch processes a batch of dashboards for cleanup
|
|
func (dr *DashboardServiceImpl) processDashboardBatch(ctx context.Context, orgID int64, items []unstructured.Unstructured) (int, []error) {
|
|
var errs []error
|
|
itemsProcessed := 0
|
|
|
|
// get users ahead of time to do just one db call, rather than 2 per item in the list
|
|
users, err := dr.getUsersForList(ctx, items, orgID)
|
|
if err != nil {
|
|
return 0, append(errs, err)
|
|
}
|
|
|
|
for _, item := range items {
|
|
dash, err := dr.unstructuredToLegacyDashboardWithUsers(&item, orgID, users)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to convert dashboard: %w", err))
|
|
continue
|
|
}
|
|
|
|
meta, _ := item.Object["metadata"].(map[string]interface{})
|
|
deletionTimestamp, _ := meta["deletionTimestamp"].(string)
|
|
resourceVersion, _ := meta["resourceVersion"].(string)
|
|
|
|
dr.log.Info("K8s dashboard resource previously got deleted, cleaning up",
|
|
"UID", dash.UID,
|
|
"orgID", orgID,
|
|
"deletionTimestamp", deletionTimestamp,
|
|
"resourceVersion", resourceVersion)
|
|
|
|
if err = dr.CleanUpDashboard(ctx, dash.UID, dash.ID, orgID); err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to clean up dashboard %s: %w", dash.UID, err))
|
|
}
|
|
itemsProcessed++
|
|
}
|
|
|
|
return itemsProcessed, errs
|
|
}
|
|
|
|
// This gets auto-invoked when grafana starts, part of the BackgroundService interface
|
|
func (dr *DashboardServiceImpl) Run(ctx context.Context) error {
|
|
cleanupBackgroundJobStopped := dr.startK8sDeletedDashboardsCleanupJob(ctx)
|
|
<-ctx.Done()
|
|
// Wait for cleanup job to finish
|
|
<-cleanupBackgroundJobStopped
|
|
return ctx.Err()
|
|
}
|
|
|
|
var _ dashboards.PermissionsRegistrationService = (*DashboardServiceImpl)(nil)
|
|
var _ registry.BackgroundService = (*DashboardServiceImpl)(nil)
|
|
|
|
// This is the uber service that implements a three smaller services
|
|
func ProvideDashboardServiceImpl(
|
|
cfg *setting.Cfg,
|
|
dashboardStore dashboards.Store,
|
|
folderStore folder.FolderStore,
|
|
features featuremgmt.FeatureToggles,
|
|
folderPermissionsService accesscontrol.FolderPermissionsService,
|
|
ac accesscontrol.AccessControl,
|
|
acService accesscontrol.Service,
|
|
folderSvc folder.Service,
|
|
r prometheus.Registerer,
|
|
quotaService quota.Service,
|
|
orgService org.Service,
|
|
publicDashboardService publicdashboards.ServiceWrapper,
|
|
dual dualwrite.Service,
|
|
serverLockService *serverlock.ServerLockService,
|
|
kvstore kvstore.KVStore,
|
|
k8sClient dashboardclient.K8sHandlerWithFallback,
|
|
) (*DashboardServiceImpl, error) {
|
|
dashSvc := &DashboardServiceImpl{
|
|
cfg: cfg,
|
|
log: log.New("dashboard-service"),
|
|
dashboardStore: dashboardStore,
|
|
features: features,
|
|
folderPermissions: folderPermissionsService,
|
|
ac: ac,
|
|
acService: acService,
|
|
folderStore: folderStore,
|
|
folderService: folderSvc,
|
|
orgService: orgService,
|
|
k8sclient: k8sClient,
|
|
metrics: newDashboardsMetrics(r),
|
|
dashboardPermissionsReady: make(chan struct{}),
|
|
publicDashboardService: publicDashboardService,
|
|
serverLockService: serverLockService,
|
|
kvstore: kvstore,
|
|
dual: dual,
|
|
}
|
|
|
|
defaultLimits, err := readQuotaConfig(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{
|
|
TargetSrv: dashboards.QuotaTargetSrv,
|
|
DefaultLimits: defaultLimits,
|
|
Reporter: dashSvc.Count,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ac.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(dashSvc, folderSvc))
|
|
ac.RegisterScopeAttributeResolver(dashboards.NewDashboardUIDScopeResolver(dashSvc, folderSvc))
|
|
|
|
if err := folderSvc.RegisterService(dashSvc); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return dashSvc, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) RegisterDashboardPermissions(service accesscontrol.DashboardPermissionsService) {
|
|
dr.dashboardPermissions = service
|
|
close(dr.dashboardPermissionsReady)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) getPermissionsService(isFolder bool) accesscontrol.PermissionsService {
|
|
if isFolder {
|
|
return dr.folderPermissions
|
|
}
|
|
<-dr.dashboardPermissionsReady
|
|
return dr.dashboardPermissions
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) {
|
|
u := "a.Map{}
|
|
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
|
if err != nil {
|
|
return u, err
|
|
}
|
|
|
|
total := int64(0)
|
|
for _, org := range orgs {
|
|
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
|
|
orgDashboards, err := dr.CountDashboardsInOrg(ctx, org.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
total += orgDashboards
|
|
|
|
if scopeParams != nil && scopeParams.OrgID == org.ID {
|
|
tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.Set(tag, orgDashboards)
|
|
}
|
|
}
|
|
|
|
tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.Set(tag, total)
|
|
|
|
return u, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*dashboards.DashboardRef, error) {
|
|
res, err := dr.k8sclient.Search(ctx, orgID, &resourcepb.ResourceSearchRequest{
|
|
Options: &resourcepb.ListOptions{
|
|
Fields: []*resourcepb.Requirement{
|
|
{
|
|
Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE,
|
|
Operator: string(selection.Equals),
|
|
Values: []string{libraryPanelUID},
|
|
},
|
|
},
|
|
},
|
|
Limit: listAllDashboardsLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results, err := dashboardsearch.ParseResults(res, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dashes := make([]*dashboards.DashboardRef, 0, len(results.Hits))
|
|
for _, row := range results.Hits {
|
|
dashes = append(dashes, &dashboards.DashboardRef{
|
|
UID: row.Name,
|
|
FolderUID: row.Folder,
|
|
ID: row.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID), // nolint:staticcheck
|
|
})
|
|
}
|
|
return dashes, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) {
|
|
resp, err := dr.k8sclient.GetStats(ctx, orgID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if len(resp.Stats) != 1 {
|
|
return 0, fmt.Errorf("expected 1 stat, got %d", len(resp.Stats))
|
|
}
|
|
|
|
return resp.Stats[0].Count, nil
|
|
}
|
|
|
|
func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) {
|
|
limits := "a.Map{}
|
|
|
|
if cfg == nil {
|
|
return limits, nil
|
|
}
|
|
|
|
globalQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope)
|
|
if err != nil {
|
|
return "a.Map{}, err
|
|
}
|
|
orgQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope)
|
|
if err != nil {
|
|
return "a.Map{}, err
|
|
}
|
|
|
|
limits.Set(globalQuotaTag, cfg.Quota.Global.Dashboard)
|
|
limits.Set(orgQuotaTag, cfg.Quota.Org.Dashboard)
|
|
return limits, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetProvisionedDashboardData(ctx context.Context, name string) ([]*dashboards.DashboardProvisioning, error) {
|
|
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results := []*dashboards.DashboardProvisioning{}
|
|
for _, org := range orgs {
|
|
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck
|
|
ManagerIdentity: name,
|
|
OrgId: org.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, r := range res {
|
|
results = append(results, &r.DashboardProvisioning)
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx context.Context, dashboardID int64) (*dashboards.DashboardProvisioning, error) {
|
|
// if dashboard id is 0, it is a new dashboard
|
|
if dashboardID == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, org := range orgs {
|
|
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck
|
|
OrgId: org.ID,
|
|
DashboardIds: []int64{dashboardID},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(res) == 1 {
|
|
return &res[0].DashboardProvisioning, nil
|
|
} else if len(res) > 1 {
|
|
return nil, fmt.Errorf("found more than one provisioned dashboard with ID %d", dashboardID)
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*dashboards.DashboardProvisioning, error) {
|
|
if dashboardUID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck
|
|
OrgId: orgID,
|
|
DashboardUIDs: []string{dashboardUID},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(res) == 1 {
|
|
return &res[0].DashboardProvisioning, nil
|
|
} else if len(res) > 1 {
|
|
return nil, fmt.Errorf("found more than one provisioned dashboard with UID %s", dashboardUID)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) ValidateBasicDashboardProperties(title string, uid string, message string) error {
|
|
if title == "" {
|
|
return dashboards.ErrDashboardTitleEmpty
|
|
}
|
|
|
|
if len(title) > 5000 {
|
|
return dashboards.ErrDashboardTitleTooLong
|
|
}
|
|
|
|
// Validate message
|
|
if message != "" && len(message) > 500 {
|
|
return dashboards.ErrDashboardMessageTooLong
|
|
}
|
|
|
|
if !util.IsValidShortUID(uid) {
|
|
return dashboards.ErrDashboardInvalidUid
|
|
} else if util.IsShortUIDTooLong(uid) {
|
|
return dashboards.ErrDashboardUidTooLong
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
//nolint:gocyclo
|
|
func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, dto *dashboards.SaveDashboardDTO,
|
|
validateProvisionedDashboard bool) (*dashboards.SaveDashboardCommand, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.BuildSaveDashboardcommand")
|
|
defer span.End()
|
|
|
|
dash := dto.Dashboard
|
|
|
|
dash.OrgID = dto.OrgID
|
|
dash.Title = strings.TrimSpace(dash.Title)
|
|
dash.Data.Set("title", dash.Title)
|
|
dash.SetUID(strings.TrimSpace(dash.UID))
|
|
|
|
if err := dr.ValidateBasicDashboardProperties(dash.Title, dash.UID, dto.Message); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
|
// nolint:staticcheck
|
|
if dash.IsFolder && dash.FolderID > 0 {
|
|
return nil, dashboards.ErrDashboardFolderCannotHaveParent
|
|
}
|
|
|
|
if dash.IsFolder && strings.EqualFold(dash.Title, dashboards.RootFolderName) {
|
|
return nil, dashboards.ErrDashboardFolderNameExists
|
|
}
|
|
|
|
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dash.Data.Get("refresh").MustString("")); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate folder
|
|
if dash.FolderID != 0 || dash.FolderUID != "" { // nolint:staticcheck
|
|
folder, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{
|
|
OrgID: dash.OrgID,
|
|
UID: &dash.FolderUID,
|
|
ID: &dash.FolderID, // nolint:staticcheck
|
|
SignedInUser: dto.User,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
|
// nolint:staticcheck
|
|
dash.FolderID = folder.ID
|
|
dash.FolderUID = folder.UID
|
|
}
|
|
|
|
isParentFolderChanged, err := dr.ValidateDashboardBeforeSave(ctx, dash, dto.Overwrite)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if isParentFolderChanged {
|
|
if canCreate, err := dr.canCreateDashboard(ctx, dto.User, dash); err != nil || !canCreate {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, dashboards.ErrDashboardUpdateAccessDenied
|
|
}
|
|
}
|
|
|
|
if dash.ID == 0 {
|
|
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
|
if canCreate, err := dr.canCreateDashboard(ctx, dto.User, dash); err != nil || !canCreate {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, dashboards.ErrDashboardUpdateAccessDenied
|
|
}
|
|
} else {
|
|
if canSave, err := dr.canSaveDashboard(ctx, dto.User, dash); err != nil || !canSave {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, dashboards.ErrDashboardUpdateAccessDenied
|
|
}
|
|
}
|
|
|
|
if validateProvisionedDashboard {
|
|
provisionedData, err := dr.GetProvisionedDashboardDataByDashboardID(ctx, dash.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if provisionedData != nil {
|
|
return nil, dashboards.ErrDashboardCannotSaveProvisionedDashboard
|
|
}
|
|
}
|
|
|
|
var userID int64
|
|
if id, err := identity.UserIdentifier(dto.User.GetID()); err == nil {
|
|
userID = id
|
|
} else if !identity.IsServiceIdentity(ctx) {
|
|
dr.log.Debug("User does not belong to a user or service account namespace, using 0 as user ID", "id", dto.User.GetID())
|
|
}
|
|
|
|
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
|
cmd := &dashboards.SaveDashboardCommand{
|
|
Dashboard: dash.Data,
|
|
Message: dto.Message,
|
|
OrgID: dto.OrgID,
|
|
Overwrite: dto.Overwrite,
|
|
UserID: userID,
|
|
FolderID: dash.FolderID, // nolint:staticcheck
|
|
FolderUID: dash.FolderUID,
|
|
IsFolder: dash.IsFolder,
|
|
PluginID: dash.PluginID,
|
|
}
|
|
|
|
if !dto.UpdatedAt.IsZero() {
|
|
cmd.UpdatedAt = dto.UpdatedAt
|
|
}
|
|
|
|
return cmd, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) ValidateDashboardBeforeSave(ctx context.Context, dashboard *dashboards.Dashboard, overwrite bool) (bool, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.ValidateDashboardBeforesave")
|
|
defer span.End()
|
|
|
|
isParentFolderChanged := false
|
|
|
|
var existingById *dashboards.Dashboard
|
|
var err error
|
|
if dashboard.ID > 0 {
|
|
// if ID is set and the dashboard is not found, ErrDashboardNotFound will be returned
|
|
existingById, err = dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: dashboard.OrgID, ID: dashboard.ID})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if dashboard.UID == "" {
|
|
dashboard.SetUID(existingById.UID)
|
|
}
|
|
}
|
|
dashWithIdExists := (existingById != nil)
|
|
|
|
var existingByUid *dashboards.Dashboard
|
|
if dashboard.UID != "" {
|
|
existingByUid, err = dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: dashboard.OrgID, UID: dashboard.UID})
|
|
if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) {
|
|
return false, err
|
|
}
|
|
}
|
|
dashWithUidExists := (existingByUid != nil)
|
|
|
|
if !dashWithIdExists && !dashWithUidExists {
|
|
return false, nil
|
|
}
|
|
|
|
if dashWithIdExists && dashWithUidExists && existingById.ID != existingByUid.ID {
|
|
return false, dashboards.ErrDashboardWithSameUIDExists
|
|
}
|
|
|
|
existing := existingById
|
|
|
|
if !dashWithIdExists && dashWithUidExists {
|
|
dashboard.SetID(existingByUid.ID)
|
|
dashboard.SetUID(existingByUid.UID)
|
|
existing = existingByUid
|
|
}
|
|
|
|
if (existing.IsFolder && !dashboard.IsFolder) ||
|
|
(!existing.IsFolder && dashboard.IsFolder) {
|
|
return isParentFolderChanged, dashboards.ErrDashboardTypeMismatch
|
|
}
|
|
|
|
if !dashboard.IsFolder && dashboard.FolderUID != existing.FolderUID {
|
|
isParentFolderChanged = true
|
|
}
|
|
|
|
// check for is someone else has written in between
|
|
if dashboard.Version != existing.Version {
|
|
if overwrite {
|
|
dashboard.SetVersion(existing.Version)
|
|
} else {
|
|
return isParentFolderChanged, dashboards.ErrDashboardVersionMismatch
|
|
}
|
|
}
|
|
|
|
// do not allow plugin dashboard updates without overwrite flag
|
|
if existing.PluginID != "" && !overwrite {
|
|
return isParentFolderChanged, dashboards.UpdatePluginDashboardError{PluginId: existing.PluginID}
|
|
}
|
|
|
|
return isParentFolderChanged, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) canSaveDashboard(ctx context.Context, user identity.Requester, dash *dashboards.Dashboard) (bool, error) {
|
|
action := dashboards.ActionDashboardsWrite
|
|
if dash.IsFolder {
|
|
action = dashboards.ActionFoldersWrite
|
|
}
|
|
scope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID)
|
|
if dash.IsFolder {
|
|
scope = dashboards.ScopeFoldersProvider.GetResourceScopeUID(dash.UID)
|
|
}
|
|
return dr.ac.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope))
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) canCreateDashboard(ctx context.Context, user identity.Requester, dash *dashboards.Dashboard) (bool, error) {
|
|
action := dashboards.ActionDashboardsCreate
|
|
if dash.IsFolder {
|
|
action = dashboards.ActionFoldersCreate
|
|
}
|
|
scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(dash.FolderUID)
|
|
if dash.FolderUID == "" {
|
|
scope = dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)
|
|
}
|
|
return dr.ac.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope))
|
|
}
|
|
|
|
// waitForSearchQuery waits for the search query to return the expected number of hits.
|
|
// Since US doesn't offer search-after-write guarantees, we can use this to wait after writes until the indexer is up to date.
|
|
func (dr *DashboardServiceImpl) waitForSearchQuery(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery, maxRetries int, expectedHits int64) error {
|
|
return retryer.Retry(func() (retryer.RetrySignal, error) {
|
|
results, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
|
|
dr.log.Debug("waitForSearchQuery", "dashboardUIDs", strings.Join(query.DashboardUIDs, ","), "total_hits", results.TotalHits, "err", err)
|
|
if err != nil {
|
|
return retryer.FuncError, err
|
|
}
|
|
if results.TotalHits == expectedHits {
|
|
return retryer.FuncComplete, nil
|
|
}
|
|
return retryer.FuncFailure, nil
|
|
}, maxRetries, 1*time.Second, 5*time.Second)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error {
|
|
// check each org for orphaned provisioned dashboards
|
|
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, org := range orgs {
|
|
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
|
|
// find all dashboards in the org that have a file repo set that is not in the given readers list
|
|
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck
|
|
ManagerIdentityNotIn: cmd.ReaderNames,
|
|
OrgId: org.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dr.log.Debug("Found dashboards to be deleted", "orgId", org.ID, "count", len(foundDashs))
|
|
|
|
// delete them
|
|
var deletedUids []string
|
|
for _, foundDash := range foundDashs {
|
|
if err = dr.deleteDashboard(ctx, foundDash.DashboardID, foundDash.DashboardUID, org.ID, false); err != nil {
|
|
return err
|
|
}
|
|
deletedUids = append(deletedUids, foundDash.DashboardUID)
|
|
}
|
|
if len(deletedUids) > 0 {
|
|
// wait for deleted dashboards to be removed from the index
|
|
err = dr.waitForSearchQuery(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: org.ID, DashboardUIDs: deletedUids}, 5, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) ValidateDashboardRefreshInterval(minRefreshInterval string, targetRefreshInterval string) error {
|
|
if minRefreshInterval == "" {
|
|
return nil
|
|
}
|
|
|
|
if targetRefreshInterval == "" || targetRefreshInterval == "auto" {
|
|
// since no refresh is set it is a valid refresh rate
|
|
return nil
|
|
}
|
|
|
|
minRefreshIntervalDur, err := gtime.ParseDuration(minRefreshInterval)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing min refresh interval %q failed: %w", minRefreshInterval, err)
|
|
}
|
|
d, err := gtime.ParseDuration(targetRefreshInterval)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing refresh duration %q failed: %w", targetRefreshInterval, err)
|
|
}
|
|
|
|
if d < minRefreshIntervalDur {
|
|
return dashboards.ErrDashboardRefreshIntervalTooShort
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO,
|
|
provisioning *dashboards.DashboardProvisioning) (*dashboards.Dashboard, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.SaveProvisionedDashboard")
|
|
defer span.End()
|
|
|
|
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard.Data.Get("refresh").MustString("")); err != nil {
|
|
dr.log.Warn("Changing refresh interval for provisioned dashboard to minimum refresh interval", "dashboardUid",
|
|
dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval", dr.cfg.MinRefreshInterval)
|
|
dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval)
|
|
}
|
|
|
|
ctx, ident := identity.WithServiceIdentity(ctx, dto.OrgID)
|
|
dto.User = ident
|
|
|
|
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cmd == nil {
|
|
return nil, fmt.Errorf("failed to build save dashboard command. cmd is nil")
|
|
}
|
|
|
|
dash, err := dr.saveProvisionedDashboardThroughK8s(ctx, cmd, provisioning, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if dto.Dashboard.ID == 0 {
|
|
dr.SetDefaultPermissions(ctx, dto, dash, true)
|
|
}
|
|
|
|
return dash, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.Context, dto *folder.CreateFolderCommand) (*folder.Folder, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.SaveFolderForProvisionedDashboards")
|
|
defer span.End()
|
|
|
|
ctx, ident := identity.WithServiceIdentity(ctx, dto.OrgID)
|
|
dto.SignedInUser = ident
|
|
|
|
f, err := dr.folderService.Create(ctx, dto)
|
|
if err != nil {
|
|
dr.log.Error("failed to create folder for provisioned dashboards", "folder", dto.Title, "org", dto.OrgID, "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO,
|
|
allowUiUpdate bool) (*dashboards.Dashboard, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.SaveDashboard")
|
|
defer span.End()
|
|
|
|
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard.Data.Get("refresh").MustString("")); err != nil {
|
|
dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval",
|
|
"dashboardUid", dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval",
|
|
dr.cfg.MinRefreshInterval)
|
|
dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval)
|
|
}
|
|
|
|
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, !allowUiUpdate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dash, err := dr.saveDashboard(ctx, cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// new dashboard created
|
|
if dto.Dashboard.ID == 0 {
|
|
dr.SetDefaultPermissions(ctx, dto, dash, false)
|
|
}
|
|
|
|
return dash, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) saveDashboard(ctx context.Context, cmd *dashboards.SaveDashboardCommand) (*dashboards.Dashboard, error) {
|
|
return dr.saveDashboardThroughK8s(ctx, cmd, cmd.OrgID)
|
|
}
|
|
|
|
// DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for
|
|
// operations by the user where we want to make sure user does not delete provisioned dashboard.
|
|
func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error {
|
|
return dr.deleteDashboard(ctx, dashboardId, dashboardUID, orgId, true)
|
|
}
|
|
|
|
// DeleteAllDashboards will delete all dashboards within a given org.
|
|
func (dr *DashboardServiceImpl) DeleteAllDashboards(ctx context.Context, orgId int64) error {
|
|
return dr.deleteAllDashboardThroughK8s(ctx, orgId)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, dashboardPublicUid string) (*dashboards.Dashboard, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned.
|
|
func (dr *DashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
|
|
ctx, _ = identity.WithServiceIdentity(ctx, orgId)
|
|
return dr.deleteDashboard(ctx, dashboardId, "", orgId, false)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64, validateProvisionedDashboard bool) error {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.deleteDashboard")
|
|
defer span.End()
|
|
|
|
cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId, UID: dashboardUID}
|
|
|
|
return dr.deleteDashboardThroughK8s(ctx, cmd, validateProvisionedDashboard)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO) (
|
|
*dashboards.Dashboard, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.ImportDashboard")
|
|
defer span.End()
|
|
|
|
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard.Data.Get("refresh").MustString("")); err != nil {
|
|
dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval",
|
|
"dashboardUid", dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title,
|
|
"minRefreshInterval", dr.cfg.MinRefreshInterval)
|
|
dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval)
|
|
}
|
|
|
|
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dash, err := dr.saveDashboard(ctx, cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dr.SetDefaultPermissions(ctx, dto, dash, false)
|
|
|
|
return dash, nil
|
|
}
|
|
|
|
// UnprovisionDashboard removes info about dashboard being provisioned. Used after provisioning configs are changed
|
|
// and provisioned dashboards are left behind but not deleted.
|
|
func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error {
|
|
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, org := range orgs {
|
|
ctx, _ = identity.WithServiceIdentity(ctx, org.ID)
|
|
dash, err := dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: org.ID, ID: dashboardId})
|
|
if err != nil {
|
|
// if we can't find it in this org, try the next one
|
|
continue
|
|
}
|
|
|
|
_, err = dr.saveProvisionedDashboardThroughK8s(ctx, &dashboards.SaveDashboardCommand{
|
|
OrgID: org.ID,
|
|
PluginID: dash.PluginID,
|
|
FolderUID: dash.FolderUID,
|
|
FolderID: dash.FolderID, // nolint:staticcheck
|
|
UpdatedAt: time.Now(),
|
|
Dashboard: dash.Data,
|
|
}, nil, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId)
|
|
}
|
|
|
|
return dashboards.ErrDashboardNotFound
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) {
|
|
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
OrgId: query.OrgID,
|
|
ManagedBy: utils.ManagerKindPlugin,
|
|
ManagerIdentity: query.PluginID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// search only returns the metadata, need to get the dashboard.Data too
|
|
results := make([]*dashboards.Dashboard, len(dashs))
|
|
for i, d := range dashs {
|
|
dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results[i] = dash
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// (sometimes) called by the k8s storage engine after creating an object
|
|
func (dr *DashboardServiceImpl) SetDefaultPermissionsAfterCreate(ctx context.Context, key *resourcepb.ResourceKey, id claims.AuthInfo, obj utils.GrafanaMetaAccessor) error {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.SetDefaultPermissionsAfterCreate")
|
|
defer span.End()
|
|
|
|
logger := logging.FromContext(ctx)
|
|
|
|
ns, err := request.NamespaceInfoFrom(ctx, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := identity.GetRequester(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid, err := user.GetInternalID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
permissions := []accesscontrol.SetResourcePermissionCommand{}
|
|
if user.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) {
|
|
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{
|
|
UserID: uid, Permission: dashboardaccess.PERMISSION_ADMIN.String(),
|
|
})
|
|
}
|
|
isNested := obj.GetFolder() != ""
|
|
if !dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesDashboards) {
|
|
// legacy behavior
|
|
if !isNested {
|
|
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
|
|
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
|
|
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
|
|
}...)
|
|
}
|
|
} else {
|
|
// Don't set any permissions for nested dashboards
|
|
if isNested {
|
|
return nil
|
|
}
|
|
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
|
|
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()},
|
|
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
|
|
}...)
|
|
}
|
|
svc := dr.getPermissionsService(key.Resource == "folders")
|
|
if _, err := svc.SetPermissions(ctx, ns.OrgID, obj.GetName(), permissions...); err != nil {
|
|
logger.Error("Could not set default permissions", "error", err)
|
|
return err
|
|
}
|
|
|
|
// Clear permission cache for the user who created the dashboard, so that new permissions are fetched for their next call
|
|
// Required for cases when caller wants to immediately interact with the newly created object
|
|
if user.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) {
|
|
dr.acService.ClearUserPermissionCache(user)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) SetDefaultPermissions(ctx context.Context, dto *dashboards.SaveDashboardDTO, dash *dashboards.Dashboard, provisioned bool) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.setDefaultPermissions")
|
|
defer span.End()
|
|
|
|
resource := "dashboard"
|
|
if dash.IsFolder {
|
|
resource = "folder"
|
|
}
|
|
|
|
if !dr.cfg.RBAC.PermissionsOnCreation(resource) {
|
|
return
|
|
}
|
|
|
|
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
|
|
|
var permissions []accesscontrol.SetResourcePermissionCommand
|
|
if !provisioned && dto.User.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) {
|
|
userID, err := dto.User.GetInternalID()
|
|
if err != nil {
|
|
dr.log.Error("Could not make user admin", "dashboard", dash.Title, "id", dto.User.GetID(), "error", err)
|
|
} else {
|
|
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{
|
|
UserID: userID, Permission: dashboardaccess.PERMISSION_ADMIN.String(),
|
|
})
|
|
}
|
|
}
|
|
|
|
if dash.FolderUID == "" {
|
|
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
|
|
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
|
|
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
|
|
}...)
|
|
}
|
|
|
|
svc := dr.getPermissionsService(dash.IsFolder)
|
|
if _, err := svc.SetPermissions(ctx, dto.OrgID, dash.UID, permissions...); err != nil {
|
|
dr.log.Error("Could not set default permissions", "dashboard", dash.Title, "error", err)
|
|
}
|
|
|
|
// Clear permission cache for the user who created the dashboard, so that new permissions are fetched for their next call
|
|
// Required for cases when caller wants to immediately interact with the newly created object
|
|
if !provisioned && dto.User.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) {
|
|
dr.acService.ClearUserPermissionCache(dto.User)
|
|
}
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) {
|
|
return dr.getDashboardThroughK8s(ctx, query)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) {
|
|
requester, err := identity.GetRequester(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
OrgId: requester.GetOrgID(),
|
|
DashboardIds: []int64{query.ID},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
return nil, dashboards.ErrDashboardNotFound
|
|
} else if len(result) > 1 {
|
|
return nil, fmt.Errorf("unexpected number of dashboards found: %d. desired: 1", len(result))
|
|
}
|
|
|
|
return &dashboards.DashboardRef{UID: result[0].UID, Slug: result[0].Slug, FolderUID: result[0].FolderUID}, nil
|
|
}
|
|
|
|
// expensive query in new flow !! use sparingly - only if you truly need dashboard.Data
|
|
func (dr *DashboardServiceImpl) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) {
|
|
if query.OrgID == 0 {
|
|
requester, err := identity.GetRequester(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query.OrgID = requester.GetOrgID()
|
|
}
|
|
|
|
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
DashboardIds: query.DashboardIDs,
|
|
OrgId: query.OrgID,
|
|
DashboardUIDs: query.DashboardUIDs,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// search only returns the metadata, need to get the dashboard.Data too
|
|
results := make([]*dashboards.Dashboard, len(dashs))
|
|
for i, d := range dashs {
|
|
dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results[i] = dash
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) getDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*dashboards.DashboardRef, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.getDashboardsSharedWithUser")
|
|
defer span.End()
|
|
|
|
permissions := user.GetPermissions()
|
|
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
|
|
dashboardUids := make([]string, 0)
|
|
for _, p := range dashboardPermissions {
|
|
if dashboardUid, found := strings.CutPrefix(p, dashboards.ScopeDashboardsPrefix); found {
|
|
if !slices.Contains(dashboardUids, dashboardUid) {
|
|
dashboardUids = append(dashboardUids, dashboardUid)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(dashboardUids) == 0 {
|
|
return []*dashboards.DashboardRef{}, nil
|
|
}
|
|
|
|
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
DashboardUIDs: dashboardUids,
|
|
OrgId: user.GetOrgID(),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sharedDashboards := make([]*dashboards.DashboardRef, len(dashs))
|
|
for i, d := range dashs {
|
|
sharedDashboards[i] = &dashboards.DashboardRef{UID: d.UID, Slug: d.Slug, FolderUID: d.FolderUID}
|
|
}
|
|
|
|
return dr.filterUserSharedDashboards(ctx, user, sharedDashboards)
|
|
}
|
|
|
|
// filterUserSharedDashboards filter dashboards directly assigned to user, but not located in folders with view permissions
|
|
func (dr *DashboardServiceImpl) filterUserSharedDashboards(ctx context.Context, user identity.Requester, userDashboards []*dashboards.DashboardRef) ([]*dashboards.DashboardRef, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.filterUserSharedDashboards")
|
|
defer span.End()
|
|
|
|
filteredDashboards := make([]*dashboards.DashboardRef, 0)
|
|
folderUIDs := make([]string, 0)
|
|
for _, dashboard := range userDashboards {
|
|
folderUIDs = append(folderUIDs, dashboard.FolderUID)
|
|
}
|
|
|
|
// GetFolders return only folders available to user. So we can use is to check access.
|
|
userDashFolders, err := dr.folderService.GetFolders(ctx, folder.GetFoldersQuery{
|
|
UIDs: folderUIDs,
|
|
OrgID: user.GetOrgID(),
|
|
OrderByTitle: true,
|
|
SignedInUser: user,
|
|
})
|
|
if err != nil {
|
|
return nil, folder.ErrInternal.Errorf("failed to fetch parent folders from store: %w", err)
|
|
}
|
|
|
|
dashFoldersMap := make(map[string]*folder.Folder, 0)
|
|
for _, f := range userDashFolders {
|
|
dashFoldersMap[f.UID] = f
|
|
}
|
|
|
|
for _, dashboard := range userDashboards {
|
|
// Filter out dashboards if user has access to parent folder
|
|
if dashboard.FolderUID == "" {
|
|
continue
|
|
}
|
|
|
|
_, hasAccess := dashFoldersMap[dashboard.FolderUID]
|
|
if !hasAccess {
|
|
filteredDashboards = append(filteredDashboards, dashboard)
|
|
}
|
|
}
|
|
return filteredDashboards, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) getUserSharedDashboardUIDs(ctx context.Context, user identity.Requester) ([]string, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.getUserSharedDashboardsUIDs")
|
|
defer span.End()
|
|
|
|
userDashboards, err := dr.getDashboardsSharedWithUser(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
userDashboardUIDs := make([]string, 0)
|
|
for _, dashboard := range userDashboards {
|
|
userDashboardUIDs = append(userDashboardUIDs, dashboard.UID)
|
|
}
|
|
return userDashboardUIDs, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.FindDashboards")
|
|
defer span.End()
|
|
|
|
if len(query.FolderUIDs) > 0 && slices.Contains(query.FolderUIDs, folder.SharedWithMeFolderUID) {
|
|
start := time.Now()
|
|
userDashboardUIDs, err := dr.getUserSharedDashboardUIDs(ctx, query.SignedInUser)
|
|
if err != nil {
|
|
dr.metrics.sharedWithMeFetchDashboardsRequestsDuration.WithLabelValues("failure").Observe(time.Since(start).Seconds())
|
|
return nil, err
|
|
}
|
|
if len(userDashboardUIDs) == 0 {
|
|
return []dashboards.DashboardSearchProjection{}, nil
|
|
}
|
|
query.DashboardUIDs = userDashboardUIDs
|
|
query.FolderUIDs = []string{}
|
|
|
|
defer func(t time.Time) {
|
|
dr.metrics.sharedWithMeFetchDashboardsRequestsDuration.WithLabelValues("success").Observe(time.Since(start).Seconds())
|
|
}(time.Now())
|
|
}
|
|
|
|
if query.OrgId == 0 {
|
|
requester, err := identity.GetRequester(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query.OrgId = requester.GetOrgID()
|
|
}
|
|
|
|
response, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
folderNames, err := dr.fetchFolderNames(ctx, query, response.Hits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
finalResults := make([]dashboards.DashboardSearchProjection, len(response.Hits))
|
|
for i, hit := range response.Hits {
|
|
folderTitle := ""
|
|
folderID := int64(0)
|
|
if f, ok := folderNames[hit.Folder]; ok {
|
|
folderTitle = f.Title
|
|
folderID = f.ID
|
|
}
|
|
|
|
result := dashboards.DashboardSearchProjection{
|
|
ID: hit.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID),
|
|
UID: hit.Name,
|
|
OrgID: query.OrgId,
|
|
Title: hit.Title,
|
|
Slug: slugify.Slugify(hit.Title),
|
|
IsFolder: false,
|
|
FolderUID: hit.Folder,
|
|
FolderTitle: folderTitle,
|
|
FolderID: folderID,
|
|
FolderSlug: slugify.Slugify(folderTitle),
|
|
Tags: hit.Tags,
|
|
}
|
|
|
|
if hit.Field != nil && query.Sort.Name != "" {
|
|
fieldName, _, err := legacysearcher.ParseSortName(query.Sort.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.SortMeta = hit.Field.GetNestedInt64(fieldName)
|
|
}
|
|
|
|
if hit.Resource == folderv1.RESOURCE {
|
|
result.IsFolder = true
|
|
}
|
|
|
|
finalResults[i] = result
|
|
}
|
|
|
|
return finalResults, nil
|
|
}
|
|
|
|
type folderRes struct {
|
|
Title string
|
|
ID int64
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) fetchFolderNames(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery, hits []dashboardv0.DashboardHit) (map[string]folderRes, error) {
|
|
// call this with elevated permissions so we can get folder names where user does not have access
|
|
// some dashboards are shared directly with user, but the folder is not accessible via the folder permissions
|
|
serviceCtx, serviceIdent := identity.WithServiceIdentity(ctx, query.OrgId)
|
|
search := folder.SearchFoldersQuery{
|
|
UIDs: getFolderUIDs(hits),
|
|
OrgID: query.OrgId,
|
|
SignedInUser: serviceIdent,
|
|
}
|
|
|
|
folders, err := dr.folderService.SearchFolders(serviceCtx, search)
|
|
if err != nil {
|
|
return nil, folder.ErrInternal.Errorf("failed to fetch parent folders: %w", err)
|
|
}
|
|
|
|
folderNames := make(map[string]folderRes)
|
|
for _, f := range folders {
|
|
folderNames[f.UID] = folderRes{
|
|
Title: f.Title,
|
|
ID: f.ID,
|
|
}
|
|
}
|
|
return folderNames, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (model.HitList, error) {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.SearchDashboards")
|
|
defer span.End()
|
|
|
|
res, err := dr.FindDashboards(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hits := makeQueryResult(query, res)
|
|
return hits, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*dashboards.Dashboard, error) {
|
|
return dr.listDashboardsThroughK8s(ctx, orgID)
|
|
}
|
|
|
|
func getHitType(item dashboards.DashboardSearchProjection) model.HitType {
|
|
var hitType model.HitType
|
|
if item.IsFolder {
|
|
hitType = model.DashHitFolder
|
|
} else {
|
|
hitType = model.DashHitDB
|
|
}
|
|
|
|
return hitType
|
|
}
|
|
|
|
func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashboards.DashboardSearchProjection) model.HitList {
|
|
hitList := make([]*model.Hit, 0)
|
|
|
|
for _, item := range res {
|
|
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
|
hit := &model.Hit{
|
|
ID: item.ID,
|
|
UID: item.UID,
|
|
OrgID: item.OrgID,
|
|
Title: item.Title,
|
|
URI: "db/" + item.Slug,
|
|
URL: dashboards.GetDashboardFolderURL(item.IsFolder, item.UID, item.Slug),
|
|
Type: getHitType(item),
|
|
FolderID: item.FolderID, // nolint:staticcheck
|
|
FolderUID: item.FolderUID,
|
|
FolderTitle: item.FolderTitle,
|
|
Tags: []string{},
|
|
}
|
|
|
|
if item.Tags != nil {
|
|
hit.Tags = item.Tags
|
|
}
|
|
|
|
// nolint:staticcheck
|
|
if item.FolderID > 0 || item.FolderUID != "" {
|
|
hit.FolderURL = dashboards.GetFolderURL(item.FolderUID, item.FolderSlug)
|
|
}
|
|
|
|
if query.Sort.MetaName != "" {
|
|
hit.SortMeta = item.SortMeta
|
|
hit.SortMetaName = query.Sort.MetaName
|
|
}
|
|
|
|
if item.Deleted != nil {
|
|
deletedDate := (*item.Deleted).Add(daysInTrash)
|
|
hit.IsDeleted = true
|
|
hit.PermanentlyDeleteDate = &deletedDate
|
|
}
|
|
|
|
hitList = append(hitList, hit)
|
|
}
|
|
return hitList
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) {
|
|
res, err := dr.k8sclient.Search(ctx, query.OrgID, &resourcepb.ResourceSearchRequest{
|
|
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
|
|
"tags": {
|
|
Field: "tags",
|
|
Limit: 100000,
|
|
},
|
|
},
|
|
Limit: 100000})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
facet, ok := res.Facet["tags"]
|
|
if !ok {
|
|
return []*dashboards.DashboardTagCloudItem{}, nil
|
|
}
|
|
|
|
results := make([]*dashboards.DashboardTagCloudItem, len(facet.Terms))
|
|
for i, item := range facet.Terms {
|
|
results[i] = &dashboards.DashboardTagCloudItem{
|
|
Term: item.Term,
|
|
Count: int(item.Count),
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (dr DashboardServiceImpl) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) {
|
|
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
OrgId: orgID,
|
|
FolderUIDs: folderUIDs,
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return int64(len(dashs)), nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) error {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.DeleteInFolders")
|
|
defer span.End()
|
|
|
|
// We need a list of dashboard uids inside the folder to delete related public dashboards
|
|
dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
|
|
SignedInUser: u,
|
|
FolderUIDs: folderUIDs,
|
|
OrgId: orgID,
|
|
Type: searchstore.TypeDashboard,
|
|
})
|
|
if err != nil {
|
|
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
|
|
}
|
|
|
|
dashboardUIDs := make([]string, 0, len(dashes))
|
|
for _, dashboard := range dashes {
|
|
dashboardUIDs = append(dashboardUIDs, dashboard.UID)
|
|
}
|
|
|
|
err = dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgID, dashboardUIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return dr.dashboardStore.DeleteDashboardsInFolders(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID})
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) Kind() string { return entity.StandardKindDashboard }
|
|
|
|
func (dr *DashboardServiceImpl) CleanUpDashboard(ctx context.Context, dashboardUID string, dashboardID int64, orgId int64) error {
|
|
ctx, span := tracer.Start(ctx, "dashboards.service.CleanUpDashboard")
|
|
defer span.End()
|
|
|
|
// cleanup things related to dashboards that are not stored in unistore yet
|
|
var err = dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgId, []string{dashboardUID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return dr.dashboardStore.CleanupAfterDelete(ctx, &dashboards.DeleteDashboardCommand{OrgID: orgId, UID: dashboardUID, ID: dashboardID})
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// Dashboard k8s functions
|
|
// -----------------------------------------------------------------------------------------
|
|
|
|
func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) {
|
|
// get uid if not passed in
|
|
if query.UID == "" {
|
|
result, err := dr.GetDashboardUIDByID(ctx, &dashboards.GetDashboardRefByIDQuery{
|
|
ID: query.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query.UID = result.UID
|
|
}
|
|
|
|
out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, v1.GetOptions{}, "")
|
|
if err != nil && !apierrors.IsNotFound(err) {
|
|
return nil, err
|
|
} else if err != nil || out == nil {
|
|
return nil, dashboards.ErrDashboardNotFound
|
|
}
|
|
|
|
return dr.UnstructuredToLegacyDashboard(ctx, out, query.OrgID)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.Context, cmd *dashboards.SaveDashboardCommand, provisioning *dashboards.DashboardProvisioning, unprovision bool) (*dashboards.Dashboard, error) {
|
|
// default to 1 if not set
|
|
if cmd.OrgID == 0 {
|
|
cmd.OrgID = 1
|
|
}
|
|
|
|
obj, err := LegacySaveCommandToUnstructured(cmd, dr.k8sclient.GetNamespace(cmd.OrgID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
meta, err := utils.MetaAccessor(obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m := utils.ManagerProperties{}
|
|
s := utils.SourceProperties{}
|
|
if !unprovision {
|
|
// TODO: the path should be relative to the root
|
|
// HOWEVER, maybe OK to leave this for now and "fix" it by using file provisioning for mode 4
|
|
m.Kind = utils.ManagerKindClassicFP // nolint:staticcheck
|
|
m.Identity = provisioning.Name
|
|
s.Path = provisioning.ExternalID
|
|
s.Checksum = provisioning.CheckSum
|
|
s.TimestampMillis = time.Unix(provisioning.Updated, 0).UnixMilli()
|
|
}
|
|
meta.SetManagerProperties(m)
|
|
meta.SetSourceProperties(s)
|
|
|
|
out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID, v1.UpdateOptions{
|
|
FieldValidation: v1.FieldValidationIgnore,
|
|
})
|
|
if err != nil && apierrors.IsNotFound(err) {
|
|
// Create if it doesn't already exist.
|
|
out, err = dr.k8sclient.Create(ctx, obj, cmd.OrgID, v1.CreateOptions{
|
|
FieldValidation: v1.FieldValidationIgnore,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return dr.UnstructuredToLegacyDashboard(ctx, out, cmd.OrgID)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd *dashboards.SaveDashboardCommand, orgID int64) (*dashboards.Dashboard, error) {
|
|
obj, err := LegacySaveCommandToUnstructured(cmd, dr.k8sclient.GetNamespace(orgID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dashboard.SetPluginIDMeta(obj, cmd.PluginID)
|
|
|
|
out, err := dr.k8sclient.Update(ctx, obj, orgID, v1.UpdateOptions{
|
|
FieldValidation: v1.FieldValidationIgnore,
|
|
})
|
|
if err != nil && apierrors.IsNotFound(err) {
|
|
// Create if it doesn't already exist.
|
|
out, err = dr.k8sclient.Create(ctx, obj, orgID, v1.CreateOptions{
|
|
FieldValidation: v1.FieldValidationIgnore,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return dr.UnstructuredToLegacyDashboard(ctx, out, orgID)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) deleteAllDashboardThroughK8s(ctx context.Context, orgID int64) error {
|
|
return dr.k8sclient.DeleteCollection(ctx, orgID)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) deleteDashboardThroughK8s(ctx context.Context, cmd *dashboards.DeleteDashboardCommand, validateProvisionedDashboard bool) error {
|
|
// get uid if not passed in
|
|
if cmd.UID == "" {
|
|
result, err := dr.GetDashboardUIDByID(ctx, &dashboards.GetDashboardRefByIDQuery{
|
|
ID: cmd.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.UID = result.UID
|
|
}
|
|
|
|
// use a grace period of 0 to indicate to skip the check of deleting provisioned dashboards
|
|
var gracePeriod *int64
|
|
if !validateProvisionedDashboard {
|
|
noGracePeriod := int64(0)
|
|
gracePeriod = &noGracePeriod
|
|
}
|
|
|
|
return dr.k8sclient.Delete(ctx, cmd.UID, cmd.OrgID, v1.DeleteOptions{
|
|
GracePeriodSeconds: gracePeriod,
|
|
})
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) listDashboardsThroughK8s(ctx context.Context, orgID int64) ([]*dashboards.Dashboard, error) {
|
|
dashes := make([]*dashboards.Dashboard, 0)
|
|
|
|
for continueToken := ""; true; {
|
|
out, err := dr.k8sclient.List(ctx, orgID, v1.ListOptions{
|
|
Limit: listAllDashboardsLimit,
|
|
Continue: continueToken,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if out == nil {
|
|
return nil, dashboards.ErrDashboardNotFound
|
|
}
|
|
|
|
// get users ahead of time to do just one db call, rather than 2 per item in the list
|
|
users, err := dr.getUsersForList(ctx, out.Items, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, item := range out.Items {
|
|
dash, err := dr.unstructuredToLegacyDashboardWithUsers(&item, orgID, users)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dashes = append(dashes, dash)
|
|
}
|
|
|
|
continueToken = out.GetContinue()
|
|
if continueToken == "" {
|
|
break
|
|
}
|
|
}
|
|
|
|
return dashes, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (dashboardv0.SearchResults, error) {
|
|
request := &resourcepb.ResourceSearchRequest{
|
|
Options: &resourcepb.ListOptions{
|
|
Fields: []*resourcepb.Requirement{},
|
|
Labels: []*resourcepb.Requirement{},
|
|
},
|
|
Limit: 100000}
|
|
|
|
if len(query.DashboardUIDs) > 0 {
|
|
request.Options.Fields = []*resourcepb.Requirement{{
|
|
Key: resource.SEARCH_FIELD_NAME,
|
|
Operator: string(selection.In),
|
|
Values: query.DashboardUIDs,
|
|
}}
|
|
} else if len(query.DashboardIds) > 0 {
|
|
values := make([]string, len(query.DashboardIds))
|
|
for i, id := range query.DashboardIds {
|
|
values[i] = strconv.FormatInt(id, 10)
|
|
}
|
|
|
|
request.Options.Labels = append(request.Options.Labels, &resourcepb.Requirement{
|
|
Key: utils.LabelKeyDeprecatedInternalID, // nolint:staticcheck
|
|
Operator: string(selection.In),
|
|
Values: values,
|
|
})
|
|
}
|
|
|
|
if len(query.FolderUIDs) > 0 {
|
|
// Grafana frontend issues a call to search for dashboards in "general" folder. General folder doesn't exists and
|
|
// should return all dashboards without a parent folder.
|
|
// We do something similar in the old sql search query https://github.com/grafana/grafana/blob/a58564a35efe8c05a21d8190b283af5bc0979d2a/pkg/services/sqlstore/searchstore/filters.go#L103
|
|
for i := range query.FolderUIDs {
|
|
if query.FolderUIDs[i] == folder.GeneralFolderUID {
|
|
query.FolderUIDs[i] = ""
|
|
break
|
|
}
|
|
}
|
|
|
|
req := []*resourcepb.Requirement{{
|
|
Key: resource.SEARCH_FIELD_FOLDER,
|
|
Operator: string(selection.In),
|
|
Values: query.FolderUIDs,
|
|
}}
|
|
request.Options.Fields = append(request.Options.Fields, req...)
|
|
} else if len(query.FolderIds) > 0 { // nolint:staticcheck
|
|
values := make([]string, len(query.FolderIds)) // nolint:staticcheck
|
|
for i, id := range query.FolderIds { // nolint:staticcheck
|
|
values[i] = strconv.FormatInt(id, 10)
|
|
}
|
|
|
|
request.Options.Labels = append(request.Options.Labels, &resourcepb.Requirement{
|
|
Key: utils.LabelKeyDeprecatedInternalID, // nolint:staticcheck
|
|
Operator: string(selection.In),
|
|
Values: values,
|
|
})
|
|
}
|
|
|
|
if query.ManagedBy != "" {
|
|
request.Options.Fields = append(request.Options.Fields, &resourcepb.Requirement{
|
|
Key: resource.SEARCH_FIELD_MANAGER_KIND,
|
|
Operator: string(selection.Equals),
|
|
Values: []string{string(query.ManagedBy)},
|
|
})
|
|
}
|
|
|
|
if query.ManagerIdentity != "" {
|
|
request.Options.Fields = append(request.Options.Fields, &resourcepb.Requirement{
|
|
Key: resource.SEARCH_FIELD_MANAGER_ID,
|
|
Operator: string(selection.In),
|
|
Values: []string{query.ManagerIdentity},
|
|
})
|
|
}
|
|
|
|
if len(query.ManagerIdentityNotIn) > 0 {
|
|
request.Options.Fields = append(request.Options.Fields, &resourcepb.Requirement{
|
|
Key: resource.SEARCH_FIELD_MANAGER_ID,
|
|
Operator: string(selection.NotIn),
|
|
Values: query.ManagerIdentityNotIn,
|
|
})
|
|
}
|
|
if query.SourcePath != "" {
|
|
request.Options.Fields = append(request.Options.Fields, &resourcepb.Requirement{
|
|
Key: resource.SEARCH_FIELD_SOURCE_PATH,
|
|
Operator: string(selection.In),
|
|
Values: []string{query.SourcePath},
|
|
})
|
|
}
|
|
|
|
if query.Title != "" {
|
|
// allow wildcard search
|
|
request.Query = "*" + strings.ToLower(query.Title) + "*"
|
|
}
|
|
|
|
if len(query.Tags) > 0 {
|
|
req := []*resourcepb.Requirement{{
|
|
Key: resource.SEARCH_FIELD_TAGS,
|
|
Operator: string(selection.In),
|
|
Values: query.Tags,
|
|
}}
|
|
request.Options.Fields = append(request.Options.Fields, req...)
|
|
}
|
|
|
|
if query.IsDeleted {
|
|
request.IsDeleted = query.IsDeleted
|
|
}
|
|
|
|
if query.Permission > 0 {
|
|
request.Permission = int64(query.Permission)
|
|
}
|
|
|
|
if query.Limit < 1 {
|
|
query.Limit = 1000
|
|
}
|
|
|
|
if query.Page < 1 {
|
|
query.Page = 1
|
|
}
|
|
|
|
request.Limit = query.Limit
|
|
request.Page = query.Page
|
|
request.Offset = (query.Page - 1) * query.Limit // only relevant when running in modes 3+
|
|
request.Fields = dashboardsearch.IncludeFields
|
|
|
|
namespace := dr.k8sclient.GetNamespace(query.OrgId)
|
|
var err error
|
|
var federate *resourcepb.ResourceKey
|
|
switch query.Type {
|
|
case "":
|
|
// When no type specified, search for dashboards
|
|
request.Options.Key, err = resource.AsResourceKey(namespace, dashboardv0.DASHBOARD_RESOURCE)
|
|
// Currently a search query is across folders and dashboards
|
|
if err == nil {
|
|
federate, err = resource.AsResourceKey(namespace, folderv1.RESOURCE)
|
|
}
|
|
case searchstore.TypeDashboard, searchstore.TypeAnnotation:
|
|
request.Options.Key, err = resource.AsResourceKey(namespace, dashboardv0.DASHBOARD_RESOURCE)
|
|
case searchstore.TypeFolder, searchstore.TypeAlertFolder:
|
|
request.Options.Key, err = resource.AsResourceKey(namespace, folderv1.RESOURCE)
|
|
default:
|
|
err = fmt.Errorf("bad type request")
|
|
}
|
|
|
|
if err != nil {
|
|
return dashboardv0.SearchResults{}, err
|
|
}
|
|
|
|
if federate != nil {
|
|
request.Federated = []*resourcepb.ResourceKey{federate}
|
|
}
|
|
|
|
if query.Sort.Name != "" {
|
|
sortName, isDesc, err := legacysearcher.ParseSortName(query.Sort.Name)
|
|
if err != nil {
|
|
return dashboardv0.SearchResults{}, err
|
|
}
|
|
request.SortBy = append(request.SortBy, &resourcepb.ResourceSearchRequest_Sort{Field: sortName, Desc: isDesc})
|
|
}
|
|
|
|
res, err := dr.k8sclient.Search(ctx, query.OrgId, request)
|
|
if err != nil {
|
|
return dashboardv0.SearchResults{}, err
|
|
}
|
|
|
|
return dashboardsearch.ParseResults(res, 0)
|
|
}
|
|
|
|
type dashboardProvisioningWithUID struct {
|
|
dashboards.DashboardProvisioning
|
|
DashboardUID string
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboardProvisioningWithUID, error) {
|
|
ctx, span := tracing.Start(ctx, "searchProvisionedDashboardsThroughK8s")
|
|
defer span.End()
|
|
|
|
if query == nil {
|
|
return nil, errors.New("query cannot be nil")
|
|
}
|
|
|
|
ctx, _ = identity.WithServiceIdentity(ctx, query.OrgId)
|
|
|
|
query.Type = searchstore.TypeDashboard
|
|
|
|
searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
span.SetAttributes(attribute.Int("hits", len(searchResults.Hits)))
|
|
|
|
dashs := make([]*dashboardProvisioningWithUID, 0)
|
|
for _, hit := range searchResults.Hits {
|
|
if utils.ParseManagerKindString(hit.Field.GetNestedString(resource.SEARCH_FIELD_MANAGER_KIND)) != utils.ManagerKindClassicFP { // nolint:staticcheck
|
|
continue
|
|
}
|
|
|
|
provisioning := &dashboardProvisioningWithUID{
|
|
DashboardProvisioning: dashboards.DashboardProvisioning{
|
|
Name: hit.Field.GetNestedString(resource.SEARCH_FIELD_MANAGER_ID),
|
|
ExternalID: hit.Field.GetNestedString(resource.SEARCH_FIELD_SOURCE_PATH),
|
|
CheckSum: hit.Field.GetNestedString(resource.SEARCH_FIELD_SOURCE_CHECKSUM),
|
|
Updated: hit.Field.GetNestedInt64(resource.SEARCH_FIELD_SOURCE_TIME),
|
|
DashboardID: hit.Field.GetNestedInt64(utils.LabelKeyDeprecatedInternalID), // nolint:staticcheck
|
|
},
|
|
DashboardUID: hit.Name,
|
|
}
|
|
dashs = append(dashs, provisioning)
|
|
}
|
|
|
|
return dashs, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) searchDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboards.Dashboard, error) {
|
|
if query == nil {
|
|
return nil, errors.New("query cannot be nil")
|
|
}
|
|
query.Type = searchstore.TypeDashboard
|
|
|
|
response, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]*dashboards.Dashboard, len(response.Hits))
|
|
for i, hit := range response.Hits {
|
|
result[i] = &dashboards.Dashboard{
|
|
OrgID: query.OrgId,
|
|
UID: hit.Name,
|
|
Slug: slugify.Slugify(hit.Title),
|
|
Title: hit.Title,
|
|
FolderUID: hit.Folder,
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) getUsersForList(ctx context.Context, items []unstructured.Unstructured, orgID int64) (map[string]*user.User, error) {
|
|
userMeta := []string{}
|
|
for _, item := range items {
|
|
obj, err := utils.MetaAccessor(&item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if obj.GetCreatedBy() != "" {
|
|
userMeta = append(userMeta, obj.GetCreatedBy())
|
|
}
|
|
if obj.GetUpdatedBy() != "" {
|
|
userMeta = append(userMeta, obj.GetUpdatedBy())
|
|
}
|
|
}
|
|
|
|
return dr.k8sclient.GetUsersFromMeta(ctx, userMeta)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) {
|
|
obj, err := utils.MetaAccessor(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users, err := dr.k8sclient.GetUsersFromMeta(ctx, []string{obj.GetCreatedBy(), obj.GetUpdatedBy()})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return dr.unstructuredToLegacyDashboardWithUsers(item, orgID, users)
|
|
}
|
|
|
|
func (dr *DashboardServiceImpl) unstructuredToLegacyDashboardWithUsers(item *unstructured.Unstructured, orgID int64, users map[string]*user.User) (*dashboards.Dashboard, error) {
|
|
spec, ok := item.Object["spec"].(map[string]any)
|
|
if !ok {
|
|
return nil, errors.New("error parsing dashboard from k8s response")
|
|
}
|
|
obj, err := utils.MetaAccessor(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
uid := obj.GetName()
|
|
spec["uid"] = uid
|
|
|
|
dashVersion := obj.GetGeneration()
|
|
spec["version"] = dashVersion
|
|
|
|
title, _, _ := unstructured.NestedString(spec, "title")
|
|
out := dashboards.Dashboard{
|
|
OrgID: orgID,
|
|
ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
|
|
UID: uid,
|
|
Slug: slugify.Slugify(title),
|
|
FolderUID: obj.GetFolder(),
|
|
Version: int(dashVersion),
|
|
Data: simplejson.NewFromAny(spec),
|
|
APIVersion: strings.TrimPrefix(item.GetAPIVersion(), dashboardv0.GROUP+"/"),
|
|
}
|
|
|
|
out.Created = obj.GetCreationTimestamp().Time
|
|
updated, err := obj.GetUpdatedTimestamp()
|
|
if err == nil && updated != nil {
|
|
// old apis return in local time, created is already doing that
|
|
localTime := updated.Local()
|
|
out.Updated = localTime
|
|
} else {
|
|
// by default, set updated to created
|
|
out.Updated = out.Created
|
|
}
|
|
|
|
deleted := obj.GetDeletionTimestamp()
|
|
if deleted != nil {
|
|
out.Deleted = obj.GetDeletionTimestamp().Time
|
|
}
|
|
|
|
out.PluginID = dashboard.GetPluginIDFromMeta(obj)
|
|
|
|
if creator, ok := users[obj.GetCreatedBy()]; ok {
|
|
out.CreatedBy = creator.ID
|
|
}
|
|
if updater, ok := users[obj.GetUpdatedBy()]; ok {
|
|
out.UpdatedBy = updater.ID
|
|
}
|
|
|
|
// any dashboards that have already been synced to unified storage will have the id in the spec
|
|
// and not as a label. We will need to support this conversion until they have all been updated
|
|
// to labels
|
|
if id, ok := spec["id"].(int64); ok {
|
|
out.ID = id
|
|
out.Data.Del("id")
|
|
}
|
|
|
|
if gnetID, ok := spec["gnet_id"].(int64); ok {
|
|
out.GnetID = gnetID
|
|
}
|
|
|
|
if isFolder, ok := spec["is_folder"].(bool); ok {
|
|
out.IsFolder = isFolder
|
|
}
|
|
|
|
if hasACL, ok := spec["has_acl"].(bool); ok {
|
|
out.HasACL = hasACL
|
|
}
|
|
|
|
if title, ok := spec["title"].(string); ok {
|
|
out.Title = title
|
|
// if slug isn't in the metadata, add it via the title
|
|
if out.Slug == "" {
|
|
out.UpdateSlug()
|
|
}
|
|
}
|
|
|
|
return &out, nil
|
|
}
|
|
|
|
func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, namespace string) (*unstructured.Unstructured, error) {
|
|
uid := cmd.GetDashboardModel().UID
|
|
if uid == "" {
|
|
uid = uuid.NewString()
|
|
}
|
|
|
|
finalObj := &unstructured.Unstructured{
|
|
Object: map[string]interface{}{},
|
|
}
|
|
|
|
obj := map[string]interface{}{}
|
|
body, err := cmd.Dashboard.ToDB()
|
|
if err != nil {
|
|
return finalObj, err
|
|
}
|
|
|
|
err = json.Unmarshal(body, &obj)
|
|
if err != nil {
|
|
return finalObj, err
|
|
}
|
|
|
|
// update the version
|
|
version, ok := obj["version"].(float64)
|
|
if !ok || version == 0 {
|
|
obj["version"] = 1
|
|
} else if !cmd.Overwrite {
|
|
obj["version"] = version + 1
|
|
}
|
|
|
|
finalObj.Object["spec"] = obj
|
|
finalObj.SetName(uid)
|
|
finalObj.SetNamespace(namespace)
|
|
finalObj.SetGroupVersionKind(dashboardv0.DashboardResourceInfo.GroupVersionKind())
|
|
|
|
meta, err := utils.MetaAccessor(finalObj)
|
|
if err != nil {
|
|
return finalObj, err
|
|
}
|
|
|
|
if cmd.FolderUID != "" {
|
|
meta.SetFolder(cmd.FolderUID)
|
|
}
|
|
|
|
if cmd.Message != "" {
|
|
meta.SetMessage(cmd.Message)
|
|
}
|
|
|
|
return finalObj, nil
|
|
}
|
|
|
|
func getFolderUIDs(hits []dashboardv0.DashboardHit) []string {
|
|
folderSet := map[string]bool{}
|
|
for _, hit := range hits {
|
|
if hit.Folder != "" && !folderSet[hit.Folder] {
|
|
folderSet[hit.Folder] = true
|
|
}
|
|
}
|
|
return maps.Keys(folderSet)
|
|
}
|