mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'main' into jackw/nx-task-orchestration
This commit is contained in:
18
.drone.yml
18
.drone.yml
@@ -499,7 +499,7 @@ steps:
|
||||
name: identify-runner
|
||||
- commands:
|
||||
- mkdir -p bin
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
|
||||
- chmod +x bin/grabpl
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: grabpl
|
||||
@@ -851,7 +851,7 @@ steps:
|
||||
name: clone-enterprise
|
||||
- commands:
|
||||
- mkdir -p bin
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
|
||||
- chmod +x bin/grabpl
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: grabpl
|
||||
@@ -1774,7 +1774,7 @@ steps:
|
||||
name: identify-runner
|
||||
- commands:
|
||||
- mkdir -p bin
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
|
||||
- chmod +x bin/grabpl
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: grabpl
|
||||
@@ -2223,7 +2223,7 @@ services:
|
||||
steps:
|
||||
- commands:
|
||||
- mkdir -p bin
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
|
||||
- chmod +x bin/grabpl
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: grabpl
|
||||
@@ -2425,7 +2425,7 @@ steps:
|
||||
name: identify-runner
|
||||
- commands:
|
||||
- $$ProgressPreference = "SilentlyContinue"
|
||||
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/windows/grabpl.exe
|
||||
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/windows/grabpl.exe
|
||||
-OutFile grabpl.exe
|
||||
image: grafana/ci-wix:0.1.1
|
||||
name: windows-init
|
||||
@@ -2555,7 +2555,7 @@ steps:
|
||||
name: identify-runner
|
||||
- commands:
|
||||
- mkdir -p bin
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
|
||||
- chmod +x bin/grabpl
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: grabpl
|
||||
@@ -3215,7 +3215,7 @@ steps:
|
||||
name: identify-runner
|
||||
- commands:
|
||||
- $$ProgressPreference = "SilentlyContinue"
|
||||
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/windows/grabpl.exe
|
||||
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/windows/grabpl.exe
|
||||
-OutFile grabpl.exe
|
||||
image: grafana/ci-wix:0.1.1
|
||||
name: windows-init
|
||||
@@ -4017,7 +4017,7 @@ services:
|
||||
steps:
|
||||
- commands:
|
||||
- mkdir -p bin
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
|
||||
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
|
||||
- chmod +x bin/grabpl
|
||||
image: byrnedo/alpine-curl:0.1.8
|
||||
name: grabpl
|
||||
@@ -4800,6 +4800,6 @@ kind: secret
|
||||
name: gcr_credentials
|
||||
---
|
||||
kind: signature
|
||||
hmac: f82455098bcac4c4b46f62ec7bc768660ccce0bb4c869da8bb85026e5845aa49
|
||||
hmac: c5243aaa05fbb21c64d03465f079ad710c9dcddb81db19b89ebb61cc9b60bc72
|
||||
|
||||
...
|
||||
|
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,3 +1,13 @@
|
||||
<!-- 10.3.3 START -->
|
||||
|
||||
# 10.3.3 (2024-02-02)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Elasticsearch:** Fix creating of legend so it is backward compatible with frontend produced frames. [#81786](https://github.com/grafana/grafana/issues/81786), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
- **ShareModal:** Fixes url sync issue that caused issue with save drawer. [#81721](https://github.com/grafana/grafana/issues/81721), [@ivanortegaalba](https://github.com/ivanortegaalba)
|
||||
|
||||
<!-- 10.3.3 END -->
|
||||
<!-- 10.3.1 START -->
|
||||
|
||||
# 10.3.1 (2024-01-22)
|
||||
@@ -103,6 +113,29 @@ Users who have InfluxDB datasource configured with SQL querying language must up
|
||||
Removes `NamespaceID` from responses of all GET routes underneath the path `/api/ruler/grafana/api/v1/rules` - 3 affected endpoints. All affected routes are not in the publicly documented or `stable` marked portion of the ngalert API. This only breaks clients who are directly using the unstable portion of the API. Such clients should use `NamespaceUID` rather than `NamespaceID` to identify namespaces. Issue [#79359](https://github.com/grafana/grafana/issues/79359)
|
||||
|
||||
<!-- 10.3.0 END -->
|
||||
<!-- 10.2.4 START -->
|
||||
|
||||
# 10.2.4 (2024-01-29)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Chore:** Upgrade Go to 1.21.5. [#79560](https://github.com/grafana/grafana/issues/79560), [@tolzhabayev](https://github.com/tolzhabayev)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Field:** Fix perf regression in getUniqueFieldName(). [#81417](https://github.com/grafana/grafana/issues/81417), [@leeoniya](https://github.com/leeoniya)
|
||||
- **Alerting:** Fix Graphite subqueries. [#80816](https://github.com/grafana/grafana/issues/80816), [@gillesdemey](https://github.com/gillesdemey)
|
||||
- **Alerting:** Fix Graphite subqueries. [#80744](https://github.com/grafana/grafana/issues/80744), [@gillesdemey](https://github.com/gillesdemey)
|
||||
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80485](https://github.com/grafana/grafana/issues/80485), [@alexweav](https://github.com/alexweav)
|
||||
- **Loki:** Fix bug duplicating parsed labels across multiple log lines. [#80368](https://github.com/grafana/grafana/issues/80368), [@svennergr](https://github.com/svennergr)
|
||||
- **Alerting:** Fix NoData & Error alerts not resolving when rule is reset. [#80241](https://github.com/grafana/grafana/issues/80241), [@JacobsonMT](https://github.com/JacobsonMT)
|
||||
- **Auth:** Fix a panic during logout when OAuth provider is not set. [#80221](https://github.com/grafana/grafana/issues/80221), [@dmihai](https://github.com/dmihai)
|
||||
- **Gauges:** Fixing broken auto sizing. [#79940](https://github.com/grafana/grafana/issues/79940), [@torkelo](https://github.com/torkelo)
|
||||
- **Templating:** Json interpolation of single-value default selection does not create valid json. [#79503](https://github.com/grafana/grafana/issues/79503), [@kaydelaney](https://github.com/kaydelaney)
|
||||
- **Tempo:** Fix cache in TraceQL editor. [#79471](https://github.com/grafana/grafana/issues/79471), [@adrapereira](https://github.com/adrapereira)
|
||||
- **Alerting:** Fix for data source filter on cloud rules. (#79327). [#79350](https://github.com/grafana/grafana/issues/79350), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
|
||||
|
||||
<!-- 10.2.4 END -->
|
||||
<!-- 10.2.3 START -->
|
||||
|
||||
# 10.2.3 (2023-12-18)
|
||||
@@ -756,6 +789,15 @@ Starting with 10.2, `parentRowIndex` is deprecated. It will be removed in a futu
|
||||
- **Drawer:** Make content scroll by default. [#75287](https://github.com/grafana/grafana/issues/75287), [@ashharrison90](https://github.com/ashharrison90)
|
||||
|
||||
<!-- 10.2.0 END -->
|
||||
<!-- 10.1.7 START -->
|
||||
|
||||
# 10.1.7 (2024-01-29)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80678](https://github.com/grafana/grafana/issues/80678), [@alexweav](https://github.com/alexweav)
|
||||
|
||||
<!-- 10.1.7 END -->
|
||||
<!-- 10.1.6 START -->
|
||||
|
||||
# 10.1.6 (2023-12-18)
|
||||
@@ -1257,6 +1299,15 @@ Starting with 10.0, changing the folder UID is deprecated. It will be removed in
|
||||
- **Grafana/ui:** Fix margin in RadioButtonGroup option when only icon is present. [#68899](https://github.com/grafana/grafana/issues/68899), [@aocenas](https://github.com/aocenas)
|
||||
|
||||
<!-- 10.1.0 END -->
|
||||
<!-- 10.0.11 START -->
|
||||
|
||||
# 10.0.11 (2024-01-29)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80681](https://github.com/grafana/grafana/issues/80681), [@alexweav](https://github.com/alexweav)
|
||||
|
||||
<!-- 10.0.11 END -->
|
||||
<!-- 10.0.10 START -->
|
||||
|
||||
# 10.0.10 (2023-12-18)
|
||||
@@ -1789,6 +1840,15 @@ The `database` field has been deprecated in the Elasticsearch datasource provisi
|
||||
- **InteractiveTable:** Updated design and minor tweak to Correlactions page. [#66443](https://github.com/grafana/grafana/issues/66443), [@torkelo](https://github.com/torkelo)
|
||||
|
||||
<!-- 10.0.0-preview END -->
|
||||
<!-- 9.5.16 START -->
|
||||
|
||||
# 9.5.16 (2024-01-29)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80682](https://github.com/grafana/grafana/issues/80682), [@alexweav](https://github.com/alexweav)
|
||||
|
||||
<!-- 9.5.16 END -->
|
||||
<!-- 9.5.15 START -->
|
||||
|
||||
# 9.5.15 (2023-12-18)
|
||||
|
@@ -207,7 +207,7 @@ See note in the [introduction](#public-dashboard-api) for an explanation.
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access denied
|
||||
|
||||
## Get a list of all public dashboards with pagination
|
||||
## Get a list of all public dashboards with pagination
|
||||
|
||||
`GET /api/dashboards/public-dashboards`
|
||||
|
||||
|
@@ -186,6 +186,7 @@ Sensitive information stripped: queries (metric, template,annotation) and panel
|
||||
| `key` | string | **Yes** | | Optional, defined the unique key of the snapshot, required if external is true |
|
||||
| `name` | string | **Yes** | | Optional, name of the snapshot |
|
||||
| `orgId` | uint32 | **Yes** | | org id of the snapshot |
|
||||
| `originalUrl` | string | **Yes** | | original url, url of the dashboard that was snapshotted |
|
||||
| `updated` | string | **Yes** | | last time when the snapshot was updated |
|
||||
| `userId` | uint32 | **Yes** | | user id of the snapshot creator |
|
||||
| `url` | string | No | | url of the snapshot, if snapshot was shared internally |
|
||||
|
@@ -494,6 +494,8 @@ lineage: schemas: [{
|
||||
external: bool @grafanamaturity(NeedsExpertReview)
|
||||
// external url, if snapshot was shared in external grafana instance
|
||||
externalUrl: string @grafanamaturity(NeedsExpertReview)
|
||||
// original url, url of the dashboard that was snapshotted
|
||||
originalUrl: string @grafanamaturity(NeedsExpertReview)
|
||||
// Unique identifier of the snapshot
|
||||
id: uint32 @grafanamaturity(NeedsExpertReview)
|
||||
// Optional, defined the unique key of the snapshot, required if external is true
|
||||
|
@@ -238,12 +238,12 @@
|
||||
"@grafana/faro-web-sdk": "^1.3.6",
|
||||
"@grafana/flamegraph": "workspace:*",
|
||||
"@grafana/google-sdk": "0.1.2",
|
||||
"@grafana/lezer-logql": "0.2.2",
|
||||
"@grafana/lezer-logql": "0.2.3",
|
||||
"@grafana/monaco-logql": "^0.0.7",
|
||||
"@grafana/o11y-ds-frontend": "workspace:*",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "^2.6.5",
|
||||
"@grafana/scenes": "^3.2.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
@@ -182,6 +182,10 @@ export const Pages = {
|
||||
stepCountIntervalSelect: 'data-testid interval variable step count input',
|
||||
minIntervalInput: 'data-testid interval variable mininum interval input',
|
||||
},
|
||||
AdHocFiltersVariable: {
|
||||
datasourceSelect: Components.DataSourcePicker.inputV2,
|
||||
infoText: 'data-testid ad-hoc filters variable info text',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -1088,6 +1088,10 @@ export interface Dashboard {
|
||||
* external url, if snapshot was shared in external grafana instance
|
||||
*/
|
||||
externalUrl: string;
|
||||
/**
|
||||
* original url, url of the dashboard that was snapshotted
|
||||
*/
|
||||
originalUrl: string;
|
||||
/**
|
||||
* Unique identifier of the snapshot
|
||||
*/
|
||||
|
@@ -5,7 +5,7 @@ import { SelectableValue, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { IconButton } from '../../components/IconButton/IconButton';
|
||||
import { TabsBar, Tab, TabContent } from '../../components/Tabs';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { useStyles2, useTheme2 } from '../../themes';
|
||||
import { IconName } from '../../types/icon';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
|
||||
@@ -25,12 +25,14 @@ export interface TabbedContainerProps {
|
||||
|
||||
export function TabbedContainer({ tabs, defaultTab, closeIconTooltip, onClose }: TabbedContainerProps) {
|
||||
const [activeTab, setActiveTab] = useState(tabs.some((tab) => tab.value === defaultTab) ? defaultTab : tabs[0].value);
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
|
||||
const onSelectTab = (item: SelectableValue<string>) => {
|
||||
setActiveTab(item.value!);
|
||||
};
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const autoHeight = `calc(100% - (${theme.components.menuTabs.height}px + ${theme.spacing(1)}))`;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -46,7 +48,7 @@ export function TabbedContainer({ tabs, defaultTab, closeIconTooltip, onClose }:
|
||||
))}
|
||||
<IconButton className={styles.close} onClick={onClose} name="times" tooltip={closeIconTooltip ?? 'Close'} />
|
||||
</TabsBar>
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
<CustomScrollbar autoHeightMin={autoHeight} autoHeightMax={autoHeight}>
|
||||
<TabContent className={styles.tabContent}>{tabs.find((t) => t.value === activeTab)?.content}</TabContent>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
@@ -60,7 +62,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabContent: css({
|
||||
padding: theme.spacing(2),
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
height: `calc(100% - ${theme.components.menuTabs.height}px)`,
|
||||
height: `100%`,
|
||||
}),
|
||||
close: css({
|
||||
position: 'absolute',
|
||||
|
@@ -694,6 +694,9 @@ type Snapshot struct {
|
||||
// OrgId org id of the snapshot
|
||||
OrgId int `json:"orgId"`
|
||||
|
||||
// OriginalUrl original url, url of the dashboard that was snapshotted
|
||||
OriginalUrl string `json:"originalUrl"`
|
||||
|
||||
// Updated last time when the snapshot was updated
|
||||
Updated time.Time `json:"updated"`
|
||||
|
||||
|
@@ -374,7 +374,7 @@
|
||||
0
|
||||
],
|
||||
"description": "A Grafana dashboard.",
|
||||
"grafanaMaturityCount": 103,
|
||||
"grafanaMaturityCount": 105,
|
||||
"lineageIsGroup": false,
|
||||
"links": {
|
||||
"docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/dashboard/schema-reference",
|
||||
|
@@ -227,8 +227,10 @@ func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Fo
|
||||
}
|
||||
|
||||
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
|
||||
dashFolder.Fullpath = dashFolder.Title
|
||||
return dashFolder, nil
|
||||
}
|
||||
|
||||
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
|
||||
// nolint:staticcheck
|
||||
if q.ID != nil {
|
||||
@@ -247,6 +249,10 @@ func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Fo
|
||||
f.ID = dashFolder.ID
|
||||
f.Version = dashFolder.Version
|
||||
|
||||
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
|
||||
f.Fullpath = f.Title // set full path to the folder title (unescaped)
|
||||
}
|
||||
|
||||
return f, err
|
||||
}
|
||||
|
||||
|
@@ -1611,6 +1611,106 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFolderServiceGetFolder(t *testing.T) {
|
||||
db := sqlstore.InitTestDB(t)
|
||||
|
||||
signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{
|
||||
orgID: {
|
||||
dashboards.ActionFoldersCreate: {},
|
||||
dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll},
|
||||
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll},
|
||||
},
|
||||
}}
|
||||
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanSaveValue: true,
|
||||
CanViewValue: true,
|
||||
})
|
||||
|
||||
getSvc := func(features featuremgmt.FeatureToggles) Service {
|
||||
quotaService := quotatest.New(false, nil)
|
||||
folderStore := ProvideDashboardFolderStore(db)
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
|
||||
featuresFlagOff := featuremgmt.WithFeatures()
|
||||
dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOff, tagimpl.ProvideService(db), quotaService)
|
||||
require.NoError(t, err)
|
||||
nestedFolderStore := ProvideStore(db, db.Cfg)
|
||||
|
||||
b := bus.ProvideBus(tracing.InitializeTracerForTest())
|
||||
ac := acimpl.ProvideAccessControl(cfg)
|
||||
|
||||
return Service{
|
||||
cfg: cfg,
|
||||
log: log.New("test-folder-service"),
|
||||
dashboardStore: dashStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: nestedFolderStore,
|
||||
features: features,
|
||||
bus: b,
|
||||
db: db,
|
||||
accessControl: ac,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
}
|
||||
}
|
||||
|
||||
folderSvcOn := getSvc(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
|
||||
folderSvcOff := getSvc(featuremgmt.WithFeatures())
|
||||
|
||||
createCmd := folder.CreateFolderCommand{
|
||||
OrgID: orgID,
|
||||
ParentUID: "",
|
||||
SignedInUser: &signedInAdminUser,
|
||||
}
|
||||
|
||||
depth := 3
|
||||
folders := CreateSubtreeInStore(t, folderSvcOn.store, &folderSvcOn, depth, "get/folder-", createCmd)
|
||||
f := folders[1]
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
svc *Service
|
||||
WithFullpath bool
|
||||
expectedFullpath string
|
||||
}{
|
||||
{
|
||||
name: "when flag is off",
|
||||
svc: &folderSvcOff,
|
||||
expectedFullpath: f.Title,
|
||||
},
|
||||
{
|
||||
name: "when flag is on and WithFullpath is false",
|
||||
svc: &folderSvcOn,
|
||||
WithFullpath: false,
|
||||
expectedFullpath: "",
|
||||
},
|
||||
{
|
||||
name: "when flag is on and WithFullpath is true",
|
||||
svc: &folderSvcOn,
|
||||
WithFullpath: true,
|
||||
expectedFullpath: "get\\/folder-folder-0/get\\/folder-folder-1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
q := folder.GetFolderQuery{
|
||||
OrgID: orgID,
|
||||
UID: &f.UID,
|
||||
WithFullpath: tc.WithFullpath,
|
||||
SignedInUser: &signedInAdminUser,
|
||||
}
|
||||
fldr, err := tc.svc.Get(context.Background(), &q)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, f.UID, fldr.UID)
|
||||
|
||||
require.Equal(t, tc.expectedFullpath, fldr.Fullpath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderServiceGetFolders(t *testing.T) {
|
||||
db := sqlstore.InitTestDB(t)
|
||||
quotaService := quotatest.New(false, nil)
|
||||
@@ -1687,7 +1787,7 @@ func TestFolderServiceGetFolders(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func CreateSubtreeInStore(t *testing.T, store *sqlStore, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []*folder.Folder {
|
||||
func CreateSubtreeInStore(t *testing.T, store store, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []*folder.Folder {
|
||||
t.Helper()
|
||||
|
||||
folders := make([]*folder.Folder, 0, depth)
|
||||
|
@@ -171,27 +171,55 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand)
|
||||
return foldr.WithURL(), err
|
||||
}
|
||||
|
||||
// If WithFullpath is true it computes also the full path of a folder.
|
||||
// The full path is a string that contains the titles of all parent folders separated by a slash.
|
||||
// For example, if the folder structure is:
|
||||
//
|
||||
// A
|
||||
// └── B
|
||||
// └── C
|
||||
//
|
||||
// The full path of C is "A/B/C".
|
||||
// The full path of B is "A/B".
|
||||
// The full path of A is "A".
|
||||
// If a folder contains a slash in its title, it is escaped with a backslash.
|
||||
// For example, if the folder structure is:
|
||||
//
|
||||
// A
|
||||
// └── B/C
|
||||
//
|
||||
// The full path of C is "A/B\/C".
|
||||
func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.Folder, error) {
|
||||
foldr := &folder.Folder{}
|
||||
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
exists := false
|
||||
var err error
|
||||
s := strings.Builder{}
|
||||
s.WriteString("SELECT *")
|
||||
if q.WithFullpath {
|
||||
s.WriteString(fmt.Sprintf(`, %s AS fullpath`, getFullpathSQL(ss.db.GetDialect())))
|
||||
}
|
||||
s.WriteString(" FROM folder f0")
|
||||
if q.WithFullpath {
|
||||
s.WriteString(getFullpathJoinsSQL())
|
||||
}
|
||||
switch {
|
||||
case q.UID != nil:
|
||||
exists, err = sess.SQL("SELECT * FROM folder WHERE uid = ? AND org_id = ?", q.UID, q.OrgID).Get(foldr)
|
||||
s.WriteString(" WHERE f0.uid = ? AND f0.org_id = ?")
|
||||
exists, err = sess.SQL(s.String(), q.UID, q.OrgID).Get(foldr)
|
||||
// nolint:staticcheck
|
||||
case q.ID != nil:
|
||||
s.WriteString(" WHERE f0.id = ?")
|
||||
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
|
||||
exists, err = sess.SQL("SELECT * FROM folder WHERE id = ?", q.ID).Get(foldr)
|
||||
exists, err = sess.SQL(s.String(), q.ID).Get(foldr)
|
||||
case q.Title != nil:
|
||||
s := strings.Builder{}
|
||||
s.WriteString("SELECT * FROM folder WHERE title = ? AND org_id = ?")
|
||||
s.WriteString(" WHERE f0.title = ? AND f0.org_id = ?")
|
||||
args := []any{*q.Title, q.OrgID}
|
||||
if q.ParentUID != nil {
|
||||
s.WriteString(" AND parent_uid = ?")
|
||||
s.WriteString(" AND f0.parent_uid = ?")
|
||||
args = append(args, *q.ParentUID)
|
||||
} else {
|
||||
s.WriteString(" AND parent_uid IS NULL")
|
||||
s.WriteString(" AND f0.parent_uid IS NULL")
|
||||
}
|
||||
exists, err = sess.SQL(s.String(), args...).Get(foldr)
|
||||
default:
|
||||
@@ -207,6 +235,7 @@ func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.F
|
||||
return nil
|
||||
})
|
||||
|
||||
foldr.Fullpath = strings.TrimLeft(foldr.Fullpath, "/")
|
||||
return foldr.WithURL(), err
|
||||
}
|
||||
|
||||
@@ -274,15 +303,16 @@ func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetChildrenQuery)
|
||||
args = append(args, q.UID, q.OrgID)
|
||||
}
|
||||
|
||||
if q.FolderUIDs != nil {
|
||||
sql.WriteString(" AND uid IN (?")
|
||||
for range q.FolderUIDs[1:] {
|
||||
sql.WriteString(", ?")
|
||||
}
|
||||
sql.WriteString(")")
|
||||
for _, uid := range q.FolderUIDs {
|
||||
if len(q.FolderUIDs) > 0 {
|
||||
sql.WriteString(" AND uid IN (")
|
||||
for i, uid := range q.FolderUIDs {
|
||||
if i > 0 {
|
||||
sql.WriteString(", ")
|
||||
}
|
||||
sql.WriteString("?")
|
||||
args = append(args, uid)
|
||||
}
|
||||
sql.WriteString(")")
|
||||
}
|
||||
sql.WriteString(" ORDER BY title ASC")
|
||||
|
||||
|
@@ -3,6 +3,7 @@ package folderimpl
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
@@ -391,11 +392,7 @@ func TestIntegrationGet(t *testing.T) {
|
||||
UID: util.GenerateShortUID(),
|
||||
ParentUID: f.UID,
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
err := folderStore.Delete(context.Background(), []string{f.UID}, orgID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("should gently fail in case of bad request", func(t *testing.T) {
|
||||
_, err = folderStore.Get(context.Background(), folder.GetFolderQuery{})
|
||||
@@ -466,6 +463,24 @@ func TestIntegrationGet(t *testing.T) {
|
||||
assert.NotEmpty(t, ff.Updated)
|
||||
assert.NotEmpty(t, ff.URL)
|
||||
})
|
||||
|
||||
t.Run("get folder with fullpath should set fullpath as expected", func(t *testing.T) {
|
||||
ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{
|
||||
UID: &subfolderWithSameName.UID,
|
||||
OrgID: orgID,
|
||||
WithFullpath: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, subfolderWithSameName.UID, ff.UID)
|
||||
assert.Equal(t, subfolderWithSameName.OrgID, ff.OrgID)
|
||||
assert.Equal(t, subfolderWithSameName.Title, ff.Title)
|
||||
assert.Equal(t, subfolderWithSameName.Description, ff.Description)
|
||||
assert.Equal(t, path.Join(f.Title, subfolderWithSameName.Title), ff.Fullpath)
|
||||
assert.Equal(t, f.UID, ff.ParentUID)
|
||||
assert.NotEmpty(t, ff.Created)
|
||||
assert.NotEmpty(t, ff.Updated)
|
||||
assert.NotEmpty(t, ff.URL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationGetParents(t *testing.T) {
|
||||
|
@@ -147,10 +147,11 @@ type DeleteFolderCommand struct {
|
||||
type GetFolderQuery struct {
|
||||
UID *string
|
||||
// Deprecated: use FolderUID instead
|
||||
ID *int64
|
||||
Title *string
|
||||
ParentUID *string
|
||||
OrgID int64
|
||||
ID *int64
|
||||
Title *string
|
||||
ParentUID *string
|
||||
OrgID int64
|
||||
WithFullpath bool
|
||||
|
||||
SignedInUser identity.Requester `json:"-"`
|
||||
}
|
||||
|
@@ -18,6 +18,7 @@ type Service interface {
|
||||
// specificity (UID, ID, Title).
|
||||
// When fetching a folder by Title, callers can optionally define a ParentUID.
|
||||
// If ParentUID is not set then the folder will be fetched from the root level.
|
||||
// If WithFullpath is true it computes also the full path of a folder.
|
||||
Get(ctx context.Context, q *GetFolderQuery) (*Folder, error)
|
||||
|
||||
// Update is used to update a folder's UID, Title and Description. To change
|
||||
|
@@ -24,5 +24,5 @@ type RuleStore interface {
|
||||
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
|
||||
|
||||
// IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace
|
||||
IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error)
|
||||
IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error)
|
||||
}
|
||||
|
@@ -368,11 +368,6 @@ type AlertRuleKeyWithVersion struct {
|
||||
AlertRuleKey `xorm:"extends"`
|
||||
}
|
||||
|
||||
type AlertRuleKeyWithVersionAndPauseStatus struct {
|
||||
IsPaused bool
|
||||
AlertRuleKeyWithVersion `xorm:"extends"`
|
||||
}
|
||||
|
||||
type AlertRuleKeyWithId struct {
|
||||
AlertRuleKey
|
||||
ID int64
|
||||
|
@@ -72,8 +72,8 @@ func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUI
|
||||
}
|
||||
|
||||
// IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace
|
||||
func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error) {
|
||||
var keys []ngmodels.AlertRuleKeyWithVersionAndPauseStatus
|
||||
func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) {
|
||||
var keys []ngmodels.AlertRuleKeyWithVersion
|
||||
err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
now := TimeNow()
|
||||
_, err := sess.Exec("UPDATE alert_rule SET version = version + 1, updated = ? WHERE namespace_uid = ? AND org_id = ?", now, namespaceUID, orgID)
|
||||
|
@@ -315,7 +315,7 @@ func (f *RuleStore) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceU
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersionAndPauseStatus, error) {
|
||||
func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersion, error) {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
|
||||
@@ -324,18 +324,15 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org
|
||||
Params: []any{orgID, namespaceUID},
|
||||
})
|
||||
|
||||
var result []models.AlertRuleKeyWithVersionAndPauseStatus
|
||||
var result []models.AlertRuleKeyWithVersion
|
||||
|
||||
for _, rule := range f.Rules[orgID] {
|
||||
if rule.NamespaceUID == namespaceUID && rule.OrgID == orgID {
|
||||
rule.Version++
|
||||
rule.Updated = time.Now()
|
||||
result = append(result, models.AlertRuleKeyWithVersionAndPauseStatus{
|
||||
IsPaused: rule.IsPaused,
|
||||
AlertRuleKeyWithVersion: models.AlertRuleKeyWithVersion{
|
||||
Version: rule.Version,
|
||||
AlertRuleKey: rule.GetKey(),
|
||||
},
|
||||
result = append(result, models.AlertRuleKeyWithVersion{
|
||||
Version: rule.Version,
|
||||
AlertRuleKey: rule.GetKey(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -7,11 +7,11 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/influxdb/influxql/util"
|
||||
"github.com/grafana/grafana/pkg/tsdb/influxdb/models"
|
||||
"github.com/grafana/grafana/pkg/util/converter/jsonitere"
|
||||
)
|
||||
|
||||
func rspErr(e error) *backend.DataResponse {
|
||||
@@ -19,7 +19,7 @@ func rspErr(e error) *backend.DataResponse {
|
||||
}
|
||||
|
||||
func ReadInfluxQLStyleResult(jIter *jsoniter.Iterator, query *models.Query) *backend.DataResponse {
|
||||
iter := jsonitere.NewIterator(jIter)
|
||||
iter := sdkjsoniter.NewIterator(jIter)
|
||||
var rsp *backend.DataResponse
|
||||
|
||||
l1Fields:
|
||||
@@ -51,7 +51,7 @@ l1Fields:
|
||||
return rsp
|
||||
}
|
||||
|
||||
func readResults(iter *jsonitere.Iterator, query *models.Query) *backend.DataResponse {
|
||||
func readResults(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataResponse {
|
||||
rsp := &backend.DataResponse{Frames: make(data.Frames, 0)}
|
||||
l1Fields:
|
||||
for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() {
|
||||
@@ -79,7 +79,7 @@ l1Fields:
|
||||
return rsp
|
||||
}
|
||||
|
||||
func readSeries(iter *jsonitere.Iterator, query *models.Query) *backend.DataResponse {
|
||||
func readSeries(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataResponse {
|
||||
var (
|
||||
measurement string
|
||||
tags map[string]string
|
||||
@@ -179,7 +179,7 @@ func readSeries(iter *jsonitere.Iterator, query *models.Query) *backend.DataResp
|
||||
return rsp
|
||||
}
|
||||
|
||||
func readTags(iter *jsonitere.Iterator) (map[string]string, error) {
|
||||
func readTags(iter *sdkjsoniter.Iterator) (map[string]string, error) {
|
||||
tags := make(map[string]string)
|
||||
for l1Field, err := iter.ReadObject(); l1Field != ""; l1Field, err = iter.ReadObject() {
|
||||
if err != nil {
|
||||
@@ -194,7 +194,7 @@ func readTags(iter *jsonitere.Iterator) (map[string]string, error) {
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func readColumns(iter *jsonitere.Iterator) (columns []string, err error) {
|
||||
func readColumns(iter *sdkjsoniter.Iterator) (columns []string, err error) {
|
||||
for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -209,7 +209,7 @@ func readColumns(iter *jsonitere.Iterator) (columns []string, err error) {
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func readValues(iter *jsonitere.Iterator, hasTimeColumn bool) (valueFields data.Fields, err error) {
|
||||
func readValues(iter *sdkjsoniter.Iterator, hasTimeColumn bool) (valueFields data.Fields, err error) {
|
||||
if hasTimeColumn {
|
||||
valueFields = append(valueFields, data.NewField("Time", nil, make([]time.Time, 0)))
|
||||
}
|
||||
|
@@ -1,66 +0,0 @@
|
||||
// Package jsonitere wraps json-iterator/go's Iterator methods with error returns
|
||||
// so linting can catch unchecked errors.
|
||||
// The underlying iterator's Error property is returned and not reset.
|
||||
// See json-iterator/go for method documentation and additional methods that
|
||||
// can be added to this library.
|
||||
package jsonitere
|
||||
|
||||
import (
|
||||
j "github.com/json-iterator/go"
|
||||
)
|
||||
|
||||
type Iterator struct {
|
||||
// named property instead of embedded so there is no
|
||||
// confusion about which method or property is called
|
||||
i *j.Iterator
|
||||
}
|
||||
|
||||
func NewIterator(i *j.Iterator) *Iterator {
|
||||
return &Iterator{i}
|
||||
}
|
||||
|
||||
func (iter *Iterator) Read() (any, error) {
|
||||
return iter.i.Read(), iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) ReadAny() (j.Any, error) {
|
||||
return iter.i.ReadAny(), iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) ReadArray() (bool, error) {
|
||||
return iter.i.ReadArray(), iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) ReadObject() (string, error) {
|
||||
return iter.i.ReadObject(), iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) ReadString() (string, error) {
|
||||
return iter.i.ReadString(), iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) WhatIsNext() (j.ValueType, error) {
|
||||
return iter.i.WhatIsNext(), iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) Skip() error {
|
||||
iter.i.Skip()
|
||||
return iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) SkipAndReturnBytes() []byte {
|
||||
return iter.i.SkipAndReturnBytes()
|
||||
}
|
||||
|
||||
func (iter *Iterator) ReadVal(obj any) error {
|
||||
iter.i.ReadVal(obj)
|
||||
return iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) ReadFloat64() (float64, error) {
|
||||
return iter.i.ReadFloat64(), iter.i.Error
|
||||
}
|
||||
|
||||
func (iter *Iterator) ReadInt8() (int8, error) {
|
||||
return iter.i.ReadInt8(), iter.i.Error
|
||||
}
|
@@ -8,15 +8,15 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/converter/jsonitere"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// helpful while debugging all the options that may appear
|
||||
func logf(format string, a ...any) {
|
||||
//fmt.Printf(format, a...)
|
||||
// fmt.Printf(format, a...)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -29,7 +29,7 @@ func rspErr(e error) backend.DataResponse {
|
||||
|
||||
// ReadPrometheusStyleResult will read results from a prometheus or loki server and return data frames
|
||||
func ReadPrometheusStyleResult(jIter *jsoniter.Iterator, opt Options) backend.DataResponse {
|
||||
iter := jsonitere.NewIterator(jIter)
|
||||
iter := sdkjsoniter.NewIterator(jIter)
|
||||
var rsp backend.DataResponse
|
||||
status := "unknown"
|
||||
errorType := ""
|
||||
@@ -102,14 +102,14 @@ l1Fields:
|
||||
return rsp
|
||||
}
|
||||
|
||||
func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) {
|
||||
func readWarnings(iter *sdkjsoniter.Iterator) ([]data.Notice, error) {
|
||||
warnings := []data.Notice{}
|
||||
next, err := iter.WhatIsNext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if next != jsoniter.ArrayValue {
|
||||
if next != sdkjsoniter.ArrayValue {
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if next == jsoniter.StringValue {
|
||||
if next == sdkjsoniter.StringValue {
|
||||
s, err := iter.ReadString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -137,18 +137,18 @@ func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) {
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func readPrometheusData(iter *jsonitere.Iterator, opt Options) backend.DataResponse {
|
||||
func readPrometheusData(iter *sdkjsoniter.Iterator, opt Options) backend.DataResponse {
|
||||
var rsp backend.DataResponse
|
||||
t, err := iter.WhatIsNext()
|
||||
if err != nil {
|
||||
return rspErr(err)
|
||||
}
|
||||
|
||||
if t == jsoniter.ArrayValue {
|
||||
if t == sdkjsoniter.ArrayValue {
|
||||
return readArrayData(iter)
|
||||
}
|
||||
|
||||
if t != jsoniter.ObjectValue {
|
||||
if t != sdkjsoniter.ObjectValue {
|
||||
return backend.DataResponse{
|
||||
Error: fmt.Errorf("expected object type"),
|
||||
}
|
||||
@@ -190,7 +190,7 @@ l1Fields:
|
||||
// if we have saved resultBytes we will parse them here
|
||||
// we saved them because when we had them we don't know the resultType
|
||||
if len(resultBytes) > 0 {
|
||||
ji := jsonitere.NewIterator(jsoniter.ParseBytes(jsoniter.ConfigDefault, resultBytes))
|
||||
ji := sdkjsoniter.NewIterator(jsoniter.ParseBytes(sdkjsoniter.ConfigDefault, resultBytes))
|
||||
rsp = readResult(resultType, rsp, ji, opt, encodingFlags)
|
||||
}
|
||||
case "result":
|
||||
@@ -200,7 +200,7 @@ l1Fields:
|
||||
if resultTypeFound {
|
||||
rsp = readResult(resultType, rsp, iter, opt, encodingFlags)
|
||||
} else {
|
||||
resultBytes = iter.SkipAndReturnBytes()
|
||||
resultBytes, _ = iter.SkipAndReturnBytes()
|
||||
}
|
||||
|
||||
case "stats":
|
||||
@@ -241,7 +241,7 @@ l1Fields:
|
||||
}
|
||||
|
||||
// will read the result object based on the resultType and return a DataResponse
|
||||
func readResult(resultType string, rsp backend.DataResponse, iter *jsonitere.Iterator, opt Options, encodingFlags []string) backend.DataResponse {
|
||||
func readResult(resultType string, rsp backend.DataResponse, iter *sdkjsoniter.Iterator, opt Options, encodingFlags []string) backend.DataResponse {
|
||||
switch resultType {
|
||||
case "matrix", "vector":
|
||||
rsp = readMatrixOrVectorMulti(iter, resultType, opt)
|
||||
@@ -279,7 +279,7 @@ func readResult(resultType string, rsp backend.DataResponse, iter *jsonitere.Ite
|
||||
}
|
||||
|
||||
// will return strings or exemplars
|
||||
func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
func readArrayData(iter *sdkjsoniter.Iterator) backend.DataResponse {
|
||||
lookup := make(map[string]*data.Field)
|
||||
|
||||
var labelFrame *data.Frame
|
||||
@@ -298,7 +298,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
}
|
||||
|
||||
switch next {
|
||||
case jsoniter.StringValue:
|
||||
case sdkjsoniter.StringValue:
|
||||
s, err := iter.ReadString()
|
||||
if err != nil {
|
||||
return rspErr(err)
|
||||
@@ -306,7 +306,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
stringField.Append(s)
|
||||
|
||||
// Either label or exemplars
|
||||
case jsoniter.ObjectValue:
|
||||
case sdkjsoniter.ObjectValue:
|
||||
exemplar, labelPairs, err := readLabelsOrExemplars(iter)
|
||||
if err != nil {
|
||||
rspErr(err)
|
||||
@@ -365,7 +365,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
}
|
||||
|
||||
// For consistent ordering read values to an array not a map
|
||||
func readLabelsAsPairs(iter *jsonitere.Iterator) ([][2]string, error) {
|
||||
func readLabelsAsPairs(iter *sdkjsoniter.Iterator) ([][2]string, error) {
|
||||
pairs := make([][2]string, 0, 10)
|
||||
for k, err := iter.ReadObject(); k != ""; k, err = iter.ReadObject() {
|
||||
if err != nil {
|
||||
@@ -380,7 +380,7 @@ func readLabelsAsPairs(iter *jsonitere.Iterator) ([][2]string, error) {
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func readLabelsOrExemplars(iter *jsonitere.Iterator) (*data.Frame, [][2]string, error) {
|
||||
func readLabelsOrExemplars(iter *sdkjsoniter.Iterator) (*data.Frame, [][2]string, error) {
|
||||
pairs := make([][2]string, 0, 10)
|
||||
labels := data.Labels{}
|
||||
var frame *data.Frame
|
||||
@@ -496,7 +496,7 @@ l1Fields:
|
||||
return frame, pairs, nil
|
||||
}
|
||||
|
||||
func readString(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
func readString(iter *sdkjsoniter.Iterator) backend.DataResponse {
|
||||
timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0)
|
||||
timeField.Name = data.TimeSeriesTimeFieldName
|
||||
valueField := data.NewFieldFromFieldType(data.FieldTypeString, 0)
|
||||
@@ -541,7 +541,7 @@ func readString(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func readScalar(iter *jsonitere.Iterator, dataPlane bool) backend.DataResponse {
|
||||
func readScalar(iter *sdkjsoniter.Iterator, dataPlane bool) backend.DataResponse {
|
||||
rsp := backend.DataResponse{}
|
||||
|
||||
timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0)
|
||||
@@ -573,7 +573,7 @@ func readScalar(iter *jsonitere.Iterator, dataPlane bool) backend.DataResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func readMatrixOrVectorMulti(iter *jsonitere.Iterator, resultType string, opt Options) backend.DataResponse {
|
||||
func readMatrixOrVectorMulti(iter *sdkjsoniter.Iterator, resultType string, opt Options) backend.DataResponse {
|
||||
rsp := backend.DataResponse{}
|
||||
|
||||
for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() {
|
||||
@@ -679,7 +679,7 @@ func readMatrixOrVectorMulti(iter *jsonitere.Iterator, resultType string, opt Op
|
||||
return rsp
|
||||
}
|
||||
|
||||
func readTimeValuePair(iter *jsonitere.Iterator) (time.Time, float64, error) {
|
||||
func readTimeValuePair(iter *sdkjsoniter.Iterator) (time.Time, float64, error) {
|
||||
if _, err := iter.ReadArray(); err != nil {
|
||||
return time.Time{}, 0, err
|
||||
}
|
||||
@@ -708,7 +708,7 @@ func readTimeValuePair(iter *jsonitere.Iterator) (time.Time, float64, error) {
|
||||
}
|
||||
|
||||
type histogramInfo struct {
|
||||
//XMax (time) YMin Ymax Count YLayout
|
||||
// XMax (time) YMin Ymax Count YLayout
|
||||
time *data.Field
|
||||
yMin *data.Field // will have labels?
|
||||
yMax *data.Field
|
||||
@@ -734,7 +734,7 @@ func newHistogramInfo() *histogramInfo {
|
||||
|
||||
// This will read a single sparse histogram
|
||||
// [ time, { count, sum, buckets: [...] }]
|
||||
func readHistogram(iter *jsonitere.Iterator, hist *histogramInfo) error {
|
||||
func readHistogram(iter *sdkjsoniter.Iterator, hist *histogramInfo) error {
|
||||
// first element
|
||||
if _, err := iter.ReadArray(); err != nil {
|
||||
return err
|
||||
@@ -834,7 +834,7 @@ func readHistogram(iter *jsonitere.Iterator, hist *histogramInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendValueFromString(iter *jsonitere.Iterator, field *data.Field) error {
|
||||
func appendValueFromString(iter *sdkjsoniter.Iterator, field *data.Field) error {
|
||||
var err error
|
||||
var s string
|
||||
if s, err = iter.ReadString(); err != nil {
|
||||
@@ -850,7 +850,7 @@ func appendValueFromString(iter *jsonitere.Iterator, field *data.Field) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readStream(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
func readStream(iter *sdkjsoniter.Iterator) backend.DataResponse {
|
||||
rsp := backend.DataResponse{}
|
||||
|
||||
labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0)
|
||||
@@ -950,7 +950,7 @@ func readStream(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
return rsp
|
||||
}
|
||||
|
||||
func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
func readCategorizedStream(iter *sdkjsoniter.Iterator) backend.DataResponse {
|
||||
rsp := backend.DataResponse{}
|
||||
|
||||
labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0)
|
||||
@@ -1084,7 +1084,7 @@ func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse {
|
||||
return rsp
|
||||
}
|
||||
|
||||
func readCategorizedStreamField(iter *jsonitere.Iterator) (map[string]interface{}, map[string]interface{}, error) {
|
||||
func readCategorizedStreamField(iter *sdkjsoniter.Iterator) (map[string]interface{}, map[string]interface{}, error) {
|
||||
parsedLabels := data.Labels{}
|
||||
structuredMetadata := data.Labels{}
|
||||
var parsedLabelsMap map[string]interface{}
|
||||
|
@@ -30,6 +30,8 @@ export interface ContactPointSelectorProps {
|
||||
refetchReceivers: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const MAX_CONTACT_POINTS_RENDERED = 500;
|
||||
|
||||
export function ContactPointSelector({
|
||||
alertManager,
|
||||
options,
|
||||
@@ -42,9 +44,10 @@ export function ContactPointSelector({
|
||||
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`);
|
||||
|
||||
const selectedContactPointWithMetadata = options.find((option) => option.value.name === contactPointInForm)?.value;
|
||||
const selectedContactPointSelectableValue = selectedContactPointWithMetadata
|
||||
? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name }
|
||||
: undefined;
|
||||
const selectedContactPointSelectableValue: SelectableValue<ContactPointWithMetadata> =
|
||||
selectedContactPointWithMetadata
|
||||
? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name }
|
||||
: { value: undefined, label: '' };
|
||||
|
||||
const LOADING_SPINNER_DURATION = 1000;
|
||||
|
||||
@@ -80,8 +83,8 @@ export function ContactPointSelector({
|
||||
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
|
||||
<>
|
||||
<div className={styles.contactPointsSelector}>
|
||||
<Select
|
||||
{...field}
|
||||
<Select<ContactPointWithMetadata>
|
||||
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
|
||||
aria-label="Contact point"
|
||||
defaultValue={selectedContactPointSelectableValue}
|
||||
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { Stack, Text } from '@grafana/ui';
|
||||
import { RuleHealth } from 'app/types/unified-alerting';
|
||||
@@ -18,14 +18,7 @@ export const RecordingBadge = ({ health }: RecordingBadgeProps) => {
|
||||
const color = hasError ? 'error' : 'success';
|
||||
const text = hasError ? 'Recording error' : 'Recording';
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<AlertStateDot color={color} />
|
||||
<Text variant="bodySmall" color={color}>
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
return <Badge color={color} text={text} />;
|
||||
};
|
||||
|
||||
// we're making a distinction here between the "state" of the rule and its "health".
|
||||
@@ -36,7 +29,7 @@ interface StateBadgeProps {
|
||||
|
||||
export const StateBadge = ({ state, health }: StateBadgeProps) => {
|
||||
let stateLabel: string;
|
||||
let color: 'success' | 'error' | 'warning';
|
||||
let color: BadgeColor;
|
||||
|
||||
switch (state) {
|
||||
case PromAlertingRuleState.Inactive:
|
||||
@@ -64,12 +57,24 @@ export const StateBadge = ({ state, health }: StateBadgeProps) => {
|
||||
stateLabel = 'No data';
|
||||
}
|
||||
|
||||
return <Badge color={color} text={stateLabel} />;
|
||||
};
|
||||
|
||||
// the generic badge component
|
||||
type BadgeColor = 'success' | 'error' | 'warning';
|
||||
|
||||
interface BadgeProps {
|
||||
color: BadgeColor;
|
||||
text: NonNullable<ReactNode>;
|
||||
}
|
||||
|
||||
function Badge({ color, text }: BadgeProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<Stack direction="row" gap={0.5} wrap={'nowrap'} flex={'0 0 auto'}>
|
||||
<AlertStateDot color={color} />
|
||||
<Text variant="bodySmall" color={color}>
|
||||
{stateLabel}
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { CoreApp } from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import {
|
||||
sceneGraph,
|
||||
SceneGridItem,
|
||||
@@ -12,12 +11,10 @@ import {
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
|
||||
import { ShowModalReactEvent } from '../../../types/events';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
|
||||
import { historySrv } from '../settings/version-history/HistorySrv';
|
||||
@@ -208,63 +205,6 @@ describe('DashboardScene', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('when opening a dashboard from a snapshot', () => {
|
||||
let scene: DashboardScene;
|
||||
beforeEach(async () => {
|
||||
scene = buildTestScene();
|
||||
locationService.push('/');
|
||||
// mockLocationHref('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
|
||||
const location = window.location;
|
||||
|
||||
//@ts-ignore
|
||||
delete window.location;
|
||||
window.location = {
|
||||
...location,
|
||||
href: 'http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash',
|
||||
};
|
||||
jest.spyOn(appEvents, 'publish');
|
||||
});
|
||||
|
||||
config.appUrl = 'http://snapshots.grafana.com/';
|
||||
|
||||
it('redirects to the original dashboard', () => {
|
||||
scene.setInitialSaveModel({
|
||||
// @ts-ignore
|
||||
snapshot: { originalUrl: '/d/c0d2742f-b827-466d-9269-fb34d6af24ff' },
|
||||
});
|
||||
|
||||
// Call the function
|
||||
scene.onOpenSnapshotOriginalDashboard();
|
||||
|
||||
// Assertions
|
||||
expect(appEvents.publish).toHaveBeenCalledTimes(0);
|
||||
expect(locationService.getLocation().pathname).toEqual('/d/c0d2742f-b827-466d-9269-fb34d6af24ff');
|
||||
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
|
||||
});
|
||||
|
||||
it('opens a confirmation modal', () => {
|
||||
scene.setInitialSaveModel({
|
||||
// @ts-ignore
|
||||
snapshot: { originalUrl: 'http://www.anotherdomain.com/' },
|
||||
});
|
||||
|
||||
// Call the function
|
||||
scene.onOpenSnapshotOriginalDashboard();
|
||||
|
||||
// Assertions
|
||||
expect(appEvents.publish).toHaveBeenCalledTimes(1);
|
||||
expect(appEvents.publish).toHaveBeenCalledWith(
|
||||
new ShowModalReactEvent(
|
||||
expect.objectContaining({
|
||||
component: ConfirmModal,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(locationService.getLocation().pathname).toEqual('/');
|
||||
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildTestScene(overrides?: Partial<DashboardSceneState>) {
|
||||
|
@@ -1,10 +1,8 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as H from 'history';
|
||||
import React from 'react';
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil, textUtil } from '@grafana/data';
|
||||
import { locationService, config } from '@grafana/runtime';
|
||||
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import {
|
||||
dataLayers,
|
||||
getUrlSyncManager,
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { Dashboard, DashboardLink } from '@grafana/schema';
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
@@ -35,7 +32,7 @@ import { DashboardModel } from 'app/features/dashboard/state';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
|
||||
import { ShowModalReactEvent, ShowConfirmModalEvent } from 'app/types/events';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
|
||||
@@ -503,47 +500,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
}
|
||||
}
|
||||
|
||||
public onOpenSnapshotOriginalDashboard = () => {
|
||||
// @ts-ignore
|
||||
const relativeURL = this.getInitialSaveModel()?.snapshot?.originalUrl ?? '';
|
||||
const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL);
|
||||
try {
|
||||
const sanitizedAppUrl = new URL(sanitizedRelativeURL, config.appUrl);
|
||||
const appUrl = new URL(config.appUrl);
|
||||
if (sanitizedAppUrl.host !== appUrl.host) {
|
||||
appEvents.publish(
|
||||
new ShowModalReactEvent({
|
||||
component: ConfirmModal,
|
||||
props: {
|
||||
title: 'Proceed to external site?',
|
||||
modalClass: css({
|
||||
width: 'max-content',
|
||||
maxWidth: '80vw',
|
||||
}),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
{`This link connects to an external website at`} <code>{relativeURL}</code>
|
||||
</p>
|
||||
<p>{"Are you sure you'd like to proceed?"}</p>
|
||||
</>
|
||||
),
|
||||
confirmVariant: 'primary',
|
||||
confirmText: 'Proceed',
|
||||
onConfirm: () => {
|
||||
window.location.href = sanitizedAppUrl.href;
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
locationService.push(sanitizedRelativeURL);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to open original dashboard', err);
|
||||
}
|
||||
};
|
||||
|
||||
public onOpenSettings = () => {
|
||||
locationService.partial({ editview: 'settings' });
|
||||
};
|
||||
|
@@ -0,0 +1,60 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
|
||||
import appEvents from '../../../core/app_events';
|
||||
import { ShowModalReactEvent } from '../../../types/events';
|
||||
|
||||
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
|
||||
|
||||
describe('GoToSnapshotOriginButton component', () => {
|
||||
beforeEach(async () => {
|
||||
locationService.push('/');
|
||||
const location = window.location;
|
||||
//@ts-ignore
|
||||
delete window.location;
|
||||
window.location = {
|
||||
...location,
|
||||
href: 'http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash',
|
||||
};
|
||||
jest.spyOn(appEvents, 'publish');
|
||||
});
|
||||
config.appUrl = 'http://snapshots.grafana.com/';
|
||||
|
||||
it('renders button and triggers onClick redirects to the original dashboard', () => {
|
||||
render(<GoToSnapshotOriginButton originalURL={'/d/c0d2742f-b827-466d-9269-fb34d6af24ff'} />);
|
||||
|
||||
// Check if the button renders with the correct testid
|
||||
expect(screen.getByTestId('button-snapshot')).toBeInTheDocument();
|
||||
|
||||
// Simulate a button click
|
||||
fireEvent.click(screen.getByTestId('button-snapshot'));
|
||||
|
||||
expect(appEvents.publish).toHaveBeenCalledTimes(0);
|
||||
expect(locationService.getLocation().pathname).toEqual('/d/c0d2742f-b827-466d-9269-fb34d6af24ff');
|
||||
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
|
||||
});
|
||||
|
||||
it('renders button and triggers onClick opens a confirmation modal', () => {
|
||||
render(<GoToSnapshotOriginButton originalURL={'http://www.anotherdomain.com/'} />);
|
||||
|
||||
// Check if the button renders with the correct testid
|
||||
expect(screen.getByTestId('button-snapshot')).toBeInTheDocument();
|
||||
|
||||
// Simulate a button click
|
||||
fireEvent.click(screen.getByTestId('button-snapshot'));
|
||||
|
||||
expect(appEvents.publish).toHaveBeenCalledTimes(1);
|
||||
expect(appEvents.publish).toHaveBeenCalledWith(
|
||||
new ShowModalReactEvent(
|
||||
expect.objectContaining({
|
||||
component: ConfirmModal,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(locationService.getLocation().pathname).toEqual('/');
|
||||
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
|
||||
});
|
||||
});
|
@@ -0,0 +1,62 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { textUtil } from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { ConfirmModal, ToolbarButton } from '@grafana/ui';
|
||||
|
||||
import appEvents from '../../../core/app_events';
|
||||
import { t } from '../../../core/internationalization';
|
||||
import { ShowModalReactEvent } from '../../../types/events';
|
||||
|
||||
export function GoToSnapshotOriginButton(props: { originalURL: string }) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
key="button-snapshot"
|
||||
data-testid="button-snapshot"
|
||||
tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')}
|
||||
icon="link"
|
||||
onClick={() => onOpenSnapshotOriginalDashboard(props.originalURL)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onOpenSnapshotOriginalDashboard = (originalUrl: string) => {
|
||||
const relativeURL = originalUrl ?? '';
|
||||
const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL);
|
||||
try {
|
||||
const sanitizedAppUrl = new URL(sanitizedRelativeURL, config.appUrl);
|
||||
const appUrl = new URL(config.appUrl);
|
||||
if (sanitizedAppUrl.host !== appUrl.host) {
|
||||
appEvents.publish(
|
||||
new ShowModalReactEvent({
|
||||
component: ConfirmModal,
|
||||
props: {
|
||||
title: 'Proceed to external site?',
|
||||
modalClass: css({
|
||||
width: 'max-content',
|
||||
maxWidth: '80vw',
|
||||
}),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
{`This link connects to an external website at`} <code>{relativeURL}</code>
|
||||
</p>
|
||||
<p>{"Are you sure you'd like to proceed?"}</p>
|
||||
</>
|
||||
),
|
||||
confirmVariant: 'primary',
|
||||
confirmText: 'Proceed',
|
||||
onConfirm: () => {
|
||||
window.location.href = sanitizedAppUrl.href;
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
locationService.push(sanitizedRelativeURL);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to open original dashboard', err);
|
||||
}
|
||||
};
|
@@ -15,6 +15,7 @@ import { DashboardInteractions } from '../utils/interactions';
|
||||
import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardScene;
|
||||
@@ -89,14 +90,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
group: 'icon-actions',
|
||||
condition: meta.isSnapshot && !isEditing,
|
||||
render: () => (
|
||||
<ToolbarButton
|
||||
key="button-snapshot"
|
||||
tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')}
|
||||
icon="link"
|
||||
onClick={() => {
|
||||
dashboard.onOpenSnapshotOriginalDashboard();
|
||||
}}
|
||||
/>
|
||||
<GoToSnapshotOriginButton originalURL={dashboard.getInitialSaveModel()?.snapshot?.originalUrl ?? ''} />
|
||||
),
|
||||
});
|
||||
|
||||
|
@@ -5,7 +5,7 @@ import { PanelContext } from '@grafana/ui';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
|
||||
import { getAdHocFilterSetFor, setDashboardPanelContext } from './setDashboardPanelContext';
|
||||
import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext';
|
||||
|
||||
const postFn = jest.fn();
|
||||
const putFn = jest.fn();
|
||||
@@ -132,26 +132,26 @@ describe('setDashboardPanelContext', () => {
|
||||
|
||||
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
|
||||
|
||||
const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' });
|
||||
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||
|
||||
expect(set.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]);
|
||||
expect(variable.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]);
|
||||
});
|
||||
|
||||
it('Should update and add filter to existing set', () => {
|
||||
const { scene, context } = buildTestScene({ existingFilterSet: true });
|
||||
const { scene, context } = buildTestScene({ existingFilterVariable: true });
|
||||
|
||||
const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' });
|
||||
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||
|
||||
set.setState({ filters: [{ key: 'existing', value: 'world', operator: '=' }] });
|
||||
variable.setState({ filters: [{ key: 'existing', value: 'world', operator: '=' }] });
|
||||
|
||||
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
|
||||
|
||||
expect(set.state.filters.length).toBe(2);
|
||||
expect(variable.state.filters.length).toBe(2);
|
||||
|
||||
// Can update existing filter value without adding a new filter
|
||||
context.onAddAdHocFilter!({ key: 'hello', value: 'world2', operator: '=' });
|
||||
// Verify existing filter value updated
|
||||
expect(set.state.filters[1].value).toBe('world2');
|
||||
expect(variable.state.filters[1].value).toBe('world2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -163,7 +163,7 @@ interface SceneOptions {
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
orgCanEdit?: boolean;
|
||||
existingFilterSet?: boolean;
|
||||
existingFilterVariable?: boolean;
|
||||
}
|
||||
|
||||
function buildTestScene(options: SceneOptions) {
|
||||
@@ -198,7 +198,7 @@ function buildTestScene(options: SceneOptions) {
|
||||
},
|
||||
],
|
||||
templating: {
|
||||
list: options.existingFilterSet
|
||||
list: options.existingFilterVariable
|
||||
? [
|
||||
{
|
||||
type: 'adhoc',
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { AnnotationChangeEvent, AnnotationEventUIModel, CoreApp, DataFrame } from '@grafana/data';
|
||||
import { AdHocFilterSet, dataLayers, SceneDataLayers, VizPanel } from '@grafana/scenes';
|
||||
import { AdHocFiltersVariable, dataLayers, SceneDataLayers, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes';
|
||||
import { DataSourceRef } from '@grafana/schema';
|
||||
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
|
||||
import { deleteAnnotation, saveAnnotation, updateAnnotation } from 'app/features/annotations/api';
|
||||
@@ -111,8 +111,8 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
||||
return;
|
||||
}
|
||||
|
||||
const filterSet = getAdHocFilterSetFor(dashboard, queryRunner.state.datasource);
|
||||
updateAdHocFilterSet(filterSet, newFilter);
|
||||
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
|
||||
updateAdHocFilterVariable(filterVar, newFilter);
|
||||
};
|
||||
|
||||
context.onUpdateData = (frames: DataFrame[]): Promise<boolean> => {
|
||||
@@ -149,33 +149,37 @@ function reRunBuiltInAnnotationsLayer(scene: DashboardScene) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdHocFilterSetFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||
const controls = scene.state.controls ?? [];
|
||||
export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||
const variables = sceneGraph.getVariables(scene);
|
||||
|
||||
for (const control of controls) {
|
||||
if (control instanceof AdHocFilterSet) {
|
||||
if (control.state.datasource === ds || control.state.datasource?.uid === ds?.uid) {
|
||||
return control;
|
||||
for (const variable of variables.state.variables) {
|
||||
if (sceneUtils.isAdHocVariable(variable)) {
|
||||
const filtersDs = variable.state.datasource;
|
||||
if (filtersDs === ds || filtersDs?.uid === ds?.uid) {
|
||||
return variable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newSet = new AdHocFilterSet({ datasource: ds });
|
||||
|
||||
// Add it to the scene
|
||||
scene.setState({
|
||||
controls: [controls[0], newSet, ...controls.slice(1)],
|
||||
const newVariable = new AdHocFiltersVariable({
|
||||
name: 'Filters',
|
||||
datasource: ds,
|
||||
});
|
||||
|
||||
return newSet;
|
||||
// Add it to the scene
|
||||
variables.setState({
|
||||
variables: [...variables.state.variables, newVariable],
|
||||
});
|
||||
|
||||
return newVariable;
|
||||
}
|
||||
|
||||
function updateAdHocFilterSet(filterSet: AdHocFilterSet, newFilter: AdHocFilterItem) {
|
||||
function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) {
|
||||
// Check if we need to update an existing filter
|
||||
for (const filter of filterSet.state.filters) {
|
||||
for (const filter of filterVar.state.filters) {
|
||||
if (filter.key === newFilter.key) {
|
||||
filterSet.setState({
|
||||
filters: filterSet.state.filters.map((f) => {
|
||||
filterVar.setState({
|
||||
filters: filterVar.state.filters.map((f) => {
|
||||
if (f.key === newFilter.key) {
|
||||
return newFilter;
|
||||
}
|
||||
@@ -187,7 +191,7 @@ function updateAdHocFilterSet(filterSet: AdHocFilterSet, newFilter: AdHocFilterI
|
||||
}
|
||||
|
||||
// Add new filter
|
||||
filterSet.setState({
|
||||
filters: [...filterSet.state.filters, newFilter],
|
||||
filterVar.setState({
|
||||
filters: [...filterVar.state.filters, newFilter],
|
||||
});
|
||||
}
|
||||
|
@@ -455,6 +455,16 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"baseFilters": [],
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "wc2AL7L7k",
|
||||
},
|
||||
"filters": [],
|
||||
"name": "Filters",
|
||||
"type": "adhoc",
|
||||
},
|
||||
{
|
||||
"auto": true,
|
||||
"auto_count": 30,
|
||||
@@ -525,14 +535,6 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
|
||||
"skipUrlSync": true,
|
||||
"type": "constant",
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "wc2AL7L7k",
|
||||
},
|
||||
"name": "Filters",
|
||||
"type": "adhoc",
|
||||
},
|
||||
],
|
||||
},
|
||||
"time": {
|
||||
@@ -757,6 +759,16 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"baseFilters": [],
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "wc2AL7L7k",
|
||||
},
|
||||
"filters": [],
|
||||
"name": "Filters",
|
||||
"type": "adhoc",
|
||||
},
|
||||
{
|
||||
"auto": true,
|
||||
"auto_count": 30,
|
||||
@@ -827,14 +839,6 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
|
||||
"skipUrlSync": true,
|
||||
"type": "constant",
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "wc2AL7L7k",
|
||||
},
|
||||
"name": "Filters",
|
||||
"type": "adhoc",
|
||||
},
|
||||
],
|
||||
},
|
||||
"time": {
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { setRunRequest } from '@grafana/runtime';
|
||||
import {
|
||||
AdHocFiltersVariable,
|
||||
ConstantVariable,
|
||||
CustomVariable,
|
||||
DataSourceVariable,
|
||||
@@ -344,4 +345,60 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should handle AdHocFiltersVariable', () => {
|
||||
const variable = new AdHocFiltersVariable({
|
||||
name: 'test',
|
||||
label: 'test-label',
|
||||
description: 'test-desc',
|
||||
datasource: { uid: 'fake-std', type: 'fake-std' },
|
||||
filters: [
|
||||
{
|
||||
key: 'filterTest',
|
||||
operator: '=',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
baseFilters: [
|
||||
{
|
||||
key: 'baseFilterTest',
|
||||
operator: '=',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
});
|
||||
const set = new SceneVariableSet({
|
||||
variables: [variable],
|
||||
});
|
||||
|
||||
const result = sceneVariablesSetToVariables(set);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"baseFilters": [
|
||||
{
|
||||
"key": "baseFilterTest",
|
||||
"operator": "=",
|
||||
"value": "test",
|
||||
},
|
||||
],
|
||||
"datasource": {
|
||||
"type": "fake-std",
|
||||
"uid": "fake-std",
|
||||
},
|
||||
"description": "test-desc",
|
||||
"filters": [
|
||||
{
|
||||
"key": "filterTest",
|
||||
"operator": "=",
|
||||
"value": "test",
|
||||
},
|
||||
],
|
||||
"label": "test-label",
|
||||
"name": "test",
|
||||
"type": "adhoc",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
@@ -104,6 +104,16 @@ export function sceneVariablesSetToVariables(set: SceneVariables) {
|
||||
},
|
||||
query: variable.state.value,
|
||||
});
|
||||
} else if (sceneUtils.isAdHocVariable(variable)) {
|
||||
variables.push({
|
||||
...commonProperties,
|
||||
name: variable.state.name!,
|
||||
type: 'adhoc',
|
||||
datasource: variable.state.datasource,
|
||||
// @ts-expect-error
|
||||
baseFilters: variable.state.baseFilters,
|
||||
filters: variable.state.filters,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Unsupported variable type');
|
||||
}
|
||||
|
@@ -11,8 +11,9 @@ import {
|
||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
AdHocFilterSet,
|
||||
AdHocFiltersVariable,
|
||||
behaviors,
|
||||
ConstantVariable,
|
||||
CustomVariable,
|
||||
DataSourceVariable,
|
||||
QueryVariable,
|
||||
@@ -120,11 +121,11 @@ describe('transformSaveModelToScene', () => {
|
||||
expect(scene.state?.$timeRange?.state.timeZone).toEqual('America/New_York');
|
||||
expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday');
|
||||
|
||||
expect(scene.state?.$variables?.state.variables).toHaveLength(1);
|
||||
expect(scene.state?.$variables?.state.variables).toHaveLength(2);
|
||||
expect(scene.state?.$variables?.getByName('constant')).toBeInstanceOf(ConstantVariable);
|
||||
expect(scene.state?.$variables?.getByName('CoolFilters')).toBeInstanceOf(AdHocFiltersVariable);
|
||||
expect(dashboardControls).toBeDefined();
|
||||
expect(dashboardControls).toBeInstanceOf(DashboardControls);
|
||||
expect(dashboardControls.state.variableControls[1]).toBeInstanceOf(AdHocFilterSet);
|
||||
expect((dashboardControls.state.variableControls[1] as AdHocFilterSet).state.name).toBe('CoolFilters');
|
||||
expect(dashboardControls.state.timeControls).toHaveLength(2);
|
||||
expect(dashboardControls.state.timeControls[0]).toBeInstanceOf(SceneTimePicker);
|
||||
expect(dashboardControls.state.timeControls[1]).toBeInstanceOf(SceneRefreshPicker);
|
||||
@@ -789,6 +790,60 @@ describe('transformSaveModelToScene', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate adhoc variable', () => {
|
||||
const variable: TypedVariableModel = {
|
||||
id: 'adhoc',
|
||||
global: false,
|
||||
index: 0,
|
||||
state: LoadingState.Done,
|
||||
error: null,
|
||||
name: 'adhoc',
|
||||
label: 'Adhoc Label',
|
||||
description: 'Adhoc Description',
|
||||
type: 'adhoc',
|
||||
rootStateKey: 'N4XLmH5Vz',
|
||||
datasource: {
|
||||
uid: 'gdev-prometheus',
|
||||
type: 'prometheus',
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
key: 'filterTest',
|
||||
operator: '=',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
baseFilters: [
|
||||
{
|
||||
key: 'baseFilterTest',
|
||||
operator: '=',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
hide: 0,
|
||||
skipUrlSync: false,
|
||||
};
|
||||
|
||||
const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable;
|
||||
const filterVarState = migrated.state;
|
||||
|
||||
expect(migrated).toBeInstanceOf(AdHocFiltersVariable);
|
||||
expect(filterVarState).toEqual({
|
||||
key: expect.any(String),
|
||||
description: 'Adhoc Description',
|
||||
hide: 0,
|
||||
label: 'Adhoc Label',
|
||||
name: 'adhoc',
|
||||
skipUrlSync: false,
|
||||
type: 'adhoc',
|
||||
filterExpression: 'filterTest="test"',
|
||||
filters: [{ key: 'filterTest', operator: '=', value: 'test' }],
|
||||
baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }],
|
||||
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
|
||||
applyMode: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['system'])('should throw for unsupported (yet) variables', (type) => {
|
||||
const variable = {
|
||||
name: 'query0',
|
||||
@@ -856,7 +911,7 @@ describe('transformSaveModelToScene', () => {
|
||||
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
||||
|
||||
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
|
||||
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
|
||||
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[1]).toBeInstanceOf(
|
||||
SceneDataLayerControls
|
||||
);
|
||||
|
||||
@@ -886,7 +941,7 @@ describe('transformSaveModelToScene', () => {
|
||||
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
||||
|
||||
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
|
||||
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
|
||||
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[1]).toBeInstanceOf(
|
||||
SceneDataLayerControls
|
||||
);
|
||||
|
||||
@@ -902,7 +957,7 @@ describe('transformSaveModelToScene', () => {
|
||||
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
||||
|
||||
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
|
||||
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
|
||||
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[1]).toBeInstanceOf(
|
||||
SceneDataLayerControls
|
||||
);
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { AdHocVariableModel, TypedVariableModel, VariableModel } from '@grafana/data';
|
||||
import { TypedVariableModel } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
VizPanel,
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
SceneDataLayers,
|
||||
SceneDataLayerProvider,
|
||||
SceneDataLayerControls,
|
||||
AdHocFilterSet,
|
||||
TextBoxVariable,
|
||||
UserActionEvent,
|
||||
AdHocFiltersVariable,
|
||||
} from '@grafana/scenes';
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { trackDashboardLoaded } from 'app/features/dashboard/utils/tracking';
|
||||
@@ -173,24 +173,11 @@ function createRowFromPanelModel(row: PanelModel, content: SceneGridItemLike[]):
|
||||
export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) {
|
||||
let variables: SceneVariableSet | undefined = undefined;
|
||||
let layers: SceneDataLayerProvider[] = [];
|
||||
let filtersSets: AdHocFilterSet[] = [];
|
||||
|
||||
if (oldModel.templating?.list?.length) {
|
||||
const variableObjects = oldModel.templating.list
|
||||
.map((v) => {
|
||||
try {
|
||||
if (isAdhocVariable(v)) {
|
||||
filtersSets.push(
|
||||
new AdHocFilterSet({
|
||||
name: v.name,
|
||||
datasource: v.datasource,
|
||||
filters: v.filters ?? [],
|
||||
baseFilters: v.baseFilters ?? [],
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return createSceneVariableFromVariableModel(v);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -282,7 +269,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
|
||||
: undefined,
|
||||
controls: [
|
||||
new DashboardControls({
|
||||
variableControls: [new VariableValueSelectors({}), ...filtersSets, new SceneDataLayerControls()],
|
||||
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
|
||||
timeControls: [
|
||||
new SceneTimePicker({}),
|
||||
new SceneRefreshPicker({
|
||||
@@ -305,6 +292,18 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
|
||||
name: variable.name,
|
||||
label: variable.label,
|
||||
};
|
||||
if (variable.type === 'adhoc') {
|
||||
return new AdHocFiltersVariable({
|
||||
...commonProperties,
|
||||
description: variable.description,
|
||||
skipUrlSync: variable.skipUrlSync,
|
||||
hide: variable.hide,
|
||||
datasource: variable.datasource,
|
||||
applyMode: 'auto',
|
||||
filters: variable.filters ?? [],
|
||||
baseFilters: variable.baseFilters ?? [],
|
||||
});
|
||||
}
|
||||
if (variable.type === 'custom') {
|
||||
return new CustomVariable({
|
||||
...commonProperties,
|
||||
@@ -480,8 +479,6 @@ export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike {
|
||||
});
|
||||
}
|
||||
|
||||
const isAdhocVariable = (v: VariableModel): v is AdHocVariableModel => v.type === 'adhoc';
|
||||
|
||||
const getLimitedDescriptionReporter = () => {
|
||||
const reportedPanels: string[] = [];
|
||||
|
||||
|
@@ -11,7 +11,6 @@ import {
|
||||
VizPanel,
|
||||
SceneDataTransformer,
|
||||
SceneVariableSet,
|
||||
AdHocFilterSet,
|
||||
LocalValueVariable,
|
||||
SceneRefreshPicker,
|
||||
} from '@grafana/scenes';
|
||||
@@ -101,17 +100,6 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
||||
refreshIntervals = control.state.intervals;
|
||||
}
|
||||
}
|
||||
|
||||
const variableControls = state.controls[0].state.variableControls;
|
||||
for (const control of variableControls) {
|
||||
if (control instanceof AdHocFilterSet) {
|
||||
variables.push({
|
||||
name: control.state.name!,
|
||||
type: 'adhoc',
|
||||
datasource: control.state.datasource,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.$behaviors) {
|
||||
|
@@ -10,7 +10,14 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
|
||||
import { SceneVariableSet, CustomVariable, SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes';
|
||||
import {
|
||||
SceneVariableSet,
|
||||
CustomVariable,
|
||||
SceneGridItem,
|
||||
SceneGridLayout,
|
||||
VizPanel,
|
||||
AdHocFiltersVariable,
|
||||
} from '@grafana/scenes';
|
||||
import { mockDataSource } from 'app/features/alerting/unified/mocks';
|
||||
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
|
||||
|
||||
@@ -97,11 +104,16 @@ describe('VariablesEditView', () => {
|
||||
query: 'test3, test4, $customVar',
|
||||
value: 'test3',
|
||||
},
|
||||
{
|
||||
type: 'adhoc',
|
||||
name: 'adhoc',
|
||||
},
|
||||
];
|
||||
const variables = variableView.getVariables();
|
||||
expect(variables).toHaveLength(2);
|
||||
expect(variables).toHaveLength(3);
|
||||
expect(variables[0].state).toMatchObject(expectedVariables[0]);
|
||||
expect(variables[1].state).toMatchObject(expectedVariables[1]);
|
||||
expect(variables[2].state).toMatchObject(expectedVariables[2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,7 +129,7 @@ describe('VariablesEditView', () => {
|
||||
const variables = variableView.getVariables();
|
||||
const variable = variables[0];
|
||||
variableView.onDuplicated(variable.state.name);
|
||||
expect(variableView.getVariables()).toHaveLength(3);
|
||||
expect(variableView.getVariables()).toHaveLength(4);
|
||||
expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar');
|
||||
});
|
||||
|
||||
@@ -125,7 +137,7 @@ describe('VariablesEditView', () => {
|
||||
const variableIdentifier = 'customVar';
|
||||
variableView.onDuplicated(variableIdentifier);
|
||||
variableView.onDuplicated(variableIdentifier);
|
||||
expect(variableView.getVariables()).toHaveLength(4);
|
||||
expect(variableView.getVariables()).toHaveLength(5);
|
||||
expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar_1');
|
||||
expect(variableView.getVariables()[2].state.name).toBe('copy_of_customVar');
|
||||
});
|
||||
@@ -133,7 +145,7 @@ describe('VariablesEditView', () => {
|
||||
it('should delete a variable', () => {
|
||||
const variableIdentifier = 'customVar';
|
||||
variableView.onDelete(variableIdentifier);
|
||||
expect(variableView.getVariables()).toHaveLength(1);
|
||||
expect(variableView.getVariables()).toHaveLength(2);
|
||||
expect(variableView.getVariables()[0].state.name).toBe('customVar2');
|
||||
});
|
||||
|
||||
@@ -147,7 +159,7 @@ describe('VariablesEditView', () => {
|
||||
|
||||
it('should keep the same order of variables with invalid indexes', () => {
|
||||
const fromIndex = 0;
|
||||
const toIndex = 2;
|
||||
const toIndex = 3;
|
||||
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
@@ -163,11 +175,11 @@ describe('VariablesEditView', () => {
|
||||
const previousVariable = variableView.getVariables()[1] as CustomVariable;
|
||||
variableView.onEdit('customVar2');
|
||||
|
||||
variableView.onTypeChange('constant');
|
||||
expect(variableView.getVariables()).toHaveLength(2);
|
||||
variableView.onTypeChange('adhoc');
|
||||
expect(variableView.getVariables()).toHaveLength(3);
|
||||
const variable = variableView.getVariables()[1];
|
||||
expect(variable).not.toBe(previousVariable);
|
||||
expect(variable.state.type).toBe('constant');
|
||||
expect(variable.state.type).toBe('adhoc');
|
||||
|
||||
// Values to be kept between the old and new variable
|
||||
expect(variable.state.name).toEqual(previousVariable.state.name);
|
||||
@@ -184,9 +196,9 @@ describe('VariablesEditView', () => {
|
||||
|
||||
it('should add default new query variable when onAdd is called', () => {
|
||||
variableView.onAdd();
|
||||
expect(variableView.getVariables()).toHaveLength(3);
|
||||
expect(variableView.getVariables()[2].state.name).toBe('query0');
|
||||
expect(variableView.getVariables()[2].state.type).toBe('query');
|
||||
expect(variableView.getVariables()).toHaveLength(4);
|
||||
expect(variableView.getVariables()[3].state.name).toBe('query0');
|
||||
expect(variableView.getVariables()[3].state.type).toBe('query');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -266,6 +278,17 @@ async function buildTestScene() {
|
||||
value: '$customVar',
|
||||
text: '$customVar',
|
||||
}),
|
||||
new AdHocFiltersVariable({
|
||||
type: 'adhoc',
|
||||
name: 'adhoc',
|
||||
filters: [
|
||||
{
|
||||
key: 'test',
|
||||
operator: '=',
|
||||
value: 'testValue',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body: new SceneGridLayout({
|
||||
|
@@ -1,14 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
SceneObjectBase,
|
||||
SceneVariable,
|
||||
SceneVariables,
|
||||
sceneGraph,
|
||||
AdHocFilterSet,
|
||||
} from '@grafana/scenes';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
@@ -46,7 +39,7 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
|
||||
return variables.findIndex((variable) => variable.state.name === identifier);
|
||||
};
|
||||
|
||||
private replaceEditVariable = (newVariable: SceneVariable | AdHocFilterSet) => {
|
||||
private replaceEditVariable = (newVariable: SceneVariable) => {
|
||||
// Find the index of the variable to be deleted
|
||||
const variableIndex = this.state.editIndex ?? -1;
|
||||
const { variables } = this.getVariableSet().state;
|
||||
@@ -58,18 +51,10 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
|
||||
return;
|
||||
}
|
||||
|
||||
if (newVariable instanceof AdHocFilterSet) {
|
||||
// TODO: Update controls in adding this fiter set to the dashboard
|
||||
} else {
|
||||
const updatedVariables = [
|
||||
...variables.slice(0, variableIndex),
|
||||
newVariable,
|
||||
...variables.slice(variableIndex + 1),
|
||||
];
|
||||
const updatedVariables = [...variables.slice(0, variableIndex), newVariable, ...variables.slice(variableIndex + 1)];
|
||||
|
||||
// Update the state or the variables array
|
||||
this.getVariableSet().setState({ variables: updatedVariables });
|
||||
}
|
||||
// Update the state or the variables array
|
||||
this.getVariableSet().setState({ variables: updatedVariables });
|
||||
};
|
||||
|
||||
public onDelete = (identifier: string) => {
|
||||
@@ -158,12 +143,9 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
|
||||
const variableIndex = variables.length;
|
||||
//add the new variable to the end of the array
|
||||
const defaultNewVariable = getVariableDefault(variables);
|
||||
if (defaultNewVariable instanceof AdHocFilterSet) {
|
||||
// TODO: Update controls in adding this fiter set to the dashboard
|
||||
} else {
|
||||
this.getVariableSet().setState({ variables: [...this.getVariables(), defaultNewVariable] });
|
||||
this.setState({ editIndex: variableIndex });
|
||||
}
|
||||
|
||||
this.getVariableSet().setState({ variables: [...this.getVariables(), defaultNewVariable] });
|
||||
this.setState({ editIndex: variableIndex });
|
||||
};
|
||||
|
||||
public onTypeChange = (type: EditableVariableType) => {
|
||||
|
@@ -0,0 +1,75 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { mockDataSource } from 'app/features/alerting/unified/mocks';
|
||||
|
||||
import { AdHocVariableForm } from './AdHocVariableForm';
|
||||
|
||||
const defaultDatasource = mockDataSource({
|
||||
name: 'Default Test Data Source',
|
||||
uid: 'test-ds',
|
||||
type: 'test',
|
||||
});
|
||||
|
||||
const promDatasource = mockDataSource({
|
||||
name: 'Prometheus',
|
||||
uid: 'prometheus',
|
||||
type: 'prometheus',
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
|
||||
...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
|
||||
getDataSourceSrv: () => ({
|
||||
get: async () => defaultDatasource,
|
||||
getList: () => [defaultDatasource, promDatasource],
|
||||
getInstanceSettings: () => ({ ...defaultDatasource }),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AdHocVariableForm', () => {
|
||||
it('should render the form with the provided data source', async () => {
|
||||
const onDataSourceChange = jest.fn();
|
||||
const { renderer } = await setup({
|
||||
datasource: defaultDatasource,
|
||||
onDataSourceChange,
|
||||
infoText: 'Test Info',
|
||||
});
|
||||
|
||||
const dataSourcePicker = renderer.getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect
|
||||
);
|
||||
const infoText = renderer.getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText
|
||||
);
|
||||
|
||||
expect(dataSourcePicker).toBeInTheDocument();
|
||||
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
|
||||
expect(infoText).toBeInTheDocument();
|
||||
expect(infoText).toHaveTextContent('Test Info');
|
||||
});
|
||||
|
||||
it('should call the onDataSourceChange callback when the data source is changed', async () => {
|
||||
const onDataSourceChange = jest.fn();
|
||||
const { renderer, user } = await setup({
|
||||
datasource: defaultDatasource,
|
||||
onDataSourceChange,
|
||||
infoText: 'Test Info',
|
||||
});
|
||||
|
||||
// Simulate changing the data source
|
||||
await user.click(renderer.getByTestId(selectors.components.DataSourcePicker.inputV2));
|
||||
await user.click(renderer.getByText(/prom/i));
|
||||
|
||||
expect(onDataSourceChange).toHaveBeenCalledTimes(1);
|
||||
expect(onDataSourceChange).toHaveBeenCalledWith(promDatasource, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
async function setup(props?: React.ComponentProps<typeof AdHocVariableForm>) {
|
||||
return {
|
||||
renderer: await act(() => render(<AdHocVariableForm onDataSourceChange={jest.fn()} {...props} />)),
|
||||
user: userEvent.setup(),
|
||||
};
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { DataSourceRef } from '@grafana/schema';
|
||||
import { Alert, Field } from '@grafana/ui';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
|
||||
import { VariableLegend } from './VariableLegend';
|
||||
|
||||
interface AdHocVariableFormProps {
|
||||
datasource?: DataSourceRef;
|
||||
onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void;
|
||||
infoText?: string;
|
||||
}
|
||||
|
||||
export function AdHocVariableForm({ datasource, infoText, onDataSourceChange }: AdHocVariableFormProps) {
|
||||
return (
|
||||
<>
|
||||
<VariableLegend>Ad-hoc options</VariableLegend>
|
||||
<Field label="Data source" htmlFor="data-source-picker">
|
||||
<DataSourcePicker current={datasource} onChange={onDataSourceChange} width={30} variables={true} noDefault />
|
||||
</Field>
|
||||
|
||||
{infoText ? (
|
||||
<Alert
|
||||
title={infoText}
|
||||
severity="info"
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,122 @@
|
||||
import { render, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
FieldType,
|
||||
LoadingState,
|
||||
PanelData,
|
||||
VariableSupportType,
|
||||
getDefaultTimeRange,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { setRunRequest } from '@grafana/runtime/src';
|
||||
import { AdHocFiltersVariable } from '@grafana/scenes';
|
||||
import { mockDataSource } from 'app/features/alerting/unified/mocks';
|
||||
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
|
||||
|
||||
import { AdHocFiltersVariableEditor } from './AdHocFiltersVariableEditor';
|
||||
|
||||
const defaultDatasource = mockDataSource({
|
||||
name: 'Default Test Data Source',
|
||||
uid: 'test-ds',
|
||||
type: 'test',
|
||||
});
|
||||
|
||||
const promDatasource = mockDataSource({
|
||||
name: 'Prometheus',
|
||||
uid: 'prometheus',
|
||||
type: 'prometheus',
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
|
||||
...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
|
||||
getDataSourceSrv: () => ({
|
||||
get: async () => ({
|
||||
...defaultDatasource,
|
||||
variables: {
|
||||
getType: () => VariableSupportType.Custom,
|
||||
query: jest.fn(),
|
||||
editor: jest.fn().mockImplementation(LegacyVariableQueryEditor),
|
||||
},
|
||||
}),
|
||||
getList: () => [defaultDatasource, promDatasource],
|
||||
getInstanceSettings: () => ({ ...defaultDatasource }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const runRequestMock = jest.fn().mockReturnValue(
|
||||
of<PanelData>({
|
||||
state: LoadingState.Done,
|
||||
series: [
|
||||
toDataFrame({
|
||||
fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }],
|
||||
}),
|
||||
],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
})
|
||||
);
|
||||
|
||||
setRunRequest(runRequestMock);
|
||||
|
||||
describe('AdHocFiltersVariableEditor', () => {
|
||||
it('renders AdHocVariableForm with correct props', async () => {
|
||||
const { renderer } = await setup();
|
||||
const dataSourcePicker = renderer.getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect
|
||||
);
|
||||
const infoText = renderer.getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText
|
||||
);
|
||||
|
||||
expect(dataSourcePicker).toBeInTheDocument();
|
||||
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
|
||||
expect(infoText).toBeInTheDocument();
|
||||
expect(infoText).toHaveTextContent('This data source does not support ad hoc filters yet.');
|
||||
});
|
||||
|
||||
it('should update the variable data source when data source picker is changed', async () => {
|
||||
const { renderer, variable, user } = await setup();
|
||||
|
||||
// Simulate changing the data source
|
||||
await user.click(renderer.getByTestId(selectors.components.DataSourcePicker.inputV2));
|
||||
await user.click(renderer.getByText(/prom/i));
|
||||
|
||||
expect(variable.state.datasource).toEqual({ uid: 'prometheus', type: 'prometheus' });
|
||||
});
|
||||
});
|
||||
|
||||
async function setup(props?: React.ComponentProps<typeof AdHocFiltersVariableEditor>) {
|
||||
const onRunQuery = jest.fn();
|
||||
const variable = new AdHocFiltersVariable({
|
||||
name: 'adhocVariable',
|
||||
type: 'adhoc',
|
||||
label: 'Ad hoc filters',
|
||||
description: 'Ad hoc filters are applied automatically to all queries that target this data source',
|
||||
datasource: { uid: defaultDatasource.uid, type: defaultDatasource.type },
|
||||
filters: [
|
||||
{
|
||||
key: 'test',
|
||||
operator: '=',
|
||||
value: 'testValue',
|
||||
},
|
||||
],
|
||||
baseFilters: [
|
||||
{
|
||||
key: 'baseTest',
|
||||
operator: '=',
|
||||
value: 'baseTestValue',
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
renderer: await act(() =>
|
||||
render(<AdHocFiltersVariableEditor variable={variable} onRunQuery={onRunQuery} {...props} />)
|
||||
),
|
||||
variable,
|
||||
user: userEvent.setup(),
|
||||
mocks: { onRunQuery },
|
||||
};
|
||||
}
|
@@ -1,12 +1,40 @@
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { AdHocFiltersVariable } from '@grafana/scenes';
|
||||
import { DataSourceRef } from '@grafana/schema';
|
||||
|
||||
import { AdHocVariableForm } from '../components/AdHocVariableForm';
|
||||
|
||||
interface AdHocFiltersVariableEditorProps {
|
||||
variable: AdHocFiltersVariable;
|
||||
onChange: (variable: AdHocFiltersVariable) => void;
|
||||
onRunQuery: (variable: AdHocFiltersVariable) => void;
|
||||
}
|
||||
|
||||
export function AdHocFiltersVariableEditor(props: AdHocFiltersVariableEditorProps) {
|
||||
return <div>AdHocFiltersVariableEditor</div>;
|
||||
const { variable } = props;
|
||||
const datasourceRef = variable.useState().datasource ?? undefined;
|
||||
|
||||
const { value: datasourceSettings } = useAsync(async () => {
|
||||
return await getDataSourceSrv().get(datasourceRef);
|
||||
}, [datasourceRef]);
|
||||
|
||||
const message = datasourceSettings?.getTagKeys
|
||||
? 'Ad hoc filters are applied automatically to all queries that target this data source'
|
||||
: 'This data source does not support ad hoc filters yet.';
|
||||
|
||||
const onDataSourceChange = (ds: DataSourceInstanceSettings) => {
|
||||
const dsRef: DataSourceRef = {
|
||||
uid: ds.uid,
|
||||
type: ds.type,
|
||||
};
|
||||
|
||||
variable.setState({
|
||||
datasource: dsRef,
|
||||
});
|
||||
};
|
||||
|
||||
return <AdHocVariableForm datasource={datasourceRef} infoText={message} onDataSourceChange={onDataSourceChange} />;
|
||||
}
|
||||
|
@@ -172,10 +172,10 @@ describe('getVariableScene', () => {
|
||||
['adhoc', AdHocFiltersVariable],
|
||||
['groupby', GroupByVariable],
|
||||
['textbox', TextBoxVariable],
|
||||
])('should return the scene variable instance for the given editable variable type', () => {
|
||||
])('should return the scene variable instance for the given editable variable type', (type, instanceType) => {
|
||||
const initialState = { name: 'MyVariable' };
|
||||
const sceneVariable = getVariableScene('custom', initialState);
|
||||
expect(sceneVariable).toBeInstanceOf(CustomVariable);
|
||||
const sceneVariable = getVariableScene(type as EditableVariableType, initialState);
|
||||
expect(sceneVariable).toBeInstanceOf(instanceType);
|
||||
expect(sceneVariable.state.name).toBe(initialState.name);
|
||||
});
|
||||
});
|
||||
|
@@ -9,10 +9,10 @@ import {
|
||||
IntervalVariable,
|
||||
TextBoxVariable,
|
||||
QueryVariable,
|
||||
AdHocFilterSet,
|
||||
GroupByVariable,
|
||||
SceneVariable,
|
||||
MultiValueVariable,
|
||||
AdHocFiltersVariable,
|
||||
SceneVariableState,
|
||||
} from '@grafana/scenes';
|
||||
import { VariableType } from '@grafana/schema';
|
||||
@@ -124,8 +124,7 @@ export function getVariableScene(type: EditableVariableType, initialState: Commo
|
||||
case 'datasource':
|
||||
return new DataSourceVariable(initialState);
|
||||
case 'adhoc':
|
||||
// TODO: Initialize properly AdHocFilterSet with initialState
|
||||
return new AdHocFilterSet({ name: initialState.name });
|
||||
return new AdHocFiltersVariable(initialState);
|
||||
case 'groupby':
|
||||
return new GroupByVariable(initialState);
|
||||
case 'textbox':
|
||||
|
@@ -10,7 +10,7 @@ export function getVariablesCompatibility(sceneObject: SceneObject): TypedVariab
|
||||
// Sadly templateSrv.getVariables returns TypedVariableModel but sceneVariablesSetToVariables return persisted schema model
|
||||
// They look close to identical (differ in what is optional in some places).
|
||||
// The way templateSrv.getVariables is used it should not matter. it is mostly used to get names of all variables (for query editors).
|
||||
// So type and name are important. Maybe some external data sourcess also check current value so that is also important.
|
||||
// So type and name are important. Maybe some external data sources also check current value so that is also important.
|
||||
// @ts-expect-error
|
||||
return legacyModels;
|
||||
}
|
||||
|
@@ -13,7 +13,6 @@ import {
|
||||
EventBus,
|
||||
ExploreLogsPanelState,
|
||||
ExplorePanelsState,
|
||||
FeatureState,
|
||||
Field,
|
||||
GrafanaTheme2,
|
||||
LinkModel,
|
||||
@@ -36,7 +35,6 @@ import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery, TimeZone } from '@grafana/schema';
|
||||
import {
|
||||
Button,
|
||||
FeatureBadge,
|
||||
InlineField,
|
||||
InlineFieldRow,
|
||||
InlineSwitch,
|
||||
@@ -654,21 +652,13 @@ class UnthemedLogs extends PureComponent<Props, State> {
|
||||
</PanelChrome>
|
||||
<PanelChrome
|
||||
titleItems={[
|
||||
config.featureToggles.logsExploreTableVisualisation
|
||||
? this.state.visualisationType === 'logs'
|
||||
? null
|
||||
: [
|
||||
<PanelChrome.TitleItem title="Experimental" key="A">
|
||||
<FeatureBadge
|
||||
featureState={FeatureState.beta}
|
||||
tooltip="Table view is experimental and may change in future versions"
|
||||
/>
|
||||
</PanelChrome.TitleItem>,
|
||||
<PanelChrome.TitleItem title="Feedback" key="B">
|
||||
<LogsFeedback feedbackUrl="https://forms.gle/5YyKdRQJ5hzq4c289" />
|
||||
</PanelChrome.TitleItem>,
|
||||
]
|
||||
: null,
|
||||
config.featureToggles.logsExploreTableVisualisation ? (
|
||||
this.state.visualisationType === 'logs' ? null : (
|
||||
<PanelChrome.TitleItem title="Feedback" key="A">
|
||||
<LogsFeedback feedbackUrl="https://forms.gle/5YyKdRQJ5hzq4c289" />
|
||||
</PanelChrome.TitleItem>
|
||||
)
|
||||
) : null,
|
||||
]}
|
||||
title={'Logs'}
|
||||
actions={
|
||||
|
@@ -94,9 +94,9 @@ const getStyles = (theme: GrafanaTheme2, height: number) => {
|
||||
`,
|
||||
footer: css`
|
||||
height: 60px;
|
||||
margin: ${theme.spacing(3)} auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: ${theme.typography.fontWeightLight};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
a {
|
||||
|
@@ -54,9 +54,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
`,
|
||||
footer: css`
|
||||
height: 60px;
|
||||
margin-top: ${theme.spacing(3)};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: ${theme.typography.fontWeightLight};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
a {
|
||||
|
@@ -9,7 +9,7 @@ import { Button, ClipboardButton, JSONFormatter, LoadingPlaceholder, Stack } fro
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
import { getPanelInspectorStyles2 } from './styles';
|
||||
|
||||
interface ExecutedQueryInfo {
|
||||
refId: string;
|
||||
@@ -212,7 +212,7 @@ export class QueryInspector extends PureComponent<Props, State> {
|
||||
const { allNodesExpanded, executedQueries, response } = this.state;
|
||||
const { onRefreshQuery, data } = this.props;
|
||||
const openNodes = this.getNrOfOpenNodes();
|
||||
const styles = getPanelInspectorStyles();
|
||||
const styles = getPanelInspectorStyles2(config.theme2);
|
||||
const haveData = Object.keys(response).length > 0;
|
||||
const isLoading = data.state === LoadingState.Loading;
|
||||
|
||||
|
@@ -28,9 +28,9 @@ export class AddToFiltersGraphAction extends SceneObjectBase<AddToFiltersGraphAc
|
||||
|
||||
const labelName = Object.keys(labels)[0];
|
||||
|
||||
variable.state.set.setState({
|
||||
variable.setState({
|
||||
filters: [
|
||||
...variable.state.set.state.filters,
|
||||
...variable.state.filters,
|
||||
{
|
||||
key: labelName,
|
||||
operator: '=',
|
||||
|
@@ -11,7 +11,7 @@ export function getLabelOptions(scenObject: SceneObject, variable: QueryVariable
|
||||
return [];
|
||||
}
|
||||
|
||||
const filters = labelFilters.state.set.state.filters;
|
||||
const filters = labelFilters.state.filters;
|
||||
|
||||
for (const option of variable.getOptionsForSelect()) {
|
||||
const filterExists = filters.find((f) => f.key === option.value);
|
||||
|
@@ -137,7 +137,7 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
|
||||
// Add metric to adhoc filters baseFilter
|
||||
const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this);
|
||||
if (filterVar instanceof AdHocFiltersVariable) {
|
||||
filterVar.state.set.setState({
|
||||
filterVar.setState({
|
||||
baseFilters: getBaseFiltersForMetric(evt.payload),
|
||||
});
|
||||
}
|
||||
@@ -208,7 +208,7 @@ function getVariableSet(initialDS?: string, metric?: string, initialFilters?: Ad
|
||||
value: initialDS,
|
||||
pluginId: metric === LOGS_METRIC ? 'loki' : 'prometheus',
|
||||
}),
|
||||
AdHocFiltersVariable.create({
|
||||
new AdHocFiltersVariable({
|
||||
name: VAR_FILTERS,
|
||||
datasource: trailDS,
|
||||
layout: 'vertical',
|
||||
|
@@ -23,7 +23,7 @@ export function DataTrailCard({ trail, onSelect, onDelete }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filters = filtersVariable.state.set.state.filters;
|
||||
const filters = filtersVariable.state.filters;
|
||||
const dsValue = getDataSource(trail);
|
||||
|
||||
return (
|
||||
|
@@ -2,6 +2,13 @@ import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY } from '../shared';
|
||||
|
||||
import { SerializedTrail, getTrailStore } from './TrailStore';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getTemplateSrv: () => ({
|
||||
getAdhocFilters: jest.fn().mockReturnValue([{ key: 'origKey', operator: '=', value: '' }]),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('TrailStore', () => {
|
||||
beforeAll(() => {
|
||||
let localStore: Record<string, string> = {};
|
||||
|
@@ -1,90 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React, { ComponentProps } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { mockDataSource } from 'app/features/alerting/unified/mocks';
|
||||
import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
|
||||
|
||||
import { adHocBuilder } from '../shared/testing/builders';
|
||||
|
||||
import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor';
|
||||
|
||||
const promDsMock = mockDataSource({
|
||||
name: 'Prometheus',
|
||||
type: DataSourceType.Prometheus,
|
||||
});
|
||||
|
||||
const lokiDsMock = mockDataSource({
|
||||
name: 'Loki',
|
||||
type: DataSourceType.Loki,
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
|
||||
return {
|
||||
getDataSourceSrv: () => ({
|
||||
get: () => {
|
||||
return Promise.resolve(promDsMock);
|
||||
},
|
||||
getList: () => [promDsMock, lokiDsMock],
|
||||
getInstanceSettings: (v: string) => {
|
||||
if (v === 'Prometheus') {
|
||||
return promDsMock;
|
||||
}
|
||||
return lokiDsMock;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const props = {
|
||||
extended: {
|
||||
dataSources: [
|
||||
{ text: 'Prometheus', value: null }, // default datasource
|
||||
{ text: 'Loki', value: { type: 'loki-ds', uid: 'abc' } },
|
||||
],
|
||||
} as ComponentProps<typeof AdHocVariableEditor>['extended'],
|
||||
variable: adHocBuilder().withId('adhoc').withRootStateKey('key').withName('adhoc').build(),
|
||||
onPropChange: jest.fn(),
|
||||
|
||||
// connected actions
|
||||
initAdHocVariableEditor: jest.fn(),
|
||||
changeVariableDatasource: jest.fn(),
|
||||
};
|
||||
|
||||
describe('AdHocVariableEditor', () => {
|
||||
beforeEach(() => {
|
||||
props.changeVariableDatasource.mockReset();
|
||||
});
|
||||
|
||||
it('has a datasource select menu', async () => {
|
||||
render(<AdHocVariableEditor {...props} />);
|
||||
|
||||
expect(await screen.getByTestId(selectors.components.DataSourcePicker.container)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the callback when changing the datasource', async () => {
|
||||
render(<AdHocVariableEditor {...props} />);
|
||||
const selectEl = screen
|
||||
.getByTestId(selectors.components.DataSourcePicker.container)
|
||||
.getElementsByTagName('input')[0];
|
||||
await userEvent.click(selectEl);
|
||||
await userEvent.click(screen.getByText('Loki'));
|
||||
|
||||
expect(props.changeVariableDatasource).toBeCalledWith(
|
||||
{ type: 'adhoc', id: 'adhoc', rootStateKey: 'key' },
|
||||
{ type: 'loki', uid: 'mock-ds-3' }
|
||||
);
|
||||
});
|
||||
|
||||
it('renders informational text', () => {
|
||||
const extended = {
|
||||
...props.extended,
|
||||
infoText: "Here's a message that should help you",
|
||||
};
|
||||
render(<AdHocVariableEditor {...props} extended={extended} />);
|
||||
|
||||
const alert = screen.getByText("Here's a message that should help you");
|
||||
expect(alert).toBeInTheDocument();
|
||||
});
|
||||
});
|
@@ -2,11 +2,9 @@ import React, { PureComponent } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
|
||||
import { Alert, Field } from '@grafana/ui';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
import { AdHocVariableForm } from 'app/features/dashboard-scene/settings/variables/components/AdHocVariableForm';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
import { VariableLegend } from '../../dashboard-scene/settings/variables/components/VariableLegend';
|
||||
import { initialVariableEditorState } from '../editor/reducer';
|
||||
import { getAdhocVariableEditorState } from '../editor/selectors';
|
||||
import { VariableEditorProps } from '../editor/types';
|
||||
@@ -58,23 +56,13 @@ export class AdHocVariableEditorUnConnected extends PureComponent<Props> {
|
||||
|
||||
render() {
|
||||
const { variable, extended } = this.props;
|
||||
const infoText = extended?.infoText ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<VariableLegend>Ad-hoc options</VariableLegend>
|
||||
<Field label="Data source" htmlFor="data-source-picker">
|
||||
<DataSourcePicker
|
||||
current={variable.datasource}
|
||||
onChange={this.onDatasourceChanged}
|
||||
width={30}
|
||||
variables={true}
|
||||
noDefault
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{infoText ? <Alert title={infoText} severity="info" /> : null}
|
||||
</>
|
||||
<AdHocVariableForm
|
||||
datasource={variable.datasource ?? undefined}
|
||||
onDataSourceChange={this.onDatasourceChanged}
|
||||
infoText={extended?.infoText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -38,7 +38,7 @@ import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@gr
|
||||
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
|
||||
import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
|
||||
import { LokiOptions } from './_importedDependencies/datasources/loki/types';
|
||||
import { PromQuery, PrometheusDatasource } from './_importedDependencies/datasources/prometheus/types';
|
||||
import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types';
|
||||
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
|
||||
import {
|
||||
defaultTableFilter,
|
||||
@@ -55,10 +55,11 @@ import TempoLanguageProvider from './language_provider';
|
||||
import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary';
|
||||
import {
|
||||
createTableFrameFromSearch,
|
||||
formatTraceQLMetrics,
|
||||
formatTraceQLResponse,
|
||||
transformFromOTLP as transformFromOTEL,
|
||||
transformTrace,
|
||||
transformTraceList,
|
||||
formatTraceQLResponse,
|
||||
} from './resultTransformer';
|
||||
import { doTempoChannelStream } from './streaming';
|
||||
import { SearchQueryParams, TempoJsonData, TempoQuery } from './types';
|
||||
@@ -337,9 +338,8 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
try {
|
||||
const appliedQuery = this.applyVariables(targets.traceql[0], options.scopedVars);
|
||||
const queryValue = appliedQuery?.query || '';
|
||||
const hexOnlyRegex = /^[0-9A-Fa-f]*$/;
|
||||
// Check whether this is a trace ID or traceQL query by checking if it only contains hex characters
|
||||
if (queryValue.trim().match(hexOnlyRegex)) {
|
||||
if (this.isTraceIdQuery(queryValue)) {
|
||||
// There's only hex characters so let's assume that this is a trace ID
|
||||
reportInteraction('grafana_traces_traceID_queried', {
|
||||
datasourceType: 'tempo',
|
||||
@@ -350,39 +350,23 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
|
||||
subQueries.push(this.handleTraceIdQuery(options, targets.traceql));
|
||||
} else {
|
||||
reportInteraction('grafana_traces_traceql_queried', {
|
||||
datasourceType: 'tempo',
|
||||
app: options.app ?? '',
|
||||
grafana_version: config.buildInfo.version,
|
||||
query: queryValue ?? '',
|
||||
streaming: config.featureToggles.traceQLStreaming,
|
||||
});
|
||||
|
||||
if (config.featureToggles.traceQLStreaming && this.isFeatureAvailable(FeatureName.streaming)) {
|
||||
subQueries.push(this.handleStreamingSearch(options, targets.traceql, queryValue));
|
||||
if (this.isTraceQlMetricsQuery(queryValue)) {
|
||||
reportInteraction('grafana_traces_traceql_metrics_queried', {
|
||||
datasourceType: 'tempo',
|
||||
app: options.app ?? '',
|
||||
grafana_version: config.buildInfo.version,
|
||||
query: queryValue ?? '',
|
||||
});
|
||||
subQueries.push(this.handleTraceQlMetricsQuery(options, queryValue));
|
||||
} else {
|
||||
subQueries.push(
|
||||
this._request('/api/search', {
|
||||
q: queryValue,
|
||||
limit: options.targets[0].limit ?? DEFAULT_LIMIT,
|
||||
spss: options.targets[0].spss ?? DEFAULT_SPSS,
|
||||
start: options.range.from.unix(),
|
||||
end: options.range.to.unix(),
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
return {
|
||||
data: formatTraceQLResponse(
|
||||
response.data.traces,
|
||||
this.instanceSettings,
|
||||
targets.traceql[0].tableType
|
||||
),
|
||||
};
|
||||
}),
|
||||
catchError((err) => {
|
||||
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
|
||||
})
|
||||
)
|
||||
);
|
||||
reportInteraction('grafana_traces_traceql_queried', {
|
||||
datasourceType: 'tempo',
|
||||
app: options.app ?? '',
|
||||
grafana_version: config.buildInfo.version,
|
||||
query: queryValue ?? '',
|
||||
streaming: config.featureToggles.traceQLStreaming,
|
||||
});
|
||||
subQueries.push(this.handleTraceQlQuery(options, targets, queryValue));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -497,6 +481,19 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
return merge(...subQueries);
|
||||
}
|
||||
|
||||
isTraceQlMetricsQuery(query: string): boolean {
|
||||
// Check whether this is a metrics query by checking if it contains a metrics function
|
||||
const metricsFnRegex =
|
||||
/\|\s*(rate|count_over_time|avg_over_time|max_over_time|min_over_time|quantile_over_time)\s*\(/;
|
||||
return !!query.trim().match(metricsFnRegex);
|
||||
}
|
||||
|
||||
isTraceIdQuery(query: string): boolean {
|
||||
const hexOnlyRegex = /^[0-9A-Fa-f]*$/;
|
||||
// Check whether this is a trace ID or traceQL query by checking if it only contains hex characters
|
||||
return !!query.trim().match(hexOnlyRegex);
|
||||
}
|
||||
|
||||
applyTemplateVariables(query: TempoQuery, scopedVars: ScopedVars) {
|
||||
return this.applyVariables(query, scopedVars);
|
||||
}
|
||||
@@ -640,6 +637,55 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
);
|
||||
}
|
||||
|
||||
handleTraceQlQuery = (
|
||||
options: DataQueryRequest<TempoQuery>,
|
||||
targets: {
|
||||
[type: string]: TempoQuery[];
|
||||
},
|
||||
queryValue: string
|
||||
): Observable<DataQueryResponse> => {
|
||||
if (config.featureToggles.traceQLStreaming && this.isFeatureAvailable(FeatureName.streaming)) {
|
||||
return this.handleStreamingSearch(options, targets.traceql, queryValue);
|
||||
} else {
|
||||
return this._request('/api/search', {
|
||||
q: queryValue,
|
||||
limit: options.targets[0].limit ?? DEFAULT_LIMIT,
|
||||
spss: options.targets[0].spss ?? DEFAULT_SPSS,
|
||||
start: options.range.from.unix(),
|
||||
end: options.range.to.unix(),
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
return {
|
||||
data: formatTraceQLResponse(response.data.traces, this.instanceSettings, targets.traceql[0].tableType),
|
||||
};
|
||||
}),
|
||||
catchError((err) => {
|
||||
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleTraceQlMetricsQuery = (
|
||||
options: DataQueryRequest<TempoQuery>,
|
||||
queryValue: string
|
||||
): Observable<DataQueryResponse> => {
|
||||
return this._request('/api/metrics/query_range', {
|
||||
query: queryValue,
|
||||
start: options.range.from.unix(),
|
||||
end: options.range.to.unix(),
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
return {
|
||||
data: formatTraceQLMetrics(response.data),
|
||||
};
|
||||
}),
|
||||
catchError((err) => {
|
||||
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
traceIdQueryRequest(options: DataQueryRequest<TempoQuery>, targets: TempoQuery[]): DataQueryRequest<TempoQuery> {
|
||||
const request = {
|
||||
...options,
|
||||
|
@@ -3,32 +3,41 @@ import { collectorTypes } from '@opentelemetry/exporter-collector';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
|
||||
import {
|
||||
createDataFrame,
|
||||
createTheme,
|
||||
DataFrame,
|
||||
DataFrameDTO,
|
||||
DataLink,
|
||||
DataLinkConfigOrigin,
|
||||
DataQueryResponse,
|
||||
DataSourceInstanceSettings,
|
||||
DataSourceJsonData,
|
||||
Field,
|
||||
FieldDTO,
|
||||
FieldType,
|
||||
getDisplayProcessor,
|
||||
Labels,
|
||||
MutableDataFrame,
|
||||
toDataFrame,
|
||||
TraceKeyValuePair,
|
||||
TraceLog,
|
||||
TraceSpanReference,
|
||||
TraceSpanRow,
|
||||
FieldDTO,
|
||||
createDataFrame,
|
||||
getDisplayProcessor,
|
||||
createTheme,
|
||||
DataFrameDTO,
|
||||
toDataFrame,
|
||||
DataLink,
|
||||
DataSourceJsonData,
|
||||
Field,
|
||||
DataLinkConfigOrigin,
|
||||
} from '@grafana/data';
|
||||
import { TraceToProfilesData } from '@grafana/o11y-ds-frontend';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
|
||||
import { SearchTableType } from './dataquery.gen';
|
||||
import { createGraphFrames } from './graphTransform';
|
||||
import { Span, SpanAttributes, Spanset, TempoJsonData, TraceSearchMetadata } from './types';
|
||||
import {
|
||||
ProtoValue,
|
||||
Span,
|
||||
SpanAttributes,
|
||||
Spanset,
|
||||
TempoJsonData,
|
||||
TraceqlMetricsResponse,
|
||||
TraceSearchMetadata,
|
||||
} from './types';
|
||||
|
||||
export function createTableFrame(
|
||||
logsFrame: DataFrame | DataFrameDTO,
|
||||
@@ -623,6 +632,46 @@ function transformToTraceData(data: TraceSearchMetadata) {
|
||||
};
|
||||
}
|
||||
|
||||
const metricsValueToString = (value: ProtoValue): string => {
|
||||
return '' + (value.stringValue || value.intValue || value.doubleValue || value.boolValue || '');
|
||||
};
|
||||
|
||||
export function formatTraceQLMetrics(data: TraceqlMetricsResponse) {
|
||||
const frames = data.series.map((series) => {
|
||||
const labels: Labels = {};
|
||||
series.labels.forEach((label) => {
|
||||
labels[label.key] = metricsValueToString(label.value);
|
||||
});
|
||||
const displayName =
|
||||
series.labels.length === 1
|
||||
? metricsValueToString(series.labels[0].value)
|
||||
: `{${series.labels.map((label) => `${label.key}=${metricsValueToString(label.value)}`).join(',')}}`;
|
||||
return createDataFrame({
|
||||
refId: series.promLabels,
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: series.samples.map((sample) => parseInt(sample.timestampMs, 10)),
|
||||
},
|
||||
{
|
||||
name: series.promLabels,
|
||||
labels,
|
||||
type: FieldType.number,
|
||||
values: series.samples.map((sample) => sample.value),
|
||||
config: {
|
||||
displayNameFromDS: displayName,
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
preferredVisualisationType: 'graph',
|
||||
},
|
||||
});
|
||||
});
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function formatTraceQLResponse(
|
||||
data: TraceSearchMetadata[],
|
||||
instanceSettings: DataSourceInstanceSettings,
|
||||
@@ -886,10 +935,6 @@ export function createTableFrameFromTraceQlQueryAsSpans(
|
||||
* @returns the spansets of the trace, if existing
|
||||
*/
|
||||
const getSpanSets = (trace: TraceSearchMetadata): Spanset[] => {
|
||||
if (trace.spanSets && trace.spanSet) {
|
||||
console.warn('Both `spanSets` and `spanSet` are set. `spanSet` will be ignored');
|
||||
}
|
||||
|
||||
return trace.spanSets || (trace.spanSet ? [trace.spanSet] : []);
|
||||
};
|
||||
|
||||
|
@@ -119,3 +119,32 @@ export type Scope = {
|
||||
name: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
// Maps to QueryRangeResponse of tempopb https://github.com/grafana/tempo/blob/cfda98fc5cb0777963f41e0949b9ad2d24b4b5b8/pkg/tempopb/tempo.proto#L360
|
||||
export type TraceqlMetricsResponse = {
|
||||
series: MetricsSeries[];
|
||||
metrics: SearchMetrics;
|
||||
};
|
||||
|
||||
export type MetricsSeries = {
|
||||
labels: MetricsSeriesLabel[];
|
||||
samples: MetricsSeriesSample[];
|
||||
promLabels: string;
|
||||
};
|
||||
|
||||
export type MetricsSeriesLabel = {
|
||||
key: string;
|
||||
value: ProtoValue;
|
||||
};
|
||||
|
||||
export type ProtoValue = {
|
||||
stringValue?: string;
|
||||
intValue?: string;
|
||||
boolValue?: boolean;
|
||||
doubleValue?: string;
|
||||
};
|
||||
|
||||
export type MetricsSeriesSample = {
|
||||
timestampMs: string;
|
||||
value: number;
|
||||
};
|
||||
|
@@ -2,7 +2,7 @@
|
||||
global variables
|
||||
"""
|
||||
|
||||
grabpl_version = "v3.0.47"
|
||||
grabpl_version = "v3.0.50"
|
||||
golang_version = "1.21.6"
|
||||
|
||||
# nodejs_version should match what's in ".nvmrc", but without the v prefix.
|
||||
|
21
yarn.lock
21
yarn.lock
@@ -3833,6 +3833,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/lezer-logql@npm:0.2.3":
|
||||
version: 0.2.3
|
||||
resolution: "@grafana/lezer-logql@npm:0.2.3"
|
||||
peerDependencies:
|
||||
"@lezer/lr": ^1.0.0
|
||||
checksum: 10/a7f2f07d328d3e8c3d5cd31c5a0ea9379beeb7d65def078bae159298814d92881d978b088f05eff5148c8e26528de84a6fb6add8edf7bb383863fa51e9bc5f56
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/lezer-traceql@npm:0.0.14":
|
||||
version: 0.0.14
|
||||
resolution: "@grafana/lezer-traceql@npm:0.0.14"
|
||||
@@ -4053,9 +4062,9 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/scenes@npm:^2.6.5":
|
||||
version: 2.6.5
|
||||
resolution: "@grafana/scenes@npm:2.6.5"
|
||||
"@grafana/scenes@npm:^3.2.1":
|
||||
version: 3.2.1
|
||||
resolution: "@grafana/scenes@npm:3.2.1"
|
||||
dependencies:
|
||||
"@grafana/e2e-selectors": "npm:10.0.2"
|
||||
react-grid-layout: "npm:1.3.4"
|
||||
@@ -4067,7 +4076,7 @@ __metadata:
|
||||
"@grafana/runtime": 10.0.3
|
||||
"@grafana/schema": 10.0.3
|
||||
"@grafana/ui": 10.0.3
|
||||
checksum: 10/68fe91a5a0c8f80b679126f3525b74b29ce3f9ad92bc558eaaf39693235137348b90796c2b02aa7c1c7929586e60df6024e139993a416ef37d4c875e548dc855
|
||||
checksum: 10/5e93c0dcdfbd7cfed977d650fb0744c9b8107b1c92af2ae6d6e9f2f61bb9ba7f3cb5ad394fa3389aaef0d892a18c6a12fedf5454a4210bdcc6797a272ddbc625
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18250,12 +18259,12 @@ __metadata:
|
||||
"@grafana/faro-web-sdk": "npm:^1.3.6"
|
||||
"@grafana/flamegraph": "workspace:*"
|
||||
"@grafana/google-sdk": "npm:0.1.2"
|
||||
"@grafana/lezer-logql": "npm:0.2.2"
|
||||
"@grafana/lezer-logql": "npm:0.2.3"
|
||||
"@grafana/monaco-logql": "npm:^0.0.7"
|
||||
"@grafana/o11y-ds-frontend": "workspace:*"
|
||||
"@grafana/prometheus": "workspace:*"
|
||||
"@grafana/runtime": "workspace:*"
|
||||
"@grafana/scenes": "npm:^2.6.5"
|
||||
"@grafana/scenes": "npm:^3.2.1"
|
||||
"@grafana/schema": "workspace:*"
|
||||
"@grafana/sql": "workspace:*"
|
||||
"@grafana/tsconfig": "npm:^1.3.0-rc1"
|
||||
|
Reference in New Issue
Block a user