(
Alert.displayName = 'Alert';
-const getStyles = (theme: GrafanaTheme2, severity: AlertVariant, elevated?: boolean, bottomSpacing?: number) => {
+const getStyles = (
+ theme: GrafanaTheme2,
+ severity: AlertVariant,
+ elevated?: boolean,
+ bottomSpacing?: number,
+ topSpacing?: number
+) => {
const color = theme.colors[severity];
const borderRadius = theme.shape.borderRadius();
@@ -92,6 +110,7 @@ const getStyles = (theme: GrafanaTheme2, severity: AlertVariant, elevated?: bool
background: ${theme.colors.background.secondary};
box-shadow: ${elevated ? theme.shadows.z3 : theme.shadows.z1};
margin-bottom: ${theme.spacing(bottomSpacing ?? 2)};
+ margin-top: ${theme.spacing(topSpacing ?? 0)};
&:before {
content: '';
diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go
index f59fe8ffbeb..5416cde6b24 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -275,6 +275,10 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"unifiedAlertingEnabled": hs.Cfg.UnifiedAlerting.Enabled,
}
+ if hs.ThumbService != nil {
+ jsonObj["dashboardPreviews"] = hs.ThumbService.GetDashboardPreviewsSetupSettings(c)
+ }
+
if hs.Cfg.GeomapDefaultBaseLayerConfig != nil {
jsonObj["geomapDefaultBaseLayerConfig"] = hs.Cfg.GeomapDefaultBaseLayerConfig
}
diff --git a/pkg/models/dashboard_thumbs.go b/pkg/models/dashboard_thumbs.go
index 954a07b1a42..0935d0c16e0 100644
--- a/pkg/models/dashboard_thumbs.go
+++ b/pkg/models/dashboard_thumbs.go
@@ -117,6 +117,10 @@ type DashboardWithStaleThumbnail struct {
Slug string
}
+type FindDashboardThumbnailCountCommand struct {
+ Result int64
+}
+
type FindDashboardsWithStaleThumbnailsCommand struct {
IncludeManuallyUploadedThumbnails bool
Theme Theme
diff --git a/pkg/services/sqlstore/dashboard_thumbs.go b/pkg/services/sqlstore/dashboard_thumbs.go
index 6b819ca46a2..907d7928991 100644
--- a/pkg/services/sqlstore/dashboard_thumbs.go
+++ b/pkg/services/sqlstore/dashboard_thumbs.go
@@ -81,6 +81,20 @@ func (ss *SQLStore) UpdateThumbnailState(ctx context.Context, cmd *models.Update
return err
}
+func (ss *SQLStore) FindThumbnailCount(ctx context.Context, cmd *models.FindDashboardThumbnailCountCommand) (int64, error) {
+ err := ss.WithDbSession(ctx, func(sess *DBSession) error {
+ count, err := sess.Count(&models.DashboardThumbnail{})
+ if err != nil {
+ return err
+ }
+
+ cmd.Result = count
+ return nil
+ })
+
+ return cmd.Result, err
+}
+
func (ss *SQLStore) FindDashboardsWithStaleThumbnails(ctx context.Context, cmd *models.FindDashboardsWithStaleThumbnailsCommand) ([]*models.DashboardWithStaleThumbnail, error) {
err := ss.WithDbSession(ctx, func(sess *DBSession) error {
sess.Table("dashboard")
diff --git a/pkg/services/sqlstore/dashboard_thumbs_test.go b/pkg/services/sqlstore/dashboard_thumbs_test.go
index 5cba1949f64..4fc9395cd57 100644
--- a/pkg/services/sqlstore/dashboard_thumbs_test.go
+++ b/pkg/services/sqlstore/dashboard_thumbs_test.go
@@ -185,6 +185,23 @@ func TestSqlStorage(t *testing.T) {
require.Len(t, res, 1)
require.Equal(t, dash.Id, res[0].Id)
})
+
+ t.Run("Should count all dashboard thumbnails", func(t *testing.T) {
+ setup()
+ dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
+ upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, 1)
+ dash2 := insertTestDashboard(t, sqlStore, "test dash 23", 2, savedFolder.Id, false, "prod", "webapp")
+ upsertTestDashboardThumbnail(t, sqlStore, dash2.Uid, dash2.OrgId, 1)
+
+ updateTestDashboard(t, sqlStore, dash, map[string]interface{}{
+ "tags": "different-tag",
+ })
+
+ cmd := models.FindDashboardThumbnailCountCommand{}
+ res, err := sqlStore.FindThumbnailCount(context.Background(), &cmd)
+ require.NoError(t, err)
+ require.Equal(t, res, int64(2))
+ })
}
func getThumbnail(t *testing.T, sqlStore *SQLStore, dashboardUID string, orgId int64) *models.DashboardThumbnail {
diff --git a/pkg/services/thumbs/dummy.go b/pkg/services/thumbs/dummy.go
index 45fd30f3b2a..6c984d412ad 100644
--- a/pkg/services/thumbs/dummy.go
+++ b/pkg/services/thumbs/dummy.go
@@ -26,6 +26,16 @@ func (ds *dummyService) Enabled() bool {
return false
}
+func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
+ return dashboardPreviewsSetupConfig{
+ SystemRequirements: dashboardPreviewsSystemRequirements{
+ Met: false,
+ RequiredImageRendererPluginVersion: "",
+ },
+ ThumbnailsExist: false,
+ }
+}
+
func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response {
result := make(map[string]string)
result["error"] = "Not enabled"
diff --git a/pkg/services/thumbs/models.go b/pkg/services/thumbs/models.go
index 38b2448b302..a7c128d195c 100644
--- a/pkg/services/thumbs/models.go
+++ b/pkg/services/thumbs/models.go
@@ -53,6 +53,16 @@ type crawlStatus struct {
Last time.Time `json:"last,omitempty"`
}
+type dashboardPreviewsSystemRequirements struct {
+ Met bool `json:"met"`
+ RequiredImageRendererPluginVersion string `json:"requiredImageRendererPluginVersion"`
+}
+
+type dashboardPreviewsSetupConfig struct {
+ SystemRequirements dashboardPreviewsSystemRequirements `json:"systemRequirements"`
+ ThumbnailsExist bool `json:"thumbnailsExist"`
+}
+
type dashRenderer interface {
// Run Assumes you have already authenticated as admin.
@@ -69,6 +79,7 @@ type dashRenderer interface {
type thumbnailRepo interface {
updateThumbnailState(ctx context.Context, state models.ThumbnailState, meta models.DashboardThumbnailMeta) error
+ doThumbnailsExist(ctx context.Context) (bool, error)
saveFromFile(ctx context.Context, filePath string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error)
saveFromBytes(ctx context.Context, bytes []byte, mimeType string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error)
getThumbnail(ctx context.Context, meta models.DashboardThumbnailMeta) (*models.DashboardThumbnail, error)
diff --git a/pkg/services/thumbs/repo.go b/pkg/services/thumbs/repo.go
index 7ce5d0aa006..71f7a7459a9 100644
--- a/pkg/services/thumbs/repo.go
+++ b/pkg/services/thumbs/repo.go
@@ -63,7 +63,7 @@ func (r *sqlThumbnailRepository) saveFromBytes(ctx context.Context, content []by
_, err := r.store.SaveThumbnail(ctx, cmd)
if err != nil {
- r.log.Error("error saving to the db", "dashboardUID", meta.DashboardUID, "err", err)
+ r.log.Error("Error saving to the db", "dashboardUID", meta.DashboardUID, "err", err)
return 0, err
}
@@ -91,3 +91,13 @@ func (r *sqlThumbnailRepository) findDashboardsWithStaleThumbnails(ctx context.C
Kind: kind,
})
}
+
+func (r *sqlThumbnailRepository) doThumbnailsExist(ctx context.Context) (bool, error) {
+ cmd := &models.FindDashboardThumbnailCountCommand{}
+ count, err := r.store.FindThumbnailCount(ctx, cmd)
+ if err != nil {
+ r.log.Error("Error finding thumbnails", "err", err)
+ return false, err
+ }
+ return count > 0, err
+}
diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go
index 2de7249389c..f5939a3f7fc 100644
--- a/pkg/services/thumbs/service.go
+++ b/pkg/services/thumbs/service.go
@@ -27,6 +27,7 @@ type Service interface {
Run(ctx context.Context) error
Enabled() bool
GetImage(c *models.ReqContext)
+ GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig
// from dashboard page
SetImage(c *models.ReqContext) // form post
@@ -41,6 +42,7 @@ type Service interface {
type thumbService struct {
scheduleOptions crawlerScheduleOptions
renderer dashRenderer
+ renderingService rendering.Service
thumbnailRepo thumbnailRepo
lockService *serverlock.ServerLockService
features featuremgmt.FeatureToggles
@@ -71,6 +73,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockS
OrgRole: models.ROLE_ADMIN,
}
return &thumbService{
+ renderingService: renderService,
renderer: newSimpleCrawler(renderService, gl, thumbnailRepo),
thumbnailRepo: thumbnailRepo,
features: features,
@@ -196,6 +199,44 @@ func (hs *thumbService) GetImage(c *models.ReqContext) {
}
}
+func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
+ systemRequirements := hs.getSystemRequirements()
+ thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(c.Req.Context())
+
+ if err != nil {
+ return dashboardPreviewsSetupConfig{
+ SystemRequirements: systemRequirements,
+ ThumbnailsExist: false,
+ }
+ }
+
+ return dashboardPreviewsSetupConfig{
+ SystemRequirements: systemRequirements,
+ ThumbnailsExist: thumbnailsExist,
+ }
+}
+
+func (hs *thumbService) getSystemRequirements() dashboardPreviewsSystemRequirements {
+ res, err := hs.renderingService.HasCapability(rendering.ScalingDownImages)
+ if err != nil {
+ hs.log.Error("Error when verifying dashboard previews system requirements thumbnail", "err", err.Error())
+ return dashboardPreviewsSystemRequirements{
+ Met: false,
+ }
+ }
+
+ if !res.IsSupported {
+ return dashboardPreviewsSystemRequirements{
+ Met: false,
+ RequiredImageRendererPluginVersion: res.SemverConstraint,
+ }
+ }
+
+ return dashboardPreviewsSystemRequirements{
+ Met: true,
+ }
+}
+
// Hack for now -- lets you upload images explicitly
func (hs *thumbService) SetImage(c *models.ReqContext) {
req := hs.parseImageReq(c, false)
diff --git a/public/app/features/search/components/ActionRow.tsx b/public/app/features/search/components/ActionRow.tsx
index 54b46fcdaa0..dc6d0af3294 100644
--- a/public/app/features/search/components/ActionRow.tsx
+++ b/public/app/features/search/components/ActionRow.tsx
@@ -17,7 +17,7 @@ const searchSrv = new SearchSrv();
interface Props {
onLayoutChange: (layout: SearchLayout) => void;
- onShowPreviewsChange: (event: ChangeEvent
) => void;
+ setShowPreviews: (newValue: boolean) => void;
onSortChange: (value: SelectableValue) => void;
onStarredFilterChange?: (event: FormEvent) => void;
onTagFilterChange: (tags: string[]) => void;
@@ -29,7 +29,7 @@ interface Props {
export const ActionRow: FC = ({
onLayoutChange,
- onShowPreviewsChange,
+ setShowPreviews,
onSortChange,
onStarredFilterChange = () => {},
onTagFilterChange,
@@ -56,7 +56,7 @@ export const ActionRow: FC = ({
label="Show previews"
showLabel
value={showPreviews}
- onChange={onShowPreviewsChange}
+ onChange={(ev: ChangeEvent) => setShowPreviews(ev.target.checked)}
transparent
/>
)}
diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx
index 4287b45aa0e..1864459e715 100644
--- a/public/app/features/search/components/DashboardSearch.tsx
+++ b/public/app/features/search/components/DashboardSearch.tsx
@@ -7,6 +7,7 @@ import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults';
import { ActionRow } from './ActionRow';
+import { PreviewsSystemRequirements } from './PreviewsSystemRequirements';
export interface Props {
onCloseSearch: () => void;
@@ -14,7 +15,7 @@ export interface Props {
export const DashboardSearch: FC = memo(({ onCloseSearch }) => {
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery({});
- const { results, loading, onToggleSection, onKeyDown, showPreviews, onShowPreviewsChange } = useDashboardSearch(
+ const { results, loading, onToggleSection, onKeyDown, showPreviews, setShowPreviews } = useDashboardSearch(
query,
onCloseSearch
);
@@ -34,13 +35,18 @@ export const DashboardSearch: FC = memo(({ onCloseSearch }) => {
onShowPreviewsChange(ev.target.checked),
+ setShowPreviews,
onSortChange,
onTagFilterChange,
query,
showPreviews,
}}
/>
+ setShowPreviews(false)}
+ />
= memo(({ folder }) => {
onMoveItems,
noFolders,
showPreviews,
- onShowPreviewsChange,
+ setShowPreviews,
} = useManageDashboards(query, {}, folder);
const onMoveTo = () => {
@@ -108,7 +108,7 @@ export const ManageDashboards: FC = memo(({ folder }) => {
canMove={hasEditPermissionInFolders && canMove}
deleteItem={onItemDelete}
moveTo={onMoveTo}
- onShowPreviewsChange={(ev) => onShowPreviewsChange(ev.target.checked)}
+ setShowPreviews={setShowPreviews}
onToggleAllChecked={onToggleAllChecked}
onStarredFilterChange={onStarredFilterChange}
onSortChange={onSortChange}
diff --git a/public/app/features/search/components/PreviewsSystemRequirements.tsx b/public/app/features/search/components/PreviewsSystemRequirements.tsx
new file mode 100644
index 00000000000..0e023c4e138
--- /dev/null
+++ b/public/app/features/search/components/PreviewsSystemRequirements.tsx
@@ -0,0 +1,93 @@
+import { Alert, useStyles2 } from '@grafana/ui';
+import React from 'react';
+import { config } from '@grafana/runtime/src';
+import { css } from '@emotion/css';
+
+export interface Props {
+ showPreviews?: boolean;
+ /** On click handler for alert button, mostly used for dismissing the alert */
+ onRemove?: (event: React.MouseEvent) => void;
+ topSpacing?: number;
+ bottomSpacing?: number;
+}
+
+const MessageLink = ({ text }: { text: string }) => (
+
+ {text}
+
+);
+
+const Message = ({ requiredImageRendererPluginVersion }: { requiredImageRendererPluginVersion?: string }) => {
+ if (requiredImageRendererPluginVersion) {
+ return (
+ <>
+ You must update the to version{' '}
+ {requiredImageRendererPluginVersion} to enable dashboard previews. Please contact your Grafana administrator to
+ update the plugin.
+ >
+ );
+ }
+
+ return (
+ <>
+ You must install the to enable dashboard previews. Please
+ contact your Grafana administrator to install the plugin.
+ >
+ );
+};
+
+export const PreviewsSystemRequirements = ({ showPreviews, onRemove, topSpacing, bottomSpacing }: Props) => {
+ const styles = useStyles2(getStyles);
+
+ const previewsEnabled = config.featureToggles.dashboardPreviews;
+ const rendererAvailable = config.rendererAvailable;
+
+ const {
+ systemRequirements: { met: systemRequirementsMet, requiredImageRendererPluginVersion },
+ thumbnailsExist,
+ } = config.dashboardPreviews;
+
+ const arePreviewsEnabled = previewsEnabled && showPreviews;
+ const areRequirementsMet = (rendererAvailable && systemRequirementsMet) || thumbnailsExist;
+ const shouldDisplayRequirements = arePreviewsEnabled && !areRequirementsMet;
+
+ const title = requiredImageRendererPluginVersion
+ ? 'Image renderer plugin needs to be updated'
+ : 'Image renderer plugin not installed';
+
+ return (
+ <>
+ {shouldDisplayRequirements && (
+
+ )}
+ >
+ );
+};
+
+const getStyles = () => {
+ return {
+ wrapper: css`
+ display: flex;
+ justify-content: center;
+ `,
+ alert: css`
+ max-width: 800px;
+ `,
+ };
+};
diff --git a/public/app/features/search/components/SearchResultsFilter.test.tsx b/public/app/features/search/components/SearchResultsFilter.test.tsx
index 798636be17d..ff587ac1ec7 100644
--- a/public/app/features/search/components/SearchResultsFilter.test.tsx
+++ b/public/app/features/search/components/SearchResultsFilter.test.tsx
@@ -37,7 +37,7 @@ const setup = (propOverrides?: Partial) => {
onLayoutChange: noop,
query: searchQuery,
onSortChange: noop,
- onShowPreviewsChange: noop,
+ setShowPreviews: noop,
editable: true,
};
diff --git a/public/app/features/search/components/SearchResultsFilter.tsx b/public/app/features/search/components/SearchResultsFilter.tsx
index 70b7cd33e74..7a8b43b9ba7 100644
--- a/public/app/features/search/components/SearchResultsFilter.tsx
+++ b/public/app/features/search/components/SearchResultsFilter.tsx
@@ -1,9 +1,10 @@
-import React, { FC, ChangeEvent, FormEvent } from 'react';
+import React, { FC, FormEvent } from 'react';
import { css } from '@emotion/css';
import { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { DashboardQuery, SearchLayout } from '../types';
import { ActionRow } from './ActionRow';
+import { PreviewsSystemRequirements } from './PreviewsSystemRequirements';
export interface Props {
allChecked?: boolean;
@@ -13,7 +14,7 @@ export interface Props {
hideLayout?: boolean;
moveTo: () => void;
onLayoutChange: (layout: SearchLayout) => void;
- onShowPreviewsChange: (event: ChangeEvent) => void;
+ setShowPreviews: (newValue: boolean) => void;
onSortChange: (value: SelectableValue) => void;
onStarredFilterChange: (event: FormEvent) => void;
onTagFilterChange: (tags: string[]) => void;
@@ -31,7 +32,7 @@ export const SearchResultsFilter: FC = ({
hideLayout,
moveTo,
onLayoutChange,
- onShowPreviewsChange,
+ setShowPreviews,
onSortChange,
onStarredFilterChange,
onTagFilterChange,
@@ -46,35 +47,43 @@ export const SearchResultsFilter: FC = ({
return (
- {editable && (
-
-
-
- )}
- {showActions ? (
-
-
-
-
- ) : (
-
- )}
+
+ {editable && (
+
+
+
+ )}
+ {showActions ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
setShowPreviews(false)}
+ />
);
};
@@ -83,6 +92,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
const { sm, md } = theme.spacing;
return {
wrapper: css`
+ display: flex;
+ flex-direction: column;
+ `,
+ rowWrapper: css`
height: ${theme.height.md}px;
display: flex;
justify-content: flex-start;
diff --git a/public/app/features/search/hooks/useDashboardSearch.ts b/public/app/features/search/hooks/useDashboardSearch.ts
index 6fbcb220ede..c3b7434f002 100644
--- a/public/app/features/search/hooks/useDashboardSearch.ts
+++ b/public/app/features/search/hooks/useDashboardSearch.ts
@@ -12,7 +12,7 @@ import { useDebounce } from 'react-use';
export const useDashboardSearch = (query: DashboardQuery, onCloseSearch: () => void) => {
const reducer = useReducer(searchReducer, dashboardsSearchState);
- const { showPreviews, onShowPreviewsChange, previewFeatureEnabled } = useShowDashboardPreviews();
+ const { showPreviews, setShowPreviews, previewFeatureEnabled } = useShowDashboardPreviews();
const {
state: { results, loading },
onToggleSection,
@@ -72,6 +72,6 @@ export const useDashboardSearch = (query: DashboardQuery, onCloseSearch: () => v
onToggleSection,
onKeyDown,
showPreviews,
- onShowPreviewsChange,
+ setShowPreviews,
};
};
diff --git a/public/app/features/search/hooks/useManageDashboards.ts b/public/app/features/search/hooks/useManageDashboards.ts
index 12a5e61422c..caa971f5b8f 100644
--- a/public/app/features/search/hooks/useManageDashboards.ts
+++ b/public/app/features/search/hooks/useManageDashboards.ts
@@ -47,7 +47,7 @@ export const useManageDashboards = (
...state,
});
- const { showPreviews, onShowPreviewsChange, previewFeatureEnabled } = useShowDashboardPreviews();
+ const { showPreviews, setShowPreviews, previewFeatureEnabled } = useShowDashboardPreviews();
useDebounce(
() => {
reportDashboardListViewed('manage_dashboards', showPreviews, previewFeatureEnabled, {
@@ -123,6 +123,6 @@ export const useManageDashboards = (
onMoveItems,
noFolders,
showPreviews,
- onShowPreviewsChange,
+ setShowPreviews,
};
};
diff --git a/public/app/features/search/hooks/useShowDashboardPreviews.ts b/public/app/features/search/hooks/useShowDashboardPreviews.ts
index 2a65e36b0a2..e15780fff6d 100644
--- a/public/app/features/search/hooks/useShowDashboardPreviews.ts
+++ b/public/app/features/search/hooks/useShowDashboardPreviews.ts
@@ -5,9 +5,6 @@ import { useLocalStorage } from 'react-use';
export const useShowDashboardPreviews = () => {
const previewFeatureEnabled = Boolean(config.featureToggles.dashboardPreviews);
const [showPreviews, setShowPreviews] = useLocalStorage(PREVIEWS_LOCAL_STORAGE_KEY, previewFeatureEnabled);
- const onShowPreviewsChange = (showPreviews: boolean) => {
- setShowPreviews(showPreviews);
- };
- return { showPreviews: Boolean(showPreviews && previewFeatureEnabled), previewFeatureEnabled, onShowPreviewsChange };
+ return { showPreviews: Boolean(showPreviews && previewFeatureEnabled), previewFeatureEnabled, setShowPreviews };
};
From b01a56c2b7c3df61cefac9ad642b0c7f2557f3f1 Mon Sep 17 00:00:00 2001
From: idafurjes <36131195+idafurjes@users.noreply.github.com>
Date: Wed, 16 Feb 2022 18:54:29 +0100
Subject: [PATCH 023/104] Fix alerting methods using AlertNotificationService
(#45477)
---
pkg/api/alerting.go | 24 ++++++++++++------------
pkg/api/http_server.go | 8 ++++++--
2 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go
index 23efe48f686..15414b6269d 100644
--- a/pkg/api/alerting.go
+++ b/pkg/api/alerting.go
@@ -239,7 +239,7 @@ func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Respo
func (hs *HTTPServer) getAlertNotificationsInternal(c *models.ReqContext) ([]*models.AlertNotification, error) {
query := &models.GetAllAlertNotificationsQuery{OrgId: c.OrgId}
- if err := hs.SQLStore.GetAllAlertNotifications(c.Req.Context(), query); err != nil {
+ if err := hs.AlertNotificationService.GetAllAlertNotifications(c.Req.Context(), query); err != nil {
return nil, err
}
@@ -260,7 +260,7 @@ func (hs *HTTPServer) GetAlertNotificationByID(c *models.ReqContext) response.Re
return response.Error(404, "Alert notification not found", nil)
}
- if err := hs.SQLStore.GetAlertNotifications(c.Req.Context(), query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), query); err != nil {
return response.Error(500, "Failed to get alert notifications", err)
}
@@ -281,7 +281,7 @@ func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.R
return response.Error(404, "Alert notification not found", nil)
}
- if err := hs.SQLStore.GetAlertNotificationsWithUid(c.Req.Context(), query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), query); err != nil {
return response.Error(500, "Failed to get alert notifications", err)
}
@@ -299,7 +299,7 @@ func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Res
}
cmd.OrgId = c.OrgId
- if err := hs.SQLStore.CreateAlertNotificationCommand(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.CreateAlertNotificationCommand(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationWithSameNameExists) || errors.Is(err, models.ErrAlertNotificationWithSameUIDExists) {
return response.Error(409, "Failed to create alert notification", err)
}
@@ -325,7 +325,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res
return response.Error(500, "Failed to update alert notification", err)
}
- if err := hs.SQLStore.UpdateAlertNotification(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.UpdateAlertNotification(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), err)
}
@@ -341,7 +341,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res
Id: cmd.Id,
}
- if err := hs.SQLStore.GetAlertNotifications(c.Req.Context(), &query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), &query); err != nil {
return response.Error(500, "Failed to get alert notification", err)
}
@@ -361,7 +361,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons
return response.Error(500, "Failed to update alert notification", err)
}
- if err := hs.SQLStore.UpdateAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.UpdateAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), nil)
}
@@ -373,7 +373,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons
Uid: cmd.Uid,
}
- if err := hs.SQLStore.GetAlertNotificationsWithUid(c.Req.Context(), &query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), &query); err != nil {
return response.Error(500, "Failed to get alert notification", err)
}
@@ -390,7 +390,7 @@ func (hs *HTTPServer) fillWithSecureSettingsData(ctx context.Context, cmd *model
Id: cmd.Id,
}
- if err := hs.SQLStore.GetAlertNotifications(ctx, query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotifications(ctx, query); err != nil {
return err
}
@@ -418,7 +418,7 @@ func (hs *HTTPServer) fillWithSecureSettingsDataByUID(ctx context.Context, cmd *
Uid: cmd.Uid,
}
- if err := hs.SQLStore.GetAlertNotificationsWithUid(ctx, query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(ctx, query); err != nil {
return err
}
@@ -447,7 +447,7 @@ func (hs *HTTPServer) DeleteAlertNotification(c *models.ReqContext) response.Res
Id: notificationId,
}
- if err := hs.SQLStore.DeleteAlertNotification(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.DeleteAlertNotification(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), nil)
}
@@ -463,7 +463,7 @@ func (hs *HTTPServer) DeleteAlertNotificationByUID(c *models.ReqContext) respons
Uid: web.Params(c.Req)[":uid"],
}
- if err := hs.SQLStore.DeleteAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.DeleteAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), nil)
}
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 15fa35dd508..8fdd76cb7d7 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -135,6 +135,7 @@ type HTTPServer struct {
dashboardProvisioningService dashboards.DashboardProvisioningService
folderService dashboards.FolderService
DatasourcePermissionsService DatasourcePermissionsService
+ AlertNotificationService *alerting.AlertNotificationService
}
type ServerOptions struct {
@@ -162,8 +163,10 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.Service,
ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service,
authInfoService login.AuthInfoService, resourcePermissionServices *resourceservices.ResourceServices,
- notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService,
- folderService dashboards.FolderService, datasourcePermissionsService DatasourcePermissionsService) (*HTTPServer, error) {
+ notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
+ dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
+ datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
+) (*HTTPServer, error) {
web.Env = cfg.Env
m := web.New()
@@ -228,6 +231,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
dashboardProvisioningService: dashboardProvisioningService,
folderService: folderService,
DatasourcePermissionsService: datasourcePermissionsService,
+ AlertNotificationService: alertNotificationService,
}
if hs.Listener != nil {
hs.log.Debug("Using provided listener")
From 50a53ef58b1b57bf7c84778a5f56e2aaeae1c925 Mon Sep 17 00:00:00 2001
From: Isabella Siu
Date: Wed, 16 Feb 2022 14:28:26 -0500
Subject: [PATCH 024/104] CloudWatch: replace metricFindQueries with
CallResourceHandler (#41571)
---
pkg/tests/api/metrics/api_metrics_test.go | 73 ++--
pkg/tsdb/cloudwatch/cloudwatch.go | 13 +-
pkg/tsdb/cloudwatch/metric_find_query.go | 174 ++++-----
pkg/tsdb/cloudwatch/metric_find_query_test.go | 350 ++++++------------
pkg/tsdb/cloudwatch/resource_handler.go | 59 +++
.../datasource/cloudwatch/datasource.ts | 140 +------
.../cloudwatch/specs/datasource.test.ts | 30 --
.../plugins/datasource/cloudwatch/types.ts | 6 -
8 files changed, 301 insertions(+), 544 deletions(-)
create mode 100644 pkg/tsdb/cloudwatch/resource_handler.go
diff --git a/pkg/tests/api/metrics/api_metrics_test.go b/pkg/tests/api/metrics/api_metrics_test.go
index 02d6cea7383..c23ad995701 100644
--- a/pkg/tests/api/metrics/api_metrics_test.go
+++ b/pkg/tests/api/metrics/api_metrics_test.go
@@ -32,7 +32,7 @@ func TestQueryCloudWatchMetrics(t *testing.T) {
grafDir, cfgPath := testinfra.CreateGrafDir(t)
addr, sqlStore := testinfra.StartGrafana(t, grafDir, cfgPath)
- setUpDatabase(t, sqlStore)
+ setUpDatabase(t, sqlStore, "metrics")
origNewCWClient := cloudwatch.NewCWClient
t.Cleanup(func() {
@@ -56,47 +56,27 @@ func TestQueryCloudWatchMetrics(t *testing.T) {
},
},
}
+ result := getCWMetrics(t, 1, addr)
- req := dtos.MetricRequest{
- Queries: []*simplejson.Json{
- simplejson.NewFromAny(map[string]interface{}{
- "type": "metricFindQuery",
- "subtype": "metrics",
- "region": "us-east-1",
- "namespace": "custom",
- "datasourceId": 1,
- }),
- },
+ type suggestData struct {
+ Text string
+ Value string
+ Label string
}
- result := makeCWRequest(t, req, addr)
-
- dataFrames := data.Frames{
- &data.Frame{
- RefID: "A",
- Fields: []*data.Field{
- data.NewField("text", nil, []string{"Test_MetricName"}),
- data.NewField("value", nil, []string{"Test_MetricName"}),
- },
- Meta: &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": float64(1),
- },
- },
- },
+ expect := []suggestData{
+ {Text: "Test_MetricName", Value: "Test_MetricName", Label: "Test_MetricName"},
}
-
- expect := backend.NewQueryDataResponse()
- expect.Responses["A"] = backend.DataResponse{
- Frames: dataFrames,
- }
- assert.Equal(t, *expect, result)
+ actual := []suggestData{}
+ err := json.Unmarshal(result, &actual)
+ require.NoError(t, err)
+ assert.Equal(t, expect, actual)
})
}
func TestQueryCloudWatchLogs(t *testing.T) {
grafDir, cfgPath := testinfra.CreateGrafDir(t)
addr, store := testinfra.StartGrafana(t, grafDir, cfgPath)
- setUpDatabase(t, store)
+ setUpDatabase(t, store, "logs")
origNewCWLogsClient := cloudwatch.NewCWLogsClient
t.Cleanup(func() {
@@ -141,6 +121,28 @@ func TestQueryCloudWatchLogs(t *testing.T) {
})
}
+func getCWMetrics(t *testing.T, datasourceId int, addr string) []byte {
+ t.Helper()
+
+ u := fmt.Sprintf("http://%s/api/datasources/%v/resources/metrics?region=us-east-1&namespace=custom", addr, datasourceId)
+ t.Logf("Making GET request to %s", u)
+ // nolint:gosec
+ resp, err := http.Get(u)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ t.Cleanup(func() {
+ err := resp.Body.Close()
+ assert.NoError(t, err)
+ })
+
+ buf := bytes.Buffer{}
+ _, err = io.Copy(&buf, resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, 200, resp.StatusCode)
+
+ return buf.Bytes()
+}
+
func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.QueryDataResponse {
t.Helper()
@@ -171,12 +173,13 @@ func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.Qu
return tr
}
-func setUpDatabase(t *testing.T, store *sqlstore.SQLStore) {
+func setUpDatabase(t *testing.T, store *sqlstore.SQLStore, uid string) {
t.Helper()
err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
_, err := sess.Insert(&models.DataSource{
- Id: 1,
+ Id: 1,
+ Uid: uid,
// This will be the ID of the main org
OrgId: 2,
Name: "Test",
diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go
index 64799982122..a2d65a23bde 100644
--- a/pkg/tsdb/cloudwatch/cloudwatch.go
+++ b/pkg/tsdb/cloudwatch/cloudwatch.go
@@ -23,6 +23,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
+ "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
@@ -83,11 +84,13 @@ type SessionCache interface {
}
func newExecutor(im instancemgmt.InstanceManager, cfg *setting.Cfg, sessions SessionCache) *cloudWatchExecutor {
- return &cloudWatchExecutor{
+ cwe := &cloudWatchExecutor{
im: im,
cfg: cfg,
sessions: sessions,
}
+ cwe.resourceHandler = httpadapter.New(cwe.newResourceMux())
+ return cwe
}
func NewInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc {
@@ -158,6 +161,12 @@ type cloudWatchExecutor struct {
im instancemgmt.InstanceManager
cfg *setting.Cfg
sessions SessionCache
+
+ resourceHandler backend.CallResourceHandler
+}
+
+func (e *cloudWatchExecutor) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
+ return e.resourceHandler.CallResource(ctx, req, sender)
}
func (e *cloudWatchExecutor) newSession(pluginCtx backend.PluginContext, region string) (*session.Session, error) {
@@ -283,8 +292,6 @@ func (e *cloudWatchExecutor) QueryData(ctx context.Context, req *backend.QueryDa
var result *backend.QueryDataResponse
switch queryType {
- case "metricFindQuery":
- result, err = e.executeMetricFindQuery(req.PluginContext, model, q)
case "annotationQuery":
result, err = e.executeAnnotationQuery(req.PluginContext, model, q)
case "logAction":
diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go
index d4ec36f073d..0284f73d996 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query.go
@@ -1,8 +1,10 @@
package cloudwatch
import (
+ "encoding/json"
"errors"
"fmt"
+ "net/url"
"reflect"
"sort"
"strings"
@@ -15,15 +17,14 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
"github.com/grafana/grafana-plugin-sdk-go/backend"
- "github.com/grafana/grafana-plugin-sdk-go/data"
- "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/util/errutil"
)
type suggestData struct {
- Text string
- Value string
+ Text string `json:"text"`
+ Value string `json:"value"`
+ Label string `json:"label,omitempty"`
}
type customMetricsCache struct {
@@ -36,61 +37,6 @@ var customMetricsDimensionsMap = make(map[string]map[string]map[string]*customMe
var regionCache sync.Map
-func (e *cloudWatchExecutor) executeMetricFindQuery(pluginCtx backend.PluginContext, model *simplejson.Json, query backend.DataQuery) (*backend.QueryDataResponse, error) {
- subType := model.Get("subtype").MustString()
-
- var data []suggestData
- var err error
- switch subType {
- case "regions":
- data, err = e.handleGetRegions(pluginCtx)
- case "namespaces":
- data, err = e.handleGetNamespaces(pluginCtx)
- case "metrics":
- data, err = e.handleGetMetrics(pluginCtx, model)
- case "all_metrics":
- data, err = e.handleGetAllMetrics()
- case "dimension_keys":
- data, err = e.handleGetDimensions(pluginCtx, model)
- case "dimension_values":
- data, err = e.handleGetDimensionValues(pluginCtx, model)
- case "ebs_volume_ids":
- data, err = e.handleGetEbsVolumeIds(pluginCtx, model)
- case "ec2_instance_attribute":
- data, err = e.handleGetEc2InstanceAttribute(pluginCtx, model)
- case "resource_arns":
- data, err = e.handleGetResourceArns(pluginCtx, model)
- }
- if err != nil {
- return nil, err
- }
-
- resp := backend.NewQueryDataResponse()
- respD := resp.Responses[query.RefID]
- respD.Frames = append(respD.Frames, transformToTable(data))
- resp.Responses[query.RefID] = respD
-
- return resp, nil
-}
-
-func transformToTable(d []suggestData) *data.Frame {
- frame := data.NewFrame("",
- data.NewField("text", nil, []string{}),
- data.NewField("value", nil, []string{}))
-
- for _, r := range d {
- frame.AppendRow(r.Text, r.Value)
- }
-
- frame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": len(d),
- },
- }
-
- return frame
-}
-
func parseMultiSelectValue(input string) []string {
trimmedInput := strings.TrimSpace(input)
if strings.HasPrefix(trimmedInput, "{") {
@@ -107,7 +53,7 @@ func parseMultiSelectValue(input string) []string {
// Whenever this list is updated, the frontend list should also be updated.
// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
-func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext) ([]suggestData, error) {
+func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
dsInfo, err := e.getDSInfo(pluginCtx)
if err != nil {
return nil, err
@@ -149,14 +95,14 @@ func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext) (
result := make([]suggestData, 0)
for _, region := range regions {
- result = append(result, suggestData{Text: region, Value: region})
+ result = append(result, suggestData{Text: region, Value: region, Label: region})
}
regionCache.Store(profile, result)
return result, nil
}
-func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext) ([]suggestData, error) {
+func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
var keys []string
for key := range metricsMap {
keys = append(keys, key)
@@ -175,15 +121,15 @@ func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext
result := make([]suggestData, 0)
for _, key := range keys {
- result = append(result, suggestData{Text: key, Value: key})
+ result = append(result, suggestData{Text: key, Value: key, Label: key})
}
return result, nil
}
-func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- namespace := parameters.Get("namespace").MustString()
+func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ namespace := parameters.Get("namespace")
var namespaceMetrics []string
if !isCustomMetrics(namespace) {
@@ -201,32 +147,40 @@ func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, p
result := make([]suggestData, 0)
for _, name := range namespaceMetrics {
- result = append(result, suggestData{Text: name, Value: name})
+ result = append(result, suggestData{Text: name, Value: name, Label: name})
}
return result, nil
}
// handleGetAllMetrics returns a slice of suggestData structs with metric and its namespace
-func (e *cloudWatchExecutor) handleGetAllMetrics() ([]suggestData, error) {
+func (e *cloudWatchExecutor) handleGetAllMetrics(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
result := make([]suggestData, 0)
for namespace, metrics := range metricsMap {
for _, metric := range metrics {
- result = append(result, suggestData{Text: namespace, Value: metric})
+ result = append(result, suggestData{Text: namespace, Value: metric, Label: namespace})
}
}
return result, nil
}
-// handleGetDimensions returns a slice of suggestData structs with dimension keys.
+// handleGetDimensionKeys returns a slice of suggestData structs with dimension keys.
// If a dimension filters parameter is specified, a new api call to list metrics will be issued to load dimension keys for the given filter.
// If no dimension filter is specified, dimension keys will be retrieved from the hard coded map in this file.
-func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- namespace := parameters.Get("namespace").MustString()
- metricName := parameters.Get("metricName").MustString("")
- dimensionFilters := parameters.Get("dimensionFilters").MustMap()
+func (e *cloudWatchExecutor) handleGetDimensionKeys(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ namespace := parameters.Get("namespace")
+ metricName := parameters.Get("metricName")
+ dimensionFilterJson := parameters.Get("dimensionFilters")
+
+ dimensionFilters := map[string]interface{}{}
+ if dimensionFilterJson != "" {
+ err := json.Unmarshal([]byte(dimensionFilterJson), &dimensionFilters)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling dimensionFilters: %v", err)
+ }
+ }
var dimensionValues []string
if !isCustomMetrics(namespace) {
@@ -303,7 +257,7 @@ func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext
result := make([]suggestData, 0)
for _, name := range dimensionValues {
- result = append(result, suggestData{Text: name, Value: name})
+ result = append(result, suggestData{Text: name, Value: name, Label: name})
}
return result, nil
@@ -311,12 +265,18 @@ func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext
// handleGetDimensionValues returns a slice of suggestData structs with dimension values.
// A call to the list metrics api is issued to retrieve the dimension values. All parameters are used as input args to the list metrics call.
-func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- namespace := parameters.Get("namespace").MustString()
- metricName := parameters.Get("metricName").MustString()
- dimensionKey := parameters.Get("dimensionKey").MustString()
- dimensionsJson := parameters.Get("dimensions").MustMap()
+func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ namespace := parameters.Get("namespace")
+ metricName := parameters.Get("metricName")
+ dimensionKey := parameters.Get("dimensionKey")
+ dimensionsJson := parameters.Get("dimensions")
+
+ dimensionsValues := map[string]interface{}{}
+ err := json.Unmarshal([]byte(dimensionsJson), &dimensionsValues)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling dimension: %v", err)
+ }
var dimensions []*cloudwatch.DimensionFilter
addDimension := func(key string, value string) {
@@ -329,8 +289,8 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo
}
dimensions = append(dimensions, filter)
}
- for k, v := range dimensionsJson {
- // due to legacy, value can be a string, a string slice or nil
+
+ for k, v := range dimensionsValues {
if vv, ok := v.(string); ok {
addDimension(k, vv)
} else if vv, ok := v.([]interface{}); ok {
@@ -364,7 +324,7 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo
}
dupCheck[*dim.Value] = true
- result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value})
+ result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value, Label: *dim.Value})
}
}
}
@@ -376,9 +336,9 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo
return result, nil
}
-func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- instanceId := parameters.Get("instanceId").MustString()
+func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ instanceId := parameters.Get("instanceId")
instanceIds := aws.StringSlice(parseMultiSelectValue(instanceId))
instances, err := e.ec2DescribeInstances(pluginCtx, region, nil, instanceIds)
@@ -390,7 +350,7 @@ func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginConte
for _, reservation := range instances.Reservations {
for _, instance := range reservation.Instances {
for _, mapping := range instance.BlockDeviceMappings {
- result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId})
+ result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId, Label: *mapping.Ebs.VolumeId})
}
}
}
@@ -398,13 +358,19 @@ func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginConte
return result, nil
}
-func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- attributeName := parameters.Get("attributeName").MustString()
- filterJson := parameters.Get("filters").MustMap()
+func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ attributeName := parameters.Get("attributeName")
+ filterJson := parameters.Get("filters")
+
+ filterMap := map[string]interface{}{}
+ err := json.Unmarshal([]byte(filterJson), &filterMap)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling filter: %v", err)
+ }
var filters []*ec2.Filter
- for k, v := range filterJson {
+ for k, v := range filterMap {
if vv, ok := v.([]interface{}); ok {
var values []*string
for _, vvv := range vv {
@@ -466,7 +432,7 @@ func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.Plu
}
dupCheck[data] = true
- result = append(result, suggestData{Text: data, Value: data})
+ result = append(result, suggestData{Text: data, Value: data, Label: data})
}
}
@@ -477,13 +443,19 @@ func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.Plu
return result, nil
}
-func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- resourceType := parameters.Get("resourceType").MustString()
- filterJson := parameters.Get("tags").MustMap()
+func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ resourceType := parameters.Get("resourceType")
+ tagsJson := parameters.Get("tags")
+
+ tagsMap := map[string]interface{}{}
+ err := json.Unmarshal([]byte(tagsJson), &tagsMap)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling filter: %v", err)
+ }
var filters []*resourcegroupstaggingapi.TagFilter
- for k, v := range filterJson {
+ for k, v := range tagsMap {
if vv, ok := v.([]interface{}); ok {
var values []*string
for _, vvv := range vv {
@@ -509,7 +481,7 @@ func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginConte
result := make([]suggestData, 0)
for _, resource := range resources.ResourceTagMappingList {
data := *resource.ResourceARN
- result = append(result, suggestData{Text: data, Value: data})
+ result = append(result, suggestData{Text: data, Value: data, Label: data})
}
return result, nil
diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go
index 705bb04c328..ebc0e25da01 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query_test.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go
@@ -1,8 +1,8 @@
package cloudwatch
import (
- "context"
"encoding/json"
+ "net/url"
"testing"
"github.com/aws/aws-sdk-go/aws"
@@ -54,40 +54,20 @@ func TestQuery_Metrics(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetMetrics(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"custom"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "metrics",
- "region": "us-east-1",
- "namespace": "custom"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, []string{"Test_MetricName"}),
- data.NewField("value", nil, []string{"Test_MetricName"}),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 1,
- },
+ expResponse := []suggestData{
+ {Text: "Test_MetricName", Value: "Test_MetricName", Label: "Test_MetricName"},
}
-
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
t.Run("Dimension keys for custom metrics", func(t *testing.T) {
@@ -109,39 +89,20 @@ func TestQuery_Metrics(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetDimensionKeys(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"custom"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "dimension_keys",
- "region": "us-east-1",
- "namespace": "custom"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, []string{"Test_DimensionName"}),
- data.NewField("value", nil, []string{"Test_DimensionName"}),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 1,
- },
+ expResponse := []suggestData{
+ {Text: "Test_DimensionName", Value: "Test_DimensionName", Label: "Test_DimensionName"},
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
@@ -168,21 +129,14 @@ func TestQuery_Regions(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetRegions(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"custom"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "regions",
- "region": "us-east-1",
- "namespace": "custom"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
expRegions := append(knownRegions, regionName)
@@ -197,12 +151,11 @@ func TestQuery_Regions(t *testing.T) {
},
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ expResponse := []suggestData{}
+ for _, region := range expRegions {
+ expResponse = append(expResponse, suggestData{Text: region, Value: region, Label: region})
+ }
+ assert.Equal(t, expResponse, resp)
})
}
@@ -242,44 +195,28 @@ func TestQuery_InstanceAttributes(t *testing.T) {
return datasourceInfo{}, nil
})
- executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
- DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
- },
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "ec2_instance_attribute",
- "region": "us-east-1",
- "attributeName": "InstanceId",
- "filters": {
- "tag:Environment": ["production"]
- }
- }`),
- },
- },
- })
+ filterMap := map[string][]string{
+ "tag:Environment": {"production"},
+ }
+ filterJson, err := json.Marshal(filterMap)
require.NoError(t, err)
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, []string{instanceID}),
- data.NewField("value", nil, []string{instanceID}),
+ executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
+ resp, err := executor.handleGetEc2InstanceAttribute(
+ backend.PluginContext{
+ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "attributeName": []string{"InstanceId"},
+ "filters": []string{string(filterJson)},
+ },
)
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 1,
- },
- }
+ require.NoError(t, err)
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ expResponse := []suggestData{
+ {Text: instanceID, Value: instanceID, Label: instanceID},
+ }
+ assert.Equal(t, expResponse, resp)
})
}
@@ -342,41 +279,22 @@ func TestQuery_EBSVolumeIDs(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetEbsVolumeIds(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "instanceId": []string{"{i-1, i-2, i-3}"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "ebs_volume_ids",
- "region": "us-east-1",
- "instanceId": "{i-1, i-2, i-3}"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
expValues := []string{"vol-1-1", "vol-1-2", "vol-2-1", "vol-2-2", "vol-3-1", "vol-3-2"}
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 6,
- },
+ expResponse := []suggestData{}
+ for _, value := range expValues {
+ expResponse = append(expResponse, suggestData{Text: value, Value: value, Label: value})
}
-
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
@@ -420,48 +338,33 @@ func TestQuery_ResourceARNs(t *testing.T) {
return datasourceInfo{}, nil
})
+ tagMap := map[string][]string{
+ "Environment": {"production"},
+ }
+ tagJson, err := json.Marshal(tagMap)
+ require.NoError(t, err)
+
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetResourceArns(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "resourceType": []string{"ec2:instance"},
+ "tags": []string{string(tagJson)},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "resource_arns",
- "region": "us-east-1",
- "resourceType": "ec2:instance",
- "tags": {
- "Environment": ["production"]
- }
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
expValues := []string{
"arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567",
"arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321",
}
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 2,
- },
+ expResponse := []suggestData{}
+ for _, value := range expValues {
+ expResponse = append(expResponse, suggestData{Text: value, Value: value, Label: value})
}
-
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
@@ -472,20 +375,14 @@ func TestQuery_GetAllMetrics(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetAllMetrics(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "all_metrics",
- "region": "us-east-1"
- }`),
- },
+ url.Values{
+ "region": []string{"us-east-1"},
},
- })
+ )
require.NoError(t, err)
metricCount := 0
@@ -493,7 +390,7 @@ func TestQuery_GetAllMetrics(t *testing.T) {
metricCount += len(metrics)
}
- assert.Equal(t, metricCount, resp.Responses[""].Frames[0].Fields[1].Len())
+ assert.Equal(t, metricCount, len(resp))
})
}
@@ -527,46 +424,28 @@ func TestQuery_GetDimensionKeys(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetDimensionKeys(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "dimension_keys",
- "region": "us-east-1",
- "namespace": "AWS/EC2",
- "dimensionFilters": {
- "InstanceId": "",
- "AutoscalingGroup": []
- }
- }`),
- },
+ url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"AWS/EC2"},
+ "dimensionFilters": []string{`{
+ "InstanceId": "",
+ "AutoscalingGroup": []
+ }`},
},
- })
-
+ )
require.NoError(t, err)
expValues := []string{"Dimension1", "Dimension2", "Dimension3"}
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": len(expValues),
- },
+ expResponse := []suggestData{}
+ for _, val := range expValues {
+ expResponse = append(expResponse, suggestData{val, val, val})
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
t.Run("should return hard coded metrics when no dimension filter is specified", func(t *testing.T) {
@@ -575,42 +454,25 @@ func TestQuery_GetDimensionKeys(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetDimensionKeys(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "dimension_keys",
- "region": "us-east-1",
- "namespace": "AWS/EC2",
- "dimensionFilters": {}
- }`),
- },
+ url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"AWS/EC2"},
+ "dimensionFilters": []string{`{}`},
},
- })
+ )
require.NoError(t, err)
expValues := dimensionsMap["AWS/EC2"]
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": len(expValues),
- },
+ expResponse := []suggestData{}
+ for _, val := range expValues {
+ expResponse = append(expResponse, suggestData{val, val, val})
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
func Test_isCustomMetrics(t *testing.T) {
diff --git a/pkg/tsdb/cloudwatch/resource_handler.go b/pkg/tsdb/cloudwatch/resource_handler.go
new file mode 100644
index 00000000000..c2f76249748
--- /dev/null
+++ b/pkg/tsdb/cloudwatch/resource_handler.go
@@ -0,0 +1,59 @@
+package cloudwatch
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
+)
+
+func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/regions", handleResourceReq(e.handleGetRegions))
+ mux.HandleFunc("/namespaces", handleResourceReq(e.handleGetNamespaces))
+ mux.HandleFunc("/metrics", handleResourceReq(e.handleGetMetrics))
+ mux.HandleFunc("/all-metrics", handleResourceReq(e.handleGetAllMetrics))
+ mux.HandleFunc("/dimension-keys", handleResourceReq(e.handleGetDimensionKeys))
+ mux.HandleFunc("/dimension-values", handleResourceReq(e.handleGetDimensionValues))
+ mux.HandleFunc("/ebs-volume-ids", handleResourceReq(e.handleGetEbsVolumeIds))
+ mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute))
+ mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns))
+ return mux
+}
+
+type handleFn func(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error)
+
+func handleResourceReq(handleFunc handleFn) func(rw http.ResponseWriter, req *http.Request) {
+ return func(rw http.ResponseWriter, req *http.Request) {
+ ctx := req.Context()
+ pluginContext := httpadapter.PluginConfigFromContext(ctx)
+ err := req.ParseForm()
+ if err != nil {
+ writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err))
+ }
+ data, err := handleFunc(pluginContext, req.URL.Query())
+ if err != nil {
+ writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err))
+ }
+ body, err := json.Marshal(data)
+ if err != nil {
+ writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err))
+ }
+ rw.WriteHeader(http.StatusOK)
+ _, err = rw.Write(body)
+ if err != nil {
+ plog.Error("Unable to write HTTP response", "error", err)
+ }
+ }
+}
+
+func writeResponse(rw http.ResponseWriter, code int, msg string) {
+ rw.WriteHeader(code)
+ _, err := rw.Write([]byte(msg))
+ if err != nil {
+ plog.Error("Unable to write HTTP response", "error", err)
+ }
+}
diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts
index a4c92c98146..f92756527f4 100644
--- a/public/app/plugins/datasource/cloudwatch/datasource.ts
+++ b/public/app/plugins/datasource/cloudwatch/datasource.ts
@@ -50,7 +50,6 @@ import {
StartQueryRequest,
TSDBResponse,
Dimensions,
- MetricFindSuggestData,
CloudWatchLogsRequest,
} from './types';
import { CloudWatchLanguageProvider } from './language_provider';
@@ -557,40 +556,8 @@ export class CloudWatchDatasource
);
}
- transformSuggestDataFromDataframes(suggestData: TSDBResponse): MetricFindSuggestData[] {
- const frames = toDataQueryResponse({ data: suggestData }).data as DataFrame[];
- const table = toLegacyResponseData(frames[0]) as TableData;
-
- return table.rows.map(([text, value]) => ({
- text,
- value,
- label: value,
- }));
- }
-
- doMetricQueryRequest(subtype: string, parameters: any): Promise {
- const range = this.timeSrv.timeRange();
- return lastValueFrom(
- this.awsRequest(DS_QUERY_ENDPOINT, {
- from: range.from.valueOf().toString(),
- to: range.to.valueOf().toString(),
- queries: [
- {
- refId: 'metricFindQuery',
- intervalMs: 1, // dummy
- maxDataPoints: 1, // dummy
- datasource: this.getRef(),
- type: 'metricFindQuery',
- subtype: subtype,
- ...parameters,
- },
- ],
- }).pipe(
- map((r) => {
- return this.transformSuggestDataFromDataframes(r);
- })
- )
- );
+ doMetricResourceRequest(subtype: string, parameters?: any): Promise> {
+ return this.getResource(subtype, parameters);
}
makeLogActionRequest(
@@ -677,14 +644,14 @@ export class CloudWatchDatasource
}
getRegions(): Promise> {
- return this.doMetricQueryRequest('regions', null).then((regions: any) => [
+ return this.doMetricResourceRequest('regions').then((regions: any) => [
{ label: 'default', value: 'default', text: 'default' },
...regions,
]);
}
getNamespaces() {
- return this.doMetricQueryRequest('namespaces', null);
+ return this.doMetricResourceRequest('namespaces');
}
async getMetrics(namespace: string | undefined, region?: string) {
@@ -692,14 +659,14 @@ export class CloudWatchDatasource
return [];
}
- return this.doMetricQueryRequest('metrics', {
+ return this.doMetricResourceRequest('metrics', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
});
}
async getAllMetrics(region: string): Promise> {
- const values = await this.doMetricQueryRequest('all_metrics', {
+ const values = await this.doMetricResourceRequest('all-metrics', {
region: this.templateSrv.replace(this.getActualRegion(region)),
});
@@ -716,10 +683,10 @@ export class CloudWatchDatasource
return [];
}
- return this.doMetricQueryRequest('dimension_keys', {
+ return this.doMetricResourceRequest('dimension-keys', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
- dimensionFilters: this.convertDimensionFormat(dimensionFilters, {}),
+ dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})),
metricName,
});
}
@@ -735,117 +702,40 @@ export class CloudWatchDatasource
return [];
}
- const values = await this.doMetricQueryRequest('dimension_values', {
+ const values = await this.doMetricResourceRequest('dimension-values', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
metricName: this.templateSrv.replace(metricName.trim()),
dimensionKey: this.templateSrv.replace(dimensionKey),
- dimensions: this.convertDimensionFormat(filterDimensions, {}),
+ dimensions: JSON.stringify(this.convertDimensionFormat(filterDimensions, {})),
});
return values;
}
getEbsVolumeIds(region: string, instanceId: string) {
- return this.doMetricQueryRequest('ebs_volume_ids', {
+ return this.doMetricResourceRequest('ebs-volume-ids', {
region: this.templateSrv.replace(this.getActualRegion(region)),
instanceId: this.templateSrv.replace(instanceId),
});
}
getEc2InstanceAttribute(region: string, attributeName: string, filters: any) {
- return this.doMetricQueryRequest('ec2_instance_attribute', {
+ return this.doMetricResourceRequest('ec2-instance-attribute', {
region: this.templateSrv.replace(this.getActualRegion(region)),
attributeName: this.templateSrv.replace(attributeName),
- filters: filters,
+ filters: JSON.stringify(filters),
});
}
getResourceARNs(region: string, resourceType: string, tags: any) {
- return this.doMetricQueryRequest('resource_arns', {
+ return this.doMetricResourceRequest('resource-arns', {
region: this.templateSrv.replace(this.getActualRegion(region)),
resourceType: this.templateSrv.replace(resourceType),
- tags: tags,
+ tags: JSON.stringify(tags),
});
}
- async metricFindQuery(query: string) {
- let region;
- let namespace;
- let metricName;
- let filterJson;
-
- const regionQuery = query.match(/^regions\(\)/);
- if (regionQuery) {
- return this.getRegions();
- }
-
- const namespaceQuery = query.match(/^namespaces\(\)/);
- if (namespaceQuery) {
- return this.getNamespaces();
- }
-
- const metricNameQuery = query.match(/^metrics\(([^\)]+?)(,\s?([^,]+?))?\)/);
- if (metricNameQuery) {
- namespace = metricNameQuery[1];
- region = metricNameQuery[3];
- return this.getMetrics(namespace, region);
- }
-
- const dimensionKeysQuery = query.match(/^dimension_keys\(([^\)]+?)(,\s?([^,]+?))?\)/);
- if (dimensionKeysQuery) {
- namespace = dimensionKeysQuery[1];
- region = dimensionKeysQuery[3];
- return this.getDimensionKeys(namespace, region);
- }
-
- const dimensionValuesQuery = query.match(
- /^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)(,\s?(.+))?\)/
- );
- if (dimensionValuesQuery) {
- region = dimensionValuesQuery[1];
- namespace = dimensionValuesQuery[2];
- metricName = dimensionValuesQuery[3];
- const dimensionKey = dimensionValuesQuery[4];
- filterJson = {};
- if (dimensionValuesQuery[6]) {
- filterJson = JSON.parse(this.templateSrv.replace(dimensionValuesQuery[6]));
- }
-
- return this.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson);
- }
-
- const ebsVolumeIdsQuery = query.match(/^ebs_volume_ids\(([^,]+?),\s?([^,]+?)\)/);
- if (ebsVolumeIdsQuery) {
- region = ebsVolumeIdsQuery[1];
- const instanceId = ebsVolumeIdsQuery[2];
- return this.getEbsVolumeIds(region, instanceId);
- }
-
- const ec2InstanceAttributeQuery = query.match(/^ec2_instance_attribute\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/);
- if (ec2InstanceAttributeQuery) {
- region = ec2InstanceAttributeQuery[1];
- const targetAttributeName = ec2InstanceAttributeQuery[2];
- filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3]));
- return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson);
- }
-
- const resourceARNsQuery = query.match(/^resource_arns\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/);
- if (resourceARNsQuery) {
- region = resourceARNsQuery[1];
- const resourceType = resourceARNsQuery[2];
- const tagsJSON = JSON.parse(this.templateSrv.replace(resourceARNsQuery[3]));
- return this.getResourceARNs(region, resourceType, tagsJSON);
- }
-
- const statsQuery = query.match(/^statistics\(\)/);
- if (statsQuery) {
- return this.standardStatistics.map((s: string) => ({ value: s, label: s, text: s }));
- }
-
- return Promise.resolve([]);
- }
-
annotationQuery(options: any) {
const annotation = options.annotation;
const statistic = this.templateSrv.replace(annotation.statistic);
diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
index 70e1262cbe6..6c608fcdf7b 100644
--- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
@@ -506,36 +506,6 @@ describe('CloudWatchDatasource', () => {
});
});
});
-
- describe('when regions query is used', () => {
- describe('and region param is left out', () => {
- it('should use the default region', async () => {
- const { ds, instanceSettings } = getTestContext();
- ds.doMetricQueryRequest = jest.fn().mockResolvedValue([]);
-
- await ds.metricFindQuery('metrics(testNamespace)');
-
- expect(ds.doMetricQueryRequest).toHaveBeenCalledWith('metrics', {
- namespace: 'testNamespace',
- region: instanceSettings.jsonData.defaultRegion,
- });
- });
- });
-
- describe('and region param is defined by user', () => {
- it('should use the user defined region', async () => {
- const { ds } = getTestContext();
- ds.doMetricQueryRequest = jest.fn().mockResolvedValue([]);
-
- await ds.metricFindQuery('metrics(testNamespace2, custom-region)');
-
- expect(ds.doMetricQueryRequest).toHaveBeenCalledWith('metrics', {
- namespace: 'testNamespace2',
- region: 'custom-region',
- });
- });
- });
- });
});
describe('When query region is "default"', () => {
diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts
index 365b5e7eac4..2401c0c3721 100644
--- a/public/app/plugins/datasource/cloudwatch/types.ts
+++ b/public/app/plugins/datasource/cloudwatch/types.ts
@@ -367,9 +367,3 @@ export interface MetricQuery {
maxDataPoints?: number;
intervalMs?: number;
}
-
-export interface MetricFindSuggestData {
- text: string;
- label: string;
- value: string;
-}
From 685ec5383eb716ef6c7c4dc6b9c17545a1dd9ba3 Mon Sep 17 00:00:00 2001
From: Ryan McKinley
Date: Wed, 16 Feb 2022 13:52:39 -0800
Subject: [PATCH 025/104] Chore: avoid pointer events in viz tooltip (#45492)
---
.../src/components/VizTooltip/VizTooltipContainer.tsx | 2 +-
public/app/plugins/panel/barchart/BarChartPanel.tsx | 2 +-
public/app/plugins/panel/xychart/scatter.ts | 7 ++-----
3 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
index 48dad2789eb..96e6a506413 100644
--- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
+++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
@@ -23,7 +23,7 @@ export const VizTooltipContainer: React.FC = ({
position: { x: positionX, y: positionY },
offset: { x: offsetX, y: offsetY },
children,
- allowPointerEvents,
+ allowPointerEvents = false,
className,
...otherProps
}) => {
diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx
index 8820d009485..7a4e7dc5f2c 100755
--- a/public/app/plugins/panel/barchart/BarChartPanel.tsx
+++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx
@@ -271,7 +271,7 @@ export const BarChartPanel: React.FunctionComponent = ({ data, options, w
{renderTooltip(info.aligned, focusedSeriesIdx, focusedPointIdx)}
diff --git a/public/app/plugins/panel/xychart/scatter.ts b/public/app/plugins/panel/xychart/scatter.ts
index 6c96b244798..5830c75b414 100644
--- a/public/app/plugins/panel/xychart/scatter.ts
+++ b/public/app/plugins/panel/xychart/scatter.ts
@@ -328,11 +328,8 @@ const prepConfig = (
let deg360 = 2 * Math.PI;
- // leon forgot to add these to the uPlot's Scale interface, but they exist!
- //let xKey = scaleX.key as string;
- //let yKey = scaleY.key as string;
- let xKey = series.facets![0].scale;
- let yKey = series.facets![1].scale;
+ let xKey = scaleX.key!;
+ let yKey = scaleY.key!;
let pointHints = scatterInfo.hints.pointSize;
const colorByValue = scatterInfo.hints.pointColor.mode.isByValue;
From bb86ba99eeb61471f8e0ba374d63e43a9898b906 Mon Sep 17 00:00:00 2001
From: Ryan McKinley
Date: Wed, 16 Feb 2022 14:49:17 -0800
Subject: [PATCH 026/104] Heatmap: new panel based based on uPlot (#44080)
Co-authored-by: Leon Sorokin
---
.../transformations/transformers/histogram.ts | 23 +-
.../src/transformations/transformers/ids.ts | 1 +
.../grafana-data/src/types/dataFrameTypes.ts | 13 +
.../src/scuemata/dashboard/dist/family.cue | 2 +
.../grafana-cli/commands/cuetsify_command.go | 1 +
.../manager/manager_integration_test.go | 1 +
.../HeatmapTransformerEditor.tsx | 49 +++
.../calculateHeatmap/editor/AxisEditor.tsx | 48 +++
.../calculateHeatmap/editor/helper.ts | 33 ++
.../calculateHeatmap/heatmap.test.ts | 23 +
.../calculateHeatmap/heatmap.ts | 372 +++++++++++++++++
.../calculateHeatmap/models.gen.ts | 18 +
.../TransformersUI/calculateHeatmap/utils.ts | 125 ++++++
.../TransformersUI/spatial/optionsHelper.tsx | 3 +-
public/app/core/utils/standardTransformers.ts | 2 +
.../app/features/plugins/built_in_plugins.ts | 2 +
.../panel/heatmap-new/HeatmapHoverView.tsx | 194 +++++++++
.../panel/heatmap-new/HeatmapPanel.tsx | 125 ++++++
.../app/plugins/panel/heatmap-new/README.md | 7 +
.../plugins/panel/heatmap-new/fields.test.ts | 13 +
.../app/plugins/panel/heatmap-new/fields.ts | 115 +++++
.../plugins/panel/heatmap-new/img/heatmap.svg | 1 +
.../panel/heatmap-new/migrations.test.ts | 135 ++++++
.../plugins/panel/heatmap-new/migrations.ts | 83 ++++
.../app/plugins/panel/heatmap-new/models.cue | 33 ++
.../plugins/panel/heatmap-new/models.gen.ts | 92 ++++
.../app/plugins/panel/heatmap-new/module.tsx | 223 ++++++++++
.../app/plugins/panel/heatmap-new/palettes.ts | 107 +++++
.../app/plugins/panel/heatmap-new/plugin.json | 18 +
.../plugins/panel/heatmap-new/suggestions.ts | 37 ++
public/app/plugins/panel/heatmap-new/utils.ts | 395 ++++++++++++++++++
31 files changed, 2285 insertions(+), 9 deletions(-)
create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx
create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx
create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts
create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts
create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts
create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts
create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/utils.ts
create mode 100644 public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
create mode 100644 public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
create mode 100644 public/app/plugins/panel/heatmap-new/README.md
create mode 100644 public/app/plugins/panel/heatmap-new/fields.test.ts
create mode 100644 public/app/plugins/panel/heatmap-new/fields.ts
create mode 100644 public/app/plugins/panel/heatmap-new/img/heatmap.svg
create mode 100644 public/app/plugins/panel/heatmap-new/migrations.test.ts
create mode 100644 public/app/plugins/panel/heatmap-new/migrations.ts
create mode 100644 public/app/plugins/panel/heatmap-new/models.cue
create mode 100644 public/app/plugins/panel/heatmap-new/models.gen.ts
create mode 100644 public/app/plugins/panel/heatmap-new/module.tsx
create mode 100644 public/app/plugins/panel/heatmap-new/palettes.ts
create mode 100644 public/app/plugins/panel/heatmap-new/plugin.json
create mode 100644 public/app/plugins/panel/heatmap-new/suggestions.ts
create mode 100644 public/app/plugins/panel/heatmap-new/utils.ts
diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts
index 0e8b2c1da88..73a4a39c323 100644
--- a/packages/grafana-data/src/transformations/transformers/histogram.ts
+++ b/packages/grafana-data/src/transformations/transformers/histogram.ts
@@ -285,15 +285,24 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform
};
}
-// function incrRound(num: number, incr: number) {
-// return Math.round(num / incr) * incr;
-// }
+/**
+ * @internal
+ */
+export function incrRound(num: number, incr: number) {
+ return Math.round(num / incr) * incr;
+}
-// function incrRoundUp(num: number, incr: number) {
-// return Math.ceil(num / incr) * incr;
-// }
+/**
+ * @internal
+ */
+export function incrRoundUp(num: number, incr: number) {
+ return Math.ceil(num / incr) * incr;
+}
-function incrRoundDn(num: number, incr: number) {
+/**
+ * @internal
+ */
+export function incrRoundDn(num: number, incr: number) {
return Math.floor(num / incr) * incr;
}
diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts
index ed2066063e6..e8254b1949a 100644
--- a/packages/grafana-data/src/transformations/transformers/ids.ts
+++ b/packages/grafana-data/src/transformations/transformers/ids.ts
@@ -28,6 +28,7 @@ export enum DataTransformerID {
prepareTimeSeries = 'prepareTimeSeries',
convertFieldType = 'convertFieldType',
fieldLookup = 'fieldLookup',
+ heatmap = 'heatmap',
spatial = 'spatial',
extractFields = 'extractFields',
}
diff --git a/packages/grafana-data/src/types/dataFrameTypes.ts b/packages/grafana-data/src/types/dataFrameTypes.ts
index 9add0f27abf..f4faa9fda88 100644
--- a/packages/grafana-data/src/types/dataFrameTypes.ts
+++ b/packages/grafana-data/src/types/dataFrameTypes.ts
@@ -8,4 +8,17 @@ export enum DataFrameType {
TimeSeriesWide = 'timeseries-wide',
TimeSeriesLong = 'timeseries-long',
TimeSeriesMany = 'timeseries-many',
+
+ /**
+ * First field is X, the rest are bucket values
+ */
+ HeatmapBuckets = 'heatmap-buckets',
+
+ /**
+ * Explicit fields for:
+ * xMin, yMin, count, ...
+ *
+ * All values in the grid exist and have regular spacing
+ */
+ HeatmapScanlines = 'heatmap-scanlines',
}
diff --git a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue
index 4f97a4230d0..2f8f169ed7e 100644
--- a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue
+++ b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue
@@ -16,6 +16,7 @@ import (
ptable "github.com/grafana/grafana/public/app/plugins/panel/table:grafanaschema"
ptext "github.com/grafana/grafana/public/app/plugins/panel/text:grafanaschema"
ptimeseries "github.com/grafana/grafana/public/app/plugins/panel/timeseries:grafanaschema"
+ pheatmap_new "github.com/grafana/grafana/public/app/plugins/panel/heatmap-new:grafanaschema"
)
// Family composes the base dashboard scuemata family with all Grafana core plugins -
@@ -40,5 +41,6 @@ Family: dashboard.Family & {
text: ptext.Panel
table: ptable.Panel
timeseries: ptimeseries.Panel
+ "heatmap-new": pheatmap_new.Panel
}
}
\ No newline at end of file
diff --git a/pkg/cmd/grafana-cli/commands/cuetsify_command.go b/pkg/cmd/grafana-cli/commands/cuetsify_command.go
index 73ae0d098c7..d71335f2431 100644
--- a/pkg/cmd/grafana-cli/commands/cuetsify_command.go
+++ b/pkg/cmd/grafana-cli/commands/cuetsify_command.go
@@ -43,6 +43,7 @@ var skipPaths = []string{
"public/app/plugins/panel/dashlist/models.cue",
"public/app/plugins/panel/gauge/models.cue",
"public/app/plugins/panel/histogram/models.cue",
+ "public/app/plugins/panel/heatmap-new/models.cue",
"public/app/plugins/panel/stat/models.cue",
"public/app/plugins/panel/candlestick/models.cue",
"public/app/plugins/panel/state-timeline/models.cue",
diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go
index fb4f358f08c..e0c871f0428 100644
--- a/pkg/plugins/manager/manager_integration_test.go
+++ b/pkg/plugins/manager/manager_integration_test.go
@@ -119,6 +119,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
"gettingstarted": {},
"graph": {},
"heatmap": {},
+ "heatmap-new": {},
"histogram": {},
"icon": {},
"live": {},
diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx b/public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx
new file mode 100644
index 00000000000..dc0e6eb1fed
--- /dev/null
+++ b/public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx
@@ -0,0 +1,49 @@
+import React, { useEffect } from 'react';
+import {
+ PanelOptionsEditorBuilder,
+ PluginState,
+ StandardEditorContext,
+ TransformerRegistryItem,
+ TransformerUIProps,
+} from '@grafana/data';
+
+import { HeatmapTransformerOptions, heatmapTransformer } from './heatmap';
+import { addHeatmapCalculationOptions } from './editor/helper';
+import { getDefaultOptions, getTransformerOptionPane } from '../spatial/optionsHelper';
+
+// Nothing defined in state
+const supplier = (
+ builder: PanelOptionsEditorBuilder,
+ context: StandardEditorContext
+) => {
+ const options = context.options ?? {};
+
+ addHeatmapCalculationOptions('', builder, options);
+};
+
+export const HeatmapTransformerEditor: React.FC> = (props) => {
+ useEffect(() => {
+ if (!props.options.xAxis?.mode) {
+ const opts = getDefaultOptions(supplier);
+ props.onChange({ ...opts, ...props.options });
+ console.log('geometry useEffect', opts);
+ }
+ });
+
+ // Shared with spatial transformer
+ const pane = getTransformerOptionPane(props, supplier);
+ return (
+
+
{pane.items.map((v) => v.render())}
+
+ );
+};
+
+export const heatmapTransformRegistryItem: TransformerRegistryItem = {
+ id: heatmapTransformer.id,
+ editor: HeatmapTransformerEditor,
+ transformation: heatmapTransformer,
+ name: heatmapTransformer.name,
+ description: heatmapTransformer.description,
+ state: PluginState.alpha,
+};
diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx b/public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx
new file mode 100644
index 00000000000..a35e9ba126c
--- /dev/null
+++ b/public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { SelectableValue, StandardEditorProps } from '@grafana/data';
+import { HorizontalGroup, Input, RadioButtonGroup } from '@grafana/ui';
+import { HeatmapCalculationAxisConfig, HeatmapCalculationMode } from '../models.gen';
+
+const modeOptions: Array> = [
+ {
+ label: 'Size',
+ value: HeatmapCalculationMode.Size,
+ description: 'Split the buckets based on size',
+ },
+ {
+ label: 'Count',
+ value: HeatmapCalculationMode.Count,
+ description: 'Split the buckets based on count',
+ },
+];
+
+export const AxisEditor: React.FC> = ({
+ value,
+ onChange,
+ item,
+}) => {
+ return (
+
+ {
+ onChange({
+ ...value,
+ mode,
+ });
+ }}
+ />
+ {
+ onChange({
+ ...value,
+ value: v.currentTarget.value,
+ });
+ }}
+ />
+
+ );
+};
diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts b/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts
new file mode 100644
index 00000000000..5ba5721f43d
--- /dev/null
+++ b/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts
@@ -0,0 +1,33 @@
+import { PanelOptionsEditorBuilder } from '@grafana/data';
+
+import { HeatmapCalculationMode, HeatmapCalculationOptions } from '../models.gen';
+import { AxisEditor } from './AxisEditor';
+
+export function addHeatmapCalculationOptions(
+ prefix: string,
+ builder: PanelOptionsEditorBuilder,
+ source?: HeatmapCalculationOptions,
+ category?: string[]
+) {
+ builder.addCustomEditor({
+ id: 'xAxis',
+ path: `${prefix}xAxis`,
+ name: 'X Buckets',
+ editor: AxisEditor,
+ category,
+ defaultValue: {
+ mode: HeatmapCalculationMode.Size,
+ },
+ });
+
+ builder.addCustomEditor({
+ id: 'yAxis',
+ path: `${prefix}yAxis`,
+ name: 'Y Buckets',
+ editor: AxisEditor,
+ category,
+ defaultValue: {
+ mode: HeatmapCalculationMode.Size,
+ },
+ });
+}
diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts
new file mode 100644
index 00000000000..58303f46cf5
--- /dev/null
+++ b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts
@@ -0,0 +1,23 @@
+import { FieldType } from '@grafana/data';
+import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame';
+import { calculateHeatmapFromData } from './heatmap';
+import { HeatmapCalculationOptions } from './models.gen';
+
+describe('Heatmap transformer', () => {
+ it('calculate heatmap from input data', async () => {
+ const options: HeatmapCalculationOptions = {
+ //
+ };
+
+ const data = toDataFrame({
+ fields: [
+ { name: 'time', type: FieldType.time, values: [1, 2, 3, 4] },
+ { name: 'temp', type: FieldType.number, values: [1.1, 2.2, 3.3, 4.4] },
+ ],
+ });
+
+ const heatmap = calculateHeatmapFromData([data], options);
+
+ expect(heatmap).toBeDefined();
+ });
+});
diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts
new file mode 100644
index 00000000000..1ca19f436ec
--- /dev/null
+++ b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts
@@ -0,0 +1,372 @@
+import {
+ ArrayVector,
+ DataFrame,
+ DataTransformerID,
+ FieldType,
+ incrRoundUp,
+ incrRoundDn,
+ SynchronousDataTransformerInfo,
+ DataFrameType,
+ getFieldDisplayName,
+ Field,
+} from '@grafana/data';
+import { map } from 'rxjs';
+import { HeatmapCalculationMode, HeatmapCalculationOptions } from './models.gen';
+import { niceLinearIncrs, niceTimeIncrs } from './utils';
+
+export interface HeatmapTransformerOptions extends HeatmapCalculationOptions {
+ /** the raw values will still exist in results after transformation */
+ keepOriginalData?: boolean;
+}
+
+export const heatmapTransformer: SynchronousDataTransformerInfo = {
+ id: DataTransformerID.heatmap,
+ name: 'Create heatmap',
+ description: 'calculate heatmap from source data',
+ defaultOptions: {},
+
+ operator: (options) => (source) => source.pipe(map((data) => heatmapTransformer.transformer(options)(data))),
+
+ transformer: (options: HeatmapTransformerOptions) => {
+ return (data: DataFrame[]) => {
+ const v = calculateHeatmapFromData(data, options);
+ if (options.keepOriginalData) {
+ return [v, ...data];
+ }
+ return [v];
+ };
+ },
+};
+
+export function sortAscStrInf(aName?: string | null, bName?: string | null) {
+ let aBound = aName === '+Inf' ? Infinity : +(aName ?? 0);
+ let bBound = bName === '+Inf' ? Infinity : +(bName ?? 0);
+
+ return aBound - bBound;
+}
+
+/** Given existing buckets, create a values style frame */
+export function createHeatmapFromBuckets(frames: DataFrame[]): DataFrame {
+ frames = frames.slice();
+
+ // sort ASC by frame.name (Prometheus bucket bound)
+ // or use frame.fields[1].config.displayNameFromDS ?
+ frames.sort((a, b) => sortAscStrInf(a.name, b.name));
+
+ const bucketBounds = frames.map((frame, i) => {
+ return i; // until we have y ordinal scales working for facets/scatter
+
+ /*
+ let bound: number;
+
+ if (frame.name === '+Inf') {
+ // TODO: until we have labeled y, treat +Inf as previous bucket + 10%
+ bound = +(frames[i - 1].name ?? 0) * 1.1;
+ } else {
+ bound = +(frame.name ?? 0);
+ }
+
+ return bound;
+ */
+ });
+
+ // assumes all Time fields are identical
+ // TODO: handle null-filling w/ fields[0].config.interval?
+ const xField = frames[0].fields[0];
+ const xValues = xField.values.toArray();
+ const yField = frames[0].fields[1];
+
+ // similar to initBins() below
+ const len = xValues.length * bucketBounds.length;
+ const xs = new Array(len);
+ const ys = new Array(len);
+ const counts2 = new Array(len);
+
+ // cumulative counts
+ const counts = frames.map((frame) => frame.fields[1].values.toArray().slice());
+
+ // de-accumulate
+ counts.reverse();
+ counts.forEach((bucketCounts, bi) => {
+ if (bi < counts.length - 1) {
+ for (let i = 0; i < bucketCounts.length; i++) {
+ bucketCounts[i] -= counts[bi + 1][i];
+ }
+ }
+ });
+ counts.reverse();
+
+ // transpose
+ counts.forEach((bucketCounts, bi) => {
+ for (let i = 0; i < bucketCounts.length; i++) {
+ counts2[counts.length * i + bi] = bucketCounts[i];
+ }
+ });
+
+ // fill flat/repeating array
+ for (let i = 0, yi = 0, xi = 0; i < len; yi = ++i % bucketBounds.length) {
+ ys[i] = bucketBounds[yi];
+
+ if (yi === 0 && i >= bucketBounds.length) {
+ xi++;
+ }
+
+ xs[i] = xValues[xi];
+ }
+
+ return {
+ length: xs.length,
+ meta: {
+ type: DataFrameType.HeatmapScanlines,
+ },
+ fields: [
+ {
+ name: 'xMax',
+ type: xField.type,
+ values: new ArrayVector(xs),
+ config: xField.config,
+ },
+ {
+ name: 'yMax',
+ type: FieldType.number,
+ values: new ArrayVector(ys),
+ config: yField.config,
+ },
+ {
+ name: 'count',
+ type: FieldType.number,
+ values: new ArrayVector(counts2),
+ config: {
+ unit: 'short',
+ },
+ },
+ ],
+ };
+}
+
+export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCalculationOptions): DataFrame {
+ //console.time('calculateHeatmapFromData');
+
+ let xs: number[] = [];
+ let ys: number[] = [];
+
+ // optimization
+ //let xMin = Infinity;
+ //let xMax = -Infinity;
+
+ let xField: Field | undefined = undefined;
+ let yField: Field | undefined = undefined;
+
+ for (let frame of frames) {
+ // TODO: assumes numeric timestamps, ordered asc, without nulls
+ const x = frame.fields.find((f) => f.type === FieldType.time);
+ if (!x) {
+ continue;
+ }
+
+ if (!xField) {
+ xField = x; // the first X
+ }
+
+ const xValues = x.values.toArray();
+ for (let field of frame.fields) {
+ if (field !== x && field.type === FieldType.number) {
+ xs = xs.concat(xValues);
+ ys = ys.concat(field.values.toArray());
+
+ if (!yField) {
+ yField = field;
+ }
+ }
+ }
+ }
+
+ if (!xField || !yField) {
+ throw 'no heatmap fields found';
+ }
+
+ const heat2d = heatmap(xs, ys, {
+ xSorted: true,
+ xTime: xField.type === FieldType.time,
+ xMode: options.xAxis?.mode,
+ xSize: +(options.xAxis?.value ?? 0),
+ yMode: options.yAxis?.mode,
+ ySize: +(options.yAxis?.value ?? 0),
+ });
+
+ const frame = {
+ length: heat2d.x.length,
+ name: getFieldDisplayName(yField),
+ meta: {
+ type: DataFrameType.HeatmapScanlines,
+ },
+ fields: [
+ {
+ name: 'xMin',
+ type: xField.type,
+ values: new ArrayVector(heat2d.x),
+ config: xField.config,
+ },
+ {
+ name: 'yMin',
+ type: FieldType.number,
+ values: new ArrayVector(heat2d.y),
+ config: yField.config, // keep units from the original source
+ },
+ {
+ name: 'count',
+ type: FieldType.number,
+ values: new ArrayVector(heat2d.count),
+ config: {},
+ },
+ ],
+ };
+
+ //console.timeEnd('calculateHeatmapFromData');
+
+ //console.log({ tiles: frame.length });
+
+ return frame;
+}
+
+interface HeatmapOpts {
+ // default is 10% of data range, snapped to a "nice" increment
+ xMode?: HeatmapCalculationMode;
+ yMode?: HeatmapCalculationMode;
+ xSize?: number;
+ ySize?: number;
+
+ // use Math.ceil instead of Math.floor for bucketing
+ xCeil?: boolean;
+ yCeil?: boolean;
+
+ // log2 or log10 buckets
+ xLog?: 2 | 10;
+ yLog?: 2 | 10;
+
+ xTime?: boolean;
+ yTime?: boolean;
+
+ // optimization hints for known data ranges (sorted, pre-scanned, etc)
+ xMin?: number;
+ xMax?: number;
+ yMin?: number;
+ yMax?: number;
+
+ xSorted?: boolean;
+ ySorted?: boolean;
+}
+
+// TODO: handle NaN, Inf, -Inf, null, undefined values in xs & ys
+function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) {
+ let len = xs.length;
+
+ let xSorted = opts?.xSorted ?? false;
+ let ySorted = opts?.ySorted ?? false;
+
+ // find x and y limits to pre-compute buckets struct
+ let minX = xSorted ? xs[0] : Infinity;
+ let minY = ySorted ? ys[0] : Infinity;
+ let maxX = xSorted ? xs[len - 1] : -Infinity;
+ let maxY = ySorted ? ys[len - 1] : -Infinity;
+
+ for (let i = 0; i < len; i++) {
+ if (!xSorted) {
+ minX = Math.min(minX, xs[i]);
+ maxX = Math.max(maxX, xs[i]);
+ }
+
+ if (!ySorted) {
+ minY = Math.min(minY, ys[i]);
+ maxY = Math.max(maxY, ys[i]);
+ }
+ }
+
+ //let scaleX = opts?.xLog === 10 ? Math.log10 : opts?.xLog === 2 ? Math.log2 : (v: number) => v;
+ //let scaleY = opts?.yLog === 10 ? Math.log10 : opts?.yLog === 2 ? Math.log2 : (v: number) => v;
+
+ let xBinIncr = opts?.xSize ?? 0;
+ let yBinIncr = opts?.ySize ?? 0;
+ let xMode = opts?.xMode;
+ let yMode = opts?.yMode;
+
+ // fall back to 10 buckets if invalid settings
+ if (!Number.isFinite(xBinIncr) || xBinIncr <= 0) {
+ xMode = HeatmapCalculationMode.Count;
+ xBinIncr = 20;
+ }
+ if (!Number.isFinite(yBinIncr) || yBinIncr <= 0) {
+ yMode = HeatmapCalculationMode.Count;
+ yBinIncr = 10;
+ }
+
+ if (xMode === HeatmapCalculationMode.Count) {
+ // TODO: optionally use view range min/max instead of data range for bucket sizing
+ let approx = (maxX - minX) / Math.max(xBinIncr - 1, 1);
+ // nice-ify
+ let xIncrs = opts?.xTime ? niceTimeIncrs : niceLinearIncrs;
+ let xIncrIdx = xIncrs.findIndex((bucketSize) => bucketSize > approx) - 1;
+ xBinIncr = xIncrs[Math.max(xIncrIdx, 0)];
+ }
+
+ if (yMode === HeatmapCalculationMode.Count) {
+ // TODO: optionally use view range min/max instead of data range for bucket sizing
+ let approx = (maxY - minY) / Math.max(yBinIncr - 1, 1);
+ // nice-ify
+ let yIncrs = opts?.yTime ? niceTimeIncrs : niceLinearIncrs;
+ let yIncrIdx = yIncrs.findIndex((bucketSize) => bucketSize > approx) - 1;
+ yBinIncr = yIncrs[Math.max(yIncrIdx, 0)];
+ }
+
+ // console.log({
+ // yBinIncr,
+ // xBinIncr,
+ // });
+
+ let binX = opts?.xCeil ? (v: number) => incrRoundUp(v, xBinIncr) : (v: number) => incrRoundDn(v, xBinIncr);
+ let binY = opts?.yCeil ? (v: number) => incrRoundUp(v, yBinIncr) : (v: number) => incrRoundDn(v, yBinIncr);
+
+ let minXBin = binX(minX);
+ let maxXBin = binX(maxX);
+ let minYBin = binY(minY);
+ let maxYBin = binY(maxY);
+
+ let xBinQty = Math.round((maxXBin - minXBin) / xBinIncr) + 1;
+ let yBinQty = Math.round((maxYBin - minYBin) / yBinIncr) + 1;
+
+ let [xs2, ys2, counts] = initBins(xBinQty, yBinQty, minXBin, xBinIncr, minYBin, yBinIncr);
+
+ for (let i = 0; i < len; i++) {
+ const xi = (binX(xs[i]) - minXBin) / xBinIncr;
+ const yi = (binY(ys[i]) - minYBin) / yBinIncr;
+ const ci = xi * yBinQty + yi;
+
+ counts[ci]++;
+ }
+
+ return {
+ x: xs2,
+ y: ys2,
+ count: counts,
+ };
+}
+
+function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin: number, yIncr: number) {
+ const len = xQty * yQty;
+ const xs = new Array(len);
+ const ys = new Array(len);
+ const counts = new Array(len);
+
+ for (let i = 0, yi = 0, x = xMin; i < len; yi = ++i % yQty) {
+ counts[i] = 0;
+ ys[i] = yMin + yi * yIncr;
+
+ if (yi === 0 && i >= yQty) {
+ x += xIncr;
+ }
+
+ xs[i] = x;
+ }
+
+ return [xs, ys, counts];
+}
diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts b/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts
new file mode 100644
index 00000000000..eda086f883b
--- /dev/null
+++ b/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts
@@ -0,0 +1,18 @@
+import { DataFrameType } from '@grafana/data';
+
+export enum HeatmapCalculationMode {
+ Size = 'size',
+ Count = 'count',
+}
+
+export interface HeatmapCalculationAxisConfig {
+ mode?: HeatmapCalculationMode;
+ value?: string; // number or interval string ie 10s
+}
+
+export interface HeatmapCalculationOptions {
+ xAxis?: HeatmapCalculationAxisConfig;
+ yAxis?: HeatmapCalculationAxisConfig;
+ xAxisField?: string; // name of the x field
+ encoding?: DataFrameType.HeatmapBuckets | DataFrameType.HeatmapScanlines;
+}
diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts b/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts
new file mode 100644
index 00000000000..3851395f197
--- /dev/null
+++ b/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts
@@ -0,0 +1,125 @@
+const { abs, round, pow } = Math;
+
+export function roundDec(val: number, dec: number) {
+ return round(val * (dec = 10 ** dec)) / dec;
+}
+
+export const fixedDec = new Map();
+
+export function guessDec(num: number) {
+ return (('' + num).split('.')[1] || '').length;
+}
+
+export function genIncrs(base: number, minExp: number, maxExp: number, mults: number[]) {
+ let incrs = [];
+
+ let multDec = mults.map(guessDec);
+
+ for (let exp = minExp; exp < maxExp; exp++) {
+ let expa = abs(exp);
+ let mag = roundDec(pow(base, exp), expa);
+
+ for (let i = 0; i < mults.length; i++) {
+ let _incr = mults[i] * mag;
+ let dec = (_incr >= 0 && exp >= 0 ? 0 : expa) + (exp >= multDec[i] ? 0 : multDec[i]);
+ let incr = roundDec(_incr, dec);
+ incrs.push(incr);
+ fixedDec.set(incr, dec);
+ }
+ }
+
+ return incrs;
+}
+
+const onlyWhole = (v: number) => v % 1 === 0;
+
+const allMults = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5];
+
+// ...0.01, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5...
+export const decIncrs = genIncrs(10, -16, 0, allMults);
+
+// 1, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10, 20, 25, 30, 40, 50...
+export const oneIncrs = genIncrs(10, 0, 16, allMults);
+
+// 1, 2, 3, 4, 5, 10, 20, 25, 50...
+export const wholeIncrs = oneIncrs.filter(onlyWhole);
+
+export const numIncrs = decIncrs.concat(oneIncrs);
+
+export const niceLinearIncrs = decIncrs.concat(wholeIncrs);
+
+const sec = 1 * 1e3;
+const min = 60 * sec;
+const hour = 60 * min;
+const day = 24 * hour;
+const year = 365 * day;
+
+// in milliseconds
+export const niceTimeIncrs = [
+ 1,
+ 2,
+ 4,
+ 5,
+ 10,
+ 20,
+ 25,
+ 40,
+ 50,
+ 100,
+ 200,
+ 250,
+ 400,
+ 500,
+
+ sec,
+ 2 * sec,
+ 4 * sec,
+ 5 * sec,
+ 10 * sec,
+ 15 * sec,
+ 20 * sec,
+ 30 * sec,
+
+ min,
+ 2 * min,
+ 4 * min,
+ 5 * min,
+ 10 * min,
+ 15 * min,
+ 20 * min,
+ 30 * min,
+
+ hour,
+ 2 * hour,
+ 4 * hour,
+ 6 * hour,
+ 8 * hour,
+ 12 * hour,
+ 18 * hour,
+
+ day,
+ 2 * day,
+ 3 * day,
+ 4 * day,
+ 5 * day,
+ 6 * day,
+ 7 * day,
+ 10 * day,
+ 15 * day,
+ 30 * day,
+ 45 * day,
+ 60 * day,
+ 90 * day,
+ 180 * day,
+
+ year,
+ 2 * year,
+ 3 * year,
+ 4 * year,
+ 5 * year,
+ 6 * year,
+ 7 * year,
+ 8 * year,
+ 9 * year,
+ 10 * year,
+];
diff --git a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx b/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx
index 1d2ee131700..07ade5b3824 100644
--- a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx
+++ b/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx
@@ -5,10 +5,9 @@ import { NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
import { set, get as lodashGet } from 'lodash';
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
-import { SpatialTransformOptions } from './models.gen';
export function getTransformerOptionPane(
- props: TransformerUIProps,
+ props: TransformerUIProps,
supplier: PanelOptionsSupplier
): OptionsPaneCategoryDescriptor {
const context: StandardEditorContext = {
diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts
index a8815ae8adf..41f29b975d0 100644
--- a/public/app/core/utils/standardTransformers.ts
+++ b/public/app/core/utils/standardTransformers.ts
@@ -20,6 +20,7 @@ import { prepareTimeseriesTransformerRegistryItem } from '../components/Transfor
import { convertFieldTypeTransformRegistryItem } from '../components/TransformersUI/ConvertFieldTypeTransformerEditor';
import { fieldLookupTransformRegistryItem } from '../components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor';
import { extractFieldsTransformRegistryItem } from '../components/TransformersUI/extractFields/ExtractFieldsTransformerEditor';
+import { heatmapTransformRegistryItem } from '../components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor';
import { spatialTransformRegistryItem } from '../components/TransformersUI/spatial/SpatialTransformerEditor';
export const getStandardTransformers = (): Array> => {
@@ -46,5 +47,6 @@ export const getStandardTransformers = (): Array> =
spatialTransformRegistryItem,
fieldLookupTransformRegistryItem,
extractFieldsTransformRegistryItem,
+ heatmapTransformRegistryItem,
];
};
diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts
index c542aa7dbb4..ebe372a6fff 100644
--- a/public/app/features/plugins/built_in_plugins.ts
+++ b/public/app/features/plugins/built_in_plugins.ts
@@ -50,6 +50,7 @@ import * as dashListPanel from 'app/plugins/panel/dashlist/module';
import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module';
import * as alertListPanel from 'app/plugins/panel/alertlist/module';
import * as annoListPanel from 'app/plugins/panel/annolist/module';
+import * as heatmapPanelNG from 'app/plugins/panel/heatmap-new/module';
import * as tablePanel from 'app/plugins/panel/table/module';
import * as statPanel from 'app/plugins/panel/stat/module';
import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
@@ -113,6 +114,7 @@ const builtInPlugins: any = {
'app/plugins/panel/alertlist/module': alertListPanel,
'app/plugins/panel/annolist/module': annoListPanel,
'app/plugins/panel/heatmap/module': heatmapPanel,
+ 'app/plugins/panel/heatmap-new/module': heatmapPanelNG,
'app/plugins/panel/table/module': tablePanel,
'app/plugins/panel/table-old/module': tableOldPanel,
'app/plugins/panel/news/module': newsPanel,
diff --git a/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
new file mode 100644
index 00000000000..de0f990dd25
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
@@ -0,0 +1,194 @@
+import React, { useEffect, useRef } from 'react';
+import { Field, FieldType, formattedValueToString, LinkModel } from '@grafana/data';
+
+import { HeatmapHoverEvent } from './utils';
+import { BucketLayout, HeatmapData } from './fields';
+import { LinkButton, VerticalGroup } from '@grafana/ui';
+import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
+
+type Props = {
+ data: HeatmapData;
+ hover: HeatmapHoverEvent;
+ showHistogram?: boolean;
+};
+
+export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
+ const xField = data.heatmap?.fields[0];
+ const yField = data.heatmap?.fields[1];
+ const countField = data.heatmap?.fields[2];
+
+ const xDisp = (v: any) => {
+ if (xField?.display) {
+ return formattedValueToString(xField.display(v));
+ }
+ if (xField?.type === FieldType.time) {
+ const tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
+ const dashboard = getDashboardSrv().getCurrent();
+ return dashboard?.formatDate(v, tooltipTimeFormat);
+ }
+ return `${v}XX`;
+ };
+
+ const xVals = xField?.values.toArray();
+ const yVals = yField?.values.toArray();
+ const countVals = countField?.values.toArray();
+
+ let yDispSrc, yDisp;
+
+ // labeled buckets
+ if (data.yAxisValues) {
+ yDispSrc = data.yAxisValues;
+ yDisp = (v: any) => v;
+ } else {
+ yDispSrc = yVals;
+ yDisp = (v: any) => {
+ if (yField?.display) {
+ return formattedValueToString(yField.display(v));
+ }
+ return `${v}YYY`;
+ };
+ }
+
+ const yValueIdx = hover.index % data.yBucketCount! ?? 0;
+
+ const yMinIdx = data.yLayout === BucketLayout.le ? yValueIdx - 1 : yValueIdx;
+ const yMaxIdx = data.yLayout === BucketLayout.le ? yValueIdx : yValueIdx + 1;
+
+ const yBucketMin = yDispSrc?.[yMinIdx];
+ const yBucketMax = yDispSrc?.[yMaxIdx];
+
+ const xBucketMin = xVals?.[hover.index];
+ const xBucketMax = xBucketMin + data.xBucketSize;
+
+ const count = countVals?.[hover.index];
+
+ const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip));
+ const links: Array> = [];
+ const linkLookup = new Set();
+
+ for (const field of visibleFields ?? []) {
+ // TODO: Currently always undefined? (getLinks)
+ if (field.getLinks) {
+ const v = field.values.get(hover.index);
+ const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v };
+
+ field.getLinks({ calculatedValue: disp, valueRowIndex: hover.index }).forEach((link) => {
+ const key = `${link.title}/${link.href}`;
+ if (!linkLookup.has(key)) {
+ links.push(link);
+ linkLookup.add(key);
+ }
+ });
+ }
+ }
+
+ let can = useRef(null);
+
+ let histCssWidth = 150;
+ let histCssHeight = 50;
+ let histCanWidth = Math.round(histCssWidth * devicePixelRatio);
+ let histCanHeight = Math.round(histCssHeight * devicePixelRatio);
+
+ useEffect(
+ () => {
+ if (showHistogram) {
+ let histCtx = can.current?.getContext('2d');
+
+ if (histCtx && xVals && yVals && countVals) {
+ let fromIdx = hover.index;
+
+ while (xVals[fromIdx--] === xVals[hover.index]) {}
+
+ fromIdx++;
+
+ let toIdx = fromIdx + data.yBucketCount!;
+
+ let maxCount = 0;
+
+ let i = fromIdx;
+ while (i < toIdx) {
+ let c = countVals[i];
+ maxCount = Math.max(maxCount, c);
+ i++;
+ }
+
+ let pHov = new Path2D();
+ let pRest = new Path2D();
+
+ i = fromIdx;
+ let j = 0;
+ while (i < toIdx) {
+ let c = countVals[i];
+
+ if (c > 0) {
+ let pctY = c / maxCount;
+ let pctX = j / (data.yBucketCount! + 1);
+
+ let p = i === hover.index ? pHov : pRest;
+
+ p.rect(
+ Math.round(histCanWidth * pctX),
+ Math.round(histCanHeight * (1 - pctY)),
+ Math.round(histCanWidth / data.yBucketCount!),
+ Math.round(histCanHeight * pctY)
+ );
+ }
+
+ i++;
+ j++;
+ }
+
+ histCtx.clearRect(0, 0, histCanWidth, histCanHeight);
+
+ histCtx.fillStyle = '#ffffff80';
+ histCtx.fill(pRest);
+
+ histCtx.fillStyle = '#ff000080';
+ histCtx.fill(pHov);
+ }
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [hover.index]
+ );
+
+ return (
+ <>
+
+
{xDisp(xBucketMin)}
+
{xDisp(xBucketMax)}
+
+ {showHistogram && (
+
+ )}
+
+
+ Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
+
+
Count: {count}
+
+ {links.length > 0 && (
+
+ {links.map((link, i) => (
+
+ {link.title}
+
+ ))}
+
+ )}
+ >
+ );
+};
diff --git a/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
new file mode 100644
index 00000000000..7835e4d3d77
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
@@ -0,0 +1,125 @@
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import { css } from '@emotion/css';
+import { GrafanaTheme2, PanelProps } from '@grafana/data';
+import { Portal, UPlotChart, useStyles2, useTheme2, VizLayout, VizTooltipContainer } from '@grafana/ui';
+import { PanelDataErrorView } from '@grafana/runtime';
+
+import { HeatmapData, prepareHeatmapData } from './fields';
+import { PanelOptions } from './models.gen';
+import { quantizeScheme } from './palettes';
+import { HeatmapHoverEvent, prepConfig } from './utils';
+import { HeatmapHoverView } from './HeatmapHoverView';
+import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
+
+interface HeatmapPanelProps extends PanelProps {}
+
+export const HeatmapPanel: React.FC = ({
+ data,
+ id,
+ timeRange,
+ timeZone,
+ width,
+ height,
+ options,
+ fieldConfig,
+ onChangeTimeRange,
+ replaceVariables,
+}) => {
+ const theme = useTheme2();
+ const styles = useStyles2(getStyles);
+
+ const info = useMemo(() => prepareHeatmapData(data.series, options, theme), [data, options, theme]);
+
+ const facets = useMemo(() => [null, info.heatmap?.fields.map((f) => f.values.toArray())], [info.heatmap]);
+
+ //console.log(facets);
+
+ const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]);
+
+ const [hover, setHover] = useState(undefined);
+ const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false);
+ const isToolTipOpen = useRef(false);
+
+ const onCloseToolTip = () => {
+ isToolTipOpen.current = false;
+ setShouldDisplayCloseButton(false);
+ onhover(null);
+ };
+
+ const onclick = () => {
+ isToolTipOpen.current = !isToolTipOpen.current;
+
+ // Linking into useState required to re-render tooltip
+ setShouldDisplayCloseButton(isToolTipOpen.current);
+ };
+
+ const onhover = useCallback(
+ (evt?: HeatmapHoverEvent | null) => {
+ setHover(evt ?? undefined);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [options, data.structureRev]
+ );
+
+ const dataRef = useRef(info);
+
+ dataRef.current = info;
+
+ const builder = useMemo(() => {
+ return prepConfig({
+ dataRef,
+ theme,
+ onhover: options.tooltip.show ? onhover : () => {},
+ onclick: options.tooltip.show ? onclick : () => {},
+ isToolTipOpen,
+ timeZone,
+ timeRange,
+ palette,
+ cellGap: options.cellGap,
+ hideThreshold: options.hideThreshold,
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [options, data.structureRev]);
+
+ if (info.warning || !info.heatmap) {
+ return ;
+ }
+
+ return (
+ <>
+
+ {(vizWidth: number, vizHeight: number) => (
+ //
+ // {JSON.stringify(scatterData, null, 2)}
+ //
+
+ {/*children ? children(config, alignedFrame) : null*/}
+
+ )}
+
+
+ {hover && (
+
+ {shouldDisplayCloseButton && (
+ <>
+
+
+ >
+ )}
+
+
+ )}
+
+ >
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ closeButtonSpacer: css`
+ margin-bottom: 15px;
+ `,
+});
diff --git a/public/app/plugins/panel/heatmap-new/README.md b/public/app/plugins/panel/heatmap-new/README.md
new file mode 100644
index 00000000000..626bf160c4f
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/README.md
@@ -0,0 +1,7 @@
+# Heatmap Panel (NEXT GEN) - Native Plugin
+
+The Heatmap panel allows you to view histograms over time and is **included** with Grafana.
+
+Read more about it here:
+
+[http://docs.grafana.org/features/panels/heatmap/](http://docs.grafana.org/features/panels/heatmap/)
diff --git a/public/app/plugins/panel/heatmap-new/fields.test.ts b/public/app/plugins/panel/heatmap-new/fields.test.ts
new file mode 100644
index 00000000000..a8c5e7ee18a
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/fields.test.ts
@@ -0,0 +1,13 @@
+import { createTheme } from '@grafana/data';
+import { PanelOptions } from './models.gen';
+
+const theme = createTheme();
+
+describe('Heatmap data', () => {
+ const options: PanelOptions = {} as PanelOptions;
+
+ it('simple test stub', () => {
+ expect(theme).toBeDefined();
+ expect(options).toBeDefined();
+ });
+});
diff --git a/public/app/plugins/panel/heatmap-new/fields.ts b/public/app/plugins/panel/heatmap-new/fields.ts
new file mode 100644
index 00000000000..3a2e0418451
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/fields.ts
@@ -0,0 +1,115 @@
+import { DataFrame, DataFrameType, getDisplayProcessor, GrafanaTheme2 } from '@grafana/data';
+import {
+ calculateHeatmapFromData,
+ createHeatmapFromBuckets,
+ sortAscStrInf,
+} from 'app/core/components/TransformersUI/calculateHeatmap/heatmap';
+import { HeatmapSourceMode, PanelOptions } from './models.gen';
+
+export const enum BucketLayout {
+ le = 'le',
+ ge = 'ge',
+}
+
+export interface HeatmapData {
+ // List of heatmap frames
+ heatmap?: DataFrame;
+
+ yAxisValues?: Array;
+
+ xBucketSize?: number;
+ yBucketSize?: number;
+
+ xBucketCount?: number;
+ yBucketCount?: number;
+
+ xLayout?: BucketLayout;
+ yLayout?: BucketLayout;
+
+ // Errors
+ warning?: string;
+}
+
+export function prepareHeatmapData(
+ frames: DataFrame[] | undefined,
+ options: PanelOptions,
+ theme: GrafanaTheme2
+): HeatmapData {
+ if (!frames?.length) {
+ return {};
+ }
+
+ const { source } = options;
+ if (source === HeatmapSourceMode.Calculate) {
+ // TODO, check for error etc
+ return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), theme);
+ }
+
+ // Find a well defined heatmap
+ let heatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapScanlines);
+ if (heatmap) {
+ return getHeatmapData(heatmap, theme);
+ }
+
+ if (source === HeatmapSourceMode.Data) {
+ // TODO: check for names xMin, yMin etc...
+ return getHeatmapData(createHeatmapFromBuckets(frames), theme);
+ }
+
+ // detect a frame-per-bucket heatmap frame
+ // TODO: improve heuristic? infer from fields[1].labels.le === '+Inf' ?
+ if (frames[0].meta?.custom?.resultType === 'matrix' && frames.some((f) => f.name?.startsWith('+Inf'))) {
+ return {
+ yAxisValues: frames.map((f) => f.name ?? null).sort(sortAscStrInf),
+ ...getHeatmapData(createHeatmapFromBuckets(frames), theme),
+ };
+ }
+
+ // TODO, check for error etc
+ return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), theme);
+}
+
+const getHeatmapData = (frame: DataFrame, theme: GrafanaTheme2): HeatmapData => {
+ if (frame.meta?.type !== DataFrameType.HeatmapScanlines) {
+ return {
+ warning: 'Expected heatmap scanlines format',
+ heatmap: frame,
+ };
+ }
+
+ if (frame.fields.length < 2 || frame.length < 2) {
+ return { heatmap: frame };
+ }
+
+ if (!frame.fields[1].display) {
+ frame.fields[1].display = getDisplayProcessor({ field: frame.fields[1], theme });
+ }
+
+ // infer bucket sizes from data (for now)
+ // the 'heatmap-scanlines' dense frame format looks like:
+ // x: 1,1,1,1,2,2,2,2
+ // y: 3,4,5,6,3,4,5,6
+ // count: 0,0,0,7,0,3,0,1
+
+ const xs = frame.fields[0].values.toArray();
+ const ys = frame.fields[1].values.toArray();
+ const dlen = xs.length;
+
+ // below is literally copy/paste from the pathBuilder code in utils.ts
+ // detect x and y bin qtys by detecting layout repetition in x & y data
+ let yBinQty = dlen - ys.lastIndexOf(ys[0]);
+ let xBinQty = dlen / yBinQty;
+ let yBinIncr = ys[1] - ys[0];
+ let xBinIncr = xs[yBinQty] - xs[0];
+
+ return {
+ heatmap: frame,
+ xBucketSize: xBinIncr,
+ yBucketSize: yBinIncr,
+ xBucketCount: xBinQty,
+ yBucketCount: yBinQty,
+ // TODO: improve heuristic
+ xLayout: frame.fields[0].name === 'xMax' ? BucketLayout.le : BucketLayout.ge,
+ yLayout: frame.fields[1].name === 'yMax' ? BucketLayout.le : BucketLayout.ge,
+ };
+};
diff --git a/public/app/plugins/panel/heatmap-new/img/heatmap.svg b/public/app/plugins/panel/heatmap-new/img/heatmap.svg
new file mode 100644
index 00000000000..25d98a0d5fd
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/img/heatmap.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/app/plugins/panel/heatmap-new/migrations.test.ts b/public/app/plugins/panel/heatmap-new/migrations.test.ts
new file mode 100644
index 00000000000..acbfbbdfc9a
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/migrations.test.ts
@@ -0,0 +1,135 @@
+import { PanelModel, FieldConfigSource } from '@grafana/data';
+import { heatmapChangedHandler } from './migrations';
+
+describe('Heatmap Migrations', () => {
+ let prevFieldConfig: FieldConfigSource;
+
+ beforeEach(() => {
+ prevFieldConfig = {
+ defaults: {},
+ overrides: [],
+ };
+ });
+
+ it('simple heatmap', () => {
+ const old: any = {
+ angular: oldHeatmap,
+ };
+ const panel = {} as PanelModel;
+ panel.options = heatmapChangedHandler(panel, 'heatmap', old, prevFieldConfig);
+ expect(panel).toMatchInlineSnapshot(`
+ Object {
+ "fieldConfig": Object {
+ "defaults": Object {},
+ "overrides": Array [],
+ },
+ "options": Object {
+ "cellGap": 2,
+ "cellSize": 10,
+ "color": Object {
+ "exponent": 0.5,
+ "fill": "dark-orange",
+ "mode": "scheme",
+ "scale": "exponential",
+ "scheme": "Oranges",
+ "steps": 256,
+ },
+ "heatmap": Object {
+ "xAxis": Object {
+ "mode": "count",
+ "value": "100",
+ },
+ "yAxis": Object {
+ "mode": "count",
+ "value": "20",
+ },
+ },
+ "legend": Object {
+ "calcs": Array [],
+ "displayMode": "list",
+ "placement": "bottom",
+ },
+ "showValue": "never",
+ "source": "calculate",
+ "tooltip": Object {
+ "show": true,
+ "yHistogram": true,
+ },
+ "yAxisLabels": "auto",
+ "yAxisReverse": false,
+ },
+ }
+ `);
+ });
+});
+
+const oldHeatmap = {
+ id: 4,
+ gridPos: {
+ x: 0,
+ y: 0,
+ w: 12,
+ h: 8,
+ },
+ type: 'heatmap',
+ title: 'Panel Title',
+ datasource: {
+ uid: '000000051',
+ type: 'testdata',
+ },
+ targets: [
+ {
+ scenarioId: 'random_walk',
+ refId: 'A',
+ datasource: {
+ uid: '000000051',
+ type: 'testdata',
+ },
+ startValue: 0,
+ seriesCount: 5,
+ spread: 10,
+ },
+ ],
+ heatmap: {},
+ cards: {
+ cardPadding: 2,
+ cardRound: 10,
+ },
+ color: {
+ mode: 'spectrum',
+ cardColor: '#b4ff00',
+ colorScale: 'sqrt',
+ exponent: 0.5,
+ colorScheme: 'interpolateBuGn',
+ min: null,
+ max: null,
+ },
+ legend: {
+ show: true,
+ },
+ dataFormat: 'timeseries',
+ yBucketBound: 'auto',
+ reverseYBuckets: false,
+ xAxis: {
+ show: true,
+ },
+ yAxis: {
+ show: true,
+ format: 'short',
+ decimals: null,
+ logBase: 1,
+ splitFactor: null,
+ min: null,
+ max: null,
+ },
+ xBucketSize: null,
+ xBucketNumber: 100,
+ yBucketSize: null,
+ yBucketNumber: 20,
+ tooltip: {
+ show: true,
+ showHistogram: true,
+ },
+ highlightCards: true,
+ hideZeroBuckets: true,
+};
diff --git a/public/app/plugins/panel/heatmap-new/migrations.ts b/public/app/plugins/panel/heatmap-new/migrations.ts
new file mode 100644
index 00000000000..aee97869fb5
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/migrations.ts
@@ -0,0 +1,83 @@
+import { FieldConfigSource, PanelModel, PanelTypeChangedHandler } from '@grafana/data';
+import { LegendDisplayMode, VisibilityMode } from '@grafana/schema';
+import {
+ HeatmapCalculationMode,
+ HeatmapCalculationOptions,
+} from 'app/core/components/TransformersUI/calculateHeatmap/models.gen';
+import { HeatmapSourceMode, PanelOptions, defaultPanelOptions } from './models.gen';
+
+/**
+ * This is called when the panel changes from another panel
+ */
+export const heatmapChangedHandler: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions, prevFieldConfig) => {
+ if (prevPluginId === 'heatmap' && prevOptions.angular) {
+ const { fieldConfig, options } = angularToReactHeatmap({
+ ...prevOptions.angular,
+ fieldConfig: prevFieldConfig,
+ });
+ panel.fieldConfig = fieldConfig; // Mutates the incoming panel
+ return options;
+ }
+ return {};
+};
+
+export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigSource; options: PanelOptions } {
+ const fieldConfig: FieldConfigSource = {
+ defaults: {},
+ overrides: [],
+ };
+
+ const source = angular.dataFormat === 'tsbuckets' ? HeatmapSourceMode.Data : HeatmapSourceMode.Calculate;
+ const heatmap: HeatmapCalculationOptions = {
+ ...defaultPanelOptions.heatmap,
+ };
+
+ if (source === HeatmapSourceMode.Calculate) {
+ if (angular.xBucketSize) {
+ heatmap.xAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.xBucketSize}` };
+ } else if (angular.xBucketNumber) {
+ heatmap.xAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.xBucketNumber}` };
+ }
+
+ if (angular.yBucketSize) {
+ heatmap.yAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.yBucketSize}` };
+ } else if (angular.xBucketNumber) {
+ heatmap.yAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.yBucketNumber}` };
+ }
+ }
+
+ const options: PanelOptions = {
+ source,
+ heatmap,
+ color: {
+ ...defaultPanelOptions.color,
+ steps: 256, // best match with existing colors
+ },
+ cellGap: asNumber(angular.cards?.cardPadding),
+ cellSize: asNumber(angular.cards?.cardRound),
+ yAxisLabels: angular.yBucketBound,
+ yAxisReverse: angular.reverseYBuckets,
+ legend: {
+ displayMode: angular.legend.show ? LegendDisplayMode.List : LegendDisplayMode.Hidden,
+ calcs: [],
+ placement: 'bottom',
+ },
+ showValue: VisibilityMode.Never,
+ tooltip: {
+ show: Boolean(angular.tooltip?.show),
+ yHistogram: Boolean(angular.tooltip?.showHistogram),
+ },
+ };
+
+ return { fieldConfig, options };
+}
+
+function asNumber(v: any): number | undefined {
+ const num = +v;
+ return isNaN(num) ? undefined : num;
+}
+
+export const heatmapMigrationHandler = (panel: PanelModel): Partial => {
+ // Nothing yet
+ return panel.options;
+};
diff --git a/public/app/plugins/panel/heatmap-new/models.cue b/public/app/plugins/panel/heatmap-new/models.cue
new file mode 100644
index 00000000000..1bb9205ed9a
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/models.cue
@@ -0,0 +1,33 @@
+// Copyright 2021 Grafana Labs
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package grafanaschema
+
+Panel: {
+ lineages: [
+ [
+ {
+ PanelOptions: {
+ // anything for now
+ ...
+ }
+ PanelFieldConfig: {
+ // anything for now
+ ...
+ }
+ }
+ ]
+ ]
+ migrations: []
+}
diff --git a/public/app/plugins/panel/heatmap-new/models.gen.ts b/public/app/plugins/panel/heatmap-new/models.gen.ts
new file mode 100644
index 00000000000..5124a5c73c9
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/models.gen.ts
@@ -0,0 +1,92 @@
+//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+// NOTE: This file will be auto generated from models.cue
+// It is currenty hand written but will serve as the target for cuetsy
+//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+import { HideableFieldConfig, LegendDisplayMode, OptionsWithLegend, VisibilityMode } from '@grafana/schema';
+import { HeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/models.gen';
+
+export const modelVersion = Object.freeze([1, 0]);
+
+export enum HeatmapSourceMode {
+ Auto = 'auto',
+ Calculate = 'calculate',
+ Data = 'data', // Use the data as is
+}
+
+export enum HeatmapColorMode {
+ Opacity = 'opacity',
+ Scheme = 'scheme',
+}
+
+export enum HeatmapColorScale {
+ Linear = 'linear',
+ Exponential = 'exponential',
+}
+
+export interface HeatmapColorOptions {
+ mode: HeatmapColorMode;
+ scheme: string; // when in scheme mode -- the d3 scheme name
+ fill: string; // when opacity mode, the target color
+ scale: HeatmapColorScale; // for opacity mode
+ exponent: number; // when scale== sqrt
+ steps: number; // 2-256
+
+ // Clamp the colors to the value range
+ field?: string;
+ min?: number;
+ max?: number;
+}
+
+export interface HeatmapTooltip {
+ show: boolean;
+ yHistogram?: boolean;
+}
+
+export interface PanelOptions extends OptionsWithLegend {
+ source: HeatmapSourceMode;
+
+ color: HeatmapColorOptions;
+ heatmap?: HeatmapCalculationOptions;
+ showValue: VisibilityMode;
+
+ cellGap?: number; // was cardPadding
+ cellSize?: number; // was cardRadius
+
+ hideThreshold?: number; // was hideZeroBuckets
+ yAxisLabels?: string;
+ yAxisReverse?: boolean;
+
+ tooltip: HeatmapTooltip;
+}
+
+export const defaultPanelOptions: PanelOptions = {
+ source: HeatmapSourceMode.Auto,
+ color: {
+ mode: HeatmapColorMode.Scheme,
+ scheme: 'Oranges',
+ fill: 'dark-orange',
+ scale: HeatmapColorScale.Exponential,
+ exponent: 0.5,
+ steps: 64,
+ },
+ showValue: VisibilityMode.Auto,
+ legend: {
+ displayMode: LegendDisplayMode.Hidden,
+ placement: 'bottom',
+ calcs: [],
+ },
+ tooltip: {
+ show: true,
+ yHistogram: false,
+ },
+ cellGap: 3,
+};
+
+export interface PanelFieldConfig extends HideableFieldConfig {
+ // TODO points vs lines etc
+}
+
+export const defaultPanelFieldConfig: PanelFieldConfig = {
+ // default to points?
+};
diff --git a/public/app/plugins/panel/heatmap-new/module.tsx b/public/app/plugins/panel/heatmap-new/module.tsx
new file mode 100644
index 00000000000..6ab1eef3663
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/module.tsx
@@ -0,0 +1,223 @@
+import { GraphFieldConfig, VisibilityMode } from '@grafana/schema';
+import { Field, FieldType, PanelPlugin } from '@grafana/data';
+import { commonOptionsBuilder } from '@grafana/ui';
+import { HeatmapPanel } from './HeatmapPanel';
+import {
+ PanelOptions,
+ defaultPanelOptions,
+ HeatmapSourceMode,
+ HeatmapColorMode,
+ HeatmapColorScale,
+} from './models.gen';
+import { HeatmapSuggestionsSupplier } from './suggestions';
+import { heatmapChangedHandler } from './migrations';
+import { addHeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/editor/helper';
+import { colorSchemes } from './palettes';
+
+export const plugin = new PanelPlugin(HeatmapPanel)
+ .useFieldConfig()
+ .setPanelChangeHandler(heatmapChangedHandler)
+ // .setMigrationHandler(heatmapMigrationHandler)
+ .setPanelOptions((builder, context) => {
+ const opts = context.options ?? defaultPanelOptions;
+
+ let category = ['Heatmap'];
+
+ builder.addRadio({
+ path: 'source',
+ name: 'Source',
+ defaultValue: HeatmapSourceMode.Auto,
+ category,
+ settings: {
+ options: [
+ { label: 'Auto', value: HeatmapSourceMode.Auto },
+ { label: 'Calculate', value: HeatmapSourceMode.Calculate },
+ { label: 'Raw data', description: 'The results are already heatmap buckets', value: HeatmapSourceMode.Data },
+ ],
+ },
+ });
+
+ if (opts.source === HeatmapSourceMode.Calculate) {
+ addHeatmapCalculationOptions('heatmap.', builder, opts.heatmap, category);
+ } else if (opts.source === HeatmapSourceMode.Data) {
+ // builder.addSliderInput({
+ // name: 'heatmap from the data...',
+ // path: 'xxx',
+ // });
+ }
+
+ category = ['Colors'];
+
+ builder.addFieldNamePicker({
+ path: `color.field`,
+ name: 'Color with field',
+ category,
+ settings: {
+ filter: (f: Field) => f.type === FieldType.number,
+ noFieldsMessage: 'No numeric fields found',
+ placeholderText: 'Auto',
+ },
+ });
+
+ builder.addRadio({
+ path: `color.mode`,
+ name: 'Mode',
+ defaultValue: defaultPanelOptions.color.mode,
+ category,
+ settings: {
+ options: [
+ { label: 'Scheme', value: HeatmapColorMode.Scheme },
+ { label: 'Opacity', value: HeatmapColorMode.Opacity },
+ ],
+ },
+ });
+
+ builder.addColorPicker({
+ path: `color.fill`,
+ name: 'Color',
+ defaultValue: defaultPanelOptions.color.fill,
+ category,
+ showIf: (opts) => opts.color.mode === HeatmapColorMode.Opacity,
+ });
+
+ builder.addRadio({
+ path: `color.scale`,
+ name: 'Scale',
+ description: '',
+ defaultValue: defaultPanelOptions.color.scale,
+ category,
+ settings: {
+ options: [
+ { label: 'Exponential', value: HeatmapColorScale.Exponential },
+ { label: 'Linear', value: HeatmapColorScale.Linear },
+ ],
+ },
+ showIf: (opts) => opts.color.mode === HeatmapColorMode.Opacity,
+ });
+
+ builder.addSliderInput({
+ path: 'color.exponent',
+ name: 'Exponent',
+ defaultValue: defaultPanelOptions.color.exponent,
+ category,
+ settings: {
+ min: 0.1, // 1 for on/off?
+ max: 2,
+ step: 0.1,
+ },
+ showIf: (opts) =>
+ opts.color.mode === HeatmapColorMode.Opacity && opts.color.scale === HeatmapColorScale.Exponential,
+ });
+
+ builder.addSelect({
+ path: `color.scheme`,
+ name: 'Scheme',
+ description: '',
+ defaultValue: defaultPanelOptions.color.scheme,
+ category,
+ settings: {
+ options: colorSchemes.map((scheme) => ({
+ value: scheme.name,
+ label: scheme.name,
+ //description: 'Set a geometry field based on the results of other fields',
+ })),
+ },
+ showIf: (opts) => opts.color.mode !== HeatmapColorMode.Opacity,
+ });
+
+ builder.addSliderInput({
+ path: 'color.steps',
+ name: 'Max steps',
+ defaultValue: defaultPanelOptions.color.steps,
+ category,
+ settings: {
+ min: 2, // 1 for on/off?
+ max: 128,
+ step: 1,
+ },
+ });
+
+ category = ['Display'];
+
+ builder
+ .addRadio({
+ path: 'showValue',
+ name: 'Show values',
+ defaultValue: defaultPanelOptions.showValue,
+ category,
+ settings: {
+ options: [
+ { value: VisibilityMode.Auto, label: 'Auto' },
+ { value: VisibilityMode.Always, label: 'Always' },
+ { value: VisibilityMode.Never, label: 'Never' },
+ ],
+ },
+ })
+ .addNumberInput({
+ path: 'hideThreshold',
+ name: 'Hide cell counts <=',
+ defaultValue: 0.000_000_001, // 1e-9
+ category,
+ })
+ .addSliderInput({
+ name: 'Cell gap',
+ path: 'cellGap',
+ defaultValue: defaultPanelOptions.cellGap,
+ category,
+ settings: {
+ min: 0,
+ max: 25,
+ },
+ })
+ // .addSliderInput({
+ // name: 'Cell radius',
+ // path: 'cellRadius',
+ // defaultValue: defaultPanelOptions.cellRadius,
+ // category,
+ // settings: {
+ // min: 0,
+ // max: 100,
+ // },
+ // })
+ .addRadio({
+ path: 'yAxisLabels',
+ name: 'Axis labels',
+ defaultValue: 'auto',
+ category,
+ settings: {
+ options: [
+ { value: 'auto', label: 'Auto' },
+ { value: 'middle', label: 'Middle' },
+ { value: 'bottom', label: 'Bottom' },
+ { value: 'top', label: 'Top' },
+ ],
+ },
+ })
+ .addBooleanSwitch({
+ path: 'yAxisReverse',
+ name: 'Reverse buckets',
+ defaultValue: defaultPanelOptions.yAxisReverse === true,
+ category,
+ });
+
+ category = ['Tooltip'];
+
+ builder.addBooleanSwitch({
+ path: 'tooltip.show',
+ name: 'Show tooltip',
+ defaultValue: defaultPanelOptions.tooltip.show,
+ category,
+ });
+
+ builder.addBooleanSwitch({
+ path: 'tooltip.yHistogram',
+ name: 'Show histogram (Y axis)',
+ defaultValue: defaultPanelOptions.tooltip.yHistogram,
+ category,
+ showIf: (opts) => opts.tooltip.show,
+ });
+
+ // custom legend?
+ commonOptionsBuilder.addLegendOptions(builder);
+ })
+ .setSuggestionsSupplier(new HeatmapSuggestionsSupplier());
diff --git a/public/app/plugins/panel/heatmap-new/palettes.ts b/public/app/plugins/panel/heatmap-new/palettes.ts
new file mode 100644
index 00000000000..50fa0a54f9b
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/palettes.ts
@@ -0,0 +1,107 @@
+import { GrafanaTheme2 } from '@grafana/data';
+import * as d3 from 'd3';
+import * as d3ScaleChromatic from 'd3-scale-chromatic';
+import { HeatmapColorOptions, defaultPanelOptions, HeatmapColorMode, HeatmapColorScale } from './models.gen';
+import tinycolor from 'tinycolor2';
+
+// https://observablehq.com/@d3/color-schemes?collection=@d3/d3-scale-chromatic
+
+// the previous heatmap panel used d3 deps and some code to interpolate to static 9-color palettes. here we just hard-code them for clarity.
+// if the need arises for configurable-sized palettes, we can bring back the deps & variable interpolation (see simplified code at end)
+
+// Schemes from d3-scale-chromatic
+// https://github.com/d3/d3-scale-chromatic
+export const colorSchemes = [
+ // Diverging
+ { name: 'BrBG', invert: 'always' },
+ { name: 'PiYG', invert: 'always' },
+ { name: 'PRGn', invert: 'always' },
+ { name: 'PuOr', invert: 'always' },
+ { name: 'RdBu', invert: 'always' },
+ { name: 'RdGy', invert: 'always' },
+ { name: 'RdYlBu', invert: 'always' },
+ { name: 'RdYlGn', invert: 'always' },
+ { name: 'Spectral', invert: 'always' },
+
+ // Sequential (Single Hue)
+ { name: 'Blues', invert: 'dark' },
+ { name: 'Greens', invert: 'dark' },
+ { name: 'Greys', invert: 'dark' },
+ { name: 'Oranges', invert: 'dark' },
+ { name: 'Purples', invert: 'dark' },
+ { name: 'Reds', invert: 'dark' },
+
+ // Sequential (Multi-Hue)
+ { name: 'Turbo', invert: 'light' },
+ { name: 'Cividis', invert: 'light' },
+ { name: 'Viridis', invert: 'light' },
+ { name: 'Magma', invert: 'light' },
+ { name: 'Inferno', invert: 'light' },
+ { name: 'Plasma', invert: 'light' },
+ { name: 'Warm', invert: 'light' },
+ { name: 'Cool', invert: 'light' },
+ { name: 'Cubehelix', invert: 'light', name2: 'CubehelixDefault' },
+ { name: 'BuGn', invert: 'dark' },
+ { name: 'BuPu', invert: 'dark' },
+ { name: 'GnBu', invert: 'dark' },
+ { name: 'OrRd', invert: 'dark' },
+ { name: 'PuBuGn', invert: 'dark' },
+ { name: 'PuBu', invert: 'dark' },
+ { name: 'PuRd', invert: 'dark' },
+ { name: 'RdPu', invert: 'dark' },
+ { name: 'YlGnBu', invert: 'dark' },
+ { name: 'YlGn', invert: 'dark' },
+ { name: 'YlOrBr', invert: 'dark' },
+ { name: 'YlOrRd', invert: 'dark' },
+
+ // Cyclical
+ { name: 'Rainbow', invert: 'always' },
+ { name: 'Sinebow', invert: 'always' },
+];
+
+type Interpolator = (t: number) => string;
+
+const DEFAULT_SCHEME = colorSchemes.find((scheme) => scheme.name === 'Spectral');
+
+export function quantizeScheme(opts: HeatmapColorOptions, theme: GrafanaTheme2): string[] {
+ const options = { ...defaultPanelOptions.color, ...opts };
+ const palette = [];
+ const steps = (options.steps ?? 128) - 1;
+
+ if (opts.mode === HeatmapColorMode.Opacity) {
+ const fill = tinycolor(theme.visualization.getColorByName(opts.fill)).toPercentageRgb();
+
+ const scale =
+ options.scale === HeatmapColorScale.Exponential
+ ? d3.scalePow().exponent(options.exponent).domain([0, 1]).range([0, 1])
+ : d3.scaleLinear().domain([0, 1]).range([0, 1]);
+
+ for (let i = 0; i <= steps; i++) {
+ fill.a = scale(i / steps);
+ palette.push(tinycolor(fill).toString('hex8'));
+ }
+ } else {
+ const scheme = colorSchemes.find((scheme) => scheme.name === options.scheme) ?? DEFAULT_SCHEME!;
+ let fnName = 'interpolate' + (scheme.name2 ?? scheme.name);
+ const interpolate: Interpolator = (d3ScaleChromatic as any)[fnName];
+
+ for (let i = 0; i <= steps; i++) {
+ let rgbStr = interpolate(i / steps);
+ let rgb =
+ rgbStr.indexOf('rgb') === 0
+ ? '#' + [...rgbStr.matchAll(/\d+/g)].map((v) => (+v[0]).toString(16).padStart(2, '0')).join('')
+ : rgbStr;
+ palette.push(rgb);
+ }
+
+ if (
+ scheme.invert === 'always' ||
+ (scheme.invert === 'dark' && theme.isDark) ||
+ (scheme.invert === 'light' && theme.isLight)
+ ) {
+ palette.reverse();
+ }
+ }
+
+ return palette;
+}
diff --git a/public/app/plugins/panel/heatmap-new/plugin.json b/public/app/plugins/panel/heatmap-new/plugin.json
new file mode 100644
index 00000000000..4a320c089fb
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/plugin.json
@@ -0,0 +1,18 @@
+{
+ "type": "panel",
+ "name": "Heatmap (preview)",
+ "id": "heatmap-new",
+ "state": "alpha",
+
+ "info": {
+ "description": "Next generation heatmap visualization",
+ "author": {
+ "name": "Grafana Labs",
+ "url": "https://grafana.com"
+ },
+ "logos": {
+ "small": "img/heatmap.svg",
+ "large": "img/heatmap.svg"
+ }
+ }
+}
diff --git a/public/app/plugins/panel/heatmap-new/suggestions.ts b/public/app/plugins/panel/heatmap-new/suggestions.ts
new file mode 100644
index 00000000000..508c88b0040
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/suggestions.ts
@@ -0,0 +1,37 @@
+import { VisualizationSuggestionsBuilder } from '@grafana/data';
+import { config } from '@grafana/runtime';
+import { prepareHeatmapData } from './fields';
+import { PanelOptions, defaultPanelOptions } from './models.gen';
+
+export class HeatmapSuggestionsSupplier {
+ getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
+ const { dataSummary } = builder;
+
+ if (
+ !builder.data?.series ||
+ !dataSummary.hasData ||
+ dataSummary.timeFieldCount < 1 ||
+ dataSummary.numberFieldCount < 2 ||
+ dataSummary.numberFieldCount > 10
+ ) {
+ return;
+ }
+
+ const info = prepareHeatmapData(builder.data.series, defaultPanelOptions, config.theme2);
+ if (!info || info.warning) {
+ return;
+ }
+
+ builder.getListAppender({
+ name: '',
+ pluginId: 'heatmap-new',
+ options: {},
+ fieldConfig: {
+ defaults: {
+ custom: {},
+ },
+ overrides: [],
+ },
+ });
+ }
+}
diff --git a/public/app/plugins/panel/heatmap-new/utils.ts b/public/app/plugins/panel/heatmap-new/utils.ts
new file mode 100644
index 00000000000..caca5fc1dc0
--- /dev/null
+++ b/public/app/plugins/panel/heatmap-new/utils.ts
@@ -0,0 +1,395 @@
+import { MutableRefObject, RefObject } from 'react';
+import { GrafanaTheme2, TimeRange } from '@grafana/data';
+import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
+import { UPlotConfigBuilder } from '@grafana/ui';
+import uPlot from 'uplot';
+
+import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
+import { BucketLayout, HeatmapData } from './fields';
+
+interface PathbuilderOpts {
+ each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
+ gap?: number | null;
+ hideThreshold?: number;
+ xCeil?: boolean;
+ yCeil?: boolean;
+ disp: {
+ fill: {
+ values: (u: uPlot, seriesIndex: number) => number[];
+ index: Array;
+ };
+ };
+}
+
+export interface HeatmapHoverEvent {
+ index: number;
+ pageX: number;
+ pageY: number;
+}
+
+interface PrepConfigOpts {
+ dataRef: RefObject;
+ theme: GrafanaTheme2;
+ onhover: (evt?: HeatmapHoverEvent | null) => void;
+ onclick: (evt?: any) => void;
+ isToolTipOpen: MutableRefObject;
+ timeZone: string;
+ timeRange: TimeRange; // should be getTimeRange() cause dynamic?
+ palette: string[];
+ cellGap?: number | null; // in css pixels
+ hideThreshold?: number;
+}
+
+export function prepConfig(opts: PrepConfigOpts) {
+ const { dataRef, theme, onhover, onclick, isToolTipOpen, timeZone, timeRange, palette, cellGap, hideThreshold } =
+ opts;
+
+ let qt: Quadtree;
+ let hRect: Rect | null;
+
+ let builder = new UPlotConfigBuilder(timeZone);
+
+ let rect: DOMRect;
+
+ builder.addHook('init', (u) => {
+ u.root.querySelectorAll('.u-cursor-pt').forEach((el) => {
+ Object.assign((el as HTMLElement).style, {
+ borderRadius: '0',
+ border: '1px solid white',
+ background: 'transparent',
+ });
+ });
+ u.over.addEventListener('click', onclick);
+ });
+
+ // rect of .u-over (grid area)
+ builder.addHook('syncRect', (u, r) => {
+ rect = r;
+ });
+
+ let pendingOnleave = 0;
+
+ builder.addHook('setLegend', (u) => {
+ if (u.cursor.idxs != null) {
+ for (let i = 0; i < u.cursor.idxs.length; i++) {
+ const sel = u.cursor.idxs[i];
+ if (sel != null && !isToolTipOpen.current) {
+ if (pendingOnleave) {
+ clearTimeout(pendingOnleave);
+ pendingOnleave = 0;
+ }
+
+ onhover({
+ index: sel,
+ pageX: rect.left + u.cursor.left!,
+ pageY: rect.top + u.cursor.top!,
+ });
+
+ return; // only show the first one
+ }
+ }
+ }
+
+ if (!isToolTipOpen.current) {
+ // if tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms)
+ if (!pendingOnleave) {
+ pendingOnleave = setTimeout(() => onhover(null), 100) as any;
+ }
+ }
+ });
+
+ builder.addHook('drawClear', (u) => {
+ qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
+
+ qt.clear();
+
+ // force-clear the path cache to cause drawBars() to rebuild new quadtree
+ u.series.forEach((s, i) => {
+ if (i > 0) {
+ // @ts-ignore
+ s._paths = null;
+ }
+ });
+ });
+
+ builder.setMode(2);
+
+ builder.addScale({
+ scaleKey: 'x',
+ isTime: true,
+ orientation: ScaleOrientation.Horizontal,
+ direction: ScaleDirection.Right,
+ // TODO: expand by x bucket size and layout
+ range: [timeRange.from.valueOf(), timeRange.to.valueOf()],
+ });
+
+ builder.addAxis({
+ scaleKey: 'x',
+ placement: AxisPlacement.Bottom,
+ theme: theme,
+ });
+
+ builder.addScale({
+ scaleKey: 'y',
+ isTime: false,
+ // distribution: ScaleDistribution.Ordinal, // does not work with facets/scatter yet
+ orientation: ScaleOrientation.Vertical,
+ direction: ScaleDirection.Up,
+ range: (u, dataMin, dataMax) => {
+ let bucketSize = dataRef.current?.yBucketSize;
+
+ if (dataRef.current?.yLayout === BucketLayout.le) {
+ dataMin -= bucketSize!;
+ } else {
+ dataMax += bucketSize!;
+ }
+
+ return [dataMin, dataMax];
+ },
+ });
+
+ const hasLabeledY = dataRef.current?.yAxisValues != null;
+
+ builder.addAxis({
+ scaleKey: 'y',
+ placement: AxisPlacement.Left,
+ theme: theme,
+ splits: hasLabeledY
+ ? () => {
+ let ys = dataRef.current?.heatmap?.fields[1].values.toArray()!;
+ let splits = ys.slice(0, ys.length - ys.lastIndexOf(ys[0]));
+
+ let bucketSize = dataRef.current?.yBucketSize!;
+
+ if (dataRef.current?.yLayout === BucketLayout.le) {
+ splits.unshift(ys[0] - bucketSize);
+ } else {
+ splits.push(ys[ys.length - 1] + bucketSize);
+ }
+
+ return splits;
+ }
+ : undefined,
+ values: hasLabeledY
+ ? () => {
+ let yAxisValues = dataRef.current?.yAxisValues?.slice()!;
+
+ if (dataRef.current?.yLayout === BucketLayout.le) {
+ yAxisValues.unshift('0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish
+ } else {
+ yAxisValues.push('+Inf');
+ }
+
+ return yAxisValues;
+ }
+ : undefined,
+ });
+
+ builder.addSeries({
+ facets: [
+ {
+ scale: 'x',
+ auto: true,
+ sorted: 1,
+ },
+ {
+ scale: 'y',
+ auto: true,
+ },
+ ],
+ pathBuilder: heatmapPaths({
+ each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => {
+ qt.add({
+ x: x - u.bbox.left,
+ y: y - u.bbox.top,
+ w: xSize,
+ h: ySize,
+ sidx: seriesIdx,
+ didx: dataIdx,
+ });
+ },
+ gap: cellGap,
+ hideThreshold,
+ xCeil: dataRef.current?.xLayout === BucketLayout.le,
+ yCeil: dataRef.current?.yLayout === BucketLayout.le,
+ disp: {
+ fill: {
+ values: (u, seriesIdx) => countsToFills(u, seriesIdx, palette),
+ index: palette,
+ },
+ },
+ }) as any,
+ theme,
+ scaleKey: '', // facets' scales used (above)
+ });
+
+ builder.setCursor({
+ dataIdx: (u, seriesIdx) => {
+ if (seriesIdx === 1) {
+ hRect = null;
+
+ let cx = u.cursor.left! * devicePixelRatio;
+ let cy = u.cursor.top! * devicePixelRatio;
+
+ qt.get(cx, cy, 1, 1, (o) => {
+ if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
+ hRect = o;
+ }
+ });
+ }
+
+ return hRect && seriesIdx === hRect.sidx ? hRect.didx : null;
+ },
+ points: {
+ fill: 'rgba(255,255,255, 0.3)',
+ bbox: (u, seriesIdx) => {
+ let isHovered = hRect && seriesIdx === hRect.sidx;
+
+ return {
+ left: isHovered ? hRect!.x / devicePixelRatio : -10,
+ top: isHovered ? hRect!.y / devicePixelRatio : -10,
+ width: isHovered ? hRect!.w / devicePixelRatio : 0,
+ height: isHovered ? hRect!.h / devicePixelRatio : 0,
+ };
+ },
+ },
+ });
+
+ return builder;
+}
+
+export function heatmapPaths(opts: PathbuilderOpts) {
+ const { disp, each, gap, hideThreshold = 0, xCeil = false, yCeil = false } = opts;
+
+ return (u: uPlot, seriesIdx: number) => {
+ uPlot.orient(
+ u,
+ seriesIdx,
+ (
+ series,
+ dataX,
+ dataY,
+ scaleX,
+ scaleY,
+ valToPosX,
+ valToPosY,
+ xOff,
+ yOff,
+ xDim,
+ yDim,
+ moveTo,
+ lineTo,
+ rect,
+ arc
+ ) => {
+ let d = u.data[seriesIdx];
+ const xs = d[0] as unknown as number[];
+ const ys = d[1] as unknown as number[];
+ const counts = d[2] as unknown as number[];
+ const dlen = xs.length;
+
+ // fill colors are mapped from interpolating densities / counts along some gradient
+ // (should be quantized to 64 colors/levels max. e.g. 16)
+ let fills = disp.fill.values(u, seriesIdx);
+ let fillPalette = disp.fill.index ?? [...new Set(fills)];
+
+ let fillPaths = fillPalette.map((color) => new Path2D());
+
+ // detect x and y bin qtys by detecting layout repetition in x & y data
+ let yBinQty = dlen - ys.lastIndexOf(ys[0]);
+ let xBinQty = dlen / yBinQty;
+ let yBinIncr = ys[1] - ys[0];
+ let xBinIncr = xs[yBinQty] - xs[0];
+
+ // uniform tile sizes based on zoom level
+ let xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff));
+ let ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff));
+
+ const autoGapFactor = 0.05;
+
+ // tile gap control
+ let xGap = gap != null ? gap * devicePixelRatio : Math.max(0, autoGapFactor * Math.min(xSize, ySize));
+ let yGap = xGap;
+
+ // clamp min tile size to 1px
+ xSize = Math.max(1, Math.round(xSize - xGap));
+ ySize = Math.max(1, Math.round(ySize - yGap));
+
+ // bucket agg direction
+ // let xCeil = false;
+ // let yCeil = false;
+
+ let xOffset = xCeil ? -xSize : 0;
+ let yOffset = yCeil ? 0 : -ySize;
+
+ // pre-compute x and y offsets
+ let cys = ys.slice(0, yBinQty).map((y) => Math.round(valToPosY(y, scaleY, yDim, yOff) + yOffset));
+ let cxs = Array.from({ length: xBinQty }, (v, i) =>
+ Math.round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) + xOffset)
+ );
+
+ for (let i = 0; i < dlen; i++) {
+ // filter out 0 counts and out of view
+ if (
+ counts[i] > hideThreshold &&
+ xs[i] + xBinIncr >= scaleX.min! &&
+ xs[i] - xBinIncr <= scaleX.max! &&
+ ys[i] + yBinIncr >= scaleY.min! &&
+ ys[i] - yBinIncr <= scaleY.max!
+ ) {
+ let cx = cxs[~~(i / yBinQty)];
+ let cy = cys[i % yBinQty];
+
+ let fillPath = fillPaths[fills[i]];
+
+ rect(fillPath, cx, cy, xSize, ySize);
+
+ each(u, 1, i, cx, cy, xSize, ySize);
+ }
+ }
+
+ u.ctx.save();
+ // u.ctx.globalAlpha = 0.8;
+ u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
+ u.ctx.clip();
+ fillPaths.forEach((p, i) => {
+ u.ctx.fillStyle = fillPalette[i];
+ u.ctx.fill(p);
+ });
+ u.ctx.restore();
+
+ return null;
+ }
+ );
+ };
+}
+
+export const countsToFills = (u: uPlot, seriesIdx: number, palette: string[]) => {
+ let counts = u.data[seriesIdx][2] as unknown as number[];
+
+ // TODO: integrate 1e-9 hideThreshold?
+ const hideThreshold = 0;
+
+ let minCount = Infinity;
+ let maxCount = -Infinity;
+
+ for (let i = 0; i < counts.length; i++) {
+ if (counts[i] > hideThreshold) {
+ minCount = Math.min(minCount, counts[i]);
+ maxCount = Math.max(maxCount, counts[i]);
+ }
+ }
+
+ let range = maxCount - minCount;
+
+ let paletteSize = palette.length;
+
+ let indexedFills = Array(counts.length);
+
+ for (let i = 0; i < counts.length; i++) {
+ indexedFills[i] =
+ counts[i] === 0 ? -1 : Math.min(paletteSize - 1, Math.floor((paletteSize * (counts[i] - minCount)) / range));
+ }
+
+ return indexedFills;
+};
From 10b47480ebda29de1fe78feacdcdfe640df09794 Mon Sep 17 00:00:00 2001
From: ying-jeanne <74549700+ying-jeanne@users.noreply.github.com>
Date: Thu, 17 Feb 2022 16:31:26 +0800
Subject: [PATCH 027/104] add dashboardsnap as service of http server (#45461)
---
pkg/api/dashboard_snapshot.go | 15 +++++++--------
pkg/api/dashboard_snapshot_test.go | 3 ++-
pkg/api/http_server.go | 4 ++++
.../dashboardsnapshots/dashboardsnapshots.go | 4 ++--
4 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go
index 5179dc0094f..1d5c45cc5f7 100644
--- a/pkg/api/dashboard_snapshot.go
+++ b/pkg/api/dashboard_snapshot.go
@@ -133,7 +133,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
metrics.MApiDashboardSnapshotCreate.Inc()
}
- if err := hs.SQLStore.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil {
+ if err := hs.DashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil {
c.JsonApiErr(500, "Failed to create snapshot", err)
return nil
}
@@ -157,7 +157,7 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon
query := &models.GetDashboardSnapshotQuery{Key: key}
- err := hs.SQLStore.GetDashboardSnapshot(query)
+ err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Error(500, "Failed to get dashboard snapshot", err)
}
@@ -224,8 +224,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r
}
query := &models.GetDashboardSnapshotQuery{DeleteKey: key}
-
- err := hs.SQLStore.GetDashboardSnapshot(query)
+ err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Error(500, "Failed to get dashboard snapshot", err)
}
@@ -239,7 +238,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r
cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
- if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
+ if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
@@ -258,7 +257,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
query := &models.GetDashboardSnapshotQuery{Key: key}
- err := hs.SQLStore.GetDashboardSnapshot(query)
+ err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Error(500, "Failed to get dashboard snapshot", err)
}
@@ -287,7 +286,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
- if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
+ if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
@@ -313,7 +312,7 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re
SignedInUser: c.SignedInUser,
}
- err := hs.SQLStore.SearchDashboardSnapshots(&searchQuery)
+ err := hs.DashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery)
if err != nil {
return response.Error(500, "Search failed", err)
}
diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go
index 5bf2ae90c4b..434714ba131 100644
--- a/pkg/api/dashboard_snapshot_test.go
+++ b/pkg/api/dashboard_snapshot_test.go
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -33,7 +34,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
editorRole := models.ROLE_EDITOR
sqlmock := mockstore.NewSQLStoreMock()
aclMockResp := []*models.DashboardAclInfoDTO{}
- hs := &HTTPServer{SQLStore: sqlmock}
+ hs := &HTTPServer{DashboardsnapshotsService: &dashboardsnapshots.Service{SQLStore: sqlmock}}
setUpSnapshotTest := func(t *testing.T) *models.DashboardSnapshot {
t.Helper()
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 8fdd76cb7d7..b665b67f961 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -34,6 +34,7 @@ import (
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/encryption"
@@ -136,6 +137,7 @@ type HTTPServer struct {
folderService dashboards.FolderService
DatasourcePermissionsService DatasourcePermissionsService
AlertNotificationService *alerting.AlertNotificationService
+ DashboardsnapshotsService *dashboardsnapshots.Service
}
type ServerOptions struct {
@@ -166,6 +168,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
+ dashboardsnapshotsService *dashboardsnapshots.Service,
) (*HTTPServer, error) {
web.Env = cfg.Env
m := web.New()
@@ -232,6 +235,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
folderService: folderService,
DatasourcePermissionsService: datasourcePermissionsService,
AlertNotificationService: alertNotificationService,
+ DashboardsnapshotsService: dashboardsnapshotsService,
}
if hs.Listener != nil {
hs.log.Debug("Using provided listener")
diff --git a/pkg/services/dashboardsnapshots/dashboardsnapshots.go b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
index 48dfb5ab2f1..990e60bb818 100644
--- a/pkg/services/dashboardsnapshots/dashboardsnapshots.go
+++ b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
@@ -12,11 +12,11 @@ import (
type Service struct {
Bus bus.Bus
- SQLStore *sqlstore.SQLStore
+ SQLStore sqlstore.Store
SecretsService secrets.Service
}
-func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service) *Service {
+func ProvideService(bus bus.Bus, store sqlstore.Store, secretsService secrets.Service) *Service {
s := &Service{
Bus: bus,
SQLStore: store,
From 3e53a08090ad8ac61d7d316b97a93a7a5ea63a57 Mon Sep 17 00:00:00 2001
From: Artur Wierzbicki
Date: Thu, 17 Feb 2022 12:34:07 +0400
Subject: [PATCH 028/104] #44449: hide dashboard preview admin UI (#45493)
---
packages/grafana-data/src/types/featureToggles.gen.ts | 1 +
pkg/api/api.go | 9 ++++++---
pkg/services/featuremgmt/registry.go | 5 +++++
pkg/services/featuremgmt/toggles_gen.go | 4 ++++
public/app/features/admin/ServerStats.tsx | 2 +-
.../components/DashboardSettings/GeneralSettings.tsx | 4 +++-
6 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index a5f13a7de0f..95bb511c0e9 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -23,6 +23,7 @@ export interface FeatureToggles {
database_metrics?: boolean;
dashboardPreviews?: boolean;
dashboardPreviewsScheduler?: boolean;
+ dashboardPreviewsAdmin?: boolean;
['live-config']?: boolean;
['live-pipeline']?: boolean;
['live-service-web-worker']?: boolean;
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 6da0aa3488c..8615e41a3ce 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -336,8 +336,11 @@ func (hs *HTTPServer) registerRoutes() {
if hs.ThumbService != nil {
dashboardRoute.Get("/uid/:uid/img/:kind/:theme", hs.ThumbService.GetImage)
- dashboardRoute.Post("/uid/:uid/img/:kind/:theme", hs.ThumbService.SetImage)
- dashboardRoute.Put("/uid/:uid/img/:kind/:theme", hs.ThumbService.UpdateThumbnailState)
+
+ if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) {
+ dashboardRoute.Post("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage)
+ dashboardRoute.Put("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.UpdateThumbnailState)
+ }
}
dashboardRoute.Post("/calculate-diff", routing.Wrap(hs.CalculateDashboardDiff))
@@ -470,7 +473,7 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats))
adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts))
- if hs.ThumbService != nil {
+ if hs.ThumbService != nil && hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) {
adminRoute.Post("/crawler/start", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StartCrawler))
adminRoute.Post("/crawler/stop", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StopCrawler))
adminRoute.Get("/crawler/status", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.CrawlerStatus))
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index bd715f9457a..d95d6b385c1 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -38,6 +38,11 @@ var (
Description: "Schedule automatic updates to dashboard previews",
State: FeatureStateAlpha,
},
+ {
+ Name: "dashboardPreviewsAdmin",
+ Description: "Manage the dashboard previews crawler process from the UI",
+ State: FeatureStateAlpha,
+ },
{
Name: "live-config",
Description: "Save grafana live configuration in SQL tables",
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 960a7b0f511..7c54af1412e 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -31,6 +31,10 @@ const (
// Schedule automatic updates to dashboard previews
FlagDashboardPreviewsScheduler = "dashboardPreviewsScheduler"
+ // FlagDashboardPreviewsAdmin
+ // Manage the dashboard previews crawler process from the UI
+ FlagDashboardPreviewsAdmin = "dashboardPreviewsAdmin"
+
// FlagLiveConfig
// Save grafana live configuration in SQL tables
FlagLiveConfig = "live-config"
diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx
index 675b0ea38f9..c883750e161 100644
--- a/public/app/features/admin/ServerStats.tsx
+++ b/public/app/features/admin/ServerStats.tsx
@@ -94,7 +94,7 @@ export const ServerStats = () => {
No stats found.
)}
- {config.featureToggles.dashboardPreviews && }
+ {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && }
>
);
};
diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx
index 5ad19559403..5a509359a14 100644
--- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx
@@ -122,7 +122,9 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
- {config.featureToggles.dashboardPreviews &&