Merge branch 'main' into bohandley/prometheus-metric-encyclopedia-updates-after-feedback

This commit is contained in:
Brendan O'Handley
2023-03-10 14:28:47 -05:00
committed by GitHub
131 changed files with 3451 additions and 1415 deletions
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,10 @@
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
@@ -13,12 +16,16 @@
]
},
"editable": true,
"gnetId": null,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": null,
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"gridPos": {
"h": 1,
"w": 24,
@@ -26,11 +33,23 @@
"y": 0
},
"id": 7,
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"refId": "A"
}
],
"title": "Cell styles",
"type": "row"
},
{
"datasource": "gdev-testdata",
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -38,16 +57,19 @@
},
"custom": {
"align": "center",
"displayMode": "color-background",
"filterable": false
"cellOptions": {
"mode": "gradient",
"type": "color-background"
},
"filterable": false,
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "percentage",
"steps": [
{
"color": "green",
"value": null
"color": "green"
},
{
"color": "blue",
@@ -124,7 +146,17 @@
},
"id": 4,
"options": {
"cellHeight": "md",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"showRowNums": false,
"sortBy": [
{
"desc": true,
@@ -132,17 +164,19 @@
}
]
},
"pluginVersion": "",
"pluginVersion": "9.5.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 15,
"stringInput": ""
}
],
"timeFrom": null,
"timeShift": null,
"title": "Colored background",
"transformations": [
{
@@ -159,23 +193,29 @@
"type": "table"
},
{
"datasource": "gdev-testdata",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": null,
"filterable": false
"align": "center",
"cellOptions": {
"type": "auto"
},
"filterable": false,
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "percentage",
"steps": [
{
"color": "orange",
"value": null
"color": "orange"
},
{
"color": "red",
@@ -188,60 +228,51 @@
{
"matcher": {
"id": "byName",
"options": "A"
"options": "Basic "
},
"properties": [
{
"id": "custom.displayMode",
"value": "gradient-gauge"
"id": "custom.cellOptions",
"value": {
"mode": "basic",
"type": "gauge"
}
},
{
"id": "custom.inspect",
"value": false
}
]
},
{
"matcher": {
"id": "byName",
"options": "Info"
"options": "Gradient text color value"
},
"properties": [
{
"id": "custom.width",
"value": 92
"id": "custom.cellOptions",
"value": {
"mode": "gradient",
"type": "gauge",
"valueMode": "text"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Min"
"options": "LCD hidden value"
},
"properties": [
{
"id": "custom.width",
"value": 76
}
]
},
{
"matcher": {
"id": "byName",
"options": "Max"
},
"properties": [
{
"id": "custom.width",
"value": 89
}
]
},
{
"matcher": {
"id": "byName",
"options": "Time"
},
"properties": [
{
"id": "custom.width",
"value": 165
"id": "custom.cellOptions",
"value": {
"mode": "lcd",
"type": "gauge",
"valueMode": "hidden"
}
}
]
}
@@ -249,13 +280,22 @@
},
"gridPos": {
"h": 16,
"w": 8,
"w": 17,
"x": 7,
"y": 1
},
"id": 2,
"options": {
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"showRowNums": false,
"sortBy": [
{
"desc": false,
@@ -263,22 +303,25 @@
}
]
},
"pluginVersion": "",
"pluginVersion": "9.5.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"scenarioId": "random_walk_table",
"stringInput": ""
}
],
"timeFrom": null,
"timeShift": null,
"title": "Bar gauge cells",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Info": true,
"Time": true
},
"indexByName": {
@@ -288,35 +331,43 @@
"Time": 0,
"Value": 4
},
"renameByName": {}
"renameByName": {
"A": "LCD hidden value",
"Max": "Gradient text color value",
"Min": "Basic "
}
}
}
],
"type": "table"
},
{
"datasource": "gdev-testdata",
"description": "",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": null,
"filterable": false
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "percentage",
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
"color": "green"
},
{
"color": "green",
"value": 50
"color": "red",
"value": 80
}
]
}
@@ -325,82 +376,329 @@
{
"matcher": {
"id": "byName",
"options": "A"
"options": "rate"
},
"properties": [
{
"id": "custom.displayMode",
"value": "lcd-gauge"
"id": "custom.cellOptions",
"value": {
"fillOpacity": 29,
"gradientMode": "opacity",
"lineWidth": 1,
"type": "sparkline"
}
},
{
"id": "custom.align",
"value": "center"
"id": "color",
"value": {
"mode": "continuous-GrYlRd"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "cluster"
},
"properties": [
{
"id": "custom.width",
"value": 120
}
]
},
{
"matcher": {
"id": "byName",
"options": "service"
},
"properties": [
{
"id": "custom.width",
"value": 120
}
]
}
]
},
"gridPos": {
"h": 16,
"h": 7,
"w": 9,
"x": 15,
"y": 1
"x": 0,
"y": 17
},
"id": 5,
"id": 14,
"maxDataPoints": 50,
"options": {
"cellHeight": "md",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"showRowNums": false,
"sortBy": []
},
"pluginVersion": "",
"pluginVersion": "9.5.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"labels": "cluster=eu,service=checkout",
"min": 0.1,
"refId": "A",
"scenarioId": "random_walk_table",
"stringInput": ""
"scenarioId": "random_walk",
"seriesCount": 3
}
],
"timeFrom": null,
"timeShift": null,
"title": "Retro LCD cell",
"title": "Sparkline cell",
"transformations": [
{
"id": "timeSeriesTable",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Info": false,
"Max": true,
"Min": true,
"Time": false
},
"indexByName": {
"Info": 1,
"Max": 3,
"Min": 2,
"Time": 0,
"Value": 4
},
"renameByName": {}
"excludeByName": {},
"indexByName": {},
"renameByName": {
"Trend": "rate"
}
}
}
],
"type": "table"
},
{
"collapsed": false,
"datasource": "gdev-testdata",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "rate"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"drawStyle": "bars",
"fillOpacity": 100,
"gradientMode": "hue",
"lineWidth": 0,
"showPoints": "auto",
"type": "sparkline"
}
},
{
"id": "color",
"value": {
"mode": "continuous-GrYlRd"
}
},
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "latency"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"fillOpacity": 40,
"gradientMode": "opacity",
"lineInterpolation": "stepBefore",
"lineWidth": 1,
"type": "sparkline"
}
},
{
"id": "color",
"value": {
"fixedColor": "purple",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "service"
},
"properties": [
{
"id": "custom.width",
"value": 120
}
]
},
{
"matcher": {
"id": "byName",
"options": "cluster"
},
"properties": [
{
"id": "custom.width",
"value": 120
}
]
}
]
},
"gridPos": {
"h": 7,
"w": 15,
"x": 9,
"y": 17
},
"id": 15,
"maxDataPoints": 30,
"options": {
"cellHeight": "md",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"showRowNums": false
},
"pluginVersion": "9.5.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"labels": "cluster=eu,service=checkout",
"min": 0.1,
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 3
},
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"hide": false,
"labels": "cluster=eu,service=checkout",
"min": 0.1,
"refId": "B",
"scenarioId": "random_walk",
"seriesCount": 3
}
],
"title": "Multiple sparkline cells per row",
"transformations": [
{
"id": "timeSeriesTable",
"options": {}
},
{
"id": "joinByField",
"options": {
"byField": "service",
"mode": "outer"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"cluster 2": true
},
"indexByName": {},
"renameByName": {
"Trend": "rate",
"Trend #A": "rate",
"Trend #B": "latency",
"cluster 1": "cluster"
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 17
"y": 24
},
"id": 9,
"panels": [],
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"refId": "A"
}
],
"title": "Data links",
"type": "row"
},
{
"datasource": "gdev-testdata",
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -408,8 +706,11 @@
},
"custom": {
"align": "center",
"displayMode": "color-text",
"filterable": false
"cellOptions": {
"type": "color-text"
},
"filterable": false,
"inspect": false
},
"decimals": 2,
"mappings": [],
@@ -417,8 +718,7 @@
"mode": "percentage",
"steps": [
{
"color": "green",
"value": null
"color": "green"
},
{
"color": "blue",
@@ -444,8 +744,7 @@
},
"properties": [
{
"id": "custom.align",
"value": null
"id": "custom.align"
}
]
},
@@ -472,17 +771,30 @@
"h": 8,
"w": 12,
"x": 0,
"y": 18
"y": 25
},
"id": 3,
"maxDataPoints": "10",
"options": {
"showHeader": true
"cellHeight": "md",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"showRowNums": false
},
"pluginVersion": "",
"pluginVersion": "9.5.0-pre",
"targets": [
{
"alias": "S1",
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"labels": "server=A",
"refId": "A",
"scenarioId": "random_walk",
@@ -491,6 +803,10 @@
},
{
"alias": "S2",
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"labels": "server=B",
"refId": "B",
"scenarioId": "random_walk",
@@ -499,6 +815,10 @@
},
{
"alias": "S3",
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"labels": "server=C",
"refId": "C",
"scenarioId": "random_walk",
@@ -506,8 +826,6 @@
"stringInput": ""
}
],
"timeFrom": null,
"timeShift": null,
"title": "Data link with labels and numeric value",
"transformations": [
{
@@ -518,7 +836,10 @@
"type": "table"
},
{
"datasource": "gdev-testdata",
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -526,16 +847,18 @@
},
"custom": {
"align": "center",
"displayMode": "auto",
"filterable": false
"cellOptions": {
"type": "auto"
},
"filterable": false,
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "percentage",
"steps": [
{
"color": "green",
"value": null
"color": "green"
},
{
"color": "blue",
@@ -559,11 +882,21 @@
"h": 8,
"w": 5,
"x": 12,
"y": 18
"y": 25
},
"id": 10,
"options": {
"cellHeight": "md",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": false,
"showRowNums": false,
"sortBy": [
{
"desc": true,
@@ -571,17 +904,19 @@
}
]
},
"pluginVersion": "",
"pluginVersion": "9.5.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"refId": "A",
"scenarioId": "random_walk_table",
"seriesCount": 5,
"stringInput": ""
}
],
"timeFrom": null,
"timeShift": null,
"title": "No header",
"transformations": [
{
@@ -606,7 +941,10 @@
"type": "table"
},
{
"datasource": "gdev-testdata",
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -614,15 +952,17 @@
},
"custom": {
"align": "auto",
"displayMode": "auto"
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
"color": "green"
},
{
"color": "red",
@@ -637,25 +977,39 @@
"h": 8,
"w": 12,
"x": 0,
"y": 26
"y": 33
},
"id": 12,
"options": {
"cellHeight": "md",
"footer": {
"show": true,
"countRows": false,
"fields": "",
"reducer": [
"sum"
]
],
"show": true
},
"showHeader": true
"showHeader": true,
"showRowNums": false
},
"pluginVersion": "",
"pluginVersion": "9.5.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata"
},
"refId": "A"
}
],
"title": "Footer",
"type": "table"
}
],
"schemaVersion": 27,
"refresh": "",
"revision": 1,
"schemaVersion": 38,
"style": "dark",
"tags": [
"gdev",
@@ -684,5 +1038,6 @@
"timezone": "",
"title": "Panel Tests - React Table",
"uid": "U_bZIMRMk",
"version": 6
"version": 7,
"weekStart": ""
}
+22 -6
View File
@@ -47,10 +47,7 @@ async function sleep(duration) {
});
}
async function lokiSendLogLine(timestampMs, line, tags) {
// we keep nanosecond-timestamp in a string because
// as a number it would be too large
const timestampNs = `${timestampMs}000000`;
async function lokiSendLogLine(timestampNs, line, tags) {
const data = {
streams: [
{
@@ -136,6 +133,24 @@ function calculateDelays(pointsCount) {
return delays;
}
function getRandomNanosecPart() {
// we want to have cases with milliseconds-only, with microsec and nanosec.
const mode = Math.random();
if (mode < 0.333) {
// only milisec precision
return '000000';
}
if (mode < 0.666) {
// microsec precision
return Math.trunc(Math.random()*1000).toString().padStart(3, '0') + '000'
}
// nanosec precision
return Math.trunc(Math.random()*1000000).toString().padStart(6, '0')
}
async function main() {
const delays = calculateDelays(DAYS * POINTS_PER_DAY);
@@ -144,9 +159,10 @@ async function main() {
for(let i =0; i < delays.length; i++ ) { // i cannot do a forEach because of the `await` inside
const delay = delays[i];
timestampMs += Math.trunc(delay * timeRange);
const timestampNs = `${timestampMs}${getRandomNanosecPart()}`;
const item = getRandomLogItem(i + 1)
await lokiSendLogLine(timestampMs, JSON.stringify(item), {place:'moon', source: 'data', instance: 'server\\1', job: '"grafana/data"'});
await lokiSendLogLine(timestampMs, logFmtLine(item), {place:'luna', source: 'data', instance: 'server\\2', job: '"grafana/data"'});
await lokiSendLogLine(timestampNs, JSON.stringify(item), {place:'moon', source: 'data', instance: 'server\\1', job: '"grafana/data"'});
await lokiSendLogLine(timestampNs, logFmtLine(item), {place:'luna', source: 'data', instance: 'server\\2', job: '"grafana/data"'});
};
}
+5
View File
@@ -2,6 +2,11 @@
aliases:
- about-alerting/
- unified-alerting/alerting/
cascade:
labels:
products:
- cloud
- oss
title: Alerting
weight: 114
---
@@ -38,7 +38,7 @@ If you are using Docker, use an environment variable to enable public dashboards
--env GF_FEATURE_TOGGLES_ENABLE=publicDashboards
```
> **Note:** For Grafana Cloud, you will need to contact support to have the feature enabled.
> **Note:** For Grafana Cloud, contact support to have the feature enabled.
## Make a dashboard public
@@ -64,7 +64,63 @@ The dashboard is no longer accessible, even with the link, until you make it sha
The link no longer works. You must create a new public URL as in [Make a dashboard public](#make-a-dashboard-public).
## Supported datasources
## Email sharing
> **Note:** Available in [Grafana Enterprise]({{< relref "../../introduction/grafana-enterprise/" >}}) and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud).
Email sharing allows you to share your public dashboard with only specific people via email, instead of having it accessible to anyone with the URL.
### Enable email sharing
> **Note:** For Grafana Cloud, contact support to have the feature enabled.
Add the `publicDashboardsEmailSharing` feature toggle to your `custom.ini` file.
```
[feature_toggles]
publicDashboardsEmailSharing = true
```
### Invite a viewer
1. Click the sharing icon to the right of the dashboard title.
1. Click the **Public dashboard** tab.
1. Click **Only specified people**.
1. Enter the email you want to share the public dashboard with.
1. Click **Invite**.
1. The recipient(s) will receive an email with a one-time use link. This link must be used within **one hour** or it expires. Once the link is used, the viewer has access to the public dashboard for **30 days**.
### Viewers requesting access
If a viewer without access tries to navigate to the public dashboard, they'll be asked to request access by providing their email. They will receive an email with a new one-time use link if the email they provided has already been invited to view the public dashboard and has not been revoked.
If the viewer doesn't have an invitation or it's been revoked, you won't be notified and no link is sent.
### Revoke access for a viewer
1. Click the sharing icon to the right of the dashboard title.
1. Click the **Public dashboard** tab.
1. Click **Revoke** on the viewer you'd like to revoke access for.
Immediately, the viewer no longer has access to the public dashboard, nor can they use any existing one-time use links they may have.
### Reinvite a viewer
1. Click the sharing icon to the right of the dashboard title.
1. Click the **Public dashboard** tab.
1. Click **Resend** on the viewer you'd like to re-share the public dashboard with.
The viewer will receive an email with a new one-time use link. This will invalidate all previously issued links for that viewer.
### Access limitations
One-time use links use browser cookies, so when a viewer is granted access via one of these links, they will only have access through the browser they used to claim the link.
A single viewer cannot generate multiple valid one-time use links. When a new one-time use link is issued for a viewer, all previous ones are invalidated.
If a Grafana user has read access to the parent dashboard, they can view the public dashboard without needing to have access granted.
## Supported data sources
Public dashboards _should_ work with any datasource that has the properties `backend` and `alerting` both set to true in it's `package.json`. However, this cannot always be
guaranteed because plugin developers can override this functionality. The following lists include data sources confirmed to work with public dashboards and data sources that should work but have not been confirmed as compatible.
@@ -166,7 +222,6 @@ guaranteed because plugin developers can override this functionality. The follow
- Panels that use frontend datasources will fail to fetch data.
- Template variables are currently not supported, but are planned to be in the future.
- The time range is permanently set to the default time range on the dashboard. If you update the default time range for a dashboard, it will be reflected in the public dashboard.
- Exemplars will be omitted from the panel.
- Only annotations that query the `-- Grafana --` datasource are supported.
- Organization annotations are not supported.
+2
View File
@@ -60,6 +60,7 @@ You can assign an **Alias** to each scenario, and many have their own options th
- **CSV Metric Values**
- **Datapoints Outside Range**
- **Exponential heatmap bucket data**
- **Flame Graph**
- **Grafana API**
- **Grafana Live**
- **Linear heatmap bucket data**
@@ -77,6 +78,7 @@ You can assign an **Alias** to each scenario, and many have their own options th
- **Slow Query**
- **Streaming Client**
- **Table Static**
- **Trace**
- **USA generated data**
## Import a pre-configured dashboard
+12 -8
View File
@@ -10,26 +10,30 @@ weight: 500
# Angular support deprecation
Angular plugin support is deprecated, and it will be removed in a future release. There are still many community plugins that depend on Grafana’s angular plugin support for them to work. The same is true for many internal (private) plugins developed over the years by Grafana users. Grafana version 9 will have a server configuration option, global for the whole instance, that will control if angular plugin support is available or not. By default angular support is still enabled but this will soon change as we complete the migration of all angular code in the core product.
Angular plugin support is deprecated and will be removed in a future release. There are still many community plugins that rely on Grafana's Angular plugin support to work. The same is true for many internal (private) plugins that have been developed by Grafana users over the years. Grafana version 9 has a server configuration option that is global to the entire instance and controls whether Angular plugin support is available or not. By default, Angular support is still enabled, but that will change soon once we complete the migration of all Angular code in the core product.
## Why are we deprecating angular support?
## Why are we deprecating Angular support?
AngularJS is an old frontend framework that stopped active development many years ago. As a result, it is a security risk. AngularJS also requires unsafe-eval in the CSP (Content Security Policy) settings which also reduces the security level of how javascript is executed in the browser.
AngularJS is an old frontend framework whose active development stopped many years ago. Therefore, it poses a security risk. As Grafana itself had already started migrating to React in v5, this presented the most logical framework for our plugin platform. AngularJS also requires unsafe-eval in the CSP (Content Security Policy) settings, which also reduces the security of running JavaScript in the browser.
## When will angular plugins stop working?
## When will Angular plugins stop working?
We hope to migrate the last remaining angular code in core Grafana later this year. Then the setting enable_angular_support can be disable by default. This will make all angular plugins stop working. If you still depend on community or internally developed plugins that require AngularJS then you will have to enable this option.
Our goal is to transfer all the remaining Angular code to the core of Grafana before Grafana 10 is released in Summer 2023. Once this is done, the option "[angular_support_enabled](https://github.com/grafana/grafana/blob/d61bcdf4ca5e69489e0067c56fbe7f0bfdf84ee4/conf/defaults.ini#L362)" will be disabled by default, resulting in the deactivation of all Angular plugins. In case you still rely on AngularJS-based plugins developed internally or by the community, you will need to enable this option to continue using them.
This is a good time to start working on migrating plugins to React.
## When will we remove Angular support completely?
Our plan is to fully remove angular plugin support in version 10 released in 2023. Meaning all plugins that do depend on angular will stop working and this temporary option to enable it introduced in v9 will be removed.
Our plan is to completely remove support for Angular plugins in version 11, which will be released in 2024. This means that all plugins that depend on Angular will stop working and the temporary option introduced in version 10 to enable Angular will be removed.
## How do I migrate an angular plugin to React?
## How do I migrate an Angular plugin to React?
Depending on if it’s a data source plugin, panel plugin, or app plugin the process will differ.
For panels, the rendering logic could in some cases be easily preserved but all options need to be redone to use the declarative options framework. For data source plugins the query editor and config options will likely need a total rewrite.
## How do I encourage a community plugin to migrate?
We encourage you to locate the repository of the corresponding plugin and create or upvote an Issue within it if you are using a plugin that is still based on Angular.
### Links
- [Migrate Angular to React](https://grafana.com/docs/grafana/latest/developers/plugins/migration-guide/#migrate-a-plugin-from-angular-to-react)
@@ -27,6 +27,7 @@ It extends [SingleStatBaseOptions](#singlestatbaseoptions).
| `minVizHeight` | uint32 | **Yes** | Default: `10`. |
| `minVizWidth` | uint32 | **Yes** | Default: `0`. |
| `showUnfilled` | boolean | **Yes** | Default: `true`. |
| `valueMode` | string | **Yes** | Allows for the table cell gauge display type to set the gauge mode.<br/>Possible values are: `color`, `text`, `hidden`. |
| `orientation` | string | No | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. |
| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | No | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs |
| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs |
@@ -23,6 +23,7 @@ title: TablePanelCfg kind
|-----------------|---------------------------------------------------|----------|--------------------------------------------------------------------------------------|
| `frameIndex` | number | **Yes** | Represents the index of the selected frame Default: `0`. |
| `showHeader` | boolean | **Yes** | Controls whether the panel should show the header Default: `true`. |
| `cellHeight` | string | No | Height of a table cell<br/>Possible values are: `sm`, `md`, `lg`. |
| `footer` | [object](#footer) | No | Controls footer options Default: `map[countRows:false reducer:[] show:false]`. |
| `showRowNums` | boolean | No | Controls whether the columns should be numbered Default: `false`. |
| `showTypeIcons` | boolean | No | Controls whether the header should show icons for the column types Default: `false`. |
@@ -195,11 +195,12 @@ TODO docs
TODO docs
| Property | Type | Required | Description |
|----------|--------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| `color` | string | **Yes** | TODO docs |
| `state` | string | No | TODO docs<br/>TODO are the values here enumerable into a disjunction?<br/>Some seem to be listed in typescript comment |
| `value` | number | No | TODO docs<br/>FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON |
| Property | Type | Required | Description |
|----------|---------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| `color` | string | **Yes** | TODO docs |
| `index` | integer | No | Threshold index, an old property that is not needed an should only appear in older dashboards |
| `state` | string | No | TODO docs<br/>TODO are the values here enumerable into a disjunction?<br/>Some seem to be listed in typescript comment |
| `value` | number | No | TODO docs<br/>FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON |
### ValueMapping
+10 -12
View File
@@ -22,25 +22,23 @@ The plugin.json file is required for all plugins. When Grafana starts, it scans
| `info` | [object](#info) | **Yes** | Metadata for the plugin. Some fields are used on the plugins page in Grafana and others on grafana.com if the plugin is published. |
| `dependencies` | [object](#dependencies) | **Yes** | Dependency information related to Grafana and other plugins. |
| `$schema` | string | No | Schema definition for the plugin.json file. Used primarily for schema validation. |
| `alerting` | boolean | No | For data source plugins, if the plugin supports alerting. |
| `alerting` | boolean | No | For data source plugins, if the plugin supports alerting. Requires `backend` to be set to `true`. |
| `annotations` | boolean | No | For data source plugins, if the plugin supports annotation queries. |
| `autoEnabled` | boolean | No | Set to true for app plugins that should be enabled by default in all orgs |
| `autoEnabled` | boolean | No | Set to true for app plugins that should be enabled and pinned to the navigation bar in all orgs. |
| `backend` | boolean | No | If the plugin has a backend component. |
| `category` | string | No | Plugin category used on the Add data source page. Possible values are: `tsdb`, `logging`, `cloud`, `tracing`, `sql`, `enterprise`, `other`. |
| `enterpriseFeatures` | [object](#enterprisefeatures) | No | Grafana Enerprise specific features. |
| `category` | string | No | Plugin category used on the "Add data source" page. Possible values are: `tsdb`, `logging`, `cloud`, `tracing`, `profiling`, `sql`, `enterprise`, `iot`, `other`. |
| `enterpriseFeatures` | [object](#enterprisefeatures) | No | Grafana Enterprise specific features |
| `executable` | string | No | The first part of the file name of the backend component executable. There can be multiple executables built for different operating system and architecture. Grafana will check for executables named `<executable>_<$GOOS>_<lower case $GOARCH><.exe for Windows>`, e.g. `plugin_linux_amd64`. Combination of $GOOS and $GOARCH can be found here: https://golang.org/doc/install/source#environment. |
| `hiddenQueries` | boolean | No | For data source plugins, include hidden queries in the data request. |
| `includes` | [object](#includes)[] | No | Resources to include in plugin. |
| `logs` | boolean | No | For data source plugins, if the plugin supports logs. |
| `metrics` | boolean | No | For data source plugins, if the plugin supports metric queries. Used in Explore. |
| `preload` | boolean | No | Initialize plugin on startup. By default, the plugin initializes on first use. |
| `logs` | boolean | No | For data source plugins, if the plugin supports logs. It may be used to filter logs only features. |
| `metrics` | boolean | No | For data source plugins, if the plugin supports metric queries. Used to enable the plugin in the panel editor. |
| `preload` | boolean | No | Initialize plugin on startup. By default, the plugin initializes on first use. Useful for app plugins that should load without user interaction. |
| `queryOptions` | [object](#queryoptions) | No | For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed. |
| `routes` | [object](#routes)[] | No | For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/docs/grafana/latest/developers/plugins/authentication/). |
| `skipDataQuery` | boolean | No | For panel plugins. Hides the query editor. |
| `state` | string | No | Marks a plugin as a pre-release. Possible values are: `alpha`, `beta`. |
| `streaming` | boolean | No | For data source plugins, if the plugin supports streaming. |
| `tables` | boolean | No | This is an undocumented feature. |
| `tracing` | boolean | No | For data source plugins, if the plugin supports tracing. |
| `streaming` | boolean | No | For data source plugins, if the plugin supports streaming. Used in Explore to start live streaming. |
| `tracing` | boolean | No | For data source plugins, if the plugin supports tracing. Used for example to link logs (e.g. Loki logs) with tracing plugins. |
## dependencies
@@ -69,7 +67,7 @@ Plugin dependency. Used to display information about plugin dependencies in the
## enterpriseFeatures
Grafana Enerprise specific features.
Grafana Enterprise specific features.
### Properties
+176 -171
View File
@@ -17,166 +17,6 @@
"description": "Plugin type.",
"enum": ["app", "datasource", "panel", "renderer", "secretsmanager"]
},
"name": {
"type": "string",
"description": "Human-readable name of the plugin that is shown to the user in the UI."
},
"dependencies": {
"type": "object",
"description": "Dependency information related to Grafana and other plugins.",
"required": ["grafanaDependency"],
"additionalProperties": false,
"properties": {
"grafanaVersion": {
"type": "string",
"description": "(Deprecated) Required Grafana version for this plugin, e.g. `6.x.x 7.x.x` to denote plugin requires Grafana v6.x.x or v7.x.x.",
"pattern": "^([0-9]+)(\\.[0-9x]+)?(\\.[0-9x])?$"
},
"grafanaDependency": {
"type": "string",
"description": "Required Grafana version for this plugin. Validated using https://github.com/npm/node-semver.",
"pattern": "^(<=|>=|<|>|=|~|\\^)?([0-9]+)(\\.[0-9x\\*]+)(\\.[0-9x\\*]+)?(\\s(<=|>=|<|=>)?([0-9]+)(\\.[0-9x]+)(\\.[0-9x]+))?$"
},
"plugins": {
"type": "array",
"description": "An array of required plugins on which this plugin depends.",
"additionalItems": false,
"items": {
"type": "object",
"description": "Plugin dependency. Used to display information about plugin dependencies in the Grafana UI.",
"required": ["id", "name", "type", "version"],
"properties": {
"id": {
"type": "string",
"pattern": "^[0-9a-z]+\\-([0-9a-z]+\\-)?(app|panel|datasource|secretsmanager)$"
},
"type": {
"type": "string",
"enum": ["app", "datasource", "panel", "secretsmanager"]
},
"name": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
},
"$schema": {
"type": "string",
"description": "Schema definition for the plugin.json file. Used primarily for schema validation."
},
"category": {
"type": "string",
"description": "Plugin category used on the Add data source page.",
"enum": ["tsdb", "logging", "cloud", "tracing", "sql", "enterprise", "other"]
},
"annotations": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports annotation queries."
},
"alerting": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports alerting."
},
"backend": {
"type": "boolean",
"description": "If the plugin has a backend component."
},
"executable": {
"type": "string",
"description": "The first part of the file name of the backend component executable. There can be multiple executables built for different operating system and architecture. Grafana will check for executables named `<executable>_<$GOOS>_<lower case $GOARCH><.exe for Windows>`, e.g. `plugin_linux_amd64`. Combination of $GOOS and $GOARCH can be found here: https://golang.org/doc/install/source#environment."
},
"preload": {
"type": "boolean",
"description": "Initialize plugin on startup. By default, the plugin initializes on first use."
},
"state": {
"type": "string",
"description": "Marks a plugin as a pre-release.",
"enum": ["alpha", "beta"]
},
"includes": {
"type": "array",
"description": "Resources to include in plugin.",
"items": {
"type": "object",
"additionalItems": false,
"properties": {
"uid": {
"type": "string",
"description": "Unique identifier of the included resource"
},
"type": {
"type": "string",
"enum": ["dashboard", "page", "panel", "datasource", "secretsmanager"]
},
"name": {
"type": "string"
},
"component": {
"type": "string",
"description": "(Legacy) The Angular component to use for a page."
},
"role": {
"type": "string",
"description": "The minimum role a user must have to see this page in the navigation menu.",
"enum": ["Admin", "Editor", "Viewer"]
},
"path": {
"type": "string",
"description": "Used for app plugins."
},
"addToNav": {
"type": "boolean",
"description": "Add the include to the navigation menu."
},
"defaultNav": {
"type": "boolean",
"description": "Page or dashboard when user clicks the icon in the side menu."
},
"icon": {
"type": "string",
"description": "Icon to use in the side menu. For information on available icon, refer to [Icons Overview](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)."
}
}
}
},
"logs": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports logs."
},
"skipDataQuery": {
"type": "boolean",
"description": "For panel plugins. Hides the query editor."
},
"metrics": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports metric queries. Used in Explore."
},
"streaming": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports streaming."
},
"tables": {
"type": "boolean",
"description": "This is an undocumented feature."
},
"tracing": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports tracing."
},
"hiddenQueries": {
"type": "boolean",
"description": "For data source plugins, include hidden queries in the data request."
},
"autoEnabled": {
"type": "boolean",
"description": "Set to true for app plugins that should be enabled by default in all orgs"
},
"info": {
"type": "object",
"description": "Metadata for the plugin. Some fields are used on the plugins page in Grafana and others on grafana.com if the plugin is published.",
@@ -308,6 +148,166 @@
}
}
},
"name": {
"type": "string",
"description": "Human-readable name of the plugin that is shown to the user in the UI."
},
"dependencies": {
"type": "object",
"description": "Dependency information related to Grafana and other plugins.",
"required": ["grafanaDependency"],
"additionalProperties": false,
"properties": {
"grafanaVersion": {
"type": "string",
"description": "(Deprecated) Required Grafana version for this plugin, e.g. `6.x.x 7.x.x` to denote plugin requires Grafana v6.x.x or v7.x.x.",
"pattern": "^([0-9]+)(\\.[0-9x]+)?(\\.[0-9x])?$"
},
"grafanaDependency": {
"type": "string",
"description": "Required Grafana version for this plugin. Validated using https://github.com/npm/node-semver.",
"pattern": "^(<=|>=|<|>|=|~|\\^)?([0-9]+)(\\.[0-9x\\*]+)(\\.[0-9x\\*]+)?(\\s(<=|>=|<|=>)?([0-9]+)(\\.[0-9x]+)(\\.[0-9x]+))?$"
},
"plugins": {
"type": "array",
"description": "An array of required plugins on which this plugin depends.",
"additionalItems": false,
"items": {
"type": "object",
"description": "Plugin dependency. Used to display information about plugin dependencies in the Grafana UI.",
"required": ["id", "name", "type", "version"],
"properties": {
"id": {
"type": "string",
"pattern": "^[0-9a-z]+\\-([0-9a-z]+\\-)?(app|panel|datasource|secretsmanager)$"
},
"type": {
"type": "string",
"enum": ["app", "datasource", "panel", "secretsmanager"]
},
"name": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
},
"$schema": {
"type": "string",
"description": "Schema definition for the plugin.json file. Used primarily for schema validation."
},
"alerting": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports alerting. Requires `backend` to be set to `true`."
},
"annotations": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports annotation queries."
},
"autoEnabled": {
"type": "boolean",
"description": "Set to true for app plugins that should be enabled and pinned to the navigation bar in all orgs."
},
"backend": {
"type": "boolean",
"description": "If the plugin has a backend component."
},
"builtIn": {
"type": "boolean",
"description": "[internal only] Indicates whether the plugin is developed and shipped as part of Grafana. Also known as a 'core plugin'."
},
"category": {
"type": "string",
"description": "Plugin category used on the Add data source page.",
"enum": ["tsdb", "logging", "cloud", "tracing", "profiling", "sql", "enterprise", "iot", "other"]
},
"enterpriseFeatures": {
"type": "object",
"description": "Grafana Enterprise specific features",
"additionalProperties": true,
"properties": {
"healthDiagnosticsErrors": {
"type": "boolean",
"description": "Enable/Disable health diagnostics errors. Requires Grafana >=7.5.5.",
"default": false
}
}
},
"executable": {
"type": "string",
"description": "The first part of the file name of the backend component executable. There can be multiple executables built for different operating system and architecture. Grafana will check for executables named `<executable>_<$GOOS>_<lower case $GOARCH><.exe for Windows>`, e.g. `plugin_linux_amd64`. Combination of $GOOS and $GOARCH can be found here: https://golang.org/doc/install/source#environment."
},
"hideFromList": {
"type": "boolean",
"description": "[internal only] Excludes the plugin from listings in Grafana's UI. Only allowed for `builtIn` plugins."
},
"includes": {
"type": "array",
"description": "Resources to include in plugin.",
"items": {
"type": "object",
"additionalItems": false,
"properties": {
"uid": {
"type": "string",
"description": "Unique identifier of the included resource"
},
"type": {
"type": "string",
"enum": ["dashboard", "page", "panel", "datasource", "secretsmanager"]
},
"name": {
"type": "string"
},
"component": {
"type": "string",
"description": "(Legacy) The Angular component to use for a page."
},
"role": {
"type": "string",
"description": "The minimum role a user must have to see this page in the navigation menu.",
"enum": ["Admin", "Editor", "Viewer"]
},
"path": {
"type": "string",
"description": "Used for app plugins."
},
"addToNav": {
"type": "boolean",
"description": "Add the include to the navigation menu."
},
"defaultNav": {
"type": "boolean",
"description": "Page or dashboard when user clicks the icon in the side menu."
},
"icon": {
"type": "string",
"description": "Icon to use in the side menu. For information on available icon, refer to [Icons Overview](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)."
}
}
}
},
"logs": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports logs. It may be used to filter logs only features."
},
"metrics": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports metric queries. Used to enable the plugin in the panel editor."
},
"pascalName": {
"type": "string",
"description": "[internal only] The PascalCase name for the plugin. Used for creating machine-friendly identifiers, typically in code generation. If not provided, defaults to name, but title-cased and sanitized (only alphabetical characters allowed).",
"pattern": "^([A-Z][a-zA-Z]{1,62})$"
},
"preload": {
"type": "boolean",
"description": "Initialize plugin on startup. By default, the plugin initializes on first use. Useful for app plugins that should load without user interaction."
},
"queryOptions": {
"type": "object",
"description": "For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed.",
@@ -461,17 +461,22 @@
}
}
},
"enterpriseFeatures": {
"type": "object",
"description": "Grafana Enterprise specific features.",
"additionalProperties": true,
"properties": {
"healthDiagnosticsErrors": {
"type": "boolean",
"description": "Enable/Disable health diagnostics errors. Requires Grafana >=7.5.5.",
"default": false
}
}
"skipDataQuery": {
"type": "boolean",
"description": "For panel plugins. Hides the query editor."
},
"state": {
"type": "string",
"description": "Marks a plugin as a pre-release.",
"enum": ["alpha", "beta"]
},
"streaming": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports streaming. Used in Explore to start live streaming."
},
"tracing": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports tracing. Used for example to link logs (e.g. Loki logs) with tracing plugins."
}
}
}
@@ -1,4 +1,7 @@
---
labels:
products:
- cloud
title: Grafana Cloud
weight: 300
---
@@ -1,6 +1,9 @@
---
aliases:
- ../enterprise/
labels:
products:
- enterprise
description: Grafana Enterprise overview
title: Grafana Enterprise
weight: 200
@@ -732,3 +732,9 @@ Here is the result after adding a Limit transformation with a value of '3':
| 2020-07-07 11:34:20 | Temperature | 25 |
| 2020-07-07 11:34:20 | Humidity | 22 |
| 2020-07-07 10:32:20 | Humidity | 29 |
### Time series to table transform
> **Note:** This transformation is available in Grafana 9.5+ as an opt-in beta feature. Modify Grafana [configuration file]({{< relref "../../../setup-grafana/configure-grafana/#configuration-file-location" >}}) to enable the `timeSeriesTable` [feature toggle]({{< relref "../../../setup-grafana/configure-grafana/#feature_toggles" >}}) to use it.
Use this transformation to convert time series result into a table, converting time series data frame into a "Trend" field. "Trend" field can then be rendered using [sparkline cell type]({{< relref "../../visualizations/table/#sparkline" >}}), producing an inline sparkline for each table row. If there are multiple time series queries, each will result in a separate table data frame. These can be joined using join or merge transforms to produce a single table with multiple sparklines per row.
@@ -108,6 +108,22 @@ The gauge is split up in small cells that are lit or unlit.
{{< figure src="/static/img/docs/tables/lcd-gauge.png" max-width="500px" caption="LCD gauge" class="docs-image--no-shadow" >}}
#### Label Options
Additionally, labels displayed alongside of the gauges can be set to be colored by value, match the theme text color, or be hidden.
**Value Color**
{{< figure src="/static/img/docs/tables/value-color-mode.png" max-width="500px" caption="Color Label by Value" class="docs-image--no-shadow" >}}
**Text Color**
{{< figure src="/static/img/docs/tables/text-color-mode.png" max-width="500px" caption="Color Label by theme color" class="docs-image--no-shadow" >}}
**Hidden**
{{< figure src="/static/img/docs/tables/hidden-mode.png" max-width="500px" caption="Hide Label" class="docs-image--no-shadow" >}}
### JSON view
Shows value formatted as code. If a value is an object the JSON view allowing browsing the JSON object will appear on hover.
@@ -122,6 +138,14 @@ If you have a field value that is an image URL or a base64 encoded image you can
{{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}}
### Sparkline
> **Note:** This cell type is available in Grafana 9.5+ as an opt-in beta feature. Modify Grafana [configuration file]({{< relref "../../../setup-grafana/configure-grafana/#configuration-file-location" >}}) to enable the `timeSeriesTable` [feature toggle]({{< relref "../../../setup-grafana/configure-grafana/#feature_toggles" >}}) to use it.
Shows value rendered as a sparkline. Requires [time series to table]({{< relref "../../query-transform-data/transform-data/#time-series-to-table-transform" >}}) data transform.
{{< figure src="/static/img/docs/tables/sparkline.png" max-width="500px" caption="Sparkline" class="docs-image--no-shadow" >}}
## Cell value inspect
Enables value inspection from table cell. The raw value is presented in a modal window.
@@ -94,6 +94,7 @@ Alpha features might be changed or removed without prior notice.
| `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. |
| `traceqlSearch` | Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries |
| `prometheusMetricEncyclopedia` | Replaces the Prometheus query builder metric select option with a paginated and filterable component |
| `timeSeriesTable` | Enable time series table transformer & sparkline cell type |
## Development feature toggles
@@ -6,7 +6,7 @@ Tooling for modern web development can be tricky to wrap your head around. While
Grafana [create-plugin tool](https://www.npmjs.com/package/@grafana/create-plugin) is a CLI application that simplifies Grafana plugin development, so that you can focus on code. The tool scaffolds a starter plugin and all the required configuration for you.
1. In the plugin directory, create a plugin from template using create-plugin. Select `datasource` when asked the kind of plugin:
1. In the plugin directory, create a plugin from template using create-plugin. When prompted for the kind of plugin, select `datasource`:
```
npx @grafana/create-plugin@latest
@@ -42,7 +42,7 @@ In this tutorial, you'll:
To build a backend for your data source plugin, Grafana requires a binary that it can execute when it loads the plugin during start-up. In this guide, we will build a binary using the [Grafana plugin SDK for Go](/docs/grafana/latest/developers/plugins/backend/grafana-plugin-sdk-for-go/).
The easiest way to get started is to use the Grafana [create-plugin tool](https://www.npmjs.com/package/@grafana/create-plugin). Navigate to the plugin folder that you configured in step 1 and type:
The easiest way to get started is to use the Grafana [create-plugin tool](https://www.npmjs.com/package/@grafana/create-plugin). Navigate to the plugin folder that you configured in step 1 and enter:
```
npx @grafana/create-plugin@latest
@@ -254,12 +254,6 @@ We've added Discord as a contact point receiver for Grafana Cloud alert rules.
We've made the following changes to alert administration.
#### Better guidance to configure your Alertmanagers
Get additional help while configuring your Alertmanager. If you enter an invalid Alertmanager configuration, an error message displays, and you can choose from a previous working configuration to restart it.
{{< figure src="/media/docs/alerting/alertmanager-config.png" max-width="750px" caption="Better guidance to configure your Alertmanager" >}}
#### Alerting landing page
Introduces a new landing page that helps you get started quickly with Alerting. It also provides you with at a glance information on how Alerting works and a video to introduce you to key concepts.
+4 -2
View File
@@ -25,8 +25,8 @@ lineage: seqs: [
// Description of dashboard.
description?: string
// This property should only be used in dashboards defined by plugins. It is a quick check
// to see if the version has changed since the last time. Unclear why using the version property
// is insufficient.
// to see if the version has changed since the last time. Unclear why using the version property
// is insufficient.
revision?: int64 @grafanamaturity(NeedsExpertReview)
// For dashboards imported from the https://grafana.com/grafana/dashboards/ portal
gnetId?: string @grafanamaturity(NeedsExpertReview)
@@ -220,6 +220,8 @@ lineage: seqs: [
value?: number @grafanamaturity(NeedsExpertReview)
// TODO docs
color: string @grafanamaturity(NeedsExpertReview)
// Threshold index, an old property that is not needed an should only appear in older dashboards
index?: int32 @grafanamaturity(NeedsExpertReview)
// TODO docs
// TODO are the values here enumerable into a disjunction?
// Some seem to be listed in typescript comment
+12 -12
View File
@@ -199,7 +199,7 @@
"eslint-webpack-plugin": "4.0.0",
"expose-loader": "4.0.0",
"fork-ts-checker-webpack-plugin": "8.0.0",
"glob": "9.1.2",
"glob": "9.2.1",
"html-loader": "4.2.0",
"html-webpack-plugin": "5.5.0",
"http-server": "14.1.1",
@@ -229,7 +229,7 @@
"react-simple-compat": "1.2.3",
"react-test-renderer": "17.0.2",
"redux-mock-store": "1.5.4",
"rimraf": "4.2.0",
"rimraf": "4.4.0",
"rudder-sdk-js": "2.25.0",
"sass": "1.58.3",
"sass-loader": "13.2.0",
@@ -245,7 +245,7 @@
"ts-node": "10.9.1",
"typescript": "4.8.4",
"wait-on": "7.0.1",
"webpack": "5.75.0",
"webpack": "5.76.0",
"webpack-bundle-analyzer": "4.8.0",
"webpack-cli": "5.0.1",
"webpack-dev-server": "4.11.1",
@@ -267,11 +267,11 @@
"@grafana/lezer-logql": "0.1.2",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^0.0.16",
"@grafana/scenes": "^0.0.18",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@kusto/monaco-kusto": "5.3.6",
"@leeoniya/ufuzzy": "1.0.2",
"@leeoniya/ufuzzy": "1.0.6",
"@lezer/common": "1.0.2",
"@lezer/highlight": "1.1.3",
"@lezer/lr": "1.3.3",
@@ -429,16 +429,16 @@
"@rushstack/ts-command-line": "4.13.2",
"@storybook/builder-webpack4/css-loader": "6.7.3",
"@storybook/builder-webpack4/html-webpack-plugin": "5.5.0",
"@storybook/builder-webpack4/webpack": "5.75.0",
"@storybook/core-common/webpack": "5.75.0",
"@storybook/core-server/webpack": "5.75.0",
"@storybook/builder-webpack4/webpack": "5.76.0",
"@storybook/core-common/webpack": "5.76.0",
"@storybook/core-server/webpack": "5.76.0",
"@storybook/manager-webpack4/css-loader": "6.7.3",
"@storybook/manager-webpack4/html-webpack-plugin": "5.5.0",
"@storybook/manager-webpack4/webpack": "5.75.0",
"@storybook/builder-webpack5/webpack": "5.75.0",
"@storybook/manager-webpack5/webpack": "5.75.0",
"@storybook/manager-webpack4/webpack": "5.76.0",
"@storybook/builder-webpack5/webpack": "5.76.0",
"@storybook/manager-webpack5/webpack": "5.76.0",
"ngtemplate-loader/loader-utils": "^2.0.0",
"trim@0.0.1": "0.0.3",
"trim": "0.0.3",
"slate-dev-environment@^0.2.2": "patch:slate-dev-environment@npm:0.2.5#.yarn/patches/slate-dev-environment-npm-0.2.5-9aeb7da7b5.patch"
},
"workspaces": {
+1 -1
View File
@@ -83,7 +83,7 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-test-renderer": "17.0.2",
"rimraf": "4.2.0",
"rimraf": "4.4.0",
"rollup": "2.79.1",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-esbuild": "5.0.0",
+1 -1
View File
@@ -7,4 +7,4 @@ export * from './dimensions';
export * from './ArrayDataFrame';
export * from './DataFrameJSON';
export * from './frameComparisons';
export { anySeriesWithTimeField } from './utils';
export { anySeriesWithTimeField, isTimeSeriesFrame, isTimeSeriesFrames } from './utils';
+3 -3
View File
@@ -2,15 +2,15 @@ import { DataFrame, FieldType } from '../types/dataFrame';
import { getTimeField } from './processDataFrame';
export function isTimeSerie(frame: DataFrame) {
export function isTimeSeriesFrame(frame: DataFrame) {
if (frame.fields.length > 2) {
return false;
}
return Boolean(frame.fields.find((field) => field.type === FieldType.time));
}
export function isTimeSeries(data: DataFrame[]) {
return !data.find((frame) => !isTimeSerie(frame));
export function isTimeSeriesFrames(data: DataFrame[]) {
return !data.find((frame) => !isTimeSeriesFrame(frame));
}
/**
+1 -1
View File
@@ -15,4 +15,4 @@ export { sortThresholds, getActiveThreshold } from './thresholds';
export { applyFieldOverrides, validateFieldConfig, applyRawFieldOverrides, useFieldOverrides } from './fieldOverrides';
export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
export { getFieldDisplayName, getFrameDisplayName } from './fieldState';
export { getScaleCalculator, getFieldConfigWithMinMax } from './scale';
export { getScaleCalculator, getFieldConfigWithMinMax, getMinMaxAndDelta } from './scale';
@@ -36,4 +36,5 @@ export enum DataTransformerID {
groupingToMatrix = 'groupingToMatrix',
limit = 'limit',
partitionByValues = 'partitionByValues',
timeSeriesTable = 'timeSeriesTable',
}
@@ -2,7 +2,7 @@ import { omit } from 'lodash';
import { map } from 'rxjs/operators';
import { MutableDataFrame, sortDataFrame } from '../../dataframe';
import { isTimeSeries } from '../../dataframe/utils';
import { isTimeSeriesFrames } from '../../dataframe/utils';
import { getFrameDisplayName } from '../../field/fieldState';
import {
Field,
@@ -30,7 +30,7 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme
return data;
}
if (!isTimeSeries(data)) {
if (!isTimeSeriesFrames(data)) {
return data;
}
@@ -18,6 +18,7 @@ export enum FieldType {
geo = 'geo',
enum = 'enum',
other = 'other', // Object, Array, etc
frame = 'frame', // DataFrame
}
/**
@@ -82,4 +82,5 @@ export interface FeatureToggles {
drawerDataSourcePicker?: boolean;
traceqlSearch?: boolean;
prometheusMetricEncyclopedia?: boolean;
timeSeriesTable?: boolean;
}
+1
View File
@@ -61,4 +61,5 @@ export {
isPluginExtensionCommand,
assertPluginExtensionCommand,
PluginExtensionTypes,
PluginExtensionPlacements,
} from './pluginExtensions';
@@ -2,6 +2,10 @@
* These types are exposed when rendering extension points
*/
export enum PluginExtensionPlacements {
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
}
export enum PluginExtensionTypes {
link = 'link',
command = 'command',
+1 -1
View File
@@ -43,7 +43,7 @@
"@rollup/plugin-node-resolve": "15.0.1",
"@types/node": "18.14.6",
"esbuild": "0.16.17",
"rimraf": "4.2.0",
"rimraf": "4.4.0",
"rollup": "2.79.1",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-esbuild": "5.0.0",
+2 -2
View File
@@ -57,7 +57,7 @@
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-esbuild": "5.0.0",
"rollup-plugin-node-externals": "^5.0.0",
"webpack": "5.75.0"
"webpack": "5.76.0"
},
"dependencies": {
"@babel/core": "7.20.5",
@@ -77,7 +77,7 @@
"lodash": "4.17.21",
"mocha": "10.2.0",
"resolve-bin": "1.0.1",
"rimraf": "4.2.0",
"rimraf": "4.4.0",
"tracelib": "1.0.1",
"ts-loader": "8.4.0",
"tslib": "2.5.0",
+1 -1
View File
@@ -67,7 +67,7 @@
"lodash": "4.17.21",
"react": "17.0.2",
"react-dom": "17.0.2",
"rimraf": "4.2.0",
"rimraf": "4.4.0",
"rollup": "2.79.1",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-esbuild": "5.0.0",
+1 -1
View File
@@ -41,7 +41,7 @@
"@rollup/plugin-json": "5.0.1",
"@rollup/plugin-node-resolve": "15.0.1",
"esbuild": "0.16.17",
"rimraf": "4.2.0",
"rimraf": "4.4.0",
"rollup": "2.79.1",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-esbuild": "5.0.0",
@@ -593,6 +593,15 @@ export enum BarGaugeDisplayMode {
Lcd = 'lcd',
}
/**
* Allows for the table cell gauge display type to set the gauge mode.
*/
export enum BarGaugeValueMode {
Color = 'color',
Hidden = 'hidden',
Text = 'text',
}
/**
* TODO docs
*/
@@ -601,6 +610,8 @@ export interface VizTooltipOptions {
sort: SortOrder;
}
export interface Labels {}
/**
* Internally, this is the "type" of cell that's being displayed
* in the table such as colored text, JSON, gauge, etc.
@@ -618,6 +629,7 @@ export enum TableCellDisplayMode {
Image = 'image',
JSONView = 'json-view',
LcdGauge = 'lcd-gauge',
Sparkline = 'sparkline',
}
/**
@@ -694,6 +706,14 @@ export interface TableImageCellOptions {
export interface TableBarGaugeCellOptions {
mode?: BarGaugeDisplayMode;
type: TableCellDisplayMode.Gauge;
valueDisplayMode?: BarGaugeValueMode;
}
/**
* Sparkline cell options
*/
export interface TableSparklineCellOptions extends GraphFieldConfig {
type: TableCellDisplayMode.Sparkline;
}
/**
@@ -708,7 +728,7 @@ export interface TableColoredBackgroundCellOptions {
* Table cell options. Each cell has a display mode
* and other potential options for that display.
*/
export type TableCellOptions = (TableAutoCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions);
export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions);
/**
* Use UTC/GMT timezone
@@ -769,7 +789,14 @@ export enum LogsDedupStrategy {
signature = 'signature',
}
export interface Labels {}
/**
* Height of a table cell
*/
export enum TableCellHeight {
Lg = 'lg',
Md = 'md',
Sm = 'sm',
}
/**
* Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
@@ -242,6 +242,9 @@ VizLegendOptions: {
// for the bar gauge component of Grafana UI
BarGaugeDisplayMode: "basic" | "lcd" | "gradient" @cuetsy(kind="enum")
// Allows for the table cell gauge display type to set the gauge mode.
BarGaugeValueMode: "color" | "text" | "hidden" @cuetsy(kind="enum")
// TODO docs
VizTooltipOptions: {
mode: TooltipDisplayMode
@@ -251,3 +254,6 @@ VizTooltipOptions: {
Labels: {
[string]: string
} @cuetsy(kind="interface")
// Height of a table cell
TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum")
+9 -2
View File
@@ -4,7 +4,7 @@ package common
// in the table such as colored text, JSON, gauge, etc.
// The color-background-solid, gradient-gauge, and lcd-gauge
// modes are deprecated in favor of new cell subOptions
TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge")
TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline")
// Display mode to the "Colored Background" display
// mode for table cells. Either displays a solid color (basic mode)
@@ -52,6 +52,13 @@ TableImageCellOptions: {
TableBarGaugeCellOptions: {
type: TableCellDisplayMode & "gauge"
mode?: BarGaugeDisplayMode
valueDisplayMode?: BarGaugeValueMode
} @cuetsy(kind="interface")
// Sparkline cell options
TableSparklineCellOptions: {
GraphFieldConfig
type: TableCellDisplayMode & "sparkline"
} @cuetsy(kind="interface")
// Colored background cell options
@@ -62,7 +69,7 @@ TableColoredBackgroundCellOptions: {
// Table cell options. Each cell has a display mode
// and other potential options for that display.
TableCellOptions: TableAutoCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions @cuetsy(kind="type")
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions @cuetsy(kind="type")
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
// Generally defines alignment, filtering capabilties, display options, etc.
@@ -238,6 +238,10 @@ export interface Threshold {
* TODO docs
*/
color: string;
/**
* Threshold index, an old property that is not needed an should only appear in older dashboards
*/
index?: number;
/**
* TODO docs
* TODO are the values here enumerable into a disjunction?
+3 -3
View File
@@ -52,7 +52,7 @@
"@grafana/data": "9.5.0-pre",
"@grafana/e2e-selectors": "9.5.0-pre",
"@grafana/schema": "9.5.0-pre",
"@leeoniya/ufuzzy": "1.0.2",
"@leeoniya/ufuzzy": "1.0.6",
"@monaco-editor/react": "4.4.6",
"@popperjs/core": "2.11.6",
"@react-aria/button": "3.6.1",
@@ -175,7 +175,7 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-test-renderer": "17.0.2",
"rimraf": "4.2.0",
"rimraf": "4.4.0",
"rollup": "2.79.1",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-esbuild": "5.0.0",
@@ -186,7 +186,7 @@
"storybook-dark-mode": "2.1.1",
"style-loader": "3.3.1",
"typescript": "4.8.4",
"webpack": "5.75.0"
"webpack": "5.76.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0",
@@ -11,18 +11,18 @@ import {
getDisplayProcessor,
createTheme,
} from '@grafana/data';
import { BarGaugeDisplayMode } from '@grafana/schema';
import { BarGaugeDisplayMode, BarGaugeValueMode } from '@grafana/schema';
import {
BarGauge,
Props,
getCellColor,
getValueColor,
getTextValueColor,
getBasicAndGradientStyles,
getBarGradient,
getTitleStyles,
getValuePercent,
calculateBarAndValueDimensions,
getCellColor,
} from './BarGauge';
const green = '#73BF69';
@@ -63,7 +63,7 @@ function getProps(propOverrides?: Partial<Props>): Props {
}
function getValue(value: number, title?: string): DisplayValue {
return { numeric: value, text: value.toString(), title: title };
return { numeric: value, text: value.toString(), title: title, color: '#FF0000' };
}
describe('BarGauge', () => {
@@ -134,12 +134,12 @@ describe('BarGauge', () => {
it('should get the threshold color if value is same as a threshold', () => {
const props = getProps();
props.value = props.display!(70);
expect(getValueColor(props)).toEqual(orange);
expect(getTextValueColor(props)).toEqual(orange);
});
it('should get the base threshold', () => {
const props = getProps();
props.value = props.display!(-10);
expect(getValueColor(props)).toEqual(green);
expect(getTextValueColor(props)).toEqual(green);
});
});
@@ -325,5 +325,46 @@ describe('BarGauge', () => {
);
expect(result.valueWidth).toBe(21);
});
it('valueWidth be zero if valueMode is hideen', () => {
const result = calculateBarAndValueDimensions(
getProps({
height: 30,
width: 100,
value: getValue(1, 'AA'),
orientation: VizOrientation.Horizontal,
valueDisplayMode: BarGaugeValueMode.Hidden,
})
);
expect(result.valueWidth).toBe(0);
});
});
describe('With valueMode set to text color', () => {
it('should color value using text color', () => {
const props = getProps({
width: 150,
value: getValue(100),
orientation: VizOrientation.Vertical,
valueDisplayMode: BarGaugeValueMode.Text,
});
const styles = getBasicAndGradientStyles(props);
expect(styles.bar.background).toBe('rgba(255, 0, 0, 0.35)');
expect(styles.value.color).toBe('rgb(204, 204, 220)');
});
});
describe('With valueMode set to text value', () => {
it('should color value value color', () => {
const props = getProps({
width: 150,
value: getValue(100),
orientation: VizOrientation.Vertical,
valueDisplayMode: BarGaugeValueMode.Color,
});
const styles = getBasicAndGradientStyles(props);
expect(styles.bar.background).toBe('rgba(255, 0, 0, 0.35)');
expect(styles.value.color).toBe('#FF0000');
});
});
});
@@ -20,7 +20,7 @@ import {
VizOrientation,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { BarGaugeDisplayMode, VizTextDisplayOptions } from '@grafana/schema';
import { BarGaugeDisplayMode, BarGaugeValueMode, VizTextDisplayOptions } from '@grafana/schema';
import { Themeable2 } from '../../types';
import { calculateFontSize, measureText } from '../../utils/measureText';
@@ -49,6 +49,7 @@ export interface Props extends Themeable2 {
className?: string;
showUnfilled?: boolean;
alignmentFactors?: DisplayValueAlignmentFactors;
valueDisplayMode?: BarGaugeValueMode;
}
export class BarGauge extends PureComponent<Props> {
@@ -111,17 +112,19 @@ export class BarGauge extends PureComponent<Props> {
}
renderBasicAndGradientBars(): ReactNode {
const { value, showUnfilled } = this.props;
const { value, showUnfilled, valueDisplayMode } = this.props;
const styles = getBasicAndGradientStyles(this.props);
return (
<div style={styles.wrapper}>
<FormattedValueDisplay
data-testid={selectors.components.Panels.Visualization.BarGauge.valueV2}
value={value}
style={styles.value}
/>
{valueDisplayMode !== BarGaugeValueMode.Hidden && (
<FormattedValueDisplay
data-testid={selectors.components.Panels.Visualization.BarGauge.valueV2}
value={value}
style={styles.value}
/>
)}
{showUnfilled && <div style={styles.emptyBar} />}
<div style={styles.bar} />
</div>
@@ -129,7 +132,8 @@ export class BarGauge extends PureComponent<Props> {
}
renderRetroBars(): ReactNode {
const { display, field, value, itemSpacing, alignmentFactors, orientation, lcdCellWidth, text } = this.props;
const { display, field, value, itemSpacing, alignmentFactors, orientation, lcdCellWidth, text, valueDisplayMode } =
this.props;
const { valueHeight, valueWidth, maxBarHeight, maxBarWidth, wrapperWidth, wrapperHeight } =
calculateBarAndValueDimensions(this.props);
const minValue = field.min ?? GAUGE_DEFAULT_MINIMUM;
@@ -141,7 +145,7 @@ export class BarGauge extends PureComponent<Props> {
const cellSpacing = itemSpacing!;
const cellCount = Math.floor(maxSize / lcdCellWidth!);
const cellSize = Math.floor((maxSize - cellSpacing * cellCount) / cellCount);
const valueColor = getValueColor(this.props);
const valueColor = getTextValueColor(this.props);
const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value;
const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text);
@@ -192,11 +196,13 @@ export class BarGauge extends PureComponent<Props> {
return (
<div style={containerStyles}>
{cells}
<FormattedValueDisplay
data-testid={selectors.components.Panels.Visualization.BarGauge.valueV2}
value={value}
style={valueStyles}
/>
{valueDisplayMode !== BarGaugeValueMode.Hidden && (
<FormattedValueDisplay
data-testid={selectors.components.Panels.Visualization.BarGauge.valueV2}
value={value}
style={valueStyles}
/>
)}
</div>
);
}
@@ -338,7 +344,7 @@ interface BarAndValueDimensions {
* Only exported for unit tests
**/
export function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
const { height, width, orientation, text, alignmentFactors } = props;
const { height, width, orientation, text, alignmentFactors, valueDisplayMode } = props;
const titleDim = calculateTitleDimensions(props);
const value = alignmentFactors ?? props.value;
const valueString = formattedValueToString(value);
@@ -363,13 +369,25 @@ export function calculateBarAndValueDimensions(props: Props): BarAndValueDimensi
}
valueWidth = width;
if (valueDisplayMode === BarGaugeValueMode.Hidden) {
valueHeight = 0;
valueWidth = 0;
}
maxBarHeight = height - (titleDim.height + valueHeight);
maxBarWidth = width;
wrapperWidth = width;
wrapperHeight = height - titleDim.height;
} else {
valueHeight = height - titleDim.height;
valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), realValueWidth);
// Calculate the width and the height of the given values
if (valueDisplayMode === BarGaugeValueMode.Hidden) {
valueHeight = 0;
valueWidth = 0;
} else {
valueHeight = height - titleDim.height;
valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), realValueWidth);
}
maxBarHeight = height - titleDim.height;
maxBarWidth = width - valueWidth - titleDim.width;
@@ -447,10 +465,11 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
const minValue = field.min ?? GAUGE_DEFAULT_MINIMUM;
const maxValue = field.max ?? GAUGE_DEFAULT_MAXIMUM;
const valuePercent = getValuePercent(value.numeric, minValue, maxValue);
const valueColor = getValueColor(props);
const textColor = getTextValueColor(props);
const barColor = value.color ?? FALLBACK_COLOR;
const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value;
const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text);
const valueStyles = getValueStyles(valueToBaseSizeOn, textColor, valueWidth, valueHeight, orientation, text);
const isBasic = displayMode === 'basic';
const wrapperStyles: CSSProperties = {
@@ -491,8 +510,8 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
if (isBasic) {
// Basic styles
barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`;
barStyles.borderTop = `2px solid ${valueColor}`;
barStyles.background = `${tinycolor(barColor).setAlpha(0.35).toRgbString()}`;
barStyles.borderTop = `2px solid ${barColor}`;
} else {
// Gradient styles
barStyles.background = getBarGradient(props, maxBarHeight);
@@ -517,8 +536,8 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
if (isBasic) {
// Basic styles
barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`;
barStyles.borderRight = `2px solid ${valueColor}`;
barStyles.background = `${tinycolor(barColor).setAlpha(0.35).toRgbString()}`;
barStyles.borderRight = `2px solid ${barColor}`;
} else {
// Gradient styles
barStyles.background = getBarGradient(props, maxBarWidth);
@@ -598,7 +617,11 @@ export function getBarGradient(props: Props, maxSize: number): string {
/**
* Only exported to for unit test
*/
export function getValueColor(props: Props): string {
export function getTextValueColor(props: Props): string {
if (props.valueDisplayMode === 'text') {
return props.theme.colors.text.primary;
}
const { value } = props;
if (value.color) {
return value.color;
@@ -285,7 +285,6 @@ const getStyles = (theme: GrafanaTheme2) => {
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
maxWidth: theme.spacing(50),
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h6.fontWeight,
}),
@@ -301,6 +300,10 @@ const getStyles = (theme: GrafanaTheme2) => {
label: 'panel-menu',
visibility: 'hidden',
border: 'none',
background: theme.colors.secondary.main,
'&:hover': {
background: theme.colors.secondary.shade,
},
}),
errorContainerFloating: css({
label: 'error-container',
@@ -44,6 +44,7 @@ const defaultConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
showPoints: VisibilityMode.Auto,
axisPlacement: AxisPlacement.Hidden,
pointSize: 2,
};
/** @internal */
@@ -181,6 +182,8 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
pxAlign: false,
scaleKey,
theme,
colorMode,
thresholds: config.thresholds,
drawStyle: customConfig.drawStyle!,
lineColor: customConfig.lineColor ?? seriesColor,
lineWidth: customConfig.lineWidth,
@@ -188,7 +191,9 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
showPoints: pointsMode,
pointSize: customConfig.pointSize,
fillOpacity: customConfig.fillOpacity,
fillColor: customConfig.fillColor ?? seriesColor,
fillColor: customConfig.fillColor,
lineStyle: customConfig.lineStyle,
gradientMode: customConfig.gradientMode,
});
}
@@ -2,7 +2,7 @@ import { isFunction } from 'lodash';
import React, { FC } from 'react';
import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data';
import { BarGaugeDisplayMode } from '@grafana/schema';
import { BarGaugeDisplayMode, BarGaugeValueMode } from '@grafana/schema';
import { BarGauge } from '../BarGauge/BarGauge';
import { DataLinksContextMenu, DataLinksContextMenuApi } from '../DataLinks/DataLinksContextMenu';
@@ -26,6 +26,8 @@ const defaultScale: ThresholdsConfig = {
export const BarGaugeCell: FC<TableCellProps> = (props) => {
const { field, innerWidth, tableStyles, cell, cellProps, row } = props;
const displayValue = field.display!(cell.value);
const cellOptions = getCellOptions(field);
let config = getFieldConfigWithMinMax(field, false);
if (!config.thresholds) {
@@ -35,14 +37,15 @@ export const BarGaugeCell: FC<TableCellProps> = (props) => {
};
}
const displayValue = field.display!(cell.value);
// Set default display mode
// Set default display mode and update if defined
// and update the valueMode if defined
let barGaugeMode: BarGaugeDisplayMode = BarGaugeDisplayMode.Gradient;
let valueDisplayMode: BarGaugeValueMode | undefined = undefined;
const cellOptions = getCellOptions(field);
if (cellOptions.type === TableCellDisplayMode.Gauge) {
barGaugeMode = cellOptions.mode ?? BarGaugeDisplayMode.Gradient;
valueDisplayMode =
cellOptions.valueDisplayMode !== undefined ? cellOptions.valueDisplayMode : BarGaugeValueMode.Text;
}
const getLinks = () => {
@@ -73,6 +76,7 @@ export const BarGaugeCell: FC<TableCellProps> = (props) => {
itemSpacing={1}
lcdCellWidth={8}
displayMode={barGaugeMode}
valueDisplayMode={valueDisplayMode}
/>
);
};
@@ -84,21 +88,7 @@ export const BarGaugeCell: FC<TableCellProps> = (props) => {
{(api) => renderComponent(api)}
</DataLinksContextMenu>
)}
{!hasLinks && (
<BarGauge
width={innerWidth}
height={tableStyles.cellHeightInner}
field={config}
display={field.display}
text={{ valueSize: 14 }}
value={displayValue}
orientation={VizOrientation.Horizontal}
theme={tableStyles.theme}
itemSpacing={1}
lcdCellWidth={8}
displayMode={barGaugeMode}
/>
)}
{!hasLinks && renderComponent({})}
</div>
);
};
@@ -16,7 +16,7 @@ export interface FooterRowProps {
tableStyles: TableStyles;
}
export const FooterRow = (props: FooterRowProps) => {
export function FooterRow(props: FooterRowProps) {
const { totalColumnsWidth, footerGroups, isPaginationVisible, tableStyles } = props;
const e2eSelectorsTable = selectors.components.Panels.Visualization.Table;
@@ -38,7 +38,7 @@ export const FooterRow = (props: FooterRowProps) => {
})}
</div>
);
};
}
function renderFooterCell(column: ColumnInstance, tableStyles: TableStyles) {
const footerProps = column.getHeaderProps();
@@ -0,0 +1,114 @@
import { isArray } from 'lodash';
import React, { FC } from 'react';
import {
ArrayVector,
FieldType,
FieldConfig,
getMinMaxAndDelta,
FieldSparkline,
isDataFrame,
Field,
} from '@grafana/data';
import {
BarAlignment,
GraphDrawStyle,
GraphFieldConfig,
GraphGradientMode,
LineInterpolation,
TableSparklineCellOptions,
TableCellDisplayMode,
VisibilityMode,
} from '@grafana/schema';
import { Sparkline } from '../Sparkline/Sparkline';
import { TableCellProps } from './types';
import { getCellOptions } from './utils';
export const defaultSparklineCellConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
lineInterpolation: LineInterpolation.Smooth,
lineWidth: 1,
fillOpacity: 17,
gradientMode: GraphGradientMode.Hue,
pointSize: 2,
barAlignment: BarAlignment.Center,
showPoints: VisibilityMode.Never,
};
export const SparklineCell: FC<TableCellProps> = (props) => {
const { field, innerWidth, tableStyles, cell, cellProps } = props;
const sparkline = getSparkline(cell.value);
if (!sparkline) {
return (
<div {...cellProps} className={tableStyles.cellContainer}>
no data
</div>
);
}
const range = getMinMaxAndDelta(sparkline.y);
sparkline.y.config.min = range.min;
sparkline.y.config.max = range.max;
sparkline.y.state = { range };
const cellOptions = getTableSparklineCellOptions(field);
const config: FieldConfig<GraphFieldConfig> = {
color: field.config.color,
custom: {
...defaultSparklineCellConfig,
...cellOptions,
},
};
return (
<div {...cellProps} className={tableStyles.cellContainer}>
<Sparkline
width={innerWidth}
height={tableStyles.cellHeightInner}
sparkline={sparkline}
config={config}
theme={tableStyles.theme}
/>
</div>
);
};
function getSparkline(value: unknown): FieldSparkline | undefined {
if (isArray(value)) {
return {
y: {
name: 'test',
type: FieldType.number,
values: new ArrayVector(value),
config: {},
},
};
}
if (isDataFrame(value)) {
const timeField = value.fields.find((x) => x.type === FieldType.time);
const numberField = value.fields.find((x) => x.type === FieldType.number);
if (timeField && numberField) {
return { x: timeField, y: numberField };
}
}
return;
}
function getTableSparklineCellOptions(field: Field): TableSparklineCellOptions {
let options = getCellOptions(field);
if (options.type === TableCellDisplayMode.Auto) {
options = { ...options, type: TableCellDisplayMode.Sparkline };
}
if (options.type === TableCellDisplayMode.Sparkline) {
return options;
}
throw new Error(`Excpected options type ${TableCellDisplayMode.Sparkline} but got ${options.type}`);
}
@@ -13,8 +13,9 @@ import {
import { VariableSizeList } from 'react-window';
import { DataFrame, Field, ReducerID } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../themes';
import { useTheme2 } from '../../themes';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Pagination } from '../Pagination/Pagination';
@@ -23,7 +24,7 @@ import { HeaderRow } from './HeaderRow';
import { TableCell } from './TableCell';
import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks';
import { getInitialState, useTableStateReducer } from './reducer';
import { getTableStyles } from './styles';
import { useTableStyles } from './styles';
import { FooterItem, GrafanaTableState, Props } from './types';
import {
getColumns,
@@ -56,13 +57,14 @@ export const Table = memo((props: Props) => {
showTypeIcons,
footerValues,
enablePagination,
cellHeight = TableCellHeight.Md,
} = props;
const listRef = useRef<VariableSizeList>(null);
const tableDivRef = useRef<HTMLDivElement>(null);
const variableSizeListScrollbarRef = useRef<HTMLDivElement>(null);
const tableStyles = useStyles2(getTableStyles);
const theme = useTheme2();
const tableStyles = useTableStyles(theme, cellHeight);
const headerHeight = noHeader ? 0 : tableStyles.rowHeight;
const [footerItems, setFooterItems] = useState<FooterItem[] | undefined>(footerValues);
@@ -385,6 +387,8 @@ export const Table = memo((props: Props) => {
<div ref={variableSizeListScrollbarRef}>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
<VariableSizeList
// This component needs an unmount/remount when row height changes
key={tableStyles.rowHeight}
height={listHeight}
itemCount={itemCount}
itemSize={getItemSize}
@@ -1,15 +1,15 @@
import { css, CSSObject } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
export const getTableStyles = (theme: GrafanaTheme2) => {
export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCellHeight) {
const borderColor = theme.colors.border.weak;
const resizerColor = theme.colors.primary.border;
const cellPadding = 6;
const lineHeight = theme.typography.body.lineHeight;
const bodyFontSize = 14;
const cellHeight = cellPadding * 2 + bodyFontSize * lineHeight;
const cellHeight = getCellHeight(theme, cellHeightOption, cellPadding);
const rowHeight = cellHeight + 2;
const headerHeight = 28;
const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03);
const buildCellContainerStyle = (color?: string, background?: string, overflowOnHover?: boolean) => {
@@ -95,7 +95,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
cellHeight,
buildCellContainerStyle,
cellPadding,
cellHeightInner: bodyFontSize * lineHeight,
cellHeightInner: cellHeight - cellPadding * 2,
rowHeight,
table: css`
height: 100%;
@@ -106,14 +106,14 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
`,
thead: css`
label: thead;
height: ${rowHeight}px;
height: ${headerHeight}px;
overflow-y: auto;
overflow-x: hidden;
position: relative;
`,
tfoot: css`
label: tfoot;
height: ${rowHeight}px;
height: ${headerHeight}px;
border-top: 1px solid ${borderColor};
overflow-y: auto;
overflow-x: hidden;
@@ -124,10 +124,12 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
border-bottom: 1px solid ${borderColor};
`,
headerCell: css`
padding: ${cellPadding}px;
height: 100%;
padding: 0 ${cellPadding}px;
overflow: hidden;
white-space: nowrap;
display: flex;
align-items: center;
font-weight: ${theme.typography.fontWeightMedium};
&:last-child {
@@ -285,6 +287,21 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
cursor: pointer;
`,
};
};
}
export type TableStyles = ReturnType<typeof getTableStyles>;
export type TableStyles = ReturnType<typeof useTableStyles>;
function getCellHeight(theme: GrafanaTheme2, cellHeightOption: TableCellHeight, cellPadding: number) {
const bodyFontSize = theme.typography.fontSize;
const lineHeight = theme.typography.body.lineHeight;
switch (cellHeightOption) {
case 'md':
return 42;
case 'lg':
return 48;
case 'sm':
default:
return cellPadding * 2 + bodyFontSize * lineHeight;
}
}
@@ -3,6 +3,7 @@ import { FC } from 'react';
import { CellProps, Column, Row, TableState, UseExpandedRowProps } from 'react-table';
import { DataFrame, Field, KeyValue, SelectableValue } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { TableStyles } from './styles';
@@ -84,6 +85,7 @@ export interface Props {
footerOptions?: TableFooterCalc;
footerValues?: FooterItem[];
enablePagination?: boolean;
cellHeight?: TableCellHeight;
/** @alpha */
subData?: DataFrame[];
}
@@ -15,6 +15,8 @@ import {
reduceField,
GrafanaTheme2,
ArrayVector,
isDataFrame,
isTimeSeriesFrame,
} from '@grafana/data';
import {
BarGaugeDisplayMode,
@@ -30,6 +32,7 @@ import { GeoCell } from './GeoCell';
import { ImageCell } from './ImageCell';
import { JSONViewCell } from './JSONViewCell';
import { RowExpander } from './RowExpander';
import { SparklineCell } from './SparklineCell';
import {
CellComponent,
TableCellDisplayMode,
@@ -190,6 +193,8 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field
return ImageCell;
case TableCellDisplayMode.Gauge:
return BarGaugeCell;
case TableCellDisplayMode.Sparkline:
return SparklineCell;
case TableCellDisplayMode.JSONView:
return JSONViewCell;
}
@@ -198,10 +203,20 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field
return GeoCell;
}
if (field.type === FieldType.frame) {
const firstValue = field.values.get(0);
if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) {
return SparklineCell;
}
return JSONViewCell;
}
// Default or Auto
if (field.type === FieldType.other) {
return JSONViewCell;
}
return DefaultCell;
}
+3 -29
View File
@@ -14,14 +14,12 @@ const databaseCacheType = "database"
type databaseCache struct {
SQLStore db.DB
codec codec
log log.Logger
}
func newDatabaseCache(sqlstore db.DB, codec codec) *databaseCache {
func newDatabaseCache(sqlstore db.DB) *databaseCache {
dc := &databaseCache{
SQLStore: sqlstore,
codec: codec,
log: log.New("remotecache.database"),
}
@@ -54,7 +52,7 @@ func (dc *databaseCache) internalRunGC() {
}
}
func (dc *databaseCache) GetByteArray(ctx context.Context, key string) ([]byte, error) {
func (dc *databaseCache) Get(ctx context.Context, key string) ([]byte, error) {
cacheHit := CacheData{}
err := dc.SQLStore.WithDbSession(ctx, func(session *db.Session) error {
@@ -85,21 +83,7 @@ func (dc *databaseCache) GetByteArray(ctx context.Context, key string) ([]byte,
return cacheHit.Data, err
}
func (dc *databaseCache) Get(ctx context.Context, key string) (interface{}, error) {
bytes, err := dc.GetByteArray(ctx, key)
if err != nil {
return nil, err
}
item := &cachedItem{}
if err = dc.codec.Decode(ctx, bytes, item); err != nil {
return nil, err
}
return item.Val, err
}
func (dc *databaseCache) SetByteArray(ctx context.Context, key string, data []byte, expire time.Duration) error {
func (dc *databaseCache) Set(ctx context.Context, key string, data []byte, expire time.Duration) error {
return dc.SQLStore.WithDbSession(ctx, func(session *db.Session) error {
var expiresInSeconds int64
if expire != 0 {
@@ -129,16 +113,6 @@ func (dc *databaseCache) SetByteArray(ctx context.Context, key string, data []by
})
}
func (dc *databaseCache) Set(ctx context.Context, key string, value interface{}, expire time.Duration) error {
item := &cachedItem{Val: value}
data, err := dc.codec.Encode(ctx, item)
if err != nil {
return err
}
return dc.SetByteArray(ctx, key, data, expire)
}
func (dc *databaseCache) Delete(ctx context.Context, key string) error {
return dc.SQLStore.WithDbSession(ctx, func(session *db.Session) error {
sql := "DELETE FROM cache_data WHERE cache_key=?"
@@ -17,11 +17,10 @@ func TestDatabaseStorageGarbageCollection(t *testing.T) {
db := &databaseCache{
SQLStore: sqlstore,
codec: &gobCodec{},
log: log.New("remotecache.database"),
}
obj := &CacheableStruct{String: "foolbar"}
obj := []byte("foolbar")
// set time.now to 2 weeks ago
var err error
@@ -66,11 +65,10 @@ func TestSecondSet(t *testing.T) {
db := &databaseCache{
SQLStore: sqlstore,
codec: &gobCodec{},
log: log.New("remotecache.database"),
}
obj := &CacheableStruct{String: "hey!"}
obj := []byte("hey!")
err = db.Set(context.Background(), "killa-gorilla", obj, 0)
assert.Equal(t, err, nil)
@@ -84,11 +82,10 @@ func TestDatabaseStorageCount(t *testing.T) {
db := &databaseCache{
SQLStore: sqlstore,
codec: &gobCodec{},
log: log.New("remotecache.database"),
}
obj := &CacheableStruct{String: "foolbar"}
obj := []byte("foolbar")
// set time.now to 2 weeks ago
var err error
+5 -34
View File
@@ -15,14 +15,12 @@ const memcachedCacheType = "memcached"
var ErrNotImplemented = errors.New("not implemented")
type memcachedStorage struct {
c *memcache.Client
codec codec
c *memcache.Client
}
func newMemcachedStorage(opts *setting.RemoteCacheOptions, codec codec) *memcachedStorage {
func newMemcachedStorage(opts *setting.RemoteCacheOptions) *memcachedStorage {
return &memcachedStorage{
c: memcache.New(opts.ConnStr),
codec: codec,
c: memcache.New(opts.ConnStr),
}
}
@@ -34,19 +32,8 @@ func newItem(sid string, data []byte, expire int32) *memcache.Item {
}
}
// Set sets value to given key in the cache.
func (s *memcachedStorage) Set(ctx context.Context, key string, val interface{}, expires time.Duration) error {
item := &cachedItem{Val: val}
bytes, err := s.codec.Encode(ctx, item)
if err != nil {
return err
}
return s.SetByteArray(ctx, key, bytes, expires)
}
// SetByteArray stores an byte array in the cache
func (s *memcachedStorage) SetByteArray(ctx context.Context, key string, data []byte, expires time.Duration) error {
func (s *memcachedStorage) Set(ctx context.Context, key string, data []byte, expires time.Duration) error {
var expiresInSeconds int64
if expires != 0 {
expiresInSeconds = int64(expires) / int64(time.Second)
@@ -56,24 +43,8 @@ func (s *memcachedStorage) SetByteArray(ctx context.Context, key string, data []
return s.c.Set(memcachedItem)
}
// Get gets value by given key in the cache.
func (s *memcachedStorage) Get(ctx context.Context, key string) (interface{}, error) {
bytes, err := s.GetByteArray(ctx, key)
if err != nil {
return nil, err
}
item := &cachedItem{}
err = s.codec.Decode(ctx, bytes, item)
if err != nil {
return nil, err
}
return item.Val, nil
}
// GetByteArray returns the cached value as an byte array
func (s *memcachedStorage) GetByteArray(ctx context.Context, key string) ([]byte, error) {
func (s *memcachedStorage) Get(ctx context.Context, key string) ([]byte, error) {
memcachedItem, err := s.c.Get(key)
if errors.Is(err, memcache.ErrCacheMiss) {
return nil, ErrCacheItemNotFound
+5 -38
View File
@@ -16,8 +16,7 @@ import (
const redisCacheType = "redis"
type redisStorage struct {
c *redis.Client
codec codec
c *redis.Client
}
// parseRedisConnStr parses k=v pairs in csv and builds a redis Options object
@@ -78,54 +77,22 @@ func parseRedisConnStr(connStr string) (*redis.Options, error) {
return options, nil
}
func newRedisStorage(opts *setting.RemoteCacheOptions, codec codec) (*redisStorage, error) {
func newRedisStorage(opts *setting.RemoteCacheOptions) (*redisStorage, error) {
opt, err := parseRedisConnStr(opts.ConnStr)
if err != nil {
return nil, err
}
return &redisStorage{c: redis.NewClient(opt), codec: codec}, nil
}
// Set sets value to given key in session.
func (s *redisStorage) Set(ctx context.Context, key string, val interface{}, expires time.Duration) error {
item := &cachedItem{Val: val}
value, err := s.codec.Encode(ctx, item)
if err != nil {
return err
}
return s.SetByteArray(ctx, key, value, expires)
return &redisStorage{c: redis.NewClient(opt)}, nil
}
// Set sets value to a given key
func (s *redisStorage) SetByteArray(ctx context.Context, key string, data []byte, expires time.Duration) error {
func (s *redisStorage) Set(ctx context.Context, key string, data []byte, expires time.Duration) error {
status := s.c.Set(ctx, key, data, expires)
return status.Err()
}
// Get gets value by given key in session.
func (s *redisStorage) Get(ctx context.Context, key string) (interface{}, error) {
v, err := s.GetByteArray(ctx, key)
if err != nil {
if err.Error() == "EOF" {
return nil, ErrCacheItemNotFound
}
return nil, err
}
item := &cachedItem{}
err = s.codec.Decode(ctx, v, item)
if err == nil {
return item.Val, nil
}
return nil, err
}
// GetByteArray returns the value as byte array
func (s *redisStorage) GetByteArray(ctx context.Context, key string) ([]byte, error) {
func (s *redisStorage) Get(ctx context.Context, key string) ([]byte, error) {
return s.c.Get(ctx, key).Bytes()
}
+45 -69
View File
@@ -1,9 +1,7 @@
package remotecache
import (
"bytes"
"context"
"encoding/gob"
"errors"
"time"
@@ -30,13 +28,7 @@ const (
func ProvideService(cfg *setting.Cfg, sqlStore db.DB, usageStats usagestats.Service,
secretsService secrets.Service) (*RemoteCache, error) {
var codec codec
if cfg.RemoteCacheOptions.Encryption {
codec = &encryptionCodec{secretsService}
} else {
codec = &gobCodec{}
}
client, err := createClient(cfg.RemoteCacheOptions, sqlStore, codec)
client, err := createClient(cfg.RemoteCacheOptions, sqlStore)
if err != nil {
return nil, err
}
@@ -69,11 +61,11 @@ func (ds *RemoteCache) getUsageStats(ctx context.Context) (map[string]interface{
// so any struct added to the cache needs to be registered with `remotecache.Register`
// ex `remotecache.Register(CacheableStruct{})`
type CacheStorage interface {
// GetByteArray gets the cache value as an byte array
GetByteArray(ctx context.Context, key string) ([]byte, error)
// Get gets the cache value as an byte array
Get(ctx context.Context, key string) ([]byte, error)
// SetByteArray saves the value as an byte array. if `expire` is set to zero it will default to 24h
SetByteArray(ctx context.Context, key string, value []byte, expire time.Duration) error
// Set saves the value as an byte array. if `expire` is set to zero it will default to 24h
Set(ctx context.Context, key string, value []byte, expire time.Duration) error
// Delete object from cache
Delete(ctx context.Context, key string) error
@@ -91,18 +83,18 @@ type RemoteCache struct {
Cfg *setting.Cfg
}
// GetByteArray returns the cached value as an byte array
func (ds *RemoteCache) GetByteArray(ctx context.Context, key string) ([]byte, error) {
return ds.client.GetByteArray(ctx, key)
// Get returns the cached value as an byte array
func (ds *RemoteCache) Get(ctx context.Context, key string) ([]byte, error) {
return ds.client.Get(ctx, key)
}
// SetByteArray stored the byte array in the cache
func (ds *RemoteCache) SetByteArray(ctx context.Context, key string, value []byte, expire time.Duration) error {
// Set stored the byte array in the cache
func (ds *RemoteCache) Set(ctx context.Context, key string, value []byte, expire time.Duration) error {
if expire == 0 {
expire = defaultMaxCacheExpiration
}
return ds.client.SetByteArray(ctx, key, value, expire)
return ds.client.Set(ctx, key, value, expire)
}
// Delete object from cache
@@ -127,14 +119,14 @@ func (ds *RemoteCache) Run(ctx context.Context) error {
return ctx.Err()
}
func createClient(opts *setting.RemoteCacheOptions, sqlstore db.DB, codec codec) (cache CacheStorage, err error) {
func createClient(opts *setting.RemoteCacheOptions, sqlstore db.DB) (cache CacheStorage, err error) {
switch opts.Name {
case redisCacheType:
cache, err = newRedisStorage(opts, codec)
cache, err = newRedisStorage(opts)
case memcachedCacheType:
cache = newMemcachedStorage(opts, codec)
cache = newMemcachedStorage(opts)
case databaseCacheType:
cache = newDatabaseCache(sqlstore, codec)
cache = newDatabaseCache(sqlstore)
default:
return nil, ErrInvalidCacheType
}
@@ -144,61 +136,45 @@ func createClient(opts *setting.RemoteCacheOptions, sqlstore db.DB, codec codec)
if opts.Prefix != "" {
cache = &prefixCacheStorage{cache: cache, prefix: opts.Prefix}
}
if opts.Encryption {
cache = &encryptedCacheStorage{cache: cache}
}
return cache, nil
}
// Register records a type, identified by a value for that type, under its
// internal type name. That name will identify the concrete type of a value
// sent or received as an interface variable. Only types that will be
// transferred as implementations of interface values need to be registered.
// Expecting to be used only during initialization, it panics if the mapping
// between types and names is not a bijection.
func Register(value interface{}) {
gob.Register(value)
type encryptedCacheStorage struct {
cache CacheStorage
secretsService encryptionService
}
type cachedItem struct {
Val interface{}
type encryptionService interface {
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
Decrypt(ctx context.Context, payload []byte) ([]byte, error)
}
type codec interface {
Encode(context.Context, *cachedItem) ([]byte, error)
Decode(context.Context, []byte, *cachedItem) error
}
type gobCodec struct{}
func (c *gobCodec) Encode(_ context.Context, item *cachedItem) ([]byte, error) {
buf := bytes.NewBuffer(nil)
err := gob.NewEncoder(buf).Encode(item)
return buf.Bytes(), err
}
func (c *gobCodec) Decode(_ context.Context, data []byte, out *cachedItem) error {
buf := bytes.NewBuffer(data)
return gob.NewDecoder(buf).Decode(&out)
}
type encryptionCodec struct {
secretsService secrets.Service
}
func (c *encryptionCodec) Encode(ctx context.Context, item *cachedItem) ([]byte, error) {
buf := bytes.NewBuffer(nil)
err := gob.NewEncoder(buf).Encode(item)
func (pcs *encryptedCacheStorage) Get(ctx context.Context, key string) ([]byte, error) {
data, err := pcs.cache.Get(ctx, key)
if err != nil {
return nil, err
}
return c.secretsService.Encrypt(ctx, buf.Bytes(), secrets.WithoutScope())
}
func (c *encryptionCodec) Decode(ctx context.Context, data []byte, out *cachedItem) error {
decrypted, err := c.secretsService.Decrypt(ctx, data)
return pcs.secretsService.Decrypt(ctx, data)
}
func (pcs *encryptedCacheStorage) Set(ctx context.Context, key string, value []byte, expire time.Duration) error {
encrypted, err := pcs.secretsService.Encrypt(ctx, value, secrets.WithoutScope())
if err != nil {
return err
}
buf := bytes.NewBuffer(decrypted)
return gob.NewDecoder(buf).Decode(&out)
return pcs.cache.Set(ctx, key, encrypted, expire)
}
func (pcs *encryptedCacheStorage) Delete(ctx context.Context, key string) error {
return pcs.cache.Delete(ctx, key)
}
func (pcs *encryptedCacheStorage) Count(ctx context.Context, prefix string) (int64, error) {
return pcs.cache.Count(ctx, prefix)
}
type prefixCacheStorage struct {
@@ -206,16 +182,16 @@ type prefixCacheStorage struct {
prefix string
}
func (pcs *prefixCacheStorage) GetByteArray(ctx context.Context, key string) ([]byte, error) {
return pcs.cache.GetByteArray(ctx, pcs.prefix+key)
func (pcs *prefixCacheStorage) Get(ctx context.Context, key string) ([]byte, error) {
return pcs.cache.Get(ctx, pcs.prefix+key)
}
func (pcs *prefixCacheStorage) SetByteArray(ctx context.Context, key string, value []byte, expire time.Duration) error {
return pcs.cache.SetByteArray(ctx, pcs.prefix+key, value, expire)
func (pcs *prefixCacheStorage) Set(ctx context.Context, key string, value []byte, expire time.Duration) error {
return pcs.cache.Set(ctx, pcs.prefix+key, value, expire)
}
func (pcs *prefixCacheStorage) Delete(ctx context.Context, key string) error {
return pcs.cache.Delete(ctx, pcs.prefix+key)
}
func (pcs *prefixCacheStorage) Count(ctx context.Context, prefix string) (int64, error) {
return pcs.cache.Count(ctx, pcs.prefix)
return pcs.cache.Count(ctx, pcs.prefix+prefix)
}
+84 -28
View File
@@ -9,21 +9,12 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/setting"
)
type CacheableStruct struct {
String string
Int64 int64
}
func init() {
Register(CacheableStruct{})
}
func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore db.DB) CacheStorage {
t.Helper()
@@ -49,7 +40,7 @@ func TestCachedBasedOnConfig(t *testing.T) {
}
func TestInvalidCacheTypeReturnsError(t *testing.T) {
_, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil, &gobCodec{})
_, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil)
assert.Equal(t, err, ErrInvalidCacheType)
}
@@ -68,13 +59,13 @@ func runCountTestsForClient(t *testing.T, opts *setting.RemoteCacheOptions, sqls
t.Run("can count items", func(t *testing.T) {
cacheableValue := []byte("hej hej")
err := client.SetByteArray(context.Background(), "pref-key1", cacheableValue, 0)
err := client.Set(context.Background(), "pref-key1", cacheableValue, 0)
require.NoError(t, err)
err = client.SetByteArray(context.Background(), "pref-key2", cacheableValue, 0)
err = client.Set(context.Background(), "pref-key2", cacheableValue, 0)
require.NoError(t, err)
err = client.SetByteArray(context.Background(), "key3-not-pref", cacheableValue, 0)
err = client.Set(context.Background(), "key3-not-pref", cacheableValue, 0)
require.NoError(t, err)
n, errC := client.Count(context.Background(), "pref-")
@@ -92,10 +83,10 @@ func runCountTestsForClient(t *testing.T, opts *setting.RemoteCacheOptions, sqls
func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) {
dataToCache := []byte("some bytes")
err := client.SetByteArray(context.Background(), "key1", dataToCache, 0)
err := client.Set(context.Background(), "key1", dataToCache, 0)
assert.Equal(t, err, nil, "expected nil. got: ", err)
data, err := client.GetByteArray(context.Background(), "key1")
data, err := client.Get(context.Background(), "key1")
assert.Equal(t, err, nil)
assert.Equal(t, string(data), "some bytes")
@@ -103,21 +94,21 @@ func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) {
err = client.Delete(context.Background(), "key1")
assert.Equal(t, err, nil)
_, err = client.GetByteArray(context.Background(), "key1")
_, err = client.Get(context.Background(), "key1")
assert.Equal(t, err, ErrCacheItemNotFound)
}
func canNotFetchExpiredItems(t *testing.T, client CacheStorage) {
dataToCache := []byte("some bytes")
err := client.SetByteArray(context.Background(), "key1", dataToCache, time.Second)
err := client.Set(context.Background(), "key1", dataToCache, time.Second)
assert.Equal(t, err, nil)
// not sure how this can be avoided when testing redis/memcached :/
<-time.After(time.Second + time.Millisecond)
// should not be able to read that value since its expired
_, err = client.GetByteArray(context.Background(), "key1")
_, err = client.Get(context.Background(), "key1")
assert.Equal(t, err, ErrCacheItemNotFound)
}
@@ -140,26 +131,91 @@ func TestCollectUsageStats(t *testing.T) {
}
func TestCachePrefix(t *testing.T) {
db := db.InitTestDB(t)
cache := &databaseCache{
SQLStore: db,
log: log.New("remotecache.database"),
codec: &gobCodec{},
}
cache := NewFakeCacheStorage()
prefixCache := &prefixCacheStorage{cache: cache, prefix: "test/"}
// Set a value (with a prefix)
err := prefixCache.SetByteArray(context.Background(), "foo", []byte("bar"), time.Hour)
err := prefixCache.Set(context.Background(), "foo", []byte("bar"), time.Hour)
require.NoError(t, err)
// Get a value (with a prefix)
v, err := prefixCache.GetByteArray(context.Background(), "foo")
v, err := prefixCache.Get(context.Background(), "foo")
require.NoError(t, err)
require.Equal(t, "bar", string(v))
// Get a value directly from the underlying cache, ensure the prefix is in the key
v, err = cache.GetByteArray(context.Background(), "test/foo")
v, err = cache.Get(context.Background(), "test/foo")
require.NoError(t, err)
require.Equal(t, "bar", string(v))
// Get a value directly from the underlying cache without a prefix, should not be there
_, err = cache.Get(context.Background(), "foo")
require.Error(t, err)
}
func TestEncryptedCache(t *testing.T) {
cache := NewFakeCacheStorage()
encryptedCache := &encryptedCacheStorage{cache: cache, secretsService: &fakeSecretsService{}}
// Set a value in the encrypted cache
err := encryptedCache.Set(context.Background(), "foo", []byte("bar"), time.Hour)
require.NoError(t, err)
// make sure the stored value is not equal to input
v, err := cache.Get(context.Background(), "foo")
require.NoError(t, err)
require.NotEqual(t, "bar", string(v))
// make sure the returned value is the same as orignial
v, err = encryptedCache.Get(context.Background(), "foo")
require.NoError(t, err)
require.Equal(t, "bar", string(v))
}
type fakeCacheStorage struct {
storage map[string][]byte
}
func (fcs fakeCacheStorage) Set(_ context.Context, key string, value []byte, exp time.Duration) error {
fcs.storage[key] = value
return nil
}
func (fcs fakeCacheStorage) Get(_ context.Context, key string) ([]byte, error) {
value, exist := fcs.storage[key]
if !exist {
return nil, ErrCacheItemNotFound
}
return value, nil
}
func (fcs fakeCacheStorage) Delete(_ context.Context, key string) error {
delete(fcs.storage, key)
return nil
}
func (fcs fakeCacheStorage) Count(_ context.Context, prefix string) (int64, error) {
return int64(len(fcs.storage)), nil
}
func NewFakeCacheStorage() CacheStorage {
return fakeCacheStorage{
storage: map[string][]byte{},
}
}
type fakeSecretsService struct{}
func (f fakeSecretsService) Encrypt(_ context.Context, payload []byte, _ secrets.EncryptionOptions) ([]byte, error) {
return f.reverse(payload), nil
}
func (f fakeSecretsService) Decrypt(_ context.Context, payload []byte) ([]byte, error) {
return f.reverse(payload), nil
}
func (f fakeSecretsService) reverse(input []byte) []byte {
r := []rune(string(input))
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return []byte(string(r))
}
@@ -701,6 +701,9 @@ type Threshold struct {
// TODO docs
Color string `json:"color"`
// Threshold index, an old property that is not needed an should only appear in older dashboards
Index *int32 `json:"index,omitempty"`
// TODO docs
// TODO are the values here enumerable into a disjunction?
// Some seem to be listed in typescript comment
+1 -1
View File
@@ -296,7 +296,7 @@
0
],
"description": "A Grafana dashboard.",
"grafanaMaturityCount": 139,
"grafanaMaturityCount": 140,
"lineageIsGroup": false,
"links": {
"docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/dashboard/schema-reference",
+1 -1
View File
@@ -615,7 +615,7 @@ func TestMiddlewareContext(t *testing.T) {
require.NoError(t, err)
key := fmt.Sprintf(authproxy.CachePrefix, h)
userIdBytes := []byte(strconv.FormatInt(userID, 10))
err = sc.remoteCacheService.SetByteArray(context.Background(), key, userIdBytes, 0)
err = sc.remoteCacheService.Set(context.Background(), key, userIdBytes, 0)
require.NoError(t, err)
sc.fakeReq("GET", "/")
+67 -75
View File
@@ -146,38 +146,33 @@ seqs: [
// Schema definition for the plugin.json file. Used primarily for schema validation.
$schema?: string
// FIXME there appears to be a bug in thema that prevents this from working. Maybe it'd
// help to refer to it with an alias, but thema can't support using current list syntax.
// syntax (fixed by grafana/thema#82). Either way, for now, pascalName gets populated in Go.
let sani = (strings.ToTitle(regexp.ReplaceAllLiteral("[^a-zA-Z]+", name, "")))
// The PascalCase name for the plugin. Used for creating machine-friendly
// identifiers, typically in code generation.
//
// If not provided, defaults to name, but title-cased and sanitized (only
// alphabetical characters allowed).
pascalName: string & =~"^([A-Z][a-zA-Z]{1,62})$" | *sani
// Plugin category used on the Add data source page.
category?: "tsdb" | "logging" | "cloud" | "tracing" | "sql" | "enterprise" | "profiling" | "other"
// For data source plugins, if the plugin supports alerting. Requires `backend` to be set to `true`.
alerting?: bool
// For data source plugins, if the plugin supports annotation
// queries.
annotations?: bool
// For data source plugins, if the plugin supports alerting.
alerting?: bool
// Set to true for app plugins that should be enabled and pinned to the navigation bar in all orgs.
autoEnabled?: bool
// If the plugin has a backend component.
backend?: bool
// builtin indicates whether the plugin is developed and shipped as part
// of Grafana. Also known as a "core plugin."
// [internal only] Indicates whether the plugin is developed and shipped as part
// of Grafana. Also known as a 'core plugin'.
builtIn: bool | *false
// hideFromList excludes the plugin from listings in Grafana's UI. Only
// allowed for builtin plugins.
hideFromList: bool | *false
// Plugin category used on the Add data source page.
category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other"
// Grafana Enterprise specific features.
enterpriseFeatures?: {
// Enable/Disable health diagnostics errors. Requires Grafana
// >=7.5.5.
healthDiagnosticsErrors?: bool | *false
...
}
// The first part of the file name of the backend component
// executable. There can be multiple executables built for
@@ -188,15 +183,9 @@ seqs: [
// https://golang.org/doc/install/source#environment.
executable?: string
// Initialize plugin on startup. By default, the plugin
// initializes on first use.
preload?: bool
// Marks a plugin as a pre-release.
state?: #ReleaseState
// ReleaseState indicates release maturity state of a plugin.
#ReleaseState: "alpha" | "beta" | "deprecated" | *"stable"
// [internal only] Excludes the plugin from listings in Grafana's UI. Only
// allowed for `builtIn` plugins.
hideFromList: bool | *false
// Resources to include in plugin.
includes?: [...#Include]
@@ -233,33 +222,64 @@ seqs: [
...
}
// For data source plugins, if the plugin supports logs.
// For data source plugins, if the plugin supports logs. It may be used to filter logs only features.
logs?: bool
// For data source plugins, if the plugin supports metric queries.
// Used to enable the plugin in the panel editor.
metrics?: bool
// FIXME there appears to be a bug in thema that prevents this from working. Maybe it'd
// help to refer to it with an alias, but thema can't support using current list syntax.
// syntax (fixed by grafana/thema#82). Either way, for now, pascalName gets populated in Go.
let sani = (strings.ToTitle(regexp.ReplaceAllLiteral("[^a-zA-Z]+", name, "")))
// [internal only] The PascalCase name for the plugin. Used for creating machine-friendly
// identifiers, typically in code generation.
//
// If not provided, defaults to name, but title-cased and sanitized (only
// alphabetical characters allowed).
pascalName: string & =~"^([A-Z][a-zA-Z]{1,62})$" | *sani
// Initialize plugin on startup. By default, the plugin
// initializes on first use.
preload?: bool
// For data source plugins. There is a query options section in
// the plugin's query editor and these options can be turned on
// if needed.
queryOptions?: {
// For data source plugins. If the `max data points` option should
// be shown in the query options section in the query editor.
maxDataPoints?: bool
// For data source plugins. If the `min interval` option should be
// shown in the query options section in the query editor.
minInterval?: bool
// For data source plugins. If the `cache timeout` option should
// be shown in the query options section in the query editor.
cacheTimeout?: bool
}
// Routes is a list of proxy routes, if any. For datasource plugins only.
routes?: [...#Route]
// For panel plugins. Hides the query editor.
skipDataQuery?: bool
// For data source plugins, if the plugin supports metric queries.
// Used in Explore.
metrics?: bool
// Marks a plugin as a pre-release.
state?: #ReleaseState
// For data source plugins, if the plugin supports streaming.
// ReleaseState indicates release maturity state of a plugin.
#ReleaseState: "alpha" | "beta" | "deprecated" | *"stable"
// For data source plugins, if the plugin supports streaming. Used in Explore to start live streaming.
streaming?: bool
// This is an undocumented feature.
tables?: bool
// For data source plugins, if the plugin supports tracing.
// For data source plugins, if the plugin supports tracing. Used for example to link logs (e.g. Loki logs) with tracing plugins.
tracing?: bool
// For data source plugins, include hidden queries in the data
// request.
hiddenQueries?: bool
// Set to true for app plugins that should be enabled by default
// in all orgs
autoEnabled?: bool
// Optional list of RBAC RoleRegistrations.
// Describes and organizes the default permissions associated with any of the Grafana basic roles,
// which characterizes what viewers, editors, admins, or grafana admins can do on the plugin.
@@ -305,26 +325,6 @@ seqs: [
// in turn inherits them from the Viewer basic role.
#BasicRole: "Grafana Admin" | "Admin" | "Editor" | "Viewer"
// For data source plugins. There is a query options section in
// the plugin's query editor and these options can be turned on
// if needed.
queryOptions?: {
// For data source plugins. If the `max data points` option should
// be shown in the query options section in the query editor.
maxDataPoints?: bool
// For data source plugins. If the `min interval` option should be
// shown in the query options section in the query editor.
minInterval?: bool
// For data source plugins. If the `cache timeout` option should
// be shown in the query options section in the query editor.
cacheTimeout?: bool
}
// Routes is a list of proxy routes, if any. For datasource plugins only.
routes?: [...#Route]
// Header describes an HTTP header that is forwarded with a proxied request for
// a plugin route.
#Header: {
@@ -406,14 +406,6 @@ seqs: [
// Parameters for the JWT token authentication request.
params: [string]: string
}
// Grafana Enerprise specific features.
enterpriseFeatures?: {
// Enable/Disable health diagnostics errors. Requires Grafana
// >=7.5.5.
healthDiagnosticsErrors?: bool | *false
...
}
},
]
},
+13 -20
View File
@@ -46,6 +46,7 @@ const (
const (
CategoryCloud Category = "cloud"
CategoryEnterprise Category = "enterprise"
CategoryIot Category = "iot"
CategoryLogging Category = "logging"
CategoryOther Category = "other"
CategoryProfiling Category = "profiling"
@@ -252,29 +253,28 @@ type PluginDef struct {
// Schema definition for the plugin.json file. Used primarily for schema validation.
Schema *string `json:"$schema,omitempty"`
// For data source plugins, if the plugin supports alerting.
// For data source plugins, if the plugin supports alerting. Requires `backend` to be set to `true`.
Alerting *bool `json:"alerting,omitempty"`
// For data source plugins, if the plugin supports annotation
// queries.
Annotations *bool `json:"annotations,omitempty"`
// Set to true for app plugins that should be enabled by default
// in all orgs
// Set to true for app plugins that should be enabled and pinned to the navigation bar in all orgs.
AutoEnabled *bool `json:"autoEnabled,omitempty"`
// If the plugin has a backend component.
Backend *bool `json:"backend,omitempty"`
// builtin indicates whether the plugin is developed and shipped as part
// of Grafana. Also known as a "core plugin."
// [internal only] Indicates whether the plugin is developed and shipped as part
// of Grafana. Also known as a 'core plugin'.
BuiltIn bool `json:"builtIn"`
// Plugin category used on the Add data source page.
Category *Category `json:"category,omitempty"`
Dependencies Dependencies `json:"dependencies"`
// Grafana Enerprise specific features.
// Grafana Enterprise specific features.
EnterpriseFeatures *struct {
// Enable/Disable health diagnostics errors. Requires Grafana
// >=7.5.5.
@@ -290,12 +290,8 @@ type PluginDef struct {
// https://golang.org/doc/install/source#environment.
Executable *string `json:"executable,omitempty"`
// For data source plugins, include hidden queries in the data
// request.
HiddenQueries *bool `json:"hiddenQueries,omitempty"`
// hideFromList excludes the plugin from listings in Grafana's UI. Only
// allowed for builtin plugins.
// [internal only] Excludes the plugin from listings in Grafana's UI. Only
// allowed for `builtIn` plugins.
HideFromList bool `json:"hideFromList"`
// Unique name of the plugin. If the plugin is published on
@@ -310,18 +306,18 @@ type PluginDef struct {
// page in Grafana and others on grafana.com, if the plugin is published.
Info Info `json:"info"`
// For data source plugins, if the plugin supports logs.
// For data source plugins, if the plugin supports logs. It may be used to filter logs only features.
Logs *bool `json:"logs,omitempty"`
// For data source plugins, if the plugin supports metric queries.
// Used in Explore.
// Used to enable the plugin in the panel editor.
Metrics *bool `json:"metrics,omitempty"`
// Human-readable name of the plugin that is shown to the user in
// the UI.
Name string `json:"name"`
// The PascalCase name for the plugin. Used for creating machine-friendly
// [internal only] The PascalCase name for the plugin. Used for creating machine-friendly
// identifiers, typically in code generation.
//
// If not provided, defaults to name, but title-cased and sanitized (only
@@ -365,13 +361,10 @@ type PluginDef struct {
// ReleaseState indicates release maturity state of a plugin.
State *ReleaseState `json:"state,omitempty"`
// For data source plugins, if the plugin supports streaming.
// For data source plugins, if the plugin supports streaming. Used in Explore to start live streaming.
Streaming *bool `json:"streaming,omitempty"`
// This is an undocumented feature.
Tables *bool `json:"tables,omitempty"`
// For data source plugins, if the plugin supports tracing.
// For data source plugins, if the plugin supports tracing. Used for example to link logs (e.g. Loki logs) with tracing plugins.
Tracing *bool `json:"tracing,omitempty"`
// type indicates which type of Grafana plugin this is, of the defined
+1 -1
View File
@@ -96,5 +96,5 @@ func (a *AnonSessionService) TagSession(ctx context.Context, httpReq *http.Reque
a.localCache.SetDefault(key, struct{}{})
return a.remoteCache.SetByteArray(ctx, key, []byte(key), thirtyDays)
return a.remoteCache.Set(ctx, key, []byte(key), thirtyDays)
}
+2 -2
View File
@@ -171,7 +171,7 @@ func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) {
var jwks keySetJWKS
if ks.cacheExpiration > 0 {
if val, err := ks.cache.GetByteArray(ctx, ks.cacheKey); err == nil {
if val, err := ks.cache.Get(ctx, ks.cacheKey); err == nil {
err := json.Unmarshal(val, &jwks)
return jwks, err
}
@@ -200,7 +200,7 @@ func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) {
}
if ks.cacheExpiration > 0 {
err = ks.cache.SetByteArray(ctx, ks.cacheKey, jsonBuf.Bytes(), ks.cacheExpiration)
err = ks.cache.Set(ctx, ks.cacheKey, jsonBuf.Bytes(), ks.cacheExpiration)
}
return jwks, err
}
+4 -4
View File
@@ -50,8 +50,8 @@ func ProvideProxy(cfg *setting.Cfg, cache proxyCache, userSrv user.Service, clie
}
type proxyCache interface {
GetByteArray(ctx context.Context, key string) ([]byte, error)
SetByteArray(ctx context.Context, key string, value []byte, expire time.Duration) error
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, expire time.Duration) error
}
type Proxy struct {
@@ -83,7 +83,7 @@ func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
if ok {
// See if we have cached the user id, in that case we can fetch the signed-in user and skip sync.
// Error here means that we could not find anything in cache, so we can proceed as usual
if entry, err := c.cache.GetByteArray(ctx, cacheKey); err == nil {
if entry, err := c.cache.Get(ctx, cacheKey); err == nil {
uid := int64(binary.LittleEndian.Uint64(entry))
usr, err := c.userSrv.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{
@@ -138,7 +138,7 @@ func (c *Proxy) Hook(ctx context.Context, identity *authn.Identity, r *authn.Req
c.log.FromContext(ctx).Debug("Cache proxy user", "userId", id)
bytes := make([]byte, 8)
binary.LittleEndian.PutUint64(bytes, uint64(id))
if err := c.cache.SetByteArray(ctx, identity.ClientParams.CacheAuthProxyKey, bytes, time.Duration(c.cfg.AuthProxySyncTTL)*time.Minute); err != nil {
if err := c.cache.Set(ctx, identity.ClientParams.CacheAuthProxyKey, bytes, time.Duration(c.cfg.AuthProxySyncTTL)*time.Minute); err != nil {
c.log.Warn("failed to cache proxy user", "error", err, "userId", id)
}
+2 -2
View File
@@ -181,10 +181,10 @@ type fakeCache struct {
expectedItem []byte
}
func (f fakeCache) GetByteArray(ctx context.Context, key string) ([]byte, error) {
func (f fakeCache) Get(ctx context.Context, key string) ([]byte, error) {
return f.expectedItem, f.expectedErr
}
func (f fakeCache) SetByteArray(ctx context.Context, key string, value []byte, expire time.Duration) error {
func (f fakeCache) Set(ctx context.Context, key string, value []byte, expire time.Duration) error {
return f.expectedErr
}
@@ -58,7 +58,7 @@ func TestInitContextWithAuthProxy_CachedInvalidUserID(t *testing.T) {
t.Logf("Injecting stale user ID in cache with key %q", key)
userIdPayload := []byte(strconv.FormatInt(int64(33), 10))
err = svc.RemoteCache.SetByteArray(context.Background(), key, userIdPayload, 0)
err = svc.RemoteCache.Set(context.Background(), key, userIdPayload, 0)
require.NoError(t, err)
authEnabled := svc.initContextWithAuthProxy(ctx, orgID)
@@ -67,7 +67,7 @@ func TestInitContextWithAuthProxy_CachedInvalidUserID(t *testing.T) {
require.Equal(t, userID, ctx.SignedInUser.UserID)
require.True(t, ctx.IsSignedIn)
cachedByteArray, err := svc.RemoteCache.GetByteArray(context.Background(), key)
cachedByteArray, err := svc.RemoteCache.Get(context.Background(), key)
require.NoError(t, err)
cacheUserId, err := strconv.ParseInt(string(cachedByteArray), 10, 64)
@@ -191,7 +191,7 @@ func (auth *AuthProxy) getUserViaCache(reqCtx *contextmodel.ReqContext) (int64,
return 0, err
}
auth.logger.Debug("Getting user ID via auth cache", "cacheKey", cacheKey)
cachedValue, err := auth.remoteCache.GetByteArray(reqCtx.Req.Context(), cacheKey)
cachedValue, err := auth.remoteCache.Get(reqCtx.Req.Context(), cacheKey)
if err != nil {
return 0, err
}
@@ -353,7 +353,7 @@ func (auth *AuthProxy) Remember(reqCtx *contextmodel.ReqContext, id int64) error
}
// Check if user already in cache
cachedValue, err := auth.remoteCache.GetByteArray(reqCtx.Req.Context(), key)
cachedValue, err := auth.remoteCache.Get(reqCtx.Req.Context(), key)
if err == nil && len(cachedValue) != 0 {
return nil
}
@@ -361,7 +361,7 @@ func (auth *AuthProxy) Remember(reqCtx *contextmodel.ReqContext, id int64) error
expiration := time.Duration(auth.cfg.AuthProxySyncTTL) * time.Minute
userIdPayload := []byte(strconv.FormatInt(id, 10))
if err := auth.remoteCache.SetByteArray(reqCtx.Req.Context(), key, userIdPayload, expiration); err != nil {
if err := auth.remoteCache.Set(reqCtx.Req.Context(), key, userIdPayload, expiration); err != nil {
return err
}
@@ -61,7 +61,7 @@ func TestMiddlewareContext(t *testing.T) {
require.NoError(t, err)
key := fmt.Sprintf(CachePrefix, h)
userIdPayload := []byte(strconv.FormatInt(id, 10))
err = cache.SetByteArray(context.Background(), key, userIdPayload, 0)
err = cache.Set(context.Background(), key, userIdPayload, 0)
require.NoError(t, err)
// Set up the middleware
auth, reqCtx := prepareMiddleware(t, cache, nil)
@@ -84,7 +84,7 @@ func TestMiddlewareContext(t *testing.T) {
require.NoError(t, err)
key := fmt.Sprintf(CachePrefix, h)
userIdPayload := []byte(strconv.FormatInt(id, 10))
err = cache.SetByteArray(context.Background(), key, userIdPayload, 0)
err = cache.Set(context.Background(), key, userIdPayload, 0)
require.NoError(t, err)
auth, reqCtx := prepareMiddleware(t, cache, func(req *http.Request, cfg *setting.Cfg) {
+1
View File
@@ -21,4 +21,5 @@ const (
grafanaAlertingSquad codeowner = "@grafana/alerting-squad"
hostedGrafanaTeam codeowner = "@grafana/hosted-grafana-team"
awsPluginsSquad codeowner = "@grafana/aws-plugins"
appO11ySquad codeowner = "@grafana/app-o11y"
)
+8
View File
@@ -151,6 +151,7 @@ var (
Description: "Adds trace ID to error notifications",
State: FeatureStateAlpha,
FrontendOnly: true,
Owner: grafanaUserEssentialsSquad,
},
{
Name: "newTraceView",
@@ -437,5 +438,12 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "timeSeriesTable",
Description: "Enable time series table transformer & sparkline cell type",
State: FeatureStateAlpha,
FrontendOnly: true,
Owner: appO11ySquad,
},
}
)
+4
View File
@@ -270,4 +270,8 @@ const (
// FlagPrometheusMetricEncyclopedia
// Replaces the Prometheus query builder metric select option with a paginated and filterable component
FlagPrometheusMetricEncyclopedia = "prometheusMetricEncyclopedia"
// FlagTimeSeriesTable
// Enable time series table transformer &amp; sparkline cell type
FlagTimeSeriesTable = "timeSeriesTable"
)
@@ -43,7 +43,6 @@ func TestFeatureToggleFiles(t *testing.T) {
ownerlessFeatures := map[string]bool{
"prometheusAzureOverrideAudience": true,
"tracing": true,
}
t.Run("all new features should have an owner", func(t *testing.T) {
@@ -140,14 +140,14 @@ func (pd *PublicDashboardServiceImpl) FindPublicDashboardAndDashboardByAccessTok
// Creates and validates the public dashboard and saves it to the database
func (pd *PublicDashboardServiceImpl) Create(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) {
// ensure dashboard exists
dashboard, err := pd.FindDashboard(ctx, u.OrgID, dto.DashboardUid)
// validate fields
err := validation.ValidatePublicDashboard(dto)
if err != nil {
return nil, err
}
// validate fields
err = validation.ValidatePublicDashboard(dto, dashboard)
// ensure dashboard exists
_, err = pd.FindDashboard(ctx, u.OrgID, dto.DashboardUid)
if err != nil {
return nil, err
}
@@ -216,6 +216,12 @@ func (pd *PublicDashboardServiceImpl) Create(ctx context.Context, u *user.Signed
// Update: updates an existing public dashboard based on publicdashboard.Uid
func (pd *PublicDashboardServiceImpl) Update(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) {
// validate fields
err := validation.ValidatePublicDashboard(dto)
if err != nil {
return nil, err
}
// validate if the dashboard exists
dashboard, err := pd.FindDashboard(ctx, u.OrgID, dto.DashboardUid)
if err != nil {
@@ -234,12 +240,6 @@ func (pd *PublicDashboardServiceImpl) Update(ctx context.Context, u *user.Signed
return nil, ErrPublicDashboardNotFound.Errorf("Update: public dashboard not found by uid: %s", dto.PublicDashboard.Uid)
}
// validate dashboard
err = validation.ValidatePublicDashboard(dto, dashboard)
if err != nil {
return nil, err
}
// set default value for time settings
if dto.PublicDashboard.TimeSettings == nil {
dto.PublicDashboard.TimeSettings = &TimeSettings{}
@@ -278,7 +278,7 @@ func TestCreatePublicDashboard(t *testing.T) {
assert.Equal(t, defaultPubdashTimeSettings, pubdash.TimeSettings)
})
t.Run("Validate pubdash whose dashboard has template variables returns error", func(t *testing.T) {
t.Run("Creates pubdash whose dashboard has template variables successfully", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
quotaService := quotatest.New(false, nil)
dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService)
@@ -286,10 +286,12 @@ func TestCreatePublicDashboard(t *testing.T) {
publicdashboardStore := database.ProvideStore(sqlStore)
templateVars := make([]map[string]interface{}, 1)
dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, templateVars, nil)
serviceWrapper := ProvideServiceWrapper(publicdashboardStore)
service := &PublicDashboardServiceImpl{
log: log.New("test.logger"),
store: publicdashboardStore,
log: log.New("test.logger"),
store: publicdashboardStore,
serviceWrapper: serviceWrapper,
}
dto := &SavePublicDashboardDTO{
@@ -304,7 +306,13 @@ func TestCreatePublicDashboard(t *testing.T) {
}
_, err = service.Create(context.Background(), SignedInUser, dto)
require.Error(t, err)
require.NoError(t, err)
pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgID, dashboard.UID)
require.NoError(t, err)
assert.Equal(t, dashboard.UID, pubdash.DashboardUid)
assert.Equal(t, dashboard.OrgID, pubdash.OrgId)
})
t.Run("Throws an error when pubdash with generated access token already exists", func(t *testing.T) {
@@ -2,17 +2,12 @@ package validation
import (
"github.com/google/uuid"
"github.com/grafana/grafana/pkg/services/dashboards"
. "github.com/grafana/grafana/pkg/services/publicdashboards/models"
"github.com/grafana/grafana/pkg/tsdb/legacydata"
"github.com/grafana/grafana/pkg/util"
)
func ValidatePublicDashboard(dto *SavePublicDashboardDTO, dashboard *dashboards.Dashboard) error {
if hasTemplateVariables(dashboard) {
return ErrPublicDashboardHasTemplateVariables.Errorf("ValidateSavePublicDashboard: public dashboard has template variables")
}
func ValidatePublicDashboard(dto *SavePublicDashboardDTO) error {
// if it is empty we override it in the service with public for retro compatibility
if dto.PublicDashboard.Share != "" && !IsValidShareType(dto.PublicDashboard.Share) {
return ErrInvalidShareType.Errorf("ValidateSavePublicDashboard: invalid share type")
@@ -21,12 +16,6 @@ func ValidatePublicDashboard(dto *SavePublicDashboardDTO, dashboard *dashboards.
return nil
}
func hasTemplateVariables(dashboard *dashboards.Dashboard) bool {
templateVariables := dashboard.Data.Get("templating").Get("list").MustArray()
return len(templateVariables) > 0
}
func ValidateQueryPublicDashboardRequest(req PublicDashboardQueryDTO, pd *PublicDashboard) error {
if req.IntervalMs < 0 {
return ErrInvalidInterval.Errorf("ValidateQueryPublicDashboardRequest: intervalMS should be greater than 0")
@@ -3,71 +3,23 @@ package validation
import (
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/dashboards"
. "github.com/grafana/grafana/pkg/services/publicdashboards/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidatePublicDashboard(t *testing.T) {
t.Run("Returns validation error when dashboard has template variables", func(t *testing.T) {
templateVars := []byte(`{
"templating": {
"list": [
{
"name": "templateVariableName"
}
]
}
}`)
dashboardData, _ := simplejson.NewJson(templateVars)
dashboard := dashboards.NewDashboardFromJson(dashboardData)
dto := &SavePublicDashboardDTO{DashboardUid: "abc123", OrgId: 1, UserId: 1, PublicDashboard: nil}
err := ValidatePublicDashboard(dto, dashboard)
require.ErrorContains(t, err, ErrPublicDashboardHasTemplateVariables.Error())
})
t.Run("Returns no validation error when dashboard has no template variables", func(t *testing.T) {
templateVars := []byte(`{
"templating": {
"list": []
}
}`)
dashboardData, _ := simplejson.NewJson(templateVars)
dashboard := dashboards.NewDashboardFromJson(dashboardData)
dto := &SavePublicDashboardDTO{DashboardUid: "abc123", OrgId: 1, UserId: 1, PublicDashboard: &PublicDashboard{}}
err := ValidatePublicDashboard(dto, dashboard)
require.NoError(t, err)
})
t.Run("Returns no error when valid shareType value is received", func(t *testing.T) {
templateVars := []byte(`{
"templating": {
"list": []
}
}`)
dashboardData, _ := simplejson.NewJson(templateVars)
dashboard := dashboards.NewDashboardFromJson(dashboardData)
dto := &SavePublicDashboardDTO{DashboardUid: "abc123", OrgId: 1, UserId: 1, PublicDashboard: &PublicDashboard{Share: EmailShareType}}
err := ValidatePublicDashboard(dto, dashboard)
err := ValidatePublicDashboard(dto)
require.NoError(t, err)
})
t.Run("Returns error when invalid shareType value", func(t *testing.T) {
templateVars := []byte(`{
"templating": {
"list": []
}
}`)
dashboardData, _ := simplejson.NewJson(templateVars)
dashboard := dashboards.NewDashboardFromJson(dashboardData)
dto := &SavePublicDashboardDTO{DashboardUid: "abc123", OrgId: 1, UserId: 1, PublicDashboard: &PublicDashboard{Share: "invalid"}}
err := ValidatePublicDashboard(dto, dashboard)
err := ValidatePublicDashboard(dto)
require.Error(t, err)
})
}
+2 -2
View File
@@ -21,7 +21,7 @@ type RenderUser struct {
}
func (rs *RenderingService) GetRenderUser(ctx context.Context, key string) (*RenderUser, bool) {
val, err := rs.RemoteCacheService.GetByteArray(ctx, fmt.Sprintf(renderKeyPrefix, key))
val, err := rs.RemoteCacheService.Get(ctx, fmt.Sprintf(renderKeyPrefix, key))
if err != nil {
rs.log.Error("Failed to get render key from cache", "error", err)
}
@@ -46,7 +46,7 @@ func setRenderKey(cache *remotecache.RemoteCache, ctx context.Context, opts Auth
return err
}
return cache.SetByteArray(ctx, fmt.Sprintf(renderKeyPrefix, renderKey), buf.Bytes(), expiry)
return cache.Set(ctx, fmt.Sprintf(renderKeyPrefix, renderKey), buf.Bytes(), expiry)
}
func generateAndSetRenderKey(cache *remotecache.RemoteCache, ctx context.Context, opts AuthOpts, expiry time.Duration) (string, error) {
@@ -12,12 +12,11 @@ import (
"testing"
"time"
"github.com/grafana/grafana/pkg/expr"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/expr"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
+24 -10
View File
@@ -23,6 +23,7 @@ func (rp *ResponseParser) Parse(buf io.ReadCloser, queries []Query) *backend.Que
resp := backend.NewQueryDataResponse()
response, jsonErr := parseJSON(buf)
if jsonErr != nil {
resp.Responses["A"] = backend.DataResponse{Error: jsonErr}
return resp
@@ -46,10 +47,12 @@ func (rp *ResponseParser) Parse(buf io.ReadCloser, queries []Query) *backend.Que
func parseJSON(buf io.ReadCloser) (Response, error) {
var response Response
dec := json.NewDecoder(buf)
dec.UseNumber()
err := dec.Decode(&response)
return response, err
}
@@ -89,8 +92,8 @@ func transformRows(rows []Row, query Query) data.Frames {
var timeArray []time.Time
var floatArray []*float64
var stringArray []string
var boolArray []bool
var stringArray []*string
var boolArray []*bool
valType := typeof(row.Values, colIndex)
name := formatFrameName(row, column, query)
@@ -99,16 +102,27 @@ func transformRows(rows []Row, query Query) data.Frames {
// we only add this row if the timestamp is valid
if timestampErr == nil {
timeArray = append(timeArray, timestamp)
if valType == "string" {
value := valuePair[colIndex].(string)
stringArray = append(stringArray, value)
} else if valType == "json.Number" {
switch valType {
case "string":
{
value, chk := valuePair[colIndex].(string)
if chk {
stringArray = append(stringArray, &value)
} else {
stringArray = append(stringArray, nil)
}
}
case "json.Number":
value := parseNumber(valuePair[colIndex])
floatArray = append(floatArray, value)
} else if valType == "bool" {
value := valuePair[colIndex].(bool)
boolArray = append(boolArray, value)
} else if valType == "null" {
case "bool":
value, chk := valuePair[colIndex].(bool)
if chk {
boolArray = append(boolArray, &value)
} else {
boolArray = append(boolArray, nil)
}
case "null":
floatArray = append(floatArray, nil)
}
}
+9 -6
View File
@@ -41,7 +41,7 @@ func TestInfluxdbResponseParser(t *testing.T) {
require.Error(t, result.Responses["A"].Error)
})
t.Run("Influxdb response parser should parse everything normally", func(t *testing.T) {
t.Run("Influxdb response parser should parse everything normally including nil bools and nil strings", func(t *testing.T) {
parser := &ResponseParser{}
response := `
@@ -54,7 +54,7 @@ func TestInfluxdbResponseParser(t *testing.T) {
"columns": ["time","mean","path","isActive"],
"tags": {"datacenter": "America"},
"values": [
[111,222,"/usr/path",true],
[111,222,null,null],
[111,222,"/usr/path",false],
[111,null,"/usr/path",true]
]
@@ -84,8 +84,9 @@ func TestInfluxdbResponseParser(t *testing.T) {
)
floatFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"}
stringField := data.NewField("value", labels, []string{
"/usr/path", "/usr/path", "/usr/path",
string_test := "/usr/path"
stringField := data.NewField("value", labels, []*string{
nil, &string_test, &string_test,
})
stringField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.path { datacenter: America }"}
stringFrame := data.NewFrame("cpu.path { datacenter: America }",
@@ -99,8 +100,10 @@ func TestInfluxdbResponseParser(t *testing.T) {
)
stringFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"}
boolField := data.NewField("value", labels, []bool{
true, false, true,
bool_true := true
bool_false := false
boolField := data.NewField("value", labels, []*bool{
nil, &bool_false, &bool_true,
})
boolField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.isActive { datacenter: America }"}
boolFrame := data.NewFrame("cpu.isActive { datacenter: America }",
@@ -75,6 +75,7 @@ export function TraceToLogsSettings({ options, onOptionsChange }: Props) {
'elasticsearch',
'grafana-splunk-datasource', // external
'grafana-opensearch-datasource', // external
'grafana-falconlogscale-datasource', // external
];
const traceToLogs = useMemo(
@@ -18,10 +18,10 @@ describe('isNewUser', function () {
getBackendSrv().get = jest.fn().mockResolvedValue(newUser);
const isNew = await isNewUser(1);
const isNew = await isNewUser();
expect(isNew).toBe(true);
expect(getBackendSrv().get).toHaveBeenCalledTimes(1);
expect(getBackendSrv().get).toHaveBeenCalledWith('/api/users/1');
expect(getBackendSrv().get).toHaveBeenCalledWith('/api/user');
});
it('should return false if the user has been created prior to the last two weeks', async () => {
@@ -32,9 +32,9 @@ describe('isNewUser', function () {
getBackendSrv().get = jest.fn().mockResolvedValue(oldUser);
const isNew = await isNewUser(2);
const isNew = await isNewUser();
expect(isNew).toBe(false);
expect(getBackendSrv().get).toHaveBeenCalledTimes(1);
expect(getBackendSrv().get).toHaveBeenCalledWith('/api/users/2');
expect(getBackendSrv().get).toHaveBeenCalledWith('/api/user');
});
});
@@ -45,9 +45,9 @@ export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise
};
}
export async function isNewUser(userId: number) {
export async function isNewUser() {
try {
const { createdAt } = await getBackendSrv().get(`/api/users/${userId}`);
const { createdAt } = await getBackendSrv().get(`/api/user`);
const limitDateForNewUser = dateTime().subtract(USER_CREATION_MIN_DAYS, 'days');
const userCreationDate = dateTime(createdAt);
@@ -61,7 +61,7 @@ export async function isNewUser(userId: number) {
}
export const trackNewAlerRuleFormSaved = async (props: AlertRuleTrackingProps) => {
const isNew = await isNewUser(props.user_id);
const isNew = await isNewUser();
if (isNew) {
return;
}
@@ -69,7 +69,7 @@ export const trackNewAlerRuleFormSaved = async (props: AlertRuleTrackingProps) =
};
export const trackNewAlerRuleFormCancelled = async (props: AlertRuleTrackingProps) => {
const isNew = await isNewUser(props.user_id);
const isNew = await isNewUser();
if (isNew) {
return;
}
@@ -77,7 +77,7 @@ export const trackNewAlerRuleFormCancelled = async (props: AlertRuleTrackingProp
};
export const trackNewAlerRuleFormError = async (props: AlertRuleTrackingProps & { error: string }) => {
const isNew = await isNewUser(props.user_id);
const isNew = await isNewUser();
if (isNew) {
return;
}
@@ -250,8 +250,7 @@ const getContentBoxStyles = (theme: GrafanaTheme2) => ({
box: css`
padding: ${theme.spacing(2)};
background-color: ${theme.colors.background.secondary};
border-radius: 3px;
outline: 1px solid ${theme.colors.border.strong};
border-radius: ${theme.shape.borderRadius()};
`,
});
@@ -96,8 +96,7 @@ const Receivers = () => {
const integrationsErrorCount = contactPointsState?.errorCount ?? 0;
const disableAmSelect = !isRoot;
let pageNav = getPageNavigationModel(type, id, isduplicatingTemplate);
let pageNav = getPageNavigationModel(type, id ? decodeURIComponent(id) : undefined, isduplicatingTemplate);
if (!alertManagerSourceName) {
return isRoot ? (
@@ -2,8 +2,7 @@ import { css, cx } from '@emotion/css';
import { capitalize, uniqueId } from 'lodash';
import React, { FC, useCallback, useState } from 'react';
import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData } from '@grafana/data';
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData, isTimeSeriesFrames } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { AutoSizeInput, clearButtonStyles, Icon, IconButton, Select, useStyles2 } from '@grafana/ui';
import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions';
@@ -138,7 +137,7 @@ export const ExpressionResult: FC<ExpressionResultProps> = ({ series, isAlertCon
// sometimes we receive results where every value is just "null" when noData occurs
const emptyResults = isEmptySeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeriesFrames(series);
return (
<div className={styles.expression.results}>
@@ -1,7 +1,6 @@
import { ValidateResult } from 'react-hook-form';
import { DataFrame, ThresholdsConfig, ThresholdsMode } from '@grafana/data';
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
import { DataFrame, ThresholdsConfig, ThresholdsMode, isTimeSeriesFrames } from '@grafana/data';
import { GraphTresholdsStyleMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
@@ -98,7 +97,7 @@ export function errorFromSeries(series: DataFrame[]): Error | undefined {
return;
}
const isTimeSeriesResults = isTimeSeries(series);
const isTimeSeriesResults = isTimeSeriesFrames(series);
let error;
if (isTimeSeriesResults) {
@@ -108,7 +108,7 @@ export const filterRules = (
const [idxs, info, order] = ufuzzy.search(namespaceHaystack, namespaceFilter);
if (info && order) {
filteredNamespaces = order.map((idx) => filteredNamespaces[info.idx[idx]]);
} else {
} else if (idxs) {
filteredNamespaces = idxs.map((idx) => filteredNamespaces[idx]);
}
}
@@ -127,7 +127,7 @@ const reduceNamespaces = (filterState: RulesFilter) => {
const [idxs, info, order] = ufuzzy.search(groupsHaystack, groupNameFilter);
if (info && order) {
filteredGroups = order.map((idx) => filteredGroups[info.idx[idx]]);
} else {
} else if (idxs) {
filteredGroups = idxs.map((idx) => filteredGroups[idx]);
}
}
@@ -157,7 +157,7 @@ const reduceGroups = (filterState: RulesFilter) => {
const [idxs, info, order] = ufuzzy.search(rulesHaystack, ruleNameQuery);
if (info && order) {
filteredRules = order.map((idx) => filteredRules[info.idx[idx]]);
} else {
} else if (idxs) {
filteredRules = idxs.map((idx) => filteredRules[idx]);
}
}
@@ -74,6 +74,7 @@ const getStyles = (theme: GrafanaTheme2) => {
timeshift: css({
color: theme.colors.text.link,
gap: theme.spacing(0.5),
whiteSpace: 'nowrap',
'&:hover': {
color: theme.colors.emphasize(theme.colors.text.link, 0.03),
@@ -1,4 +1,10 @@
import { PanelMenuItem, PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
import {
PanelMenuItem,
PluginExtension,
PluginExtensionLink,
PluginExtensionTypes,
PluginExtensionPlacements,
} from '@grafana/data';
import {
PluginExtensionPanelContext,
PluginExtensionRegistryItem,
@@ -7,7 +13,6 @@ import {
import { LoadingState } from '@grafana/schema';
import config from 'app/core/config';
import * as actions from 'app/features/explore/state/main';
import { GrafanaExtensions } from 'app/features/plugins/extensions/placements';
import { setStore } from 'app/store/store';
import { PanelModel } from '../state';
@@ -138,7 +143,7 @@ describe('getPanelMenu()', () => {
describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', () => {
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>({
type: PluginExtensionTypes.link,
title: 'Declare incident',
@@ -166,7 +171,7 @@ describe('getPanelMenu()', () => {
it('should truncate menu item title to 25 chars', () => {
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>({
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
@@ -202,7 +207,7 @@ describe('getPanelMenu()', () => {
});
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
@@ -233,7 +238,7 @@ describe('getPanelMenu()', () => {
it('should hide menu item if configure function returns undefined', () => {
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
@@ -266,7 +271,7 @@ describe('getPanelMenu()', () => {
const configure = jest.fn();
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
@@ -348,7 +353,7 @@ describe('getPanelMenu()', () => {
};
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
@@ -1,4 +1,9 @@
import { isPluginExtensionCommand, isPluginExtensionLink, PanelMenuItem } from '@grafana/data';
import {
isPluginExtensionCommand,
isPluginExtensionLink,
PanelMenuItem,
PluginExtensionPlacements,
} from '@grafana/data';
import {
AngularComponent,
getDataSourceSrv,
@@ -26,7 +31,6 @@ import {
} from 'app/features/dashboard/utils/panel';
import { InspectTab } from 'app/features/inspector/types';
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
import { GrafanaExtensions } from 'app/features/plugins/extensions/placements';
import { store } from 'app/store/store';
import { navigateToExplore } from '../../explore/state/main';
@@ -43,6 +47,7 @@ export function getPanelMenu(
locationService.partial({
viewPanel: panel.id,
});
reportInteraction('dashboards_panelheader_view_clicked');
};
const onEditPanel = (event: React.MouseEvent<any>) => {
@@ -50,21 +55,25 @@ export function getPanelMenu(
locationService.partial({
editPanel: panel.id,
});
reportInteraction('dashboards_panelheader_edit_clicked');
};
const onSharePanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
sharePanel(dashboard, panel);
reportInteraction('dashboards_panelheader_share_clicked');
};
const onAddLibraryPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
addLibraryPanel(dashboard, panel);
reportInteraction('dashboards_panelheader_createlibrarypanel_clicked');
};
const onUnlinkLibraryPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
unlinkLibraryPanel(panel);
reportInteraction('dashboards_panelheader_unlinklibrarypanel_clicked');
};
const onInspectPanel = (tab?: InspectTab) => {
@@ -72,10 +81,7 @@ export function getPanelMenu(
inspect: panel.id,
inspectTab: tab,
});
reportInteraction('grafana_panel_menu_inspect', {
tab: tab ?? InspectTab.Data,
});
reportInteraction('dashboards_panelheader_inspect_clicked', { tab: tab ?? InspectTab.Data });
};
const onMore = (event: React.MouseEvent<any>) => {
@@ -85,16 +91,19 @@ export function getPanelMenu(
const onDuplicatePanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
duplicatePanel(dashboard, panel);
reportInteraction('dashboards_panelheader_duplicate_clicked');
};
const onCopyPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
copyPanel(panel);
reportInteraction('dashboards_panelheader_copy_clicked');
};
const onRemovePanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
removePanel(dashboard, panel, true);
reportInteraction('dashboards_panelheader_remove_clicked');
};
const onNavigateToExplore = (event: React.MouseEvent<any>) => {
@@ -102,16 +111,19 @@ export function getPanelMenu(
const openInNewWindow =
event.ctrlKey || event.metaKey ? (url: string) => window.open(`${config.appSubUrl}${url}`) : undefined;
store.dispatch(navigateToExplore(panel, { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow }) as any);
reportInteraction('dashboards_panelheader_explore_clicked');
};
const onToggleLegend = (event: React.MouseEvent) => {
event.preventDefault();
toggleLegend(panel);
reportInteraction('dashboards_panelheader_togglelegend_clicked');
};
const onCancelStreaming = (event: React.MouseEvent) => {
event.preventDefault();
panel.getQueryRunner().cancelQuery();
reportInteraction('dashboards_panelheader_cancelstreaming_clicked');
};
const menu: PanelMenuItem[] = [];
@@ -287,7 +299,7 @@ export function getPanelMenu(
}
const { extensions } = getPluginExtensions({
placement: GrafanaExtensions.DashboardPanelMenu,
placement: PluginExtensionPlacements.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
});
@@ -0,0 +1,59 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { Button, useStyles2 } from '@grafana/ui';
export const getStyles = (theme: GrafanaTheme2) => {
return {
ActionButton: css`
label: ActionButton;
overflow: hidden;
position: relative;
width: 110px;
justify-content: center;
&:after {
content: '';
background: ${theme.colors.primary.main};
display: block;
position: absolute;
right: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: all 0.8s;
}
&:active:after {
margin: 0;
opacity: 0.3;
transition: 0s;
}
`,
};
};
export type ActionButtonProps = {
onClick: () => void;
ariaLabel: string;
label: string;
icon: IconName;
};
export default function ActionButton(props: ActionButtonProps) {
const { onClick, ariaLabel, label, icon } = props;
const styles = useStyles2(getStyles);
return (
<Button
className={styles.ActionButton}
variant="secondary"
fill={'outline'}
type="button"
icon={icon}
aria-label={ariaLabel}
onClick={onClick}
>
{label}
</Button>
);
}
@@ -0,0 +1,51 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { useStyles2 } from '@grafana/ui';
import ActionButton from './ActionButton';
export const getStyles = () => {
return {
TracePageActions: css`
label: TracePageActions;
display: flex;
gap: 4px;
`,
};
};
export type TracePageActionsProps = {
traceId: string;
};
export default function TracePageActions(props: TracePageActionsProps) {
const { traceId } = props;
const styles = useStyles2(getStyles);
const [copyTraceIdClicked, setCopyTraceIdClicked] = useState(false);
const copyTraceId = () => {
navigator.clipboard.writeText(traceId);
setCopyTraceIdClicked(true);
setTimeout(() => {
setCopyTraceIdClicked(false);
}, 5000);
};
return (
<div className={styles.TracePageActions}>
<ActionButton
onClick={copyTraceId}
ariaLabel={'Copy Trace ID'}
label={copyTraceIdClicked ? 'Copied!' : 'Trace ID'}
icon={'copy'}
/>
<ActionButton
onClick={() => alert('not implemented')}
ariaLabel={'Export Trace'}
label={'Export'}
icon={'save'}
/>
</div>
);
}
@@ -26,15 +26,29 @@ import { getTraceLinks } from '../model/link-patterns';
import { getHeaderTags, getTraceName } from '../model/trace-viewer';
import { formatDuration } from '../utils/date';
import TracePageActions from './Actions/TracePageActions';
import SpanGraph from './SpanGraph';
import { TracePageHeaderEmbedProps, timestamp, getStyles } from './TracePageHeader';
const getNewStyles = (theme: GrafanaTheme2) => {
return {
titleRow: css`
label: TracePageHeaderTitleRow;
align-items: center;
display: flex;
padding: 0 0.5em 0 0.5em;
`,
title: css`
label: TracePageHeaderTitle;
color: inherit;
flex: 1;
font-size: 1.7em;
line-height: 1em;
`,
subtitle: css`
flex: 1;
line-height: 1em;
margin: -0.5em 0 1.5em 0.5em;
margin: -0.5em 0.5em 1.5em 0.5em;
`,
tag: css`
margin: 0 0.5em 0 0;
@@ -56,7 +70,7 @@ const getNewStyles = (theme: GrafanaTheme2) => {
position: sticky;
top: 0;
z-index: 5;
padding: 10px 5px 0 5px;
padding: 0.5em 0.25em 0 0.25em;
& > :last-child {
border-bottom: 1px solid ${autoColor(theme, '#ccc')};
}
@@ -82,7 +96,7 @@ export function NewTracePageHeader(props: TracePageHeaderEmbedProps) {
const { method, status, url } = getHeaderTags(trace.spans);
const title = (
<h1 className={cx(styles.TracePageHeaderTitle)}>
<h1 className={cx(styles.title)}>
<TraceName traceName={getTraceName(trace.spans)} />
<small>
<span className={styles.divider}>|</span>
@@ -102,9 +116,10 @@ export function NewTracePageHeader(props: TracePageHeaderEmbedProps) {
return (
<header className={styles.header}>
<div className={styles.TracePageHeaderTitleRow}>
<div className={styles.titleRow}>
{links && links.length > 0 && <ExternalLinks links={links} className={styles.TracePageHeaderBack} />}
{title}
<TracePageActions traceId={trace.traceID} />
</div>
<div className={styles.subtitle}>
@@ -863,6 +863,133 @@ describe('createSpanLinkFactory', () => {
expect(links?.logLinks).toBeUndefined();
});
});
describe('should return falconLogScale link', () => {
const falconLogScaleUID = 'falconLogScaleUID';
beforeAll(() => {
setDataSourceSrv({
getInstanceSettings() {
return {
uid: falconLogScaleUID,
name: 'FalconLogScale',
type: 'grafana-falconlogscale-datasource',
} as unknown as DataSourceInstanceSettings;
},
} as unknown as DataSourceSrv);
setLinkSrv(new LinkSrv());
setTemplateSrv(new TemplateSrv());
});
it('the `lsql` keyword is used in the link', () => {
const createLink = setupSpanLinkFactory({
datasourceUid: falconLogScaleUID,
});
const links = createLink!(createTraceSpan());
const linkDef = links?.logLinks?.[0];
expect(linkDef).toBeDefined();
expect(linkDef!.href).toContain(`${encodeURIComponent('datasource":"falconLogScaleUID","queries":[{"lsql"')}`);
});
it('formats query correctly if filterByTraceID and or filterBySpanID is true', () => {
const createLink = setupSpanLinkFactory({
datasourceUid: falconLogScaleUID,
filterByTraceID: true,
filterBySpanID: true,
});
expect(createLink).toBeDefined();
const links = createLink!(createTraceSpan());
const linkDef = links?.logLinks?.[0];
expect(linkDef).toBeDefined();
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"cluster=\\"cluster1\\" OR hostname=\\"hostname1\\" or \\"7946b05c2e2e4e5a\\" or \\"6605c7b08e715d6c\\"","refId":""}]}'
)}`
);
});
it('should format one tag correctly', () => {
const createLink = setupSpanLinkFactory({
tags: [{ key: 'ip' }],
});
expect(createLink).toBeDefined();
const links = createLink!(
createTraceSpan({
process: {
serviceName: 'service',
tags: [{ key: 'ip', value: '192.168.0.1' }],
},
})
);
const linkDef = links?.logLinks?.[0];
expect(linkDef).toBeDefined();
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"ip=\\"192.168.0.1\\"","refId":""}]}'
)}`
);
});
it('should format multiple tags correctly', () => {
const createLink = setupSpanLinkFactory({
tags: [{ key: 'ip' }, { key: 'hostname' }],
});
expect(createLink).toBeDefined();
const links = createLink!(
createTraceSpan({
process: {
serviceName: 'service',
tags: [
{ key: 'hostname', value: 'hostname1' },
{ key: 'ip', value: '192.168.0.1' },
],
},
})
);
const linkDef = links?.logLinks?.[0];
expect(linkDef).toBeDefined();
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"hostname=\\"hostname1\\" OR ip=\\"192.168.0.1\\"","refId":""}]}'
)}`
);
});
it('handles renamed tags', () => {
const createLink = setupSpanLinkFactory({
tags: [
{ key: 'service.name', value: 'service' },
{ key: 'k8s.pod.name', value: 'pod' },
],
});
expect(createLink).toBeDefined();
const links = createLink!(
createTraceSpan({
process: {
serviceName: 'service',
tags: [
{ key: 'service.name', value: 'serviceName' },
{ key: 'k8s.pod.name', value: 'podName' },
],
},
})
);
const linkDef = links?.logLinks?.[0];
expect(linkDef).toBeDefined();
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"falconLogScaleUID","queries":[{"lsql":"service=\\"serviceName\\" OR pod=\\"podName\\"","refId":""}]}'
)}`
);
});
});
});
function setupSpanLinkFactory(options: Partial<TraceToLogsOptionsV2> = {}, datasourceUid = 'lokiUid') {
@@ -164,6 +164,9 @@ function legacyCreateSpanLinkFactory(
tags = getFormattedTags(span, tagsToUse, { labelValueSign: ':', joinBy: ' AND ' });
query = getQueryForElasticsearchOrOpensearch(span, traceToLogsOptions, tags, customQuery);
break;
case 'grafana-falconlogscale-datasource':
tags = getFormattedTags(span, tagsToUse, { joinBy: ' OR ' });
query = getQueryForFalconLogScale(span, traceToLogsOptions, tags, customQuery);
}
// query can be false in case the simple UI tag mapping is used but none of them are present in the span.
@@ -405,6 +408,35 @@ function getQueryForSplunk(span: TraceSpan, options: TraceToLogsOptionsV2, tags:
};
}
function getQueryForFalconLogScale(span: TraceSpan, options: TraceToLogsOptionsV2, tags: string, customQuery?: string) {
const { filterByTraceID, filterBySpanID } = options;
if (customQuery) {
return {
lsql: customQuery,
refId: '',
};
}
if (!tags) {
return undefined;
}
let lsql = '${__tags}';
if (filterByTraceID && span.traceID) {
lsql += ' or "${__span.traceId}"';
}
if (filterBySpanID && span.spanID) {
lsql += ' or "${__span.spanId}"';
}
return {
lsql,
refId: '',
};
}
/**
* Creates a string representing all the tags already formatted for use in the query. The tags are filtered so that
* only intersection of tags that exist in a span and tags that you want are serialized into the string.
@@ -11,7 +11,16 @@ import {
DataSourceWithLogsContextSupport,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, Button, ClickOutsideWrapper, CustomScrollbar, IconButton, List, useStyles2 } from '@grafana/ui';
import {
Alert,
Button,
ClickOutsideWrapper,
CustomScrollbar,
IconButton,
List,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { LogMessageAnsi } from './LogMessageAnsi';
import { HasMoreContextRows, LogRowContextQueryErrors, LogRowContextRows } from './LogRowContextProvider';
@@ -98,7 +107,7 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean,
background: ${theme.colors.background.canvas};
`,
top: css`
border-radius: 0 0 ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)};
border-radius: 0 0 ${theme.shape.borderRadius()} ${theme.shape.borderRadius()};
box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black};
clip-path: inset(0px -${theme.spacing(1.25)} -${theme.spacing(1.25)} -${theme.spacing(1.25)});
`,
@@ -110,7 +119,7 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean,
height: ${headerHeight}px;
background: ${theme.colors.background.secondary};
border: 1px solid ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)} 0 0;
border-radius: ${theme.shape.borderRadius()} ${theme.shape.borderRadius()} 0 0;
box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black};
clip-path: inset(-${theme.spacing(1.25)} -${theme.spacing(1.25)} 0px -${theme.spacing(1.25)});
font-family: ${theme.typography.fontFamily};
@@ -158,6 +167,7 @@ interface LogRowContextGroupHeaderProps {
logsSortOrder?: LogsSortOrder | null;
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
runContextQuery?: () => void;
onHeightChange?: (height: number) => void;
}
interface LogRowContextGroupProps extends LogRowContextGroupHeaderProps {
rows: Array<string | DataQueryError>;
@@ -175,14 +185,12 @@ const LogRowContextGroupHeader: React.FunctionComponent<LogRowContextGroupHeader
logsSortOrder,
getLogRowContextUi,
runContextQuery,
onHeightChange,
}) => {
const [height, setHeight] = useState(0);
const datasourceUiRef = React.createRef<HTMLDivElement>();
const {
datasourceUi: dsUi,
header,
headerButton,
} = useStyles2((theme) => getLogRowContextStyles(theme, undefined, height));
const theme = useTheme2();
const { datasourceUi, header, headerButton } = getLogRowContextStyles(theme, undefined, height);
// determine the position in time for this LogGroup by taking the ordering of
// logs and position of the component itself into account.
@@ -204,9 +212,12 @@ const LogRowContextGroupHeader: React.FunctionComponent<LogRowContextGroupHeader
new ResizeObserver((entries) => {
for (let entry of entries) {
setHeight(entry.contentRect.height);
if (onHeightChange) {
onHeightChange(entry.contentRect.height);
}
}
}),
[]
[onHeightChange]
);
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -226,7 +237,7 @@ const LogRowContextGroupHeader: React.FunctionComponent<LogRowContextGroupHeader
return (
<>
{config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && (
<div ref={datasourceUiRef} className={dsUi}>
<div ref={datasourceUiRef} className={datasourceUi}>
{getLogRowContextUi(row, runContextQuery)}
</div>
)}
@@ -260,8 +271,11 @@ export const LogRowContextGroup: React.FunctionComponent<LogRowContextGroupProps
logsSortOrder,
getLogRowContextUi,
runContextQuery,
onHeightChange,
}) => {
const { commonStyles, logs, bottomContext } = useStyles2(getLogRowContextStyles);
const [height, setHeight] = useState(0);
const theme = useTheme2();
const { commonStyles, logs, bottomContext, afterContext } = getLogRowContextStyles(theme, undefined, height);
const [scrollTop, setScrollTop] = useState(0);
const [scrollHeight, setScrollHeight] = useState(0);
@@ -306,6 +320,13 @@ export const LogRowContextGroup: React.FunctionComponent<LogRowContextGroupProps
}
};
const changeHeight = (height: number) => {
setHeight(height);
if (onHeightChange) {
onHeightChange(height);
}
};
const headerProps = {
row,
rows,
@@ -318,9 +339,11 @@ export const LogRowContextGroup: React.FunctionComponent<LogRowContextGroupProps
};
return (
<div className={cx(commonStyles, className, groupPosition === LogGroupPosition.Bottom ? bottomContext : '')}>
<div
className={cx(commonStyles, className, groupPosition === LogGroupPosition.Bottom ? bottomContext : afterContext)}
>
{/* When displaying "after" context */}
{shouldScrollToBottom && !error && <LogRowContextGroupHeader {...headerProps} />}
{shouldScrollToBottom && !error && <LogRowContextGroupHeader onHeightChange={changeHeight} {...headerProps} />}
<div className={logs}>
<CustomScrollbar autoHide onScroll={updateScroll} scrollTop={scrollTop} autoHeightMin={'210px'}>
<div ref={listContainerRef}>
@@ -373,8 +396,9 @@ export const LogRowContext: React.FunctionComponent<LogRowContextProps> = ({
document.removeEventListener('keydown', handleEscKeyDown, false);
};
}, [onOutsideClick, row]);
const { afterContext, beforeContext, title, top, actions, width } = useStyles2((theme) =>
getLogRowContextStyles(theme, wrapLogMessage)
const [height, setHeight] = useState(0);
const { beforeContext, title, top, actions, width } = useStyles2((theme) =>
getLogRowContextStyles(theme, wrapLogMessage, height)
);
const handleOutsideClick = useCallback(() => onOutsideClick('close_outside_click'), [onOutsideClick]);
@@ -390,7 +414,7 @@ export const LogRowContext: React.FunctionComponent<LogRowContextProps> = ({
rows={context.after}
error={errors && errors.after}
row={row}
className={cx(afterContext, top, width)}
className={cx(top, width)}
shouldScrollToBottom
canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.after : false}
onLoadMoreContext={onLoadMoreContext}
@@ -398,6 +422,7 @@ export const LogRowContext: React.FunctionComponent<LogRowContextProps> = ({
logsSortOrder={logsSortOrder}
getLogRowContextUi={getLogRowContextUi}
runContextQuery={runContextQuery}
onHeightChange={setHeight}
/>
)}
@@ -18,8 +18,8 @@ interface Props {
export const PluginList = ({ plugins, displayMode }: Props) => {
const isList = displayMode === PluginListDisplayMode.List;
const styles = useStyles2(getStyles);
const location = useLocation();
const pathName = config.appSubUrl + location.pathname;
const { pathname } = useLocation();
const pathName = config.appSubUrl + (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
return (
<div className={cx(styles.container, { [styles.list]: isList })} data-testid="plugin-list">

Some files were not shown because too many files have changed in this diff Show More