mirror of
https://github.com/grafana/grafana.git
synced 2026-01-07 22:41:10 +08:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9cb2a313e | ||
|
|
08d483e0bd | ||
|
|
05948043e8 | ||
|
|
3513179722 | ||
|
|
c652135724 | ||
|
|
426fab32eb | ||
|
|
0e8f0d4b4a | ||
|
|
7ab181383b | ||
|
|
e0cf9e4331 | ||
|
|
9b5269dc4f | ||
|
|
2e36890b56 | ||
|
|
d147f3d366 | ||
|
|
c0ddd089ef | ||
|
|
556bec41c9 | ||
|
|
b95b9f0c47 | ||
|
|
4e2446d3e3 | ||
|
|
2cbaab0e4c | ||
|
|
85383e9b43 | ||
|
|
d895cad92a | ||
|
|
000cfe1dfc | ||
|
|
c4756c394b | ||
|
|
f5d7e0ed92 | ||
|
|
e20a296176 | ||
|
|
14878bcf99 | ||
|
|
f1efd350e6 | ||
|
|
66ceacd98f | ||
|
|
7a543052ee | ||
|
|
7e02e2aef3 | ||
|
|
a4919a6d69 | ||
|
|
17d98d79d9 | ||
|
|
82e6fdfaf9 | ||
|
|
ff49b0de7e | ||
|
|
068a41c6a7 | ||
|
|
812c85602b | ||
|
|
2fff4175b4 | ||
|
|
6dc4223e0b | ||
|
|
946daeb01a | ||
|
|
280e796635 | ||
|
|
dc23aa9a0f | ||
|
|
eb9f63c715 | ||
|
|
4c3adeff7c | ||
|
|
a2263b9249 | ||
|
|
692bd0ac00 | ||
|
|
df10c952c5 | ||
|
|
0491f55ad6 | ||
|
|
f3ffc1a495 | ||
|
|
3a1ffd88d5 | ||
|
|
eda3fb190c | ||
|
|
129b74fe08 | ||
|
|
8ae02b4b7b | ||
|
|
0bb76df454 | ||
|
|
7e509a19f1 | ||
|
|
3de3eee655 | ||
|
|
1bcdaeb910 | ||
|
|
90a904fbd5 | ||
|
|
7899b5ae72 | ||
|
|
3c353ab1c1 | ||
|
|
e1bf7aa65e | ||
|
|
6a6e05bfa1 | ||
|
|
28b0af4476 | ||
|
|
72ac08cc7b | ||
|
|
90946b68a2 | ||
|
|
9521c90651 | ||
|
|
d6ca111109 | ||
|
|
200915fc91 | ||
|
|
3bd24136a0 | ||
|
|
47a04102ee | ||
|
|
f35ab2f79d | ||
|
|
e07f115f98 | ||
|
|
001179771e | ||
|
|
661c72d6b7 | ||
|
|
4e31338e94 | ||
|
|
d5d4685d20 | ||
|
|
d7e459fdee | ||
|
|
4db1f3b850 | ||
|
|
4fb033d525 | ||
|
|
ffa649e377 | ||
|
|
450ff445ea | ||
|
|
311b4b9b6f | ||
|
|
710124b5de | ||
|
|
61eb256882 | ||
|
|
7ec8550652 | ||
|
|
4890db3089 | ||
|
|
81b0dd7686 | ||
|
|
cb9df3bfdb | ||
|
|
a0eb08f01b |
@@ -1538,7 +1538,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/utils.test.ts:5381": [
|
||||
"packages/grafana-ui/src/components/Table/utils.test.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
@@ -1548,19 +1548,9 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "16"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "17"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "18"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "19"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/utils.ts:5381": [
|
||||
"packages/grafana-ui/src/components/Table/utils.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
@@ -3997,12 +3987,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/folders/state/actions.test.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/geo/editor/GazetteerPathEditor.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"public/app/features/geo/format/geohash.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
@@ -5395,8 +5379,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/datasource.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
|
||||
@@ -87,7 +87,11 @@
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/no-static-element-interactions": "off"
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"jsx-a11y/label-has-associated-control": [ "error", {
|
||||
"controlComponents": ["NumberInput"],
|
||||
"depth": 2
|
||||
}]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
4
.github/workflows/bump-version.yml
vendored
4
.github/workflows/bump-version.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
id: regex-match
|
||||
with:
|
||||
text: ${{ github.event.inputs.version }}
|
||||
regex: '^(\d+.\d+).\d+(?:-beta.\d+)?$'
|
||||
regex: '^(\d+.\d+).\d+(?:-beta\d+)?$'
|
||||
- uses: actions-ecosystem/action-regex-match@v2.0.2
|
||||
if: ${{ inputs.version_call != '' }}
|
||||
id: regex-match-version-call
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run: |
|
||||
echo "The input version format is not correct, please respect:\
|
||||
major.minor.patch or major.minor.patch-beta.number format. \
|
||||
example: 7.4.3 or 7.4.3-beta.1"
|
||||
example: 7.4.3 or 7.4.3-beta1"
|
||||
exit 1
|
||||
- name: Validate input version call
|
||||
if: ${{ inputs.version_call != '' && steps.regex-match-version-call.outputs.match == '' }}
|
||||
|
||||
170
CHANGELOG.md
170
CHANGELOG.md
@@ -1,3 +1,173 @@
|
||||
<!-- 9.3.0-beta1 START -->
|
||||
|
||||
# 9.3.0-beta1 (2022-11-15)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Alerting:** Add Alertmanager choice warning. [#55311](https://github.com/grafana/grafana/pull/55311), [@konrad147](https://github.com/konrad147)
|
||||
- **Alerting:** Add support for linking external images securely - Azure Blob (#1). [#56598](https://github.com/grafana/grafana/pull/56598), [@petr-stupka](https://github.com/petr-stupka)
|
||||
- **Alerting:** Add threshold expression. [#55102](https://github.com/grafana/grafana/pull/55102), [@gillesdemey](https://github.com/gillesdemey)
|
||||
- **Alerting:** Add traceability headers for alert queries. [#57127](https://github.com/grafana/grafana/pull/57127), [@alexweav](https://github.com/alexweav)
|
||||
- **Alerting:** Allow none provenance alert rule creation from provisioning API. [#58410](https://github.com/grafana/grafana/pull/58410), [@alexmobo](https://github.com/alexmobo)
|
||||
- **Alerting:** Cache result of dashboard ID lookups. [#56587](https://github.com/grafana/grafana/pull/56587), [@alexweav](https://github.com/alexweav)
|
||||
- **Alerting:** Expressions pipeline redesign. [#54601](https://github.com/grafana/grafana/pull/54601), [@gillesdemey](https://github.com/gillesdemey)
|
||||
- **Alerting:** Fall back to "range" query type for unified alerting when "both" is specified. [#57288](https://github.com/grafana/grafana/pull/57288), [@gillesdemey](https://github.com/gillesdemey)
|
||||
- **Alerting:** Implement the Webex notifier. [#58480](https://github.com/grafana/grafana/pull/58480), [@gotjosh](https://github.com/gotjosh)
|
||||
- **Alerting:** Improve group modal with validation on evaluation interval. [#57830](https://github.com/grafana/grafana/pull/57830), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
|
||||
- **Alerting:** Persist annotations from multidimensional rules in batches. [#56575](https://github.com/grafana/grafana/pull/56575), [@alexweav](https://github.com/alexweav)
|
||||
- **Alerting:** Query time logging. [#57585](https://github.com/grafana/grafana/pull/57585), [@konrad147](https://github.com/konrad147)
|
||||
- **Alerting:** Remove the alert manager selection from the data source configuration. [#57369](https://github.com/grafana/grafana/pull/57369), [@VikaCep](https://github.com/VikaCep)
|
||||
- **Alerting:** Remove the alert manager selection from the data source configuration. [#56460](https://github.com/grafana/grafana/pull/56460), [@gitstart](https://github.com/gitstart)
|
||||
- **Alerting:** Support values in notification templates. [#56457](https://github.com/grafana/grafana/pull/56457), [@grobinson-grafana](https://github.com/grobinson-grafana)
|
||||
- **Alerting:** Templated URLs for webhook type contact points. [#57296](https://github.com/grafana/grafana/pull/57296), [@santihernandezc](https://github.com/santihernandezc)
|
||||
- **Annotations:** Disable "Add annotation" button when annotations are disabled. [#57481](https://github.com/grafana/grafana/pull/57481), [@ryantxu](https://github.com/ryantxu)
|
||||
- **Auth:** Add validation and ingestion of conflict file. [#53014](https://github.com/grafana/grafana/pull/53014), [@eleijonmarck](https://github.com/eleijonmarck)
|
||||
- **Auth:** Make built-in login configurable. [#46978](https://github.com/grafana/grafana/pull/46978), [@TsotosA](https://github.com/TsotosA)
|
||||
- **Auth:** Refresh OAuth access_token automatically using the refresh_token. [#56076](https://github.com/grafana/grafana/pull/56076), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
- **Auth:** Validate Azure ID token version on login is not v1. [#58088](https://github.com/grafana/grafana/pull/58088), [@Jguer](https://github.com/Jguer)
|
||||
- **BackendSrv:** Make it possible to pass `options` to `.get|post|patch...` methods. [#51316](https://github.com/grafana/grafana/pull/51316), [@leventebalogh](https://github.com/leventebalogh)
|
||||
- **Canvas:** Add tabs to inline editor. [#57778](https://github.com/grafana/grafana/pull/57778), [@adela-almasan](https://github.com/adela-almasan)
|
||||
- **Canvas:** Extend root context menu. [#58097](https://github.com/grafana/grafana/pull/58097), [@adela-almasan](https://github.com/adela-almasan)
|
||||
- **Chore:** Switch Grafana to using faro libraries. [#58186](https://github.com/grafana/grafana/pull/58186), [@tolzhabayev](https://github.com/tolzhabayev)
|
||||
- **Chore:** Use strings.ReplaceAll and preallocate containers. [#58483](https://github.com/grafana/grafana/pull/58483), [@sashamelentyev](https://github.com/sashamelentyev)
|
||||
- **CloudWatch:** Cache resource request responses in the browser. [#57082](https://github.com/grafana/grafana/pull/57082), [@sunker](https://github.com/sunker)
|
||||
- **Config:** Change jwt config value to be "expect_claims". [#58284](https://github.com/grafana/grafana/pull/58284), [@conorevans](https://github.com/conorevans)
|
||||
- **Configuration:** Update ssl_mode documentation in sample.ini to match default.ini. [#55138](https://github.com/grafana/grafana/pull/55138), [@alecxvs](https://github.com/alecxvs)
|
||||
- **Correlations:** Add query editor and target field to settings page. [#55567](https://github.com/grafana/grafana/pull/55567), [@Elfo404](https://github.com/Elfo404)
|
||||
- **Dashboard:** Record the number of cached queries for usage insights. [#56050](https://github.com/grafana/grafana/pull/56050), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **Dashboard:** Record the number of cached queries for usage insights. (Enterprise)
|
||||
- **Datasources:** Support mixed datasources in a single query. [#56832](https://github.com/grafana/grafana/pull/56832), [@mmandrus](https://github.com/mmandrus)
|
||||
- **Docs:** Add documentation for Custom Branding on Public Dashboards. [#58090](https://github.com/grafana/grafana/pull/58090), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **Docs:** Add missing documentation for enterprise features. [#56753](https://github.com/grafana/grafana/pull/56753), [@mmandrus](https://github.com/mmandrus)
|
||||
- **Docs:** Clarify that audit logs are generated only for API requests. [#57521](https://github.com/grafana/grafana/pull/57521), [@spinillos](https://github.com/spinillos)
|
||||
- **Echo:** Add config option to prevent duplicate page views for GA4. [#57619](https://github.com/grafana/grafana/pull/57619), [@tolzhabayev](https://github.com/tolzhabayev)
|
||||
- **Elasticsearch:** Add trace to logs functionality. [#58063](https://github.com/grafana/grafana/pull/58063), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
- **Elasticsearch:** Reuse http client in the backend. [#55172](https://github.com/grafana/grafana/pull/55172), [@gabor](https://github.com/gabor)
|
||||
- **Explore:** Add tracesToMetrics span time shift options (#54710). [#55335](https://github.com/grafana/grafana/pull/55335), [@hanjm](https://github.com/hanjm)
|
||||
- **Explore:** Logs volume histogram: always start Y axis from zero. [#56200](https://github.com/grafana/grafana/pull/56200), [@gabor](https://github.com/gabor)
|
||||
- **Explore:** Remove explore2Dashboard feature toggle. [#58329](https://github.com/grafana/grafana/pull/58329), [@Elfo404](https://github.com/Elfo404)
|
||||
- **Explore:** Support fields interpolation in logs panel. [#58426](https://github.com/grafana/grafana/pull/58426), [@ifrost](https://github.com/ifrost)
|
||||
- **Frontend Routing:** Always render standalone plugin pages using the `<AppRootPage>`. [#57771](https://github.com/grafana/grafana/pull/57771), [@leventebalogh](https://github.com/leventebalogh)
|
||||
- **GRPC Server:** Add gRPC server service. [#47849](https://github.com/grafana/grafana/pull/47849), [@FZambia](https://github.com/FZambia)
|
||||
- **Geomap:** Add photo layer. [#57307](https://github.com/grafana/grafana/pull/57307), [@drew08t](https://github.com/drew08t)
|
||||
- **Geomap:** Upgrade to openlayers 7.x. [#57317](https://github.com/grafana/grafana/pull/57317), [@ryantxu](https://github.com/ryantxu)
|
||||
- **GrafanaData:** Deprecate logs functions. [#56077](https://github.com/grafana/grafana/pull/56077), [@gabor](https://github.com/gabor)
|
||||
- **GrafanaData:** Deprecate the LogsParser type. [#56242](https://github.com/grafana/grafana/pull/56242), [@gabor](https://github.com/gabor)
|
||||
- **Kindsys:** Introduce Kind framework. [#56492](https://github.com/grafana/grafana/pull/56492), [@sdboyer](https://github.com/sdboyer)
|
||||
- **LDAP:** Add `skip_org_role_sync` configuration option. [#56792](https://github.com/grafana/grafana/pull/56792), [@grafanabot](https://github.com/grafanabot)
|
||||
- **LDAP:** Add `skip_org_role_sync` configuration option. [#56679](https://github.com/grafana/grafana/pull/56679), [@gamab](https://github.com/gamab)
|
||||
- **LDAPSync:** Improve performance of sync and make it case insensitive. (Enterprise)
|
||||
- **LibraryPanels:** Load library panels in the frontend rather than the backend. [#50560](https://github.com/grafana/grafana/pull/50560), [@ryantxu](https://github.com/ryantxu)
|
||||
- **LogContext:** Add header and close button to modal. [#56283](https://github.com/grafana/grafana/pull/56283), [@svennergr](https://github.com/svennergr)
|
||||
- **LogContext:** Improve text describing the loglines. [#55475](https://github.com/grafana/grafana/pull/55475), [@svennergr](https://github.com/svennergr)
|
||||
- **Logs:** Allow collapsing the logs volume histogram. [#52808](https://github.com/grafana/grafana/pull/52808), [@gabor](https://github.com/gabor)
|
||||
- **Logs:** Center `show context` modal on click. [#55989](https://github.com/grafana/grafana/pull/55989), [@svennergr](https://github.com/svennergr)
|
||||
- **Logs:** Center `show context` modal on click. [#55405](https://github.com/grafana/grafana/pull/55405), [@svennergr](https://github.com/svennergr)
|
||||
- **Logs:** Show LogRowMenu also for long logs and wrap-lines turned off. [#56030](https://github.com/grafana/grafana/pull/56030), [@svennergr](https://github.com/svennergr)
|
||||
- **LogsContext:** Added button to load 10 more log lines. [#55923](https://github.com/grafana/grafana/pull/55923), [@svennergr](https://github.com/svennergr)
|
||||
- **Loki:** Add case insensitive line contains operation. [#58177](https://github.com/grafana/grafana/pull/58177), [@gwdawson](https://github.com/gwdawson)
|
||||
- **Loki:** Monaco Query Editor enabled by default. [#58080](https://github.com/grafana/grafana/pull/58080), [@matyax](https://github.com/matyax)
|
||||
- **Loki:** Redesign and improve query patterns. [#55097](https://github.com/grafana/grafana/pull/55097), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
- **Loki:** Rename log browser to label browser. [#58416](https://github.com/grafana/grafana/pull/58416), [@gwdawson](https://github.com/gwdawson)
|
||||
- **Loki:** Show invalid fields in label filter. [#55751](https://github.com/grafana/grafana/pull/55751), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
- **MSSQL:** Add connection timeout setting in configuration page. [#58631](https://github.com/grafana/grafana/pull/58631), [@mdvictor](https://github.com/mdvictor)
|
||||
- **Navigation:** Add `pluginId` to standalone plugin page NavLinks. [#57769](https://github.com/grafana/grafana/pull/57769), [@leventebalogh](https://github.com/leventebalogh)
|
||||
- **Navigation:** Expose new props to extend `Page`/`PluginPage`. [#58465](https://github.com/grafana/grafana/pull/58465), [@ashharrison90](https://github.com/ashharrison90)
|
||||
- **Navtree:** Make it possible to configure standalone plugin pages. [#56393](https://github.com/grafana/grafana/pull/56393), [@leventebalogh](https://github.com/leventebalogh)
|
||||
- **Node Graph:** Always show context menu. [#56876](https://github.com/grafana/grafana/pull/56876), [@joey-grafana](https://github.com/joey-grafana)
|
||||
- **Number formatting:** Strip trailing zeros after decimal point when decimals=auto. [#57373](https://github.com/grafana/grafana/pull/57373), [@leeoniya](https://github.com/leeoniya)
|
||||
- **OAuth:** Feature toggle for access token expiration check and docs. [#58179](https://github.com/grafana/grafana/pull/58179), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
- **Opentsdb:** Allow template variables for filter keys. [#57226](https://github.com/grafana/grafana/pull/57226), [@bohandley](https://github.com/bohandley)
|
||||
- **PanelEdit:** Allow test id to be passed to panel editors. [#55417](https://github.com/grafana/grafana/pull/55417), [@mckn](https://github.com/mckn)
|
||||
- **Plugins:** Add hook to make it easier to track interactions in plugins. [#56126](https://github.com/grafana/grafana/pull/56126), [@mckn](https://github.com/mckn)
|
||||
- **Plugins:** Introduce new Flame graph panel. [#56376](https://github.com/grafana/grafana/pull/56376), [@joey-grafana](https://github.com/joey-grafana)
|
||||
- **Plugins:** Make "README" the default markdown request param. [#58264](https://github.com/grafana/grafana/pull/58264), [@wbrowne](https://github.com/wbrowne)
|
||||
- **PostgreSQL:** Migrate to React. [#52831](https://github.com/grafana/grafana/pull/52831), [@zoltanbedi](https://github.com/zoltanbedi)
|
||||
- **Preferences:** Create indices. [#48356](https://github.com/grafana/grafana/pull/48356), [@sakjur](https://github.com/sakjur)
|
||||
- **Profiling:** Add Phlare and Parca datasources. [#57809](https://github.com/grafana/grafana/pull/57809), [@aocenas](https://github.com/aocenas)
|
||||
- **Prometheus:** Handle errors and warnings in buffered client. [#58504](https://github.com/grafana/grafana/pull/58504), [@itsmylife](https://github.com/itsmylife)
|
||||
- **Prometheus:** Make Prometheus streaming parser as default client. [#58365](https://github.com/grafana/grafana/pull/58365), [@itsmylife](https://github.com/itsmylife)
|
||||
- **Public Dashboards:** Add audit table. [#54508](https://github.com/grafana/grafana/pull/54508), [@jalevin](https://github.com/jalevin)
|
||||
- **PublicDashboards:** Add PubDash support to Angular panel plugins. [#57293](https://github.com/grafana/grafana/pull/57293), [@mmandrus](https://github.com/mmandrus)
|
||||
- **PublicDashboards:** Add annotations support. [#56413](https://github.com/grafana/grafana/pull/56413), [@owensmallwood](https://github.com/owensmallwood)
|
||||
- **PublicDashboards:** Add custom branding for Public Dashboard. (Enterprise)
|
||||
- **PublicDashboards:** Add delete public dashboard button in public dashboard modal. [#58095](https://github.com/grafana/grafana/pull/58095), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **PublicDashboards:** Cached queries column added in public dashboard insight query. (Enterprise)
|
||||
- **PublicDashboards:** Can toggle annotations in modal. [#57312](https://github.com/grafana/grafana/pull/57312), [@owensmallwood](https://github.com/owensmallwood)
|
||||
- **PublicDashboards:** Delete public dashboard in public dashboard table. [#57766](https://github.com/grafana/grafana/pull/57766), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **PublicDashboards:** Delete public dashboard when dashboard is deleted. [#57291](https://github.com/grafana/grafana/pull/57291), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **PublicDashboards:** Extract config of Public Dashboard. [#57788](https://github.com/grafana/grafana/pull/57788), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **PublicDashboards:** Hide top navigation bar. [#56873](https://github.com/grafana/grafana/pull/56873), [@evictorero](https://github.com/evictorero)
|
||||
- **PublicDashboards:** Make mixed datasource calls concurrently. [#56421](https://github.com/grafana/grafana/pull/56421), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **PublicDashboards:** Orphaned public dashboard item list modified. [#58014](https://github.com/grafana/grafana/pull/58014), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **PublicDashboards:** Rename PubdashFooter frontend component. [#58137](https://github.com/grafana/grafana/pull/58137), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **PublicDashboards:** Update docs with supported datasources. [#57629](https://github.com/grafana/grafana/pull/57629), [@owensmallwood](https://github.com/owensmallwood)
|
||||
- **PublicDashboards:** Validate access token. [#57298](https://github.com/grafana/grafana/pull/57298), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **PublicDashboards:** Validate access token not to be duplicated and add retries. [#56755](https://github.com/grafana/grafana/pull/56755), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **RBAC:** Improve performance of dashboard filter query. [#56813](https://github.com/grafana/grafana/pull/56813), [@kalleep](https://github.com/kalleep)
|
||||
- **Rendering:** Add configuration options for `renderKey` lifetime. [#57339](https://github.com/grafana/grafana/pull/57339), [@Willena](https://github.com/Willena)
|
||||
- **Reports:** Dynamic scale factor per report. (Enterprise)
|
||||
- **SAML:** Set cookie option SameSite=none and Secure=true. (Enterprise)
|
||||
- **SQLStore:** Optionally retry queries if sqlite returns database is locked. [#56096](https://github.com/grafana/grafana/pull/56096), [@papagian](https://github.com/papagian)
|
||||
- **Server:** Make unix socket permission configurable. [#52944](https://github.com/grafana/grafana/pull/52944), [@unknowndevQwQ](https://github.com/unknowndevQwQ)
|
||||
- **Tempo:** Add start time and end time parameters while querying traces. [#48068](https://github.com/grafana/grafana/pull/48068), [@bikashmishra100](https://github.com/bikashmishra100)
|
||||
- **TimeSeries:** Render null-bounded points at data edges. [#57798](https://github.com/grafana/grafana/pull/57798), [@leeoniya](https://github.com/leeoniya)
|
||||
- **Tracing:** Allow trace to logs for OpenSearch. [#58161](https://github.com/grafana/grafana/pull/58161), [@gabor](https://github.com/gabor)
|
||||
- **Transformers:** PartitionByValues. [#56767](https://github.com/grafana/grafana/pull/56767), [@leeoniya](https://github.com/leeoniya)
|
||||
- **UsageStats:** Add traces when sending usage stats. [#55474](https://github.com/grafana/grafana/pull/55474), [@sakjur](https://github.com/sakjur)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Alerting:** Fix mathexp.NoData in ConditionsCmd. [#56812](https://github.com/grafana/grafana/pull/56812), [@grobinson-grafana](https://github.com/grobinson-grafana)
|
||||
- **BarChart:** Fix coloring from thresholds and value mappings. [#58285](https://github.com/grafana/grafana/pull/58285), [@leeoniya](https://github.com/leeoniya)
|
||||
- **BarChart:** Fix stacked hover. [#57711](https://github.com/grafana/grafana/pull/57711), [@leeoniya](https://github.com/leeoniya)
|
||||
- **Explore:** Fix shared crosshair for logs, logsvolume and graph panels. [#57892](https://github.com/grafana/grafana/pull/57892), [@Elfo404](https://github.com/Elfo404)
|
||||
- **Flame Graph:** Exact search. [#56769](https://github.com/grafana/grafana/pull/56769), [@joey-grafana](https://github.com/joey-grafana)
|
||||
- **Flame Graph:** Fix for dashboard scrolling. [#56555](https://github.com/grafana/grafana/pull/56555), [@joey-grafana](https://github.com/joey-grafana)
|
||||
- **LogContext:** Fix scroll behavior in context modal. [#56070](https://github.com/grafana/grafana/pull/56070), [@svennergr](https://github.com/svennergr)
|
||||
- **Loki:** Fix showing of history of querying in query editor. [#57344](https://github.com/grafana/grafana/pull/57344), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
- **OAuth:** Fix misleading warn log related to oauth and increase logged content. [#57336](https://github.com/grafana/grafana/pull/57336), [@Jguer](https://github.com/Jguer)
|
||||
- **Plugins:** Plugin details page visual alignment issues. [#57729](https://github.com/grafana/grafana/issues/57729)
|
||||
- **PublicDashboards:** Fix GET public dashboard that doesn't match. [#57571](https://github.com/grafana/grafana/pull/57571), [@juanicabanas](https://github.com/juanicabanas)
|
||||
- **PublicDashboards:** Fix annotations error for public dashboards. [#57455](https://github.com/grafana/grafana/pull/57455), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **PublicDashboards:** Fix granularity discrepancy between public and original dashboard. [#57129](https://github.com/grafana/grafana/pull/57129), [@guicaulada](https://github.com/guicaulada)
|
||||
- **PublicDashboards:** Fix granularity issue caused by query caching. (Enterprise)
|
||||
- **PublicDashboards:** Fix hidden queries execution. (Enterprise)
|
||||
- **RBAC:** Add primary key to seed_assignment table. [#56540](https://github.com/grafana/grafana/pull/56540), [@kalleep](https://github.com/kalleep)
|
||||
- **Tempo:** Fix search removing service name from query. [#58630](https://github.com/grafana/grafana/pull/58630), [@joey-grafana](https://github.com/joey-grafana)
|
||||
- **TimeRangeInput:** Fix clear button type. [#56545](https://github.com/grafana/grafana/pull/56545), [@Clarity-89](https://github.com/Clarity-89)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
Removes the unused close-milestone command from `@grafana/toolkit`. Issue [#57062](https://github.com/grafana/grafana/issues/57062)
|
||||
|
||||
@grafana/toolkit `cherrypick` command was removed. Issue [#56114](https://github.com/grafana/grafana/issues/56114)
|
||||
|
||||
`EmotionPerfTest` is no longer exported from the `@grafana/ui` bundle. Issue [#56100](https://github.com/grafana/grafana/issues/56100)
|
||||
|
||||
Removing the unused `changelog` command in `@grafana/toolkit`. Issue [#56073](https://github.com/grafana/grafana/issues/56073)
|
||||
|
||||
### Deprecations
|
||||
|
||||
The interface type `LogsParser` in `grafana-data` is deprecated. Issue [#56242](https://github.com/grafana/grafana/issues/56242)
|
||||
|
||||
The following functions and classes related to logs are deprecated in the `grafana-ui` package: `getLogLevel`, `getLogLevelFromKey`, `addLogLevelToSeries`, `LogsParsers`, `calculateFieldStats`, `calculateLogsLabelStats`, `calculateStats`, `getParser`, `sortInAscendingOrder`, `sortInDescendingOrder`, `sortLogsResult`, `sortLogRows`, `checkLogsError`, `escapeUnescapedString`. Issue [#56077](https://github.com/grafana/grafana/issues/56077)
|
||||
|
||||
### Plugin development fixes & changes
|
||||
|
||||
- **Toolkit:** Deprecate `plugin:update-circleci` command. [#57743](https://github.com/grafana/grafana/pull/57743), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Deprecate `plugin:github-publish` command. [#57726](https://github.com/grafana/grafana/pull/57726), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Deprecate `plugin:bundle-managed` command and move its functionality to a bash script. [#57719](https://github.com/grafana/grafana/pull/57719), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Deprecate and replace toolkit:build with plain yarn scripts. [#57620](https://github.com/grafana/grafana/pull/57620), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Deprecate node-version-check command. [#57591](https://github.com/grafana/grafana/pull/57591), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Deprecate searchTestData command. [#57589](https://github.com/grafana/grafana/pull/57589), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Remove unused close-milestone command. [#57062](https://github.com/grafana/grafana/pull/57062), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Remove unused legacy cherrypick command. [#56114](https://github.com/grafana/grafana/pull/56114), [@academo](https://github.com/academo)
|
||||
- **Grafana UI:** Clean up bundle. [#56100](https://github.com/grafana/grafana/pull/56100), [@jackw](https://github.com/jackw)
|
||||
- **Toolkit:** Deprecate `component:create` command. [#56086](https://github.com/grafana/grafana/pull/56086), [@academo](https://github.com/academo)
|
||||
- **Toolkit:** Remove changelog command. [#56073](https://github.com/grafana/grafana/pull/56073), [@gitstart](https://github.com/gitstart)
|
||||
|
||||
<!-- 9.3.0-beta1 END -->
|
||||
<!-- 9.2.4 START -->
|
||||
|
||||
# 9.2.4 (2022-11-07)
|
||||
|
||||
@@ -1256,6 +1256,8 @@ license_path =
|
||||
# enable = feature1,feature2
|
||||
enable =
|
||||
|
||||
internationalization = true
|
||||
|
||||
# feature1 = true
|
||||
# feature2 = false
|
||||
|
||||
|
||||
@@ -27,9 +27,6 @@ Before you can create your first dashboard, you need to add your data source.
|
||||
**To add a data source:**
|
||||
|
||||
1. Select the cog icon on the side menu to show the configuration options.
|
||||
|
||||
{{< figure src="/static/img/docs/v75/sidemenu-datasource-7-5.png" max-width="150px" class="docs-image--no-shadow">}}
|
||||
|
||||
1. Select **Data sources**.
|
||||
|
||||
This opens the data sources page, which displays a list of previously configured data sources for the Grafana instance.
|
||||
@@ -44,9 +41,7 @@ Before you can create your first dashboard, you need to add your data source.
|
||||
|
||||
1. Move the cursor over the data source you want to add.
|
||||
|
||||
{{< figure src="/static/img/docs/v75/select-data-source-7-5.png" max-width="700px" class="docs-image--no-shadow">}}
|
||||
|
||||
1. Select **Select**.
|
||||
1. Click **Select**.
|
||||
|
||||
This opens the data source configuration page.
|
||||
|
||||
@@ -63,8 +58,6 @@ Each data source's configuration includes a permissions page where you can enabl
|
||||
|
||||
### Enable data source permissions
|
||||
|
||||
{{< figure src="/static/img/docs/enterprise/datasource_permissions_enable_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/static/img/docs/enterprise/datasource_permissions_enable.gif" >}}
|
||||
|
||||
By default, data sources in an organization can be queried by any user in that organization. For example, a user with the `Viewer` role can issue any possible query to a data source, not just
|
||||
queries that exist on dashboards they have access to.
|
||||
|
||||
@@ -82,8 +75,6 @@ When permissions are enabled for a data source in an organization, the user who
|
||||
|
||||
### Allow users and teams to query a data source
|
||||
|
||||
{{< figure src="/static/img/docs/enterprise/datasource_permissions_add_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/static/img/docs/enterprise/datasource_permissions_add.gif" >}}
|
||||
|
||||
After you have enabled permissions for a data source you can assign query permissions to users and teams which will allow access to query the data source.
|
||||
|
||||
**Assign query permission to users and teams:**
|
||||
@@ -98,8 +89,6 @@ After you have enabled permissions for a data source you can assign query permis
|
||||
|
||||
### Disable data source permissions
|
||||
|
||||
{{< figure src="/static/img/docs/enterprise/datasource_permissions_disable_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/static/img/docs/enterprise/datasource_permissions_disable.gif" >}}
|
||||
|
||||
If you have enabled permissions for a data source and want to return data source permissions to the default, then you can disable permissions with a click of a button.
|
||||
|
||||
Note that _all_ existing permissions created for the data source will be deleted.
|
||||
|
||||
@@ -60,8 +60,6 @@ Complete this task when you want to view a list of existing organizations.
|
||||
|
||||
A list of organizations appears.
|
||||
|
||||

|
||||
|
||||
## Create an organization
|
||||
|
||||
Create an organization when you want to isolate dashboards and other resources from each other.
|
||||
@@ -117,5 +115,3 @@ Edit an organization when you want to change its name.
|
||||
1. Hover your cursor over the **Server Admin** (shield) icon until a menu appears, and click **Orgs**.
|
||||
1. Click the organization you want to edit.
|
||||
1. Update the organization name and click **Update**.
|
||||
|
||||

|
||||
|
||||
@@ -88,8 +88,6 @@ To browse for available plugins:
|
||||
1. Click the **All** filter to browse all available plugins.
|
||||
1. Click the **Data sources**, **Panels**, or **Applications** buttons to filter by plugin type.
|
||||
|
||||

|
||||
|
||||
### Install a plugin
|
||||
|
||||
To install a plugin:
|
||||
@@ -101,8 +99,6 @@ To install a plugin:
|
||||
|
||||
When the update is complete, you see a confirmation message that the installation was successful.
|
||||
|
||||

|
||||
|
||||
### Update a plugin
|
||||
|
||||
To update a plugin:
|
||||
@@ -113,8 +109,6 @@ To update a plugin:
|
||||
|
||||
When the update is complete, you see a confirmation message that the update was successful.
|
||||
|
||||

|
||||
|
||||
### Uninstall a plugin
|
||||
|
||||
To uninstall a plugin:
|
||||
@@ -125,8 +119,6 @@ To uninstall a plugin:
|
||||
|
||||
When the update is complete, you see a confirmation message that the uninstall was successful.
|
||||
|
||||

|
||||
|
||||
## Install Grafana plugins
|
||||
|
||||
Grafana supports data source, panel, and app plugins. Having panels as plugins makes it easy to create and add any kind of panel, to show your data, or improve your favorite dashboards. Apps enable the bundling of data sources, panels, dashboards, and Grafana pages into a cohesive experience.
|
||||
|
||||
@@ -53,8 +53,6 @@ In both cases, the assignment applies only to the user, team or service account
|
||||
4. In the **Role** column, select the fixed role that you want to assign to the user, team or service account.
|
||||
5. Click **Update**.
|
||||
|
||||

|
||||
|
||||
**To assign a fixed role as a server administrator:**
|
||||
|
||||
1. Sign in to Grafana, hover your cursor over **Server Admin** (the shield icon) in the left navigation menu, and click **Users**.
|
||||
@@ -62,8 +60,6 @@ In both cases, the assignment applies only to the user, team or service account
|
||||
1. In the **Organizations** section, select a role within an organization that you want to assign to the user.
|
||||
1. Click **Update**.
|
||||
|
||||

|
||||
|
||||
## Assign fixed or custom roles to a team using provisioning
|
||||
|
||||
Instead of using the Grafana role picker, you can use file-based provisioning to assign fixed roles to teams. If you have a large number of teams, provisioning can provide an easier approach to assigning and managing role assignments.
|
||||
|
||||
@@ -14,9 +14,10 @@ weight: 30
|
||||
|
||||
The table below describes all RBAC configuration options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../../../../setup-grafana/configure-grafana/#configure-with-environment-variables" >}}).
|
||||
|
||||
| Setting | Required | Description | Default |
|
||||
| ------------------ | -------- | ---------------------------------------------------------------------------- | ------- |
|
||||
| `permission_cache` | No | Enable to use in memory cache for loading and evaluating users' permissions. | `true` |
|
||||
| Setting | Required | Description | Default |
|
||||
| ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `permission_cache` | No | Enable to use in memory cache for loading and evaluating users' permissions. | `true` |
|
||||
| `permission_validation_enabled` | No | Grafana enforces validation for permissions when a user creates or updates a role. The system checks the internal list of scopes and actions for each permission to determine they are valid. By default, if a scope or action is not recognized, Grafana logs a warning message. When set to `true`, Grafana returns an error. | `false` |
|
||||
|
||||
## Example RBAC configuration
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@ To add a team member:
|
||||
1. Choose if you want to add the user as a team Member or an Admin.
|
||||
1. Click **Add to team**.
|
||||
|
||||

|
||||
|
||||
## Grant team member permissions
|
||||
|
||||
Complete this task when you want to add or modify team member permissions.
|
||||
@@ -76,8 +74,6 @@ To grant team member permissions:
|
||||
1. In the team member list, find and click the user that you want to change. You can use the search field to filter the list if necessary.
|
||||
1. Click the **Permission** list, and then click the new user permission level.
|
||||
|
||||

|
||||
|
||||
## Remove a team member
|
||||
|
||||
You can remove a team member when you no longer want to apply team permissions to the user
|
||||
|
||||
@@ -33,8 +33,6 @@ You can see a list of users with accounts in your Grafana organization. If neces
|
||||
1. Sign in to Grafana as an organization administrator.
|
||||
1. Hover your cursor over the **Configuration** (gear) icon in the side menu and click **Users**.
|
||||
|
||||

|
||||
|
||||
> **Note:** If you have [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can also [view a global list of users]({{< relref "../server-user-management#view-a-list-of-users" >}}) in the Server Admin section of Grafana.
|
||||
|
||||
## Change a user's organization permissions
|
||||
@@ -96,8 +94,6 @@ When you invite users to join an organization, you assign the **Admin**, **Edito
|
||||
|
||||
If the invitee is not already a user, the system adds them.
|
||||
|
||||
.
|
||||
|
||||
## Manage a pending invitation
|
||||
|
||||
Periodically review invitations you have sent so that you can see a list of users that have not yet accepted the invitation or cancel a pending invitation.
|
||||
@@ -116,14 +112,10 @@ Periodically review invitations you have sent so that you can see a list of user
|
||||
|
||||
The **Pending Invites** button appears only when there are unaccepted invitations.
|
||||
|
||||

|
||||
|
||||
To cancel an invitation, click the red **X** next to the invitation.
|
||||
|
||||
To copy an invitation link and send it directly to a user, click Copy Invite. You can then paste the invite link into a message.
|
||||
|
||||

|
||||
|
||||
## Remove a user from an organization
|
||||
|
||||
You can remove a user from an organization when they no longer require access to the dashboard or data sources owned by the organization. No longer requiring access to an organization might occur when the user has left your company or has internally moved to another organization.
|
||||
|
||||
@@ -38,8 +38,6 @@ You can see a list of users with accounts on your Grafana server. This action mi
|
||||
1. Sign in to Grafana as a server administrator.
|
||||
1. Hover your cursor over the **Server Admin** (shield) icon until a menu appears, and click **Users**.
|
||||
|
||||

|
||||
|
||||
> **Note:** If you have [organization administrator]({{< relref "../../roles-and-permissions/#organization-roles" >}}) permissions and _not_ [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can still [view of list of users in a given organization]({{< relref "../manage-org-users/#view-a-list-of-organization-users" >}}).
|
||||
|
||||
## View user details
|
||||
@@ -62,26 +60,18 @@ A user account contains the following sections.
|
||||
|
||||
This section contains basic user information, which users can update.
|
||||
|
||||

|
||||
|
||||
#### Permissions
|
||||
|
||||
This indicates whether the user account has the Grafana administrator flag applied. If the flag is set to **Yes**, then the user is a Grafana server administrator.
|
||||
|
||||

|
||||
|
||||
#### Organizations
|
||||
|
||||
This section lists the organizations the user belongs to and their assigned role.
|
||||
|
||||

|
||||
|
||||
#### Sessions
|
||||
|
||||
This section includes recent user sessions and information about the time the user logged in and they system they used. You can force logouts, if necessary.
|
||||
|
||||

|
||||
|
||||
## Edit a user account
|
||||
|
||||
Edit a user account when you want to modify user login credentials, or delete, disable, or enable a user.
|
||||
|
||||
@@ -10,18 +10,34 @@ keywords:
|
||||
- guide
|
||||
- rules
|
||||
- create
|
||||
title: Annotations and labels for alerting rules
|
||||
title: Labels and annotations
|
||||
weight: 401
|
||||
---
|
||||
|
||||
# Annotations and labels for alerting rules
|
||||
# Labels and annotations
|
||||
|
||||
Annotations and labels are key value pairs associated with alerts originating from the alerting rule, datasource response, and as a result of alerting rule evaluation. They can be used in alert notifications directly or in templates and template functions to create notification content dynamically.
|
||||
Labels and annotations contain information about an alert. Both labels and annotations have the same structure: a set of named values; however their intended uses are different. An example of label, or the equivalent annotation, might be `alertname="test"`.
|
||||
|
||||
## Annotations
|
||||
The main difference between a label and an annotation is that labels are used to differentiate an alert from all other alerts, while annotations are used to add additional information to an existing alert.
|
||||
|
||||
Annotations are key-value pairs that provide additional meta-information about an alert. You can use the following annotations: `description`, `summary`, `runbook_url`, `alertId`, `dashboardUid`, and `panelId`. For example, a description, a summary, and a runbook URL. These are displayed in rule and alert details in the UI and can be used in contact point message templates.
|
||||
For example, consider two high CPU alerts: one for `server1` and another for `server2`. In such an example we might have a label called `server` where the first alert has the label `server="server1"` and the second alert has the label `server="server2"`. However, we might also want to add a description to each alert such as `"The CPU usage for server1 is above 75%."`, where `server1` and `75%` are replaced with the name and CPU usage of the server (please refer to the documentation on [templating labels and annotations]({{< relref "./variables-label-annotation" >}}) for how to do this). This kind of description would be more suitable as an annotation.
|
||||
|
||||
## Labels
|
||||
|
||||
Labels are key-value pairs that contain information about, and are used to uniquely identify an alert. The label set for an alert is generated and added to throughout the alerting evaluation and notification process.
|
||||
Labels contain information that identifies an alert. An example of a label might be `server=server1`. Each alert can have more than one label, and the complete set of labels for an alert is called its label set. It is this label set that identifies the alert.
|
||||
|
||||
For example, an alert might have the label set `{alertname="High CPU usage",server="server1"}` while another alert might have the label set `{alertname="High CPU usage",server="server2"}`. These are two separate alerts because although their `alertname` labels are the same, their `server` labels are different.
|
||||
|
||||
The label set for an alert is a combination of the labels from the datasource, custom labels from the alert rule, and a number of reserved labels such as `alertname`.
|
||||
|
||||
### Custom Labels
|
||||
|
||||
Custom labels are additional labels from the alert rule. Like annotations, custom labels must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. Documentation on how to template custom labels can be found [here]({{< relref "./variables-label-annotation" >}}).
|
||||
|
||||
When using custom labels with templates it is important to make sure that the label value does not change between consecutive evaluations of the alert rule as this will end up creating large numbers of distinct alerts. However, it is OK for the template to produce different label values for different alerts. For example, do not put the value of the query in a custom label as this will end up creating a new set of alerts each time the value changes. Instead use annotations.
|
||||
|
||||
It is also important to make sure that the label set for an alert does not have two or more labels with the same name. If a custom label has the same name as a label from the datasource then it will replace that label. However, should a custom label have the same name as a reserved label then the custom label will be omitted from the alert.
|
||||
|
||||
## Annotations
|
||||
|
||||
Annotations are named pairs that add additional information to existing alerts. There are a number of suggested annotations in Grafana such as `description`, `summary`, `runbook_url`, `dashboardUId` and `panelId`. Like custom labels, annotations must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. If an annotation contains template code, the template is evaluated once when the alert is fired. It is not re-evaluated, even when the alert is resolved. Documentation on how to template annotations can be found [here]({{< relref "./variables-label-annotation" >}}).
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
---
|
||||
aliases:
|
||||
- /docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation/
|
||||
description: Learn about labels and label matchers in alerting
|
||||
description: Learn about templating of labels and annotations
|
||||
keywords:
|
||||
- grafana
|
||||
- alerting
|
||||
- guide
|
||||
- fundamentals
|
||||
title: How to template annotations and labels
|
||||
title: Templating labels and annotations
|
||||
weight: 117
|
||||
---
|
||||
|
||||
# How to template annotations and labels
|
||||
# Templating labels and annotations
|
||||
|
||||
In Grafana it is possible to template annotations and labels just like you would in Prometheus. Those who have used
|
||||
Prometheus before should be familiar with the `$labels` variable which holds the label key/value pairs of the alert
|
||||
instance and the `$value` variable which holds the evaluated value of the alert instance.
|
||||
In Grafana it is possible to template labels and annotations just like you would in Prometheus. Those who have used Prometheus before should be familiar with the `$labels` variable which holds the label key/value pairs of the alert instance and the `$value` variable which holds the evaluated value of the alert instance.
|
||||
|
||||
In Grafana it is possible to use the same variables from Prometheus to template annotations and labels, even if your
|
||||
alert does not use a Prometheus datasource.
|
||||
In Grafana it is possible to use the same variables from Prometheus to template labels and annotations, even if your alert does not use a Prometheus datasource.
|
||||
|
||||
For example, let's suppose we want to create an alert in Grafana that tells us when one of our instances is down for
|
||||
more than 5 minutes. Like in Prometheus, we can add a summary annotation to show the instance which is down:
|
||||
For example, let's suppose we want to create an alert in Grafana that tells us when one of our instances is down for more than 5 minutes. Like in Prometheus, we can add a summary annotation to show the instance which is down:
|
||||
|
||||
```
|
||||
Instance {{ $labels.instance }} has been down for more than 5 minutes
|
||||
@@ -43,17 +39,13 @@ of the condition at the time the alert fired. For example:
|
||||
|
||||
## Alert rules with two or more queries or expressions
|
||||
|
||||
In the case where an alert rule has two or more queries, or uses reduce and math expressions, it is possible to template
|
||||
the reduced result of each query and expression with the `$values` variable. This variable holds the labels and value of
|
||||
each reduced query, and the results of any math expressions. However, it does not hold the samples for each query.
|
||||
In the case where an alert rule has two or more queries, or uses reduce and math expressions, it is possible to template the reduced result of each query and expression with the `$values` variable. This variable holds the labels and value of each reduced query, and the results of any math expressions. However, it does not hold the samples for each query.
|
||||
|
||||
For example, suppose you have the following alert rule:
|
||||
|
||||
{{< figure src="/static/img/docs/alerting/unified/grafana-alerting-histogram-quantile.png" class="docs-image--no-shadow" caption="An alert rule that uses histogram_quantile to compute 95th percentile" >}}
|
||||
|
||||
Should this rule create an alert instance `$values` will hold the result of the reduce expression `B` and the math
|
||||
expression `C`. It will not hold the results returned by query `A` because query `A` does not return a single value
|
||||
but rather a series of values over time.
|
||||
Should this rule create an alert instance `$values` will hold the result of the reduce expression `B` and the math expression `C`. It will not hold the results returned by query `A` because query `A` does not return a single value but rather a series of values over time.
|
||||
|
||||
If we were to write a summary annotation such as:
|
||||
|
||||
@@ -61,15 +53,13 @@ If we were to write a summary annotation such as:
|
||||
{{ $labels.instance }} has a 95th percentile request latency above 1s: {{ $value }})
|
||||
```
|
||||
|
||||
We would find that because the condition of the alert, the math expression `C` must be a boolean comparison, it must
|
||||
return either a `0` or a `1`. What we want instead is the 95th percentile from the reduce expression `B`:
|
||||
We would find that because the condition of the alert, the math expression `C` must be a boolean comparison, it must return either a `0` or a `1`. What we want instead is the 95th percentile from the reduce expression `B`:
|
||||
|
||||
```
|
||||
{{ $labels.instance }} has a 95th percentile request latency above 1s: {{ $values.B }})
|
||||
```
|
||||
|
||||
We can also show the labels of `B`, however since this alert rule has just one query the labels of `B` are equivalent to
|
||||
`$labels`:
|
||||
We can also show the labels of `B`, however since this alert rule has just one query the labels of `B` are equivalent to `$labels`:
|
||||
|
||||
```
|
||||
{{ $values.B.Labels.instance }} has a 95th percentile request latency above 1s: {{ $values.B }})
|
||||
@@ -78,8 +68,7 @@ We can also show the labels of `B`, however since this alert rule has just one q
|
||||
### No data and execution errors or timeouts
|
||||
|
||||
Should query `A` return no data then the reduce expression `B` will also return no data. This means that
|
||||
`{{ $values.B }}` will be nil. To ensure that annotations and labels can still be templated even when a query returns
|
||||
no data, we can use an if statement to check for `$values.B`:
|
||||
`{{ $values.B }}` will be nil. To ensure that labels and annotations can still be templated even when a query returns no data, we can use an if statement to check for `$values.B`:
|
||||
|
||||
```
|
||||
{{ if $values.B }}{{ $labels.instance }} has a 95th percentile request latency above 1s: {{ $values.B }}){{ end }}
|
||||
@@ -87,12 +76,11 @@ no data, we can use an if statement to check for `$values.B`:
|
||||
|
||||
## Classic conditions
|
||||
|
||||
If the rule uses a classic condition instead of a reduce and math expression, then `$values` contains the combination
|
||||
of the `refID` and position of the condition. For example, `{{ $values.A0 }}` and `{{ $values.A1 }}`.
|
||||
If the rule uses a classic condition instead of a reduce and math expression, then `$values` contains the combination of the `refID` and position of the condition. For example, `{{ $values.A0 }}` and `{{ $values.A1 }}`.
|
||||
|
||||
## Variables
|
||||
|
||||
The following template variables are available when expanding annotations and labels.
|
||||
The following template variables are available when expanding labels and annotations.
|
||||
|
||||
| Name | Description |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -12,34 +12,58 @@ weight: 460
|
||||
|
||||
# Use images in notifications
|
||||
|
||||
Images in notifications helps recipients of alert notifications better understand why an alert has fired or resolved by including an image of the panel associated with the Grafana managed alert rule.
|
||||
Images in notifications helps recipients of alert notifications better understand why an alert has fired or resolved by including a screenshot of the panel associated with the alert.
|
||||
|
||||
> **Note**: Images in notifications are not available for Grafana Mimir and Loki managed alert rules, or when Grafana is set up to send alert notifications to an external Alertmanager.
|
||||
> **Note**: This feature is not supported for Mimir or Loki rules, or when Grafana sends alert notifications to an external Alertmanager.
|
||||
|
||||
If Grafana is set up to send images in notifications, it takes a screenshot of the panel for the Grafana managed alert rule when either of the following happen:
|
||||
When an alert is fired or resolved Grafana takes a screenshot of the panel associated with the alert. This is determined via the Dashboard UID and Panel ID annotations of the rule. Grafana cannot take a screenshot for alerts that are not associated with a panel.
|
||||
|
||||
1. The alert rule transitions from pending to firing
|
||||
2. The alert rule transitions from firing to OK
|
||||
Because a number of contact points, such as email, do not support uploading screenshots at the time of sending a notification; Grafana can also upload the screenshot to a cloud storage service such as Amazon S3, Azure Blob Storage and Google Cloud Storage, where a link to the uploaded screenshot can be added to the notification. However, if using a cloud storage service is not an option then Grafana can be its own cloud storage service such that the screenshot is available under the same domain as Grafana.
|
||||
|
||||
Grafana does not support images for alert rules that are not associated with a panel. An alert rule is associated with a panel when it has both Dashboard UID and Panel ID annotations.
|
||||
Should either the cloud storage service, or Grafana if acting as its own cloud storage service, be protected by a firewall, gateway service or VPN, then screenshots might not be shown in notifications.
|
||||
|
||||
Images are stored in the [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) path and so Grafana must have write-access to this path. If Grafana cannot write to this path then screenshots cannot be saved to disk and an error will be logged for each failed screenshot attempt. In addition to storing images on disk, Grafana can also store the image in an external image store such as Amazon S3, Azure Blob Storage, Google Cloud Storage and even Grafana where screenshots are stored in `public/img/attachments`. Screenshots older than `temp_data_lifetime` are deleted from disk but not the external image store. If Grafana is the external image store then screenshots are deleted from `data` but not from `public/img/attachments`.
|
||||
How to choose between uploading screenshots at the time of sending the notification, using a cloud storage service, or using Grafana as its own cloud storage service, depends on which contact points you plan to use and whether you use a firewall, gateway service or VPN.
|
||||
|
||||
> **Note**: It is recommended that you use an external image store, as not all contact points support uploading images from disk. It is also possible that the image on disk is deleted before an alert notification is sent if `temp_data_lifetime` is less than the `group_wait` and `group_interval` options used in Alertmanager.
|
||||
For example, if a contact point supports uploading images at the time of notification is it not required to use cloud storage. Cloud storage is required when a contact point does not support uploading images at the time of sending a notification, such as email. We don't recommend using cloud storage if the cloud storage service is behind a firewall, gateway service, or VPN, as screenshots might not be shown in notifications.
|
||||
|
||||
Please refer to the table at the end of this page for a list of contact points and their support for images in notifications.
|
||||
|
||||
## Requirements
|
||||
|
||||
To use images in notifications, Grafana must be set up to use [image rendering](https://grafana.com/docs/grafana/next/setup-grafana/image-rendering/). It is also recommended that Grafana is set up to upload images to an external image store, such as Amazon S3, Azure Blob Storage, Google Cloud Storage or even Grafana.
|
||||
To use images in notifications, Grafana must be set up to use [image rendering](https://grafana.com/docs/grafana/next/setup-grafana/image-rendering/). You can either install the image rendering plugin or run it as a remote rendering service.
|
||||
|
||||
When a screenshot is taken it is saved to the [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) path. This is where screenshots are stored before being sent in a notification or uploaded to a cloud storage service. Grafana must have write-access to this path. If Grafana cannot write to this path then screenshots cannot be saved to disk and an error will be logged for each failed screenshot attempt.
|
||||
|
||||
If using a [cloud storage service](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_image_storage) such as Amazon S3, Azure Blob Storage or Google Cloud Storage, uploaded images need to be accessible outside of a firewall, gateway service or VPN for screenshots to be shown in notifications. Grafana will not delete screenshots from cloud storage. We recommend configuring a retention policy on the bucket to delete screenshots older than 1 month.
|
||||
|
||||
If using Grafana as its own cloud storage service then screenshots will be saved to `static_root_path/img/attachments`. `static_root_path` is a configuration option for Grafana and can be found in `defaults.ini`. However, like when using a cloud storage service, images need to be accessible outside of a firewall, gateway service or VPN for screenshots to be shown in notifications.
|
||||
|
||||
When using Grafana as its own cloud storage service screenshots are copied from [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) to `static_root_path/img/attachments`. Screenshots older than `temp_data_lifetime` are deleted from [data]({{< relref "../../setup-grafana/configure-grafana/#paths" >}}) but not from `static_root_path/images/attachments`. To delete screenshots from `static_root_path` after a certain amount of time we recommend setting up a CRON job.
|
||||
|
||||
## Configuration
|
||||
|
||||
If Grafana has been set up to use image rendering, images in notifications can be turned on via the `capture` option in `[unified_alerting.screenshots]`:
|
||||
Having installed either the image rendering plugin, or set up Grafana to use a remote rendering service, set `capture` in `[unified_alerting.screenshots]` to `true`:
|
||||
|
||||
# Enable screenshots in notifications. This option requires the Grafana Image Renderer plugin.
|
||||
# For more information on configuration options, refer to [rendering].
|
||||
capture = true
|
||||
capture = false
|
||||
|
||||
It is recommended that `max_concurrent_screenshots` is set to a value that is less than or equal to `concurrent_render_request_limit`. The default value for both `max_concurrent_screenshots` and `concurrent_render_request_limit` is `5`:
|
||||
If screenshots should be uploaded to cloud storage then `upload_external_image_storage` should also be set to `true`:
|
||||
|
||||
# Uploads screenshots to the local Grafana server or remote storage such as Azure, S3 and GCS. Please
|
||||
# see [external_image_storage] for further configuration options. If this option is false, screenshots
|
||||
# will be persisted to disk for up to temp_data_lifetime.
|
||||
upload_external_image_storage = false
|
||||
|
||||
Please see [`[external_image_storage]`](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_image_storage) for instructions on how to configure cloud storage. Grafana will not start if `upload_external_image_storage` is `true` and `[external_image_storage]` contains missing or invalid configuration.
|
||||
|
||||
If Grafana is acting as its own cloud storage then `[upload_external_image_storage]` should be set to `true` and the `local` provider should be set in [`[external_image_storage]`](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_image_storage).
|
||||
|
||||
Restart Grafana for the changes to take effect.
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
We recommended that `max_concurrent_screenshots` is less than or equal to `concurrent_render_request_limit`. The default value for both `max_concurrent_screenshots` and `concurrent_render_request_limit` is `5`:
|
||||
|
||||
# The maximum number of screenshots that can be taken at the same time. This option is different from
|
||||
# concurrent_render_request_limit as max_concurrent_screenshots sets the number of concurrent screenshots
|
||||
@@ -47,57 +71,60 @@ It is recommended that `max_concurrent_screenshots` is set to a value that is le
|
||||
# the total number of concurrent screenshots across all Grafana services.
|
||||
max_concurrent_screenshots = 5
|
||||
|
||||
If Grafana has been set up to use an external image store, `upload_external_image_storage` should be set to `true`:
|
||||
## Support for images in contact points
|
||||
|
||||
# Uploads screenshots to the local Grafana server or remote storage such as Azure, S3 and GCS. Please
|
||||
# see [external_image_storage] for further configuration options. If this option is false, screenshots
|
||||
# will be persisted to disk for up to temp_data_lifetime.
|
||||
upload_external_image_storage = false
|
||||
Grafana supports a wide range of contact points with varied support for images in notifications. The table below shows the list of all contact points supported in Grafana and their support for uploading images at the time of sending the notification and images uploaded to cloud storage, including when Grafana is acting as its own cloud storage service.
|
||||
|
||||
Restart Grafana for the changes to take affect.
|
||||
| Name | Upload image at time of notification | Cloud storage |
|
||||
| ----------------------- | ------------------------------------ | ------------- |
|
||||
| DingDing | No | No |
|
||||
| Discord | Yes | Yes |
|
||||
| Email | Yes | Yes |
|
||||
| Google Hangouts Chat | No | Yes |
|
||||
| Kafka | No | No |
|
||||
| Line | No | No |
|
||||
| Microsoft Teams | No | Yes |
|
||||
| Opsgenie | No | Yes |
|
||||
| Pagerduty | No | Yes |
|
||||
| Prometheus Alertmanager | No | No |
|
||||
| Pushover | Yes | No |
|
||||
| Sensu Go | No | No |
|
||||
| Slack | No (will be available in 9.4) | Yes |
|
||||
| Telegram | Yes | No |
|
||||
| Threema | No | No |
|
||||
| VictorOps | No | No |
|
||||
| Webhook | No | Yes |
|
||||
| Cisco Webex Teams | No | Yes |
|
||||
|
||||
## Supported notifiers
|
||||
## Limitations
|
||||
|
||||
Images in notifications are supported in the following notifiers and additional support will be added in the future:
|
||||
- This feature is not supported for Mimir or Loki rules, or when Grafana sends alert notifications to an external Alertmanager.
|
||||
- When multiple alerts are sent in a single notification a screenshot might be included for each alert. The order the images are shown in random.
|
||||
- Some contact points support at most one image per notification. In this case, the first image associated with an alert will be attached.
|
||||
- We don't recommend using cloud storage if the cloud storage service is behind a firewall, gateway service, or VPN, as screenshots might not be shown in notifications.
|
||||
|
||||
| Name | Upload images from disk | Include images from URL |
|
||||
| ----------------------- | ----------------------- | ----------------------- |
|
||||
| DingDing | No | No |
|
||||
| Discord | Yes | Yes |
|
||||
| Email | Yes | Yes |
|
||||
| Google Hangouts Chat | No | Yes |
|
||||
| Kafka | No | No |
|
||||
| Line | No | No |
|
||||
| Microsoft Teams | No | Yes |
|
||||
| Opsgenie | No | Yes |
|
||||
| Pagerduty | No | Yes |
|
||||
| Prometheus Alertmanager | No | No |
|
||||
| Pushover | Yes | No |
|
||||
| Sensu Go | No | No |
|
||||
| Slack | No | Yes |
|
||||
| Telegram | No | No |
|
||||
| Threema | No | No |
|
||||
| VictorOps | No | No |
|
||||
| Webhook | No | Yes |
|
||||
| Cisco Webex Teams | No | Yes |
|
||||
## Troubleshooting
|
||||
|
||||
Include images from URL refers to using the external image store.
|
||||
If Grafana has been set up to send images in notifications, however notifications are still being received without them, follow the troubleshooting steps below:
|
||||
|
||||
1. Check that images in notifications has been set up as per the instructions.
|
||||
2. Enable debug logging in Grafana and look for logs with the logger `ngalert.image`.
|
||||
3. If the alert is not associated with a dashboard there will be logs for `Cannot take screenshot for alert rule as it is not associated with a dashboard`.
|
||||
4. If the alert is associated with a dashboard, but no panel in the dashboard, there will be logs for `Cannot take screenshot for alert rule as it is not associated with a panel`.
|
||||
5. If images cannot be taken because of mis-configuration or an issue with image rendering there will be logs for `Failed to take an image` including the Dashboard UID, Panel ID, and the error message.
|
||||
6. Check that the contact point supports images in notifications, and the present configuration, as per the table.
|
||||
7. If the image was uploaded to cloud storage make sure it is public.
|
||||
8. If images are made available via Grafana's built in web server make sure it is accessible via the Internet.
|
||||
|
||||
## Metrics
|
||||
|
||||
Grafana provides the following metrics to observe the performance and failure rate of images in notifications.
|
||||
For example, if a screenshot could not be taken within the expected time (10 seconds) then the counter `grafana_screenshot_failures_total` is updated.
|
||||
|
||||
- `grafana_screenshot_cache_hits_total`
|
||||
- `grafana_screenshot_cache_misses_total`
|
||||
- `grafana_alerting_image_cache_hits_total`
|
||||
- `grafana_alerting_image_cache_misses_total`
|
||||
- `grafana_screenshot_duration_seconds`
|
||||
- `grafana_screenshot_failures_total`
|
||||
- `grafana_screenshot_successes_total`
|
||||
- `grafana_screenshot_upload_failures_total`
|
||||
- `grafana_screenshot_upload_successes_total`
|
||||
|
||||
## Limitations
|
||||
|
||||
- Images in notifications are not available for Grafana Mimir and Loki managed alert rules, or when Grafana is set up to send alert notifications to an external Alertmanager.
|
||||
- When alerts generated by different alert rules are sent in a single notification, there may be screenshots for each alert rule. This happens if an alert group contains multiple alerting rules. The order the images are attached is random. If you need to guarantee the ordering of images, make sure that your alert groups contain a single alerting rule.
|
||||
- Some contact points only handle a single image. In this case, the first image associated with an alert will be attached. Because the ordering is random, this may not always be an image for the same alert rule. If you need to guarantee you receive a screenshot for a particular rule, make sure that your alert groups contain a single alerting rule.
|
||||
|
||||
@@ -168,6 +168,21 @@ You can attach these permissions to the IAM role or IAM user you configured in [
|
||||
}
|
||||
```
|
||||
|
||||
**Cross-account observability:**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": ["oam:ListSinks", "oam:ListAttachedLinks"],
|
||||
"Effect": "Allow",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Configure CloudWatch settings
|
||||
|
||||
#### Namespaces of Custom Metrics
|
||||
|
||||
@@ -214,6 +214,33 @@ When making `stats` queries in [Explore]({{< relref "../../../explore/" >}}), ma
|
||||
|
||||
{{< figure src="/static/img/docs/v70/explore-mode-switcher.png" max-width="500px" class="docs-image--right" caption="Explore mode switcher" >}}
|
||||
|
||||
## Cross-account observability
|
||||
|
||||
The CloudWatch plugin provides the ability to monitor and troubleshoot applications that span across multiple accounts within a region. Using cross-account observability, you can seamlessly search, visualize and analyze metrics and logs, without having to worry about account boundaries.
|
||||
|
||||
> **Note:** This feature is currently behind the `cloudWatchCrossAccountQuerying` feature toggle.
|
||||
|
||||
> You can enable feature toggles through configuration file or environment variables. See configuration [docs]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}) for details.
|
||||
> Grafana Cloud users can access this feature by [opening a support ticket in the Cloud Portal](https://grafana.com/profile/org#support).
|
||||
|
||||
### Getting started
|
||||
|
||||
To enable cross-account observability, first enable it in CloudWatch using the official [CloudWatch docs](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html), then add [two new API actions]({{< relref "../#cross-account-observability" >}}) to the IAM policy attached to the role/user running the plugin.
|
||||
|
||||
Cross-account querying is available in the plugin through the `Logs` mode and the `Metric search` mode. Once you have it configured correctly, you'll see a "Monitoring account" badge displayed in the query editor header.
|
||||
|
||||
{{< figure src="/static/img/docs/cloudwatch/cloudwatch-monitoring-badge-9.3.0.png" max-width="1200px" caption="Monitoring account badge" >}}
|
||||
|
||||
### Metrics editor
|
||||
|
||||
When you select the `Builder` mode within the Metric search editor, a new Account field displays. Use the Account field to specify which of the linked accounts to target for the given query. By default, the `All` option is specified, which will target all linked accounts.
|
||||
|
||||
While in `Code` mode, you can specify any math expression. If the Monitoring account badge displays in the query editor header, all `SEARCH` expressions entered in this field will be cross-account by default. You can limit the search to one or a set of accounts, as documented in the [AWS documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
|
||||
|
||||
### Logs editor
|
||||
|
||||
The Log group selector allows you to specify what log groups to target in the logs query. If the Monitoring account badge is displayed in the query editor header, it is possible to search and select log groups across multiple accounts. You can use the Account field in the Log Group Selector to filter Log Groups by Account. If you have many log groups and do not see the log group you'd like to select in the selector, use the prefix search to narrow down the possible log groups.
|
||||
|
||||
### Deep-link Grafana panels to the CloudWatch console
|
||||
|
||||
{{< figure src="/static/img/docs/v70/cloudwatch-logs-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch Logs deep linking" >}}
|
||||
|
||||
@@ -61,7 +61,7 @@ Grafana Enterprise adds the following features:
|
||||
- [Custom branding]({{< relref "../setup-grafana/configure-grafana/configure-custom-branding/" >}}) to customize Grafana from the brand and logo to the footer links.
|
||||
- [Usage insights]({{< relref "../dashboards/assess-dashboard-usage/" >}}) to understand how your Grafana instance is used.
|
||||
- [Recorded queries]({{< relref "../administration/recorded-queries" >}}) to see trends over time for your data sources.
|
||||
- [Vault integration]({{< relref "../setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/" >}}) to manage your configuration or provisioning secrets with Vault.
|
||||
- [Vault integration]({{< relref "../setup-grafana/configure-security/configure-database-encryption/#encrypting-your-database-with-a-key-from-a-key-management-service-kms" >}}) to manage your configuration or provisioning secrets with Vault.
|
||||
- [Auditing]({{< relref "../setup-grafana/configure-security/audit-grafana/" >}}) tracks important changes to your Grafana instance to help you manage and mitigate suspicious activity and meet compliance requirements.
|
||||
- [Request security]({{< relref "../setup-grafana/configure-security/configure-request-security/" >}}) makes it possible to restrict outgoing requests from the Grafana server.
|
||||
- [Settings updates at runtime]({{< relref "../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}) allows you to update Grafana settings at runtime without requiring a restart.
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
. scripts/grafana-server/variables
|
||||
|
||||
./e2e/run-suite verify/specs
|
||||
# The run-suite script requires a second argument to determine if videos should be recorded
|
||||
./e2e/run-suite verify/specs true
|
||||
|
||||
6
go.mod
6
go.mod
@@ -29,7 +29,7 @@ require (
|
||||
github.com/BurntSushi/toml v1.1.0
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f
|
||||
github.com/aws/aws-sdk-go v1.44.109
|
||||
github.com/aws/aws-sdk-go v1.44.146
|
||||
github.com/beevik/etree v1.1.0
|
||||
github.com/benbjohnson/clock v1.3.0
|
||||
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
|
||||
@@ -108,7 +108,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.7.0
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
|
||||
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591 // indirect
|
||||
golang.org/x/net v0.1.0 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858
|
||||
@@ -230,7 +230,7 @@ require (
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
go.uber.org/atomic v1.9.0
|
||||
go.uber.org/goleak v1.1.12 // indirect
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
|
||||
golang.org/x/sys v0.1.0 // indirect
|
||||
golang.org/x/text v0.4.0
|
||||
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
|
||||
13
go.sum
13
go.sum
@@ -368,8 +368,9 @@ github.com/aws/aws-sdk-go v1.38.60/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z
|
||||
github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.109 h1:+Na5JPeS0kiEHoBp5Umcuuf+IDqXqD0lXnM920E31YI=
|
||||
github.com/aws/aws-sdk-go v1.44.109/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.146 h1:7YdGgPxDPRJu/yYffzZp/H7yHzQ6AqmuNFZPYraaN8I=
|
||||
github.com/aws/aws-sdk-go v1.44.146/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
|
||||
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
|
||||
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA=
|
||||
@@ -2851,8 +2852,8 @@ golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI=
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -3073,14 +3074,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"],
|
||||
"version": "9.4.0-pre"
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "9.3.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"name": "grafana",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"repository": "github:grafana/grafana",
|
||||
"scripts": {
|
||||
"build": "yarn i18n:compile && NODE_ENV=production webpack --config scripts/webpack/webpack.prod.js",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/data",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.1",
|
||||
"@grafana/schema": "9.4.0-pre",
|
||||
"@grafana/schema": "9.3.0-beta.1",
|
||||
"@types/d3-interpolate": "^1.4.0",
|
||||
"d3-interpolate": "1.4.0",
|
||||
"date-fns": "2.29.3",
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface FeatureToggles {
|
||||
objectStore?: boolean;
|
||||
traceqlEditor?: boolean;
|
||||
flameGraph?: boolean;
|
||||
cloudWatchCrossAccountQuerying?: boolean;
|
||||
redshiftAsyncQueryDataSupport?: boolean;
|
||||
athenaAsyncQueryDataSupport?: boolean;
|
||||
increaseInMemDatabaseQueryCache?: boolean;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e-selectors",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"description": "Grafana End-to-End Test Selectors Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -3,7 +3,7 @@ const execa = require('execa');
|
||||
const { resolve, sep } = require('path');
|
||||
const resolveBin = require('resolve-as-bin');
|
||||
|
||||
const cypress = (commandName, { updateScreenshots }) => {
|
||||
const cypress = (commandName, { updateScreenshots, browser }) => {
|
||||
// Support running an unpublished dev build
|
||||
const dirname = __dirname.split(sep).pop();
|
||||
const projectPath = resolve(`${__dirname}${dirname === 'dist' ? '/..' : ''}`);
|
||||
@@ -16,6 +16,10 @@ const cypress = (commandName, { updateScreenshots }) => {
|
||||
|
||||
const cypressOptions = [commandName, '--env', `${CWD},${UPDATE_SCREENSHOTS}`, `--project=${projectPath}`];
|
||||
|
||||
if (browser) {
|
||||
cypressOptions.push('--browser', browser);
|
||||
}
|
||||
|
||||
const execaOptions = {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
@@ -32,17 +36,21 @@ const cypress = (commandName, { updateScreenshots }) => {
|
||||
module.exports = () => {
|
||||
const updateOption = '-u, --update-screenshots';
|
||||
const updateDescription = 'update expected screenshots';
|
||||
const browserOption = '-b, --browser <browser>';
|
||||
const browserDescription = 'specify which browser to use';
|
||||
|
||||
program
|
||||
.command('open')
|
||||
.description('runs tests within the interactive GUI')
|
||||
.option(updateOption, updateDescription)
|
||||
.option(browserOption, browserDescription)
|
||||
.action((options) => cypress('open', options));
|
||||
|
||||
program
|
||||
.command('run')
|
||||
.description('runs tests from the CLI without the GUI')
|
||||
.option(updateOption, updateDescription)
|
||||
.option(browserOption, browserDescription)
|
||||
.action((options) => cypress('run', options));
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"description": "Grafana End-to-End Test Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@babel/core": "7.19.6",
|
||||
"@babel/preset-env": "7.19.4",
|
||||
"@cypress/webpack-preprocessor": "5.15.2",
|
||||
"@grafana/e2e-selectors": "9.4.0-pre",
|
||||
"@grafana/e2e-selectors": "9.3.0-beta.1",
|
||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||
"@mochajs/json-file-reporter": "^1.2.0",
|
||||
"babel-loader": "9.1.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/runtime",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"description": "Grafana Runtime Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -35,10 +35,10 @@
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "9.4.0-pre",
|
||||
"@grafana/e2e-selectors": "9.4.0-pre",
|
||||
"@grafana/data": "9.3.0-beta.1",
|
||||
"@grafana/e2e-selectors": "9.3.0-beta.1",
|
||||
"@grafana/faro-web-sdk": "1.0.0-beta2",
|
||||
"@grafana/ui": "9.4.0-pre",
|
||||
"@grafana/ui": "9.3.0-beta.1",
|
||||
"@sentry/browser": "6.19.7",
|
||||
"history": "4.10.1",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -42,7 +42,8 @@ jest.mock('../services', () => ({
|
||||
|
||||
describe('DataSourceWithBackend', () => {
|
||||
test('check the executed queries', () => {
|
||||
const mock = runQueryAndReturnFetchMock({
|
||||
const { mock, ds } = createMockDatasource();
|
||||
ds.query({
|
||||
maxDataPoints: 10,
|
||||
intervalMs: 5000,
|
||||
targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }],
|
||||
@@ -93,8 +94,22 @@ describe('DataSourceWithBackend', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
test('should apply template variables only for the current data source', () => {
|
||||
const { mock, ds } = createMockDatasource();
|
||||
ds.applyTemplateVariables = jest.fn();
|
||||
ds.query({
|
||||
maxDataPoints: 10,
|
||||
intervalMs: 5000,
|
||||
targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }],
|
||||
} as DataQueryRequest);
|
||||
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(ds.applyTemplateVariables).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('check that the executed queries is hidden from inspector', () => {
|
||||
const mock = runQueryAndReturnFetchMock({
|
||||
const { mock, ds } = createMockDatasource();
|
||||
ds.query({
|
||||
maxDataPoints: 10,
|
||||
intervalMs: 5000,
|
||||
targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }],
|
||||
@@ -169,9 +184,7 @@ describe('DataSourceWithBackend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function runQueryAndReturnFetchMock(
|
||||
request: DataQueryRequest
|
||||
): jest.MockContext<Promise<FetchResponse>, BackendSrvRequest[]> {
|
||||
function createMockDatasource() {
|
||||
const settings = {
|
||||
name: 'test',
|
||||
id: 1234,
|
||||
@@ -184,7 +197,5 @@ function runQueryAndReturnFetchMock(
|
||||
mockDatasourceRequest.mockReturnValue(Promise.resolve({} as FetchResponse));
|
||||
|
||||
const ds = new MyDataSource(settings);
|
||||
ds.query(request);
|
||||
|
||||
return mockDatasourceRequest.mock;
|
||||
return { ds, mock: mockDatasourceRequest.mock };
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ class DataSourceWithBackend<
|
||||
const queries = targets.map((q) => {
|
||||
let datasource = this.getRef();
|
||||
let datasourceId = this.id;
|
||||
let shouldApplyTemplateVariables = true;
|
||||
|
||||
if (isExpressionReference(q.datasource)) {
|
||||
hasExpr = true;
|
||||
@@ -149,8 +150,15 @@ class DataSourceWithBackend<
|
||||
throw new Error(`Unknown Datasource: ${JSON.stringify(q.datasource)}`);
|
||||
}
|
||||
|
||||
datasource = ds.rawRef ?? getDataSourceRef(ds);
|
||||
datasourceId = ds.id;
|
||||
const dsRef = ds.rawRef ?? getDataSourceRef(ds);
|
||||
const dsId = ds.id;
|
||||
if (dsRef.uid !== datasource.uid || datasourceId !== dsId) {
|
||||
datasource = dsRef;
|
||||
datasourceId = dsId;
|
||||
// If the query is using a different datasource, we would need to retrieve the datasource
|
||||
// instance (async) and apply the template variables but it seems it's not necessary for now.
|
||||
shouldApplyTemplateVariables = false;
|
||||
}
|
||||
}
|
||||
if (datasource.type?.length) {
|
||||
pluginIDs.add(datasource.type);
|
||||
@@ -159,7 +167,7 @@ class DataSourceWithBackend<
|
||||
dsUIDs.add(datasource.uid);
|
||||
}
|
||||
return {
|
||||
...this.applyTemplateVariables(q, request.scopedVars),
|
||||
...(shouldApplyTemplateVariables ? this.applyTemplateVariables(q, request.scopedVars) : q),
|
||||
datasource,
|
||||
datasourceId, // deprecated!
|
||||
intervalMs,
|
||||
@@ -291,7 +299,7 @@ class DataSourceWithBackend<
|
||||
const result = await lastValueFrom(
|
||||
getBackendSrv().fetch<T>({
|
||||
...options,
|
||||
method: 'GET',
|
||||
method: 'POST',
|
||||
headers: options?.headers ? { ...options.headers, ...headers } : headers,
|
||||
data: data ?? { ...data },
|
||||
url: `/api/datasources/${this.id}/resources/${path}`,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/schema",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"description": "Grafana Schema Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/toolkit",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"description": "Grafana Toolkit",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -51,10 +51,10 @@
|
||||
"@babel/preset-env": "7.18.9",
|
||||
"@babel/preset-react": "7.18.6",
|
||||
"@babel/preset-typescript": "7.18.6",
|
||||
"@grafana/data": "9.4.0-pre",
|
||||
"@grafana/data": "9.3.0-beta.1",
|
||||
"@grafana/eslint-config": "5.0.0",
|
||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||
"@grafana/ui": "9.4.0-pre",
|
||||
"@grafana/ui": "9.3.0-beta.1",
|
||||
"@jest/core": "27.5.1",
|
||||
"@types/command-exists": "^1.2.0",
|
||||
"@types/eslint": "8.4.1",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/ui",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"description": "Grafana Components Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -47,9 +47,9 @@
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.10.5",
|
||||
"@emotion/react": "11.10.5",
|
||||
"@grafana/data": "9.4.0-pre",
|
||||
"@grafana/e2e-selectors": "9.4.0-pre",
|
||||
"@grafana/schema": "9.4.0-pre",
|
||||
"@grafana/data": "9.3.0-beta.1",
|
||||
"@grafana/e2e-selectors": "9.3.0-beta.1",
|
||||
"@grafana/schema": "9.3.0-beta.1",
|
||||
"@leeoniya/ufuzzy": "0.8.0",
|
||||
"@monaco-editor/react": "4.4.6",
|
||||
"@popperjs/core": "2.11.6",
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useCallback, useRef, useState, useEffect } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { Trans } from '../../../src/utils/i18n';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Button, ButtonProps } from '../Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
@@ -62,7 +63,7 @@ export function ClipboardButton({
|
||||
<>
|
||||
{showCopySuccess && (
|
||||
<InlineToast placement="top" referenceElement={buttonRef.current}>
|
||||
Copied
|
||||
<Trans i18nKey="clipboard-button.inline-toast.success">Copied</Trans>
|
||||
</InlineToast>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { FunctionComponent, useState } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { clearButtonStyles } from '../Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
@@ -71,9 +72,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
label: collapse__header;
|
||||
padding: ${theme.spacing(1, 2, 1, 2)};
|
||||
display: flex;
|
||||
cursor: inherit;
|
||||
transition: all 0.1s linear;
|
||||
cursor: pointer;
|
||||
`,
|
||||
headerCollapsed: css`
|
||||
label: collapse__header--collapsed;
|
||||
@@ -132,6 +131,7 @@ export const Collapse = ({
|
||||
className,
|
||||
children,
|
||||
}: React.PropsWithChildren<Props>) => {
|
||||
const buttonStyles = useStyles2(clearButtonStyles);
|
||||
const style = useStyles2(getStyles);
|
||||
const onClickToggle = () => {
|
||||
if (onToggle) {
|
||||
@@ -145,10 +145,10 @@ export const Collapse = ({
|
||||
|
||||
return (
|
||||
<div className={panelClass}>
|
||||
<div className={headerClass} onClick={onClickToggle}>
|
||||
<button type="button" className={cx(buttonStyles, headerClass)} onClick={onClickToggle}>
|
||||
{collapsible && <Icon className={style.icon} name={isOpen ? 'angle-down' : 'angle-right'} />}
|
||||
<div className={cx([style.headerLabel])}>{label}</div>
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={cx([style.collapseBody])}>
|
||||
<div className={loaderClass} />
|
||||
|
||||
@@ -46,6 +46,7 @@ export const Basic: ComponentStory<typeof ConfirmModal> = ({
|
||||
body,
|
||||
description,
|
||||
confirmText,
|
||||
confirmButtonVariant,
|
||||
dismissText,
|
||||
icon,
|
||||
isOpen,
|
||||
@@ -58,6 +59,7 @@ export const Basic: ComponentStory<typeof ConfirmModal> = ({
|
||||
body={body}
|
||||
description={description}
|
||||
confirmText={confirmText}
|
||||
confirmButtonVariant={confirmButtonVariant}
|
||||
dismissText={dismissText}
|
||||
icon={icon}
|
||||
onConfirm={onConfirm}
|
||||
@@ -77,6 +79,7 @@ Basic.args = {
|
||||
body: 'Are you sure you want to delete this user?',
|
||||
description: 'Removing the user will not remove any dashboards the user has created',
|
||||
confirmText: 'Delete',
|
||||
confirmButtonVariant: 'destructive',
|
||||
dismissText: 'Cancel',
|
||||
icon: 'exclamation-triangle',
|
||||
isOpen: true,
|
||||
@@ -112,7 +115,7 @@ export const AlternativeAction: ComponentStory<typeof ConfirmModal> = ({
|
||||
|
||||
AlternativeAction.parameters = {
|
||||
controls: {
|
||||
exclude: [...defaultExcludes, 'confirmationText'],
|
||||
exclude: [...defaultExcludes, 'confirmationText', 'confirmButtonVariant'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -155,7 +158,7 @@ export const WithConfirmation: ComponentStory<typeof ConfirmModal> = ({
|
||||
|
||||
WithConfirmation.parameters = {
|
||||
controls: {
|
||||
exclude: [...defaultExcludes, 'alternativeText'],
|
||||
exclude: [...defaultExcludes, 'alternativeText', 'confirmButtonVariant'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
import { HorizontalGroup, Input } from '..';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { IconName } from '../../types/icon';
|
||||
import { Button } from '../Button';
|
||||
import { Button, ButtonVariant } from '../Button';
|
||||
import { Modal } from '../Modal/Modal';
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
@@ -31,6 +31,8 @@ export interface ConfirmModalProps {
|
||||
confirmationText?: string;
|
||||
/** Text for alternative button */
|
||||
alternativeText?: string;
|
||||
/** Confirm button variant */
|
||||
confirmButtonVariant?: ButtonVariant;
|
||||
/** Confirm action callback */
|
||||
onConfirm(): void;
|
||||
/** Dismiss action callback */
|
||||
@@ -53,6 +55,7 @@ export const ConfirmModal = ({
|
||||
onConfirm,
|
||||
onDismiss,
|
||||
onAlternative,
|
||||
confirmButtonVariant = 'destructive',
|
||||
}: ConfirmModalProps): JSX.Element => {
|
||||
const [disabled, setDisabled] = useState(Boolean(confirmationText));
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -86,7 +89,7 @@ export const ConfirmModal = ({
|
||||
{dismissText}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant={confirmButtonVariant}
|
||||
onClick={onConfirm}
|
||||
disabled={disabled}
|
||||
ref={buttonRef}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TooltipPlacement } from '../Tooltip/types';
|
||||
export interface Props {
|
||||
overlay: React.ReactElement | (() => React.ReactElement);
|
||||
placement?: TooltipPlacement;
|
||||
children: React.ReactElement;
|
||||
children: React.ReactElement | ((isOpen: boolean) => React.ReactElement);
|
||||
}
|
||||
|
||||
export const Dropdown = React.memo(({ children, overlay, placement }: Props) => {
|
||||
@@ -38,7 +38,7 @@ export const Dropdown = React.memo(({ children, overlay, placement }: Props) =>
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
{React.cloneElement(typeof children === 'function' ? children(visible) : children, {
|
||||
ref: setTriggerRef,
|
||||
})}
|
||||
{visible && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { IconName } from '../../types';
|
||||
import { clearButtonStyles } from '../Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
export interface FilterPillProps {
|
||||
@@ -16,28 +17,28 @@ export interface FilterPillProps {
|
||||
|
||||
export const FilterPill: React.FC<FilterPillProps> = ({ label, selected, onClick, icon = 'check' }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const clearButton = useStyles2(clearButtonStyles);
|
||||
return (
|
||||
<div className={cx(styles.wrapper, selected && styles.selected)} onClick={onClick}>
|
||||
<button type="button" className={cx(clearButton, styles.wrapper, selected && styles.selected)} onClick={onClick}>
|
||||
<span>{label}</span>
|
||||
{selected && <Icon name={icon} className={styles.icon} />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrapper: css`
|
||||
padding: ${theme.spacing(0.25)} ${theme.spacing(1)};
|
||||
background: ${theme.colors.background.secondary};
|
||||
border-radius: ${theme.shape.borderRadius(8)};
|
||||
padding: ${theme.spacing(0, 2)};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
line-height: ${theme.typography.bodySmall.lineHeight};
|
||||
color: ${theme.colors.text.secondary};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colors.action.hover};
|
||||
|
||||
@@ -19,6 +19,9 @@ const field: FieldConfig = {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [{ value: -Infinity, color: '#7EB26D' }],
|
||||
},
|
||||
custom: {
|
||||
neeutral: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const props: Props = {
|
||||
|
||||
@@ -98,6 +98,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
gauge: {
|
||||
min,
|
||||
max,
|
||||
neutralValue: field.custom?.neutral,
|
||||
background: { color: backgroundColor },
|
||||
border: { color: null },
|
||||
shadow: { show: false },
|
||||
|
||||
41
packages/grafana-ui/src/components/Table/RowExpander.tsx
Normal file
41
packages/grafana-ui/src/components/Table/RowExpander.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Row } from 'react-table';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
import { getTableStyles } from './styles';
|
||||
|
||||
export interface Props {
|
||||
row: Row;
|
||||
expandedIndexes: Set<number>;
|
||||
setExpandedIndexes: (indexes: Set<number>) => void;
|
||||
}
|
||||
|
||||
export const RowExpander: FC<Props> = ({ row, expandedIndexes, setExpandedIndexes }) => {
|
||||
const tableStyles = useStyles2(getTableStyles);
|
||||
const isExpanded = expandedIndexes.has(row.index);
|
||||
// Use Cell to render an expander for each row.
|
||||
// We can use the getToggleRowExpandedProps prop-getter
|
||||
// to build the expander.
|
||||
return (
|
||||
<div
|
||||
className={tableStyles.expanderCell}
|
||||
onClick={() => {
|
||||
const newExpandedIndexes = new Set(expandedIndexes);
|
||||
if (isExpanded) {
|
||||
newExpandedIndexes.delete(row.index);
|
||||
} else {
|
||||
newExpandedIndexes.add(row.index);
|
||||
}
|
||||
setExpandedIndexes(newExpandedIndexes);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
aria-label={isExpanded ? 'Close trace' : 'Open trace'}
|
||||
name={isExpanded ? 'angle-down' : 'angle-right'}
|
||||
size="xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,14 @@ import { Table } from './Table';
|
||||
|
||||
Used for displaying tabular data
|
||||
|
||||
## Sub-tables
|
||||
|
||||
Sub-tables are supported through the usage of the prop `subData` Dataframe array.
|
||||
The frames are linked to each row using the following custom properties under `dataframe.meta.custom`
|
||||
|
||||
- **parentRowIndex**: number - The index of the parent row in the main dataframe (under the `data` prop of the Table component)
|
||||
- **noHeader**: boolean - Sets the noHeader of each sub-table
|
||||
|
||||
## Usage
|
||||
|
||||
<Props of={Table} />
|
||||
|
||||
@@ -36,7 +36,7 @@ const meta: ComponentMeta<typeof Table> = {
|
||||
args: {
|
||||
width: 700,
|
||||
height: 500,
|
||||
columnMinWidth: 150,
|
||||
columnMinWidth: 130,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -98,6 +98,61 @@ function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): D
|
||||
return prepDataForStorybook([data], theme)[0];
|
||||
}
|
||||
|
||||
function buildSubTablesData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): DataFrame[] {
|
||||
const frames: DataFrame[] = [];
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const data = new MutableDataFrame({
|
||||
meta: {
|
||||
custom: {
|
||||
parentRowIndex: i,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [] }, // The time field
|
||||
{
|
||||
name: 'Quantity',
|
||||
type: FieldType.number,
|
||||
values: [],
|
||||
config: {
|
||||
decimals: 0,
|
||||
custom: {
|
||||
align: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'Quality', type: FieldType.string, values: [] }, // The time field
|
||||
{
|
||||
name: 'Progress',
|
||||
type: FieldType.number,
|
||||
values: [],
|
||||
config: {
|
||||
unit: 'percent',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const field of data.fields) {
|
||||
field.config = merge(field.config, config[field.name]);
|
||||
}
|
||||
|
||||
for (let i = 0; i < Math.random() * 4 + 1; i++) {
|
||||
data.appendRow([
|
||||
new Date().getTime(),
|
||||
Math.random() * 2,
|
||||
Math.random() > 0.7 ? 'Good' : 'Bad',
|
||||
Math.random() * 100,
|
||||
]);
|
||||
}
|
||||
|
||||
frames.push(data);
|
||||
}
|
||||
return prepDataForStorybook(frames, theme);
|
||||
}
|
||||
|
||||
function buildFooterData(data: DataFrame): FooterItem[] {
|
||||
const values = data.fields[3].values.toArray();
|
||||
const valueSum = values.reduce((prev, curr) => {
|
||||
@@ -195,4 +250,23 @@ Pagination.args = {
|
||||
enablePagination: true,
|
||||
};
|
||||
|
||||
export const SubTables: ComponentStory<typeof Table> = (args) => {
|
||||
const theme = useTheme2();
|
||||
const data = buildData(theme, {});
|
||||
const subData = buildSubTablesData(theme, {
|
||||
Progress: {
|
||||
custom: {
|
||||
displayMode: 'gradient-gauge',
|
||||
},
|
||||
thresholds: defaultThresholds,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="panel-container" style={{ width: 'auto', height: 'unset' }}>
|
||||
<Table {...args} data={data} subData={subData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -398,4 +398,54 @@ describe('Table', () => {
|
||||
expect(() => screen.getByTestId('table-footer')).toThrow('Unable to find an element');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when mounted with data and sub-data', () => {
|
||||
it('then correct rows should be rendered and new table is rendered when expander is clicked', () => {
|
||||
getTestContext({
|
||||
subData: new Array(getDefaultDataFrame().length).fill(0).map((i) =>
|
||||
toDataFrame({
|
||||
name: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'number' + i,
|
||||
type: FieldType.number,
|
||||
values: [i, i, i],
|
||||
config: {
|
||||
custom: {
|
||||
filterable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
custom: {
|
||||
parentRowIndex: i,
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
expect(getTable()).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('columnheader')).toHaveLength(4);
|
||||
expect(getColumnHeader(/time/)).toBeInTheDocument();
|
||||
expect(getColumnHeader(/temperature/)).toBeInTheDocument();
|
||||
expect(getColumnHeader(/img/)).toBeInTheDocument();
|
||||
|
||||
const rows = within(getTable()).getAllByRole('row');
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(getRowsData(rows)).toEqual([
|
||||
{ time: '2021-01-01 00:00:00', temperature: '10', link: '10' },
|
||||
{ time: '2021-01-01 03:00:00', temperature: 'NaN', link: 'NaN' },
|
||||
{ time: '2021-01-01 01:00:00', temperature: '11', link: '11' },
|
||||
{ time: '2021-01-01 02:00:00', temperature: '12', link: '12' },
|
||||
]);
|
||||
|
||||
within(rows[1]).getByLabelText('Open trace').click();
|
||||
const rowsAfterClick = within(getTable()).getAllByRole('row');
|
||||
expect(within(rowsAfterClick[1]).getByRole('table')).toBeInTheDocument();
|
||||
expect(within(rowsAfterClick[1]).getByText(/number0/)).toBeInTheDocument();
|
||||
|
||||
expect(within(rowsAfterClick[2]).queryByRole('table')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
|
||||
import { DataFrame, getFieldDisplayName, Field } from '@grafana/data';
|
||||
|
||||
@@ -30,7 +31,14 @@ import {
|
||||
TableFooterCalc,
|
||||
GrafanaTableColumn,
|
||||
} from './types';
|
||||
import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils';
|
||||
import {
|
||||
getColumns,
|
||||
sortCaseInsensitive,
|
||||
sortNumber,
|
||||
getFooterItems,
|
||||
createFooterCalculationValues,
|
||||
EXPANDER_WIDTH,
|
||||
} from './utils';
|
||||
|
||||
const COLUMN_MIN_WIDTH = 150;
|
||||
|
||||
@@ -51,6 +59,8 @@ export interface Props {
|
||||
footerOptions?: TableFooterCalc;
|
||||
footerValues?: FooterItem[];
|
||||
enablePagination?: boolean;
|
||||
/** @alpha */
|
||||
subData?: DataFrame[];
|
||||
}
|
||||
|
||||
function useTableStateReducer({ onColumnResize, onSortByChange, data }: Props) {
|
||||
@@ -121,6 +131,7 @@ export const Table = memo((props: Props) => {
|
||||
const {
|
||||
ariaLabel,
|
||||
data,
|
||||
subData,
|
||||
height,
|
||||
onCellFilterAdded,
|
||||
width,
|
||||
@@ -134,13 +145,15 @@ export const Table = memo((props: Props) => {
|
||||
enablePagination,
|
||||
} = props;
|
||||
|
||||
const listRef = useRef<FixedSizeList>(null);
|
||||
const listRef = useRef<VariableSizeList>(null);
|
||||
const tableDivRef = useRef<HTMLDivElement>(null);
|
||||
const fixedSizeListScrollbarRef = useRef<HTMLDivElement>(null);
|
||||
const variableSizeListScrollbarRef = useRef<HTMLDivElement>(null);
|
||||
const tableStyles = useStyles2(getTableStyles);
|
||||
const theme = useTheme2();
|
||||
const headerHeight = noHeader ? 0 : tableStyles.cellHeight;
|
||||
const [footerItems, setFooterItems] = useState<FooterItem[] | undefined>(footerValues);
|
||||
const [expandedIndexes, setExpandedIndexes] = useState<Set<number>>(new Set());
|
||||
const prevExpandedIndexes = usePrevious(expandedIndexes);
|
||||
|
||||
const footerHeight = useMemo(() => {
|
||||
const EXTENDED_ROW_HEIGHT = 33;
|
||||
@@ -177,8 +190,8 @@ export const Table = memo((props: Props) => {
|
||||
|
||||
// React-table column definitions
|
||||
const memoizedColumns = useMemo(
|
||||
() => getColumns(data, width, columnMinWidth, footerItems),
|
||||
[data, width, columnMinWidth, footerItems]
|
||||
() => getColumns(data, width, columnMinWidth, expandedIndexes, setExpandedIndexes, !!subData?.length, footerItems),
|
||||
[data, width, columnMinWidth, footerItems, subData, expandedIndexes]
|
||||
);
|
||||
|
||||
// Internal react table state reducer
|
||||
@@ -260,14 +273,23 @@ export const Table = memo((props: Props) => {
|
||||
setPageSize(pageSize);
|
||||
}, [pageSize, setPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
// react-table caches the height of cells so we need to reset them when expanding/collapsing rows
|
||||
// We need to take the minimum of the current expanded indexes and the previous expandedIndexes array to account
|
||||
// for collapsed rows, since they disappear from expandedIndexes but still keep their expanded height
|
||||
listRef.current?.resetAfterIndex(
|
||||
Math.min(...Array.from(expandedIndexes), ...(prevExpandedIndexes ? Array.from(prevExpandedIndexes) : []))
|
||||
);
|
||||
}, [expandedIndexes, prevExpandedIndexes]);
|
||||
|
||||
useEffect(() => {
|
||||
// To have the custom vertical scrollbar always visible (https://github.com/grafana/grafana/issues/52136),
|
||||
// we need to bring the element from the FixedSizeList scope to the outer Table container scope,
|
||||
// because the FixedSizeList scope has overflow. By moving scrollbar to container scope we will have
|
||||
// we need to bring the element from the VariableSizeList scope to the outer Table container scope,
|
||||
// because the VariableSizeList scope has overflow. By moving scrollbar to container scope we will have
|
||||
// it always visible since the entire width is in view.
|
||||
|
||||
// Select the scrollbar element from the FixedSizeList scope
|
||||
const listVerticalScrollbarHTML = (fixedSizeListScrollbarRef.current as HTMLDivElement)?.querySelector(
|
||||
// Select the scrollbar element from the VariableSizeList scope
|
||||
const listVerticalScrollbarHTML = (variableSizeListScrollbarRef.current as HTMLDivElement)?.querySelector(
|
||||
'.track-vertical'
|
||||
);
|
||||
|
||||
@@ -283,6 +305,36 @@ export const Table = memo((props: Props) => {
|
||||
}
|
||||
});
|
||||
|
||||
const renderSubTable = React.useCallback(
|
||||
(rowIndex: number) => {
|
||||
if (expandedIndexes.has(rowIndex)) {
|
||||
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === rowIndex);
|
||||
if (rowSubData) {
|
||||
const noHeader = !!rowSubData.meta?.custom?.noHeader;
|
||||
const subTableStyle: CSSProperties = {
|
||||
height: tableStyles.rowHeight * (rowSubData.length + (noHeader ? 0 : 1)), // account for the header with + 1
|
||||
background: theme.colors.emphasize(theme.colors.background.primary, 0.015),
|
||||
paddingLeft: EXPANDER_WIDTH,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
};
|
||||
return (
|
||||
<div style={subTableStyle}>
|
||||
<Table
|
||||
data={rowSubData}
|
||||
width={width - EXPANDER_WIDTH}
|
||||
height={tableStyles.rowHeight * (rowSubData.length + 1)}
|
||||
noHeader={noHeader}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[expandedIndexes, subData, tableStyles.rowHeight, theme.colors, width]
|
||||
);
|
||||
|
||||
const RenderRow = React.useCallback(
|
||||
({ index: rowIndex, style }: { index: number; style: CSSProperties }) => {
|
||||
let row = rows[rowIndex];
|
||||
@@ -290,8 +342,11 @@ export const Table = memo((props: Props) => {
|
||||
row = page[rowIndex];
|
||||
}
|
||||
prepareRow(row);
|
||||
|
||||
return (
|
||||
<div {...row.getRowProps({ style })} className={tableStyles.row}>
|
||||
{/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
|
||||
{renderSubTable(rowIndex)}
|
||||
{row.cells.map((cell: Cell, index: number) => (
|
||||
<TableCell
|
||||
key={index}
|
||||
@@ -305,7 +360,7 @@ export const Table = memo((props: Props) => {
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles]
|
||||
[onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles, renderSubTable]
|
||||
);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
@@ -344,6 +399,17 @@ export const Table = memo((props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const getItemSize = (index: number): number => {
|
||||
if (expandedIndexes.has(index)) {
|
||||
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === index);
|
||||
if (rowSubData) {
|
||||
const noHeader = !!rowSubData.meta?.custom?.noHeader;
|
||||
return tableStyles.rowHeight * (rowSubData.length + 1 + (noHeader ? 0 : 1)); // account for the header and the row data with + 1 + 1
|
||||
}
|
||||
}
|
||||
return tableStyles.rowHeight;
|
||||
};
|
||||
|
||||
const handleScroll: React.UIEventHandler = (event) => {
|
||||
const { scrollTop } = event.target as HTMLDivElement;
|
||||
|
||||
@@ -358,18 +424,18 @@ export const Table = memo((props: Props) => {
|
||||
<div className={tableStyles.tableContentWrapper(totalColumnsWidth)}>
|
||||
{!noHeader && <HeaderRow headerGroups={headerGroups} showTypeIcons={showTypeIcons} />}
|
||||
{itemCount > 0 ? (
|
||||
<div ref={fixedSizeListScrollbarRef}>
|
||||
<div ref={variableSizeListScrollbarRef}>
|
||||
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
|
||||
<FixedSizeList
|
||||
<VariableSizeList
|
||||
height={listHeight}
|
||||
itemCount={itemCount}
|
||||
itemSize={tableStyles.rowHeight}
|
||||
itemSize={getItemSize}
|
||||
width={'100%'}
|
||||
ref={listRef}
|
||||
style={{ overflow: undefined }}
|
||||
>
|
||||
{RenderRow}
|
||||
</FixedSizeList>
|
||||
</VariableSizeList>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -11,6 +11,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
|
||||
const lineHeight = theme.typography.body.lineHeight;
|
||||
const bodyFontSize = 14;
|
||||
const cellHeight = cellPadding * 2 + bodyFontSize * lineHeight;
|
||||
const rowHeight = cellHeight + 2;
|
||||
const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03);
|
||||
|
||||
const buildCellContainerStyle = (color?: string, background?: string, overflowOnHover?: boolean) => {
|
||||
@@ -36,7 +37,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
|
||||
label: ${overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow'};
|
||||
padding: ${cellPadding}px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: ${rowHeight}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-right: 1px solid ${borderColor};
|
||||
@@ -95,7 +96,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
|
||||
buildCellContainerStyle,
|
||||
cellPadding,
|
||||
cellHeightInner: bodyFontSize * lineHeight,
|
||||
rowHeight: cellHeight + 2,
|
||||
rowHeight,
|
||||
table: css`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -253,6 +254,13 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`,
|
||||
expanderCell: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: ${rowHeight}px;
|
||||
cursor: pointer;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Row } from 'react-table';
|
||||
|
||||
import { ArrayVector, Field, FieldType, MutableDataFrame, SelectableValue } from '@grafana/data';
|
||||
|
||||
import {
|
||||
@@ -44,21 +46,29 @@ function getData() {
|
||||
describe('Table utils', () => {
|
||||
describe('getColumns', () => {
|
||||
it('Should build columns from DataFrame', () => {
|
||||
const columns = getColumns(getData(), 1000, 120);
|
||||
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false);
|
||||
|
||||
expect(columns[0].Header).toBe('Time');
|
||||
expect(columns[1].Header).toBe('Value');
|
||||
});
|
||||
|
||||
it('Should distribute width and use field config width', () => {
|
||||
const columns = getColumns(getData(), 1000, 120);
|
||||
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false);
|
||||
|
||||
expect(columns[0].width).toBe(450);
|
||||
expect(columns[1].width).toBe(100);
|
||||
});
|
||||
|
||||
it('Should distribute width and use field config width with expander enabled', () => {
|
||||
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, true);
|
||||
|
||||
expect(columns[0].width).toBe(50); // expander column
|
||||
expect(columns[1].width).toBe(425);
|
||||
expect(columns[2].width).toBe(100);
|
||||
});
|
||||
|
||||
it('Should set field on columns', () => {
|
||||
const columns = getColumns(getData(), 1000, 120);
|
||||
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false);
|
||||
|
||||
expect(columns[0].field.name).toBe('Time');
|
||||
expect(columns[1].field.name).toBe('Value');
|
||||
@@ -82,8 +92,8 @@ describe('Table utils', () => {
|
||||
|
||||
describe('filterByValue', () => {
|
||||
describe('happy path', () => {
|
||||
const field: any = { values: new ArrayVector(['a', 'aa', 'ab', 'b', 'ba', 'bb', 'c']) };
|
||||
const rows: any = [
|
||||
const field = { values: new ArrayVector(['a', 'aa', 'ab', 'b', 'ba', 'bb', 'c']) } as unknown as Field;
|
||||
const rows = [
|
||||
{ index: 0, values: { 0: 'a' } },
|
||||
{ index: 1, values: { 0: 'aa' } },
|
||||
{ index: 2, values: { 0: 'ab' } },
|
||||
@@ -91,7 +101,7 @@ describe('Table utils', () => {
|
||||
{ index: 4, values: { 0: 'ba' } },
|
||||
{ index: 5, values: { 0: 'bb' } },
|
||||
{ index: 6, values: { 0: 'c' } },
|
||||
];
|
||||
] as unknown as Row[];
|
||||
const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }];
|
||||
|
||||
const result = filterByValue(field)(rows, '0', filterValues);
|
||||
@@ -106,8 +116,8 @@ describe('Table utils', () => {
|
||||
describe('fast exit cases', () => {
|
||||
describe('no rows', () => {
|
||||
it('should return empty array', () => {
|
||||
const field: any = { values: new ArrayVector(['a']) };
|
||||
const rows: any = [];
|
||||
const field = { values: new ArrayVector(['a']) } as unknown as Field;
|
||||
const rows: Row[] = [];
|
||||
const filterValues = [{ value: 'a' }];
|
||||
|
||||
const result = filterByValue(field)(rows, '', filterValues);
|
||||
@@ -118,8 +128,8 @@ describe('Table utils', () => {
|
||||
|
||||
describe('no filterValues', () => {
|
||||
it('should return rows', () => {
|
||||
const field: any = { values: new ArrayVector(['a']) };
|
||||
const rows: any = [{}];
|
||||
const field = { values: new ArrayVector(['a']) } as unknown as Field;
|
||||
const rows = [{}] as Row[];
|
||||
const filterValues = undefined;
|
||||
|
||||
const result = filterByValue(field)(rows, '', filterValues);
|
||||
@@ -131,7 +141,7 @@ describe('Table utils', () => {
|
||||
describe('no field', () => {
|
||||
it('should return rows', () => {
|
||||
const field = undefined;
|
||||
const rows: any = [{}];
|
||||
const rows = [{}] as Row[];
|
||||
const filterValues = [{ value: 'a' }];
|
||||
|
||||
const result = filterByValue(field)(rows, '', filterValues);
|
||||
@@ -142,12 +152,12 @@ describe('Table utils', () => {
|
||||
|
||||
describe('missing id in values', () => {
|
||||
it('should return rows', () => {
|
||||
const field: any = { values: new ArrayVector(['a', 'b', 'c']) };
|
||||
const rows: any = [
|
||||
const field = { values: new ArrayVector(['a', 'b', 'c']) } as unknown as Field;
|
||||
const rows = [
|
||||
{ index: 0, values: { 0: 'a' } },
|
||||
{ index: 1, values: { 0: 'b' } },
|
||||
{ index: 2, values: { 0: 'c' } },
|
||||
];
|
||||
] as unknown as Row[];
|
||||
const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }];
|
||||
|
||||
const result = filterByValue(field)(rows, '1', filterValues);
|
||||
@@ -188,7 +198,7 @@ describe('Table utils', () => {
|
||||
text: '1.0',
|
||||
}),
|
||||
};
|
||||
const rows: any[] = [];
|
||||
const rows = [] as Row[];
|
||||
|
||||
const result = calculateUniqueFieldValues(rows, field);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Property } from 'csstype';
|
||||
import { clone } from 'lodash';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import React from 'react';
|
||||
import { Row } from 'react-table';
|
||||
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import { getFooterValue } from './FooterRow';
|
||||
import { GeoCell } from './GeoCell';
|
||||
import { ImageCell } from './ImageCell';
|
||||
import { JSONViewCell } from './JSONViewCell';
|
||||
import { RowExpander } from './RowExpander';
|
||||
import {
|
||||
CellComponent,
|
||||
TableCellDisplayMode,
|
||||
@@ -32,6 +34,8 @@ import {
|
||||
TableFooterCalc,
|
||||
} from './types';
|
||||
|
||||
export const EXPANDER_WIDTH = 50;
|
||||
|
||||
export function getTextAlign(field?: Field): Property.JustifyContent {
|
||||
if (!field) {
|
||||
return 'flex-start';
|
||||
@@ -61,11 +65,37 @@ export function getColumns(
|
||||
data: DataFrame,
|
||||
availableWidth: number,
|
||||
columnMinWidth: number,
|
||||
expandedIndexes: Set<number>,
|
||||
setExpandedIndexes: (indexes: Set<number>) => void,
|
||||
expander: boolean,
|
||||
footerValues?: FooterItem[]
|
||||
): GrafanaTableColumn[] {
|
||||
const columns: GrafanaTableColumn[] = [];
|
||||
const columns: GrafanaTableColumn[] = expander
|
||||
? [
|
||||
{
|
||||
// Make an expander cell
|
||||
Header: () => null, // No header
|
||||
id: 'expander', // It needs an ID
|
||||
Cell: ({ row }) => {
|
||||
return <RowExpander row={row} expandedIndexes={expandedIndexes} setExpandedIndexes={setExpandedIndexes} />;
|
||||
},
|
||||
width: EXPANDER_WIDTH,
|
||||
minWidth: EXPANDER_WIDTH,
|
||||
filter: (rows: Row[], id: string, filterValues?: SelectableValue[]) => {
|
||||
return [];
|
||||
},
|
||||
justifyContent: 'left',
|
||||
field: data.fields[0],
|
||||
sortType: 'basic',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
let fieldCountWithoutWidth = 0;
|
||||
|
||||
if (expander) {
|
||||
availableWidth -= EXPANDER_WIDTH;
|
||||
}
|
||||
|
||||
for (const [fieldIndex, field] of data.fields.entries()) {
|
||||
const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions;
|
||||
|
||||
@@ -65,8 +65,8 @@ export class UPlotChart extends Component<PlotProps, UPlotChartState> {
|
||||
});
|
||||
|
||||
const config: Options = {
|
||||
width: this.props.width,
|
||||
height: this.props.height,
|
||||
width: Math.floor(this.props.width),
|
||||
height: Math.floor(this.props.height),
|
||||
...this.props.config.getConfig(),
|
||||
};
|
||||
|
||||
@@ -93,8 +93,8 @@ export class UPlotChart extends Component<PlotProps, UPlotChartState> {
|
||||
|
||||
if (!sameDims(prevProps, this.props)) {
|
||||
plot?.setSize({
|
||||
width: this.props.width,
|
||||
height: this.props.height,
|
||||
width: Math.floor(this.props.width),
|
||||
height: Math.floor(this.props.height),
|
||||
});
|
||||
} else if (!sameConfig(prevProps, this.props)) {
|
||||
this.reinitPlot();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jaegertracing/jaeger-ui-components",
|
||||
"version": "9.4.0-pre",
|
||||
"version": "9.3.0-beta.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -31,10 +31,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.10.5",
|
||||
"@grafana/data": "9.4.0-pre",
|
||||
"@grafana/e2e-selectors": "9.4.0-pre",
|
||||
"@grafana/runtime": "9.4.0-pre",
|
||||
"@grafana/ui": "9.4.0-pre",
|
||||
"@grafana/data": "9.3.0-beta.1",
|
||||
"@grafana/e2e-selectors": "9.3.0-beta.1",
|
||||
"@grafana/runtime": "9.3.0-beta.1",
|
||||
"@grafana/ui": "9.3.0-beta.1",
|
||||
"chance": "^1.0.10",
|
||||
"classnames": "^2.2.5",
|
||||
"combokeys": "^3.0.0",
|
||||
|
||||
@@ -215,10 +215,20 @@ export default class ListView extends React.Component<TListViewProps> {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
componentDidUpdate(prevProps: TListViewProps) {
|
||||
if (this._itemHolderElm) {
|
||||
this._scanItemHeights();
|
||||
}
|
||||
// When windowScroller is set to false, we can continue to handle scrollElement
|
||||
if (this.props.windowScroller) {
|
||||
return;
|
||||
}
|
||||
// check if the scrollElement changes and update its scroll listener
|
||||
if (prevProps.scrollElement !== this.props.scrollElement) {
|
||||
prevProps.scrollElement?.removeEventListener('scroll', this._onScroll);
|
||||
this._wrapperElm = this.props.scrollElement;
|
||||
this._wrapperElm?.addEventListener('scroll', this._onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
@@ -214,7 +215,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa
|
||||
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, &usertest.FakeUserService{}, sqlStore)
|
||||
loginService := &logintest.LoginServiceFake{}
|
||||
authenticator := &logintest.AuthenticatorFake{}
|
||||
ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures())
|
||||
ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures(), nil)
|
||||
|
||||
return ctxHdlr
|
||||
}
|
||||
@@ -250,15 +251,16 @@ func (s *fakeRenderService) Init() error {
|
||||
func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []accesscontrol.Permission) (*scenarioContext, *HTTPServer) {
|
||||
store := sqlstore.InitTestDB(t)
|
||||
hs := &HTTPServer{
|
||||
Cfg: cfg,
|
||||
Live: newTestLive(t, store),
|
||||
License: &licensing.OSSLicensingService{},
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
QuotaService: quotatest.New(false, nil),
|
||||
RouteRegister: routing.NewRouteRegister(),
|
||||
AccessControl: accesscontrolmock.New().WithPermissions(permissions),
|
||||
searchUsersService: searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), usertest.NewUserServiceFake()),
|
||||
ldapGroups: ldap.ProvideGroupsService(),
|
||||
Cfg: cfg,
|
||||
Live: newTestLive(t, store),
|
||||
License: &licensing.OSSLicensingService{},
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
QuotaService: quotatest.New(false, nil),
|
||||
RouteRegister: routing.NewRouteRegister(),
|
||||
AccessControl: accesscontrolmock.New().WithPermissions(permissions),
|
||||
searchUsersService: searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), usertest.NewUserServiceFake()),
|
||||
ldapGroups: ldap.ProvideGroupsService(),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
|
||||
@@ -470,7 +470,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
|
||||
}
|
||||
|
||||
if liveerr != nil {
|
||||
hs.log.Warn("unable to broadcast save event", "uid", dashboard.Uid, "error", err)
|
||||
hs.log.Warn("unable to broadcast save event", "uid", dashboard.Uid, "error", liveerr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +478,12 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
|
||||
return apierrors.ToDashboardErrorResponse(ctx, hs.pluginStore, err)
|
||||
}
|
||||
|
||||
// Clear permission cache for the user who's created the dashboard, so that new permissions are fetched for their next call
|
||||
// Required for cases when caller wants to immediately interact with the newly created object
|
||||
if newDashboard && !hs.accesscontrolService.IsDisabled() {
|
||||
hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
|
||||
}
|
||||
|
||||
// connect library panels for this dashboard after the dashboard is stored and has an ID
|
||||
err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(ctx, c.SignedInUser, dashboard)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry/corekind"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
@@ -1093,6 +1094,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
|
||||
folderService: folderService,
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
Kinds: corekind.NewBase(nil),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
@@ -1201,6 +1203,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
dashboardVersionService: fakeDashboardVersionService,
|
||||
Kinds: corekind.NewBase(nil),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
|
||||
@@ -396,6 +396,12 @@ func (hs *HTTPServer) AddDataSource(c *models.ReqContext) response.Response {
|
||||
return response.Error(500, "Failed to add datasource", err)
|
||||
}
|
||||
|
||||
// Clear permission cache for the user who's created the data source, so that new permissions are fetched for their next call
|
||||
// Required for cases when caller wants to immediately interact with the newly created object
|
||||
if !hs.AccessControl.IsDisabled() {
|
||||
hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
|
||||
}
|
||||
|
||||
ds := hs.convertModelToDtos(c.Req.Context(), cmd.Result)
|
||||
return response.JSON(http.StatusOK, util.DynMap{
|
||||
"message": "Datasource added",
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/datasources/permissions"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -112,7 +114,9 @@ func TestAddDataSource_URLWithoutProtocol(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{
|
||||
expectedDatasource: &datasources.DataSource{},
|
||||
},
|
||||
Cfg: setting.NewCfg(),
|
||||
Cfg: setting.NewCfg(),
|
||||
AccessControl: acimpl.ProvideAccessControl(setting.NewCfg()),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources")
|
||||
@@ -224,7 +228,9 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{
|
||||
expectedDatasource: &datasources.DataSource{},
|
||||
},
|
||||
Cfg: setting.NewCfg(),
|
||||
Cfg: setting.NewCfg(),
|
||||
AccessControl: acimpl.ProvideAccessControl(setting.NewCfg()),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources/1234")
|
||||
|
||||
@@ -128,6 +128,12 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response {
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
}
|
||||
|
||||
// Clear permission cache for the user who's created the folder, so that new permissions are fetched for their next call
|
||||
// Required for cases when caller wants to immediately interact with the newly created object
|
||||
if !hs.AccessControl.IsDisabled() {
|
||||
hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
|
||||
}
|
||||
|
||||
g := guardian.New(c.Req.Context(), folder.ID, c.OrgID, c.SignedInUser)
|
||||
// TODO set ParentUID if nested folders are enabled
|
||||
return response.JSON(http.StatusOK, hs.newToFolderDto(c, g, folder))
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -242,10 +243,11 @@ func createFolderScenario(t *testing.T, desc string, url string, routePattern st
|
||||
store := mockstore.NewSQLStoreMock()
|
||||
guardian.InitLegacyGuardian(store, dashSvc, teamSvc)
|
||||
hs := HTTPServer{
|
||||
AccessControl: acmock.New(),
|
||||
folderService: folderService,
|
||||
Cfg: setting.NewCfg(),
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
AccessControl: acmock.New(),
|
||||
folderService: folderService,
|
||||
Cfg: setting.NewCfg(),
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(t, url)
|
||||
|
||||
@@ -58,7 +58,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.
|
||||
grafanaUpdateChecker: &updatechecker.GrafanaService{},
|
||||
AccessControl: accesscontrolmock.New().WithDisabled(),
|
||||
PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService),
|
||||
SocialService: social.ProvideService(cfg),
|
||||
SocialService: social.ProvideService(cfg, features),
|
||||
}
|
||||
|
||||
m := web.New()
|
||||
|
||||
@@ -77,6 +77,11 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat
|
||||
settings["isPublicDashboardView"] = true
|
||||
}
|
||||
|
||||
weekStart := ""
|
||||
if prefs.WeekStart != nil {
|
||||
weekStart = *prefs.WeekStart
|
||||
}
|
||||
|
||||
data := dtos.IndexViewData{
|
||||
User: &dtos.CurrentUser{
|
||||
Id: c.UserID,
|
||||
@@ -93,7 +98,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat
|
||||
IsGrafanaAdmin: c.IsGrafanaAdmin,
|
||||
LightTheme: prefs.Theme == lightName,
|
||||
Timezone: prefs.Timezone,
|
||||
WeekStart: prefs.WeekStart,
|
||||
WeekStart: weekStart,
|
||||
Locale: locale,
|
||||
HelpFlags1: c.HelpFlags1,
|
||||
HasEditPermissionInFolders: hasEditPerm,
|
||||
|
||||
@@ -97,9 +97,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) {
|
||||
|
||||
code := ctx.Query("code")
|
||||
if code == "" {
|
||||
// FIXME: access_type is a Google OAuth2 specific thing, consider refactoring this and moving to google_oauth.go
|
||||
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
if provider.UsePKCE {
|
||||
ascii, pkce, err := genPKCECode()
|
||||
if err != nil {
|
||||
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ func setupOAuthTest(t *testing.T, cfg *setting.Cfg) *web.Mux {
|
||||
Cfg: cfg,
|
||||
License: &licensing.OSSLicensingService{Cfg: cfg},
|
||||
SQLStore: sqlStore,
|
||||
SocialService: social.ProvideService(cfg),
|
||||
SocialService: social.ProvideService(cfg, featuremgmt.WithFeatures()),
|
||||
HooksService: hooks.ProvideService(),
|
||||
SecretsService: fakes.NewFakeSecretsService(),
|
||||
}
|
||||
|
||||
@@ -82,12 +82,17 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team
|
||||
}
|
||||
}
|
||||
|
||||
weekStart := ""
|
||||
if preference.WeekStart != nil {
|
||||
weekStart = *preference.WeekStart
|
||||
}
|
||||
|
||||
dto := dtos.Prefs{
|
||||
Theme: preference.Theme,
|
||||
HomeDashboardID: preference.HomeDashboardID,
|
||||
HomeDashboardUID: dashboardUID,
|
||||
Timezone: preference.Timezone,
|
||||
WeekStart: preference.WeekStart,
|
||||
WeekStart: weekStart,
|
||||
}
|
||||
|
||||
if preference.JSONData != nil {
|
||||
|
||||
@@ -41,6 +41,12 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response {
|
||||
return response.Error(500, "Failed to create Team", err)
|
||||
}
|
||||
|
||||
// Clear permission cache for the user who's created the team, so that new permissions are fetched for their next call
|
||||
// Required for cases when caller wants to immediately interact with the newly created object
|
||||
if !hs.AccessControl.IsDisabled() {
|
||||
hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
|
||||
}
|
||||
|
||||
if accessControlEnabled || (c.OrgRole == org.RoleEditor && hs.Cfg.EditorsCanAdmin) {
|
||||
// if the request is authenticated using API tokens
|
||||
// the SignedInUser is an empty struct therefore
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log/logtest"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
pref "github.com/grafana/grafana/pkg/services/preference"
|
||||
"github.com/grafana/grafana/pkg/services/preference/preftest"
|
||||
@@ -213,6 +215,8 @@ func TestTeamAPIEndpoint_CreateTeam_RBAC(t *testing.T) {
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.Cfg = setting.NewCfg()
|
||||
hs.teamService = teamtest.NewFakeService()
|
||||
hs.AccessControl = acimpl.ProvideAccessControl(setting.NewCfg())
|
||||
hs.accesscontrolService = actest.FakeService{}
|
||||
})
|
||||
|
||||
input := strings.NewReader(fmt.Sprintf(teamCmd, 1))
|
||||
|
||||
@@ -144,11 +144,12 @@ func (s *Service) buildGraph(req *Request) (*simple.DirectedGraph, error) {
|
||||
}
|
||||
|
||||
rn := &rawNode{
|
||||
Query: rawQueryProp,
|
||||
RefID: query.RefID,
|
||||
TimeRange: query.TimeRange,
|
||||
QueryType: query.QueryType,
|
||||
DataSource: query.DataSource,
|
||||
Query: rawQueryProp,
|
||||
RefID: query.RefID,
|
||||
TimeRange: query.TimeRange,
|
||||
QueryType: query.QueryType,
|
||||
DataSource: query.DataSource,
|
||||
QueryEnricher: query.QueryEnricher,
|
||||
}
|
||||
|
||||
var node Node
|
||||
|
||||
@@ -42,11 +42,12 @@ type baseNode struct {
|
||||
}
|
||||
|
||||
type rawNode struct {
|
||||
RefID string `json:"refId"`
|
||||
Query map[string]interface{}
|
||||
QueryType string
|
||||
TimeRange TimeRange
|
||||
DataSource *datasources.DataSource
|
||||
RefID string `json:"refId"`
|
||||
Query map[string]interface{}
|
||||
QueryType string
|
||||
TimeRange TimeRange
|
||||
DataSource *datasources.DataSource
|
||||
QueryEnricher QueryDataRequestEnricher
|
||||
}
|
||||
|
||||
func (rn *rawNode) GetCommandType() (c CommandType, err error) {
|
||||
@@ -139,8 +140,9 @@ const (
|
||||
// DSNode is a DPNode that holds a datasource request.
|
||||
type DSNode struct {
|
||||
baseNode
|
||||
query json.RawMessage
|
||||
datasource *datasources.DataSource
|
||||
query json.RawMessage
|
||||
datasource *datasources.DataSource
|
||||
queryEnricher QueryDataRequestEnricher
|
||||
|
||||
orgID int64
|
||||
queryType string
|
||||
@@ -169,14 +171,15 @@ func (s *Service) buildDSNode(dp *simple.DirectedGraph, rn *rawNode, req *Reques
|
||||
id: dp.NewNode().ID(),
|
||||
refID: rn.RefID,
|
||||
},
|
||||
orgID: req.OrgId,
|
||||
query: json.RawMessage(encodedQuery),
|
||||
queryType: rn.QueryType,
|
||||
intervalMS: defaultIntervalMS,
|
||||
maxDP: defaultMaxDP,
|
||||
timeRange: rn.TimeRange,
|
||||
request: *req,
|
||||
datasource: rn.DataSource,
|
||||
orgID: req.OrgId,
|
||||
query: json.RawMessage(encodedQuery),
|
||||
queryType: rn.QueryType,
|
||||
intervalMS: defaultIntervalMS,
|
||||
maxDP: defaultMaxDP,
|
||||
timeRange: rn.TimeRange,
|
||||
request: *req,
|
||||
datasource: rn.DataSource,
|
||||
queryEnricher: rn.QueryEnricher,
|
||||
}
|
||||
|
||||
var floatIntervalMS float64
|
||||
@@ -211,24 +214,29 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s
|
||||
OrgID: dn.orgID,
|
||||
DataSourceInstanceSettings: dsInstanceSettings,
|
||||
PluginID: dn.datasource.Type,
|
||||
User: dn.request.User,
|
||||
}
|
||||
|
||||
q := []backend.DataQuery{
|
||||
{
|
||||
RefID: dn.refID,
|
||||
MaxDataPoints: dn.maxDP,
|
||||
Interval: time.Duration(int64(time.Millisecond) * dn.intervalMS),
|
||||
JSON: dn.query,
|
||||
TimeRange: dn.timeRange.AbsoluteTime(now),
|
||||
QueryType: dn.queryType,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := s.dataService.QueryData(ctx, &backend.QueryDataRequest{
|
||||
req := &backend.QueryDataRequest{
|
||||
PluginContext: pc,
|
||||
Queries: q,
|
||||
Headers: dn.request.Headers,
|
||||
})
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: dn.refID,
|
||||
MaxDataPoints: dn.maxDP,
|
||||
Interval: time.Duration(int64(time.Millisecond) * dn.intervalMS),
|
||||
JSON: dn.query,
|
||||
TimeRange: dn.timeRange.AbsoluteTime(now),
|
||||
QueryType: dn.queryType,
|
||||
},
|
||||
},
|
||||
Headers: dn.request.Headers,
|
||||
}
|
||||
|
||||
if dn.queryEnricher != nil {
|
||||
ctx = dn.queryEnricher(ctx, req)
|
||||
}
|
||||
|
||||
resp, err := s.dataService.QueryData(ctx, req)
|
||||
if err != nil {
|
||||
return mathexp.Results{}, err
|
||||
}
|
||||
@@ -389,7 +397,7 @@ func extractNumberSet(frame *data.Frame) ([]mathexp.Number, error) {
|
||||
labels[key] = val.(string) // TODO check assertion / return error
|
||||
}
|
||||
|
||||
n := mathexp.NewNumber("", labels)
|
||||
n := mathexp.NewNumber(frame.Fields[numericField].Name, labels)
|
||||
|
||||
// The new value fields' configs gets pointed to the one in the original frame
|
||||
n.Frame.Fields[0].Config = frame.Fields[numericField].Config
|
||||
|
||||
@@ -35,14 +35,19 @@ type Request struct {
|
||||
Debug bool
|
||||
OrgId int64
|
||||
Queries []Query
|
||||
User *backend.User
|
||||
}
|
||||
|
||||
// QueryDataRequestEnricher function definition for enriching a backend.QueryDataRequest request.
|
||||
type QueryDataRequestEnricher func(ctx context.Context, req *backend.QueryDataRequest) context.Context
|
||||
|
||||
// Query is like plugins.DataSubQuery, but with a a time range, and only the UID
|
||||
// for the data source. Also interval is a time.Duration.
|
||||
type Query struct {
|
||||
RefID string
|
||||
TimeRange TimeRange
|
||||
DataSource *datasources.DataSource `json:"datasource"`
|
||||
QueryEnricher QueryDataRequestEnricher
|
||||
JSON json.RawMessage
|
||||
Interval time.Duration
|
||||
QueryType string
|
||||
|
||||
9
pkg/infra/serverlock/errors.go
Normal file
9
pkg/infra/serverlock/errors.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package serverlock
|
||||
|
||||
type ServerLockExistsError struct {
|
||||
actionName string
|
||||
}
|
||||
|
||||
func (e *ServerLockExistsError) Error() string {
|
||||
return "there is already a lock for this actionName: " + e.actionName
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package serverlock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -185,7 +184,7 @@ func (sl *ServerLockService) acquireForRelease(ctx context.Context, actionName s
|
||||
if len(lockRows) > 0 {
|
||||
result := lockRows[0]
|
||||
if sl.isLockWithinInterval(result, maxInterval) {
|
||||
return errors.New("there is already a lock for this actionName: " + actionName)
|
||||
return &ServerLockExistsError{actionName: actionName}
|
||||
} else {
|
||||
// lock has timeouted, so we update the timestamp
|
||||
result.LastExecution = time.Now().Unix()
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
"gopkg.in/square/go-jose.v2/jwt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
func trueBoolPtr() *bool {
|
||||
@@ -54,7 +56,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
ID: "1234",
|
||||
},
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
want: &BasicUserInfo{
|
||||
Id: "1234",
|
||||
@@ -93,7 +95,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
ID: "1234",
|
||||
},
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
want: &BasicUserInfo{
|
||||
Id: "1234",
|
||||
@@ -143,7 +145,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
{
|
||||
name: "Only other roles",
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
claims: &azureClaims{
|
||||
Email: "me@example.com",
|
||||
@@ -171,7 +173,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
ID: "1234",
|
||||
},
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
want: &BasicUserInfo{
|
||||
Id: "1234",
|
||||
@@ -220,7 +222,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Grafana Admin but setting is disabled",
|
||||
fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false)},
|
||||
fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false, *featuremgmt.WithFeatures())},
|
||||
claims: &azureClaims{
|
||||
Email: "me@example.com",
|
||||
PreferredUsername: "",
|
||||
@@ -242,7 +244,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
name: "Editor roles in claim and GrafanaAdminAssignment enabled",
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread",
|
||||
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false)},
|
||||
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures())},
|
||||
claims: &azureClaims{
|
||||
Email: "me@example.com",
|
||||
PreferredUsername: "",
|
||||
@@ -263,7 +265,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
{
|
||||
name: "Grafana Admin and Editor roles in claim",
|
||||
fields: fields{SocialBase: newSocialBase("azuread",
|
||||
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false)},
|
||||
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures())},
|
||||
claims: &azureClaims{
|
||||
Email: "me@example.com",
|
||||
PreferredUsername: "",
|
||||
@@ -302,7 +304,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
fields: fields{
|
||||
allowedGroups: []string{"foo", "bar"},
|
||||
SocialBase: newSocialBase("azuread",
|
||||
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer", false),
|
||||
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
claims: &azureClaims{
|
||||
Email: "me@example.com",
|
||||
@@ -324,7 +326,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
{
|
||||
name: "Fetch groups when ClaimsNames and ClaimsSources is set",
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
claims: &azureClaims{
|
||||
ID: "1",
|
||||
@@ -349,7 +351,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
{
|
||||
name: "Fetch groups when forceUseGraphAPI is set",
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()),
|
||||
forceUseGraphAPI: true,
|
||||
},
|
||||
claims: &azureClaims{
|
||||
@@ -376,7 +378,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
{
|
||||
name: "Fetch empty role when strict attribute role is true and no match",
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
claims: &azureClaims{
|
||||
Email: "me@example.com",
|
||||
@@ -392,7 +394,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
{
|
||||
name: "Fetch empty role when strict attribute role is true and no role claims returned",
|
||||
fields: fields{
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false),
|
||||
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()),
|
||||
},
|
||||
claims: &azureClaims{
|
||||
Email: "me@example.com",
|
||||
@@ -416,7 +418,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
if tt.fields.SocialBase == nil {
|
||||
s.SocialBase = newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false)
|
||||
s.SocialBase = newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures())
|
||||
}
|
||||
|
||||
key := []byte("secret")
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
type SocialGenericOAuth struct {
|
||||
@@ -509,3 +511,10 @@ func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string,
|
||||
|
||||
return logins, true
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
opts = append(opts, oauth2.AccessTypeOffline)
|
||||
}
|
||||
return s.SocialBase.AuthCodeURL(state, opts...)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
const testGHUserTeamsJSON = `[
|
||||
@@ -202,7 +204,7 @@ func TestSocialGitHub_UserInfo(t *testing.T) {
|
||||
|
||||
s := &SocialGithub{
|
||||
SocialBase: newSocialBase("github", &oauth2.Config{},
|
||||
&OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole, false),
|
||||
&OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole, false, *featuremgmt.WithFeatures()),
|
||||
allowedOrganizations: []string{},
|
||||
apiUrl: server.URL + "/user",
|
||||
teamIds: []int{},
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
type SocialGoogle struct {
|
||||
@@ -44,3 +46,10 @@ func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
|
||||
Login: data.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
|
||||
}
|
||||
return s.SocialBase.AuthCodeURL(state, opts...)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -58,7 +59,7 @@ type OAuthInfo struct {
|
||||
UsePKCE bool
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager) *SocialService {
|
||||
ss := SocialService{
|
||||
cfg: cfg,
|
||||
oAuthProvider: make(map[string]*OAuthInfo),
|
||||
@@ -139,7 +140,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
// GitHub.
|
||||
if name == "github" {
|
||||
ss.socialMap["github"] = &SocialGithub{
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync),
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||
apiUrl: info.ApiUrl,
|
||||
teamIds: sec.Key("team_ids").Ints(","),
|
||||
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
|
||||
@@ -149,7 +150,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
// GitLab.
|
||||
if name == "gitlab" {
|
||||
ss.socialMap["gitlab"] = &SocialGitlab{
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync),
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||
apiUrl: info.ApiUrl,
|
||||
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
|
||||
}
|
||||
@@ -158,7 +159,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
// Google.
|
||||
if name == "google" {
|
||||
ss.socialMap["google"] = &SocialGoogle{
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync),
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||
hostedDomain: info.HostedDomain,
|
||||
apiUrl: info.ApiUrl,
|
||||
}
|
||||
@@ -167,7 +168,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
// AzureAD.
|
||||
if name == "azuread" {
|
||||
ss.socialMap["azuread"] = &SocialAzureAD{
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync),
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
|
||||
forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false),
|
||||
}
|
||||
@@ -176,7 +177,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
// Okta
|
||||
if name == "okta" {
|
||||
ss.socialMap["okta"] = &SocialOkta{
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync),
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||
apiUrl: info.ApiUrl,
|
||||
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
|
||||
}
|
||||
@@ -185,7 +186,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
// Generic - Uses the same scheme as GitHub.
|
||||
if name == "generic_oauth" {
|
||||
ss.socialMap["generic_oauth"] = &SocialGenericOAuth{
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync),
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||
apiUrl: info.ApiUrl,
|
||||
teamsUrl: info.TeamsUrl,
|
||||
emailAttributeName: info.EmailAttributeName,
|
||||
@@ -214,8 +215,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
|
||||
}
|
||||
|
||||
ss.socialMap[grafanaCom] = &SocialGrafanaCom{
|
||||
SocialBase: newSocialBase(name, &config, info,
|
||||
cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync),
|
||||
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
||||
url: cfg.GrafanaComURL,
|
||||
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
|
||||
}
|
||||
@@ -262,6 +262,7 @@ type SocialBase struct {
|
||||
roleAttributeStrict bool
|
||||
autoAssignOrgRole string
|
||||
skipOrgRoleSync bool
|
||||
features featuremgmt.FeatureManager
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
@@ -296,6 +297,7 @@ func newSocialBase(name string,
|
||||
info *OAuthInfo,
|
||||
autoAssignOrgRole string,
|
||||
skipOrgRoleSync bool,
|
||||
features featuremgmt.FeatureManager,
|
||||
) *SocialBase {
|
||||
logger := log.New("oauth." + name)
|
||||
|
||||
@@ -309,6 +311,7 @@ func newSocialBase(name string,
|
||||
roleAttributePath: info.RoleAttributePath,
|
||||
roleAttributeStrict: info.RoleAttributeStrict,
|
||||
skipOrgRoleSync: skipOrgRoleSync,
|
||||
features: features,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -833,7 +833,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.Fake
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, userService, mockSQLStore)
|
||||
authenticator := &logintest.AuthenticatorFake{ExpectedUser: &user.User{}}
|
||||
return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck))
|
||||
return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck), nil)
|
||||
}
|
||||
|
||||
type fakeRenderService struct {
|
||||
|
||||
@@ -127,7 +127,7 @@ func (s *Server) init() error {
|
||||
}
|
||||
|
||||
login.ProvideService(s.HTTPServer.SQLStore, s.HTTPServer.Login, s.loginAttemptService, s.userService)
|
||||
social.ProvideService(s.cfg)
|
||||
social.ProvideService(s.cfg, s.HTTPServer.Features)
|
||||
|
||||
if err := s.roleRegistry.RegisterFixedRoles(s.context); err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,6 +26,8 @@ type Service interface {
|
||||
registry.ProvidesUsageStats
|
||||
// GetUserPermissions returns user permissions with only action and scope fields set.
|
||||
GetUserPermissions(ctx context.Context, user *user.SignedInUser, options Options) ([]Permission, error)
|
||||
// ClearUserPermissionCache removes the permission cache entry for the given user
|
||||
ClearUserPermissionCache(user *user.SignedInUser)
|
||||
// DeleteUserPermissions removes all permissions user has in org and all permission to that user
|
||||
// If orgID is set to 0 remove permissions from all orgs
|
||||
DeleteUserPermissions(ctx context.Context, orgID, userID int64) error
|
||||
|
||||
@@ -147,6 +147,14 @@ func (s *Service) getCachedUserPermissions(ctx context.Context, user *user.Signe
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
func (s *Service) ClearUserPermissionCache(user *user.SignedInUser) {
|
||||
key, err := permissionCacheKey(user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.cache.Delete(key)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUserPermissions(ctx context.Context, orgID int64, userID int64) error {
|
||||
return s.store.DeleteUserPermissions(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ func (f FakeService) GetUserPermissions(ctx context.Context, user *user.SignedIn
|
||||
return f.ExpectedPermissions, f.ExpectedErr
|
||||
}
|
||||
|
||||
func (f FakeService) ClearUserPermissionCache(user *user.SignedInUser) {}
|
||||
|
||||
func (f FakeService) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error {
|
||||
return f.ExpectedErr
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type fullAccessControl interface {
|
||||
type Calls struct {
|
||||
Evaluate []interface{}
|
||||
GetUserPermissions []interface{}
|
||||
ClearUserPermissionCache []interface{}
|
||||
IsDisabled []interface{}
|
||||
DeclareFixedRoles []interface{}
|
||||
DeclarePluginRoles []interface{}
|
||||
@@ -43,6 +44,7 @@ type Mock struct {
|
||||
// Override functions
|
||||
EvaluateFunc func(context.Context, *user.SignedInUser, accesscontrol.Evaluator) (bool, error)
|
||||
GetUserPermissionsFunc func(context.Context, *user.SignedInUser, accesscontrol.Options) ([]accesscontrol.Permission, error)
|
||||
ClearUserPermissionCacheFunc func(*user.SignedInUser)
|
||||
IsDisabledFunc func() bool
|
||||
DeclareFixedRolesFunc func(...accesscontrol.RoleRegistration) error
|
||||
DeclarePluginRolesFunc func(context.Context, string, string, []plugins.RoleRegistration) error
|
||||
@@ -138,6 +140,14 @@ func (m *Mock) GetUserPermissions(ctx context.Context, user *user.SignedInUser,
|
||||
return m.permissions, nil
|
||||
}
|
||||
|
||||
func (m *Mock) ClearUserPermissionCache(user *user.SignedInUser) {
|
||||
m.Calls.ClearUserPermissionCache = append(m.Calls.ClearUserPermissionCache, []interface{}{user})
|
||||
// Use override if provided
|
||||
if m.ClearUserPermissionCacheFunc != nil {
|
||||
m.ClearUserPermissionCacheFunc(user)
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware checks if service disabled or not to switch to fallback authorization.
|
||||
// This mock return m.disabled unless an override is provided.
|
||||
func (m *Mock) IsDisabled() bool {
|
||||
|
||||
@@ -104,7 +104,7 @@ func getContextHandler(t *testing.T) *ContextHandler {
|
||||
|
||||
return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc,
|
||||
renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator,
|
||||
&userService, orgService, nil, nil)
|
||||
&userService, orgService, nil, nil, nil)
|
||||
}
|
||||
|
||||
type FakeGetSignUserStore struct {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/middleware/cookies"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -48,6 +49,11 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS
|
||||
tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service,
|
||||
apiKeyService apikey.Service, authenticator loginpkg.Authenticator, userService user.Service,
|
||||
orgService org.Service, oauthTokenService oauthtoken.OAuthTokenService, features *featuremgmt.FeatureManager,
|
||||
// before 9.3.0 the quota service used to depend on on the ActiveTokenService
|
||||
// since 9.3.0 after the quota refactoring ActiveTokenService depends on the quota
|
||||
// therefore it's added to avoid cycle dependencies
|
||||
// since it's used only by the middleware for enforcing quota limits.
|
||||
activeTokenService auth.ActiveTokenService,
|
||||
) *ContextHandler {
|
||||
return &ContextHandler{
|
||||
Cfg: cfg,
|
||||
@@ -443,20 +449,14 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org
|
||||
return false
|
||||
}
|
||||
|
||||
getTime := h.GetTime
|
||||
if getTime == nil {
|
||||
getTime = time.Now
|
||||
}
|
||||
|
||||
if h.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
|
||||
// Check whether the logged in User has a token (whether the User used an OAuth provider to login)
|
||||
oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult)
|
||||
if exists {
|
||||
// Skip where the OAuthExpiry is default/zero/unset
|
||||
if !oauthToken.OAuthExpiry.IsZero() && oauthToken.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) {
|
||||
if h.hasAccessTokenExpired(oauthToken) {
|
||||
reqContext.Logger.Info("access token expired", "userId", query.UserID, "expiry", fmt.Sprintf("%v", oauthToken.OAuthExpiry))
|
||||
|
||||
// If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and Invalidate the OAuth tokens
|
||||
// If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and invalidate the OAuth tokens
|
||||
if err = h.oauthTokenService.TryTokenRefresh(ctx, oauthToken); err != nil {
|
||||
if !errors.Is(err, oauthtoken.ErrNoRefreshTokenFound) {
|
||||
reqContext.Logger.Error("could not fetch a new access token", "userId", oauthToken.UserId, "error", err)
|
||||
@@ -726,3 +726,16 @@ func AuthHTTPHeaderListFromContext(c context.Context) *AuthHTTPHeaderList {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ContextHandler) hasAccessTokenExpired(token *models.UserAuth) bool {
|
||||
if token.OAuthExpiry.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
getTime := h.GetTime
|
||||
if getTime == nil {
|
||||
getTime = time.Now
|
||||
}
|
||||
|
||||
return token.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime())
|
||||
}
|
||||
|
||||
@@ -270,8 +270,8 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "internationalization",
|
||||
Description: "Enables work-in-progress internationalization",
|
||||
State: FeatureStateAlpha,
|
||||
Description: "Enables internationalization",
|
||||
State: FeatureStateStable,
|
||||
},
|
||||
{
|
||||
Name: "topnav",
|
||||
@@ -300,6 +300,11 @@ var (
|
||||
Description: "Show the flame graph",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "cloudWatchCrossAccountQuerying",
|
||||
Description: "Use cross-account querying in CloudWatch datasource",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "redshiftAsyncQueryDataSupport",
|
||||
Description: "Enable async query data support for Redshift",
|
||||
|
||||
@@ -196,7 +196,7 @@ const (
|
||||
FlagDataConnectionsConsole = "dataConnectionsConsole"
|
||||
|
||||
// FlagInternationalization
|
||||
// Enables work-in-progress internationalization
|
||||
// Enables internationalization
|
||||
FlagInternationalization = "internationalization"
|
||||
|
||||
// FlagTopnav
|
||||
@@ -219,6 +219,10 @@ const (
|
||||
// Show the flame graph
|
||||
FlagFlameGraph = "flameGraph"
|
||||
|
||||
// FlagCloudWatchCrossAccountQuerying
|
||||
// Use cross-account querying in CloudWatch datasource
|
||||
FlagCloudWatchCrossAccountQuerying = "cloudWatchCrossAccountQuerying"
|
||||
|
||||
// FlagRedshiftAsyncQueryDataSupport
|
||||
// Enable async query data support for Redshift
|
||||
FlagRedshiftAsyncQueryDataSupport = "redshiftAsyncQueryDataSupport"
|
||||
|
||||
@@ -36,10 +36,14 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
NavIDRoot = "root"
|
||||
NavIDDashboards = "dashboards"
|
||||
NavIDDashboardsBrowse = "dashboards/browse"
|
||||
NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node
|
||||
NavIDAdmin = "admin"
|
||||
NavIDAdminGeneral = "admin/general"
|
||||
NavIDAdminPlugins = "admin/plugins"
|
||||
NavIDAdminAccess = "admin/access"
|
||||
NavIDAlertsAndIncidents = "alerts-and-incidents"
|
||||
NavIDAlerting = "alerting"
|
||||
NavIDMonitoring = "monitoring"
|
||||
@@ -110,22 +114,7 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t
|
||||
}
|
||||
|
||||
if topNavEnabled {
|
||||
orgAdminNode := root.FindById(NavIDCfg)
|
||||
|
||||
if orgAdminNode != nil {
|
||||
orgAdminNode.Url = "/admin"
|
||||
orgAdminNode.Text = "Administration"
|
||||
}
|
||||
|
||||
if serverAdminNode := root.FindById(NavIDAdmin); serverAdminNode != nil {
|
||||
serverAdminNode.Url = "/admin/server"
|
||||
serverAdminNode.SortWeight = 0
|
||||
|
||||
if orgAdminNode != nil {
|
||||
orgAdminNode.Children = append(orgAdminNode.Children, serverAdminNode)
|
||||
root.RemoveSection(serverAdminNode)
|
||||
}
|
||||
}
|
||||
ApplyAdminIA(root)
|
||||
|
||||
// Move reports into dashboards
|
||||
if reports := root.FindById(NavIDReporting); reports != nil {
|
||||
@@ -151,6 +140,10 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t
|
||||
node.Url = node.Children[0].Url
|
||||
}
|
||||
}
|
||||
|
||||
if len(root.Children) < 1 {
|
||||
root.Children = make([]*NavLink, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (root *NavTreeRoot) Sort() {
|
||||
@@ -180,6 +173,99 @@ func Sort(nodes []*NavLink) {
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyAdminIA(root *NavTreeRoot) {
|
||||
orgAdminNode := root.FindById(NavIDCfg)
|
||||
|
||||
if orgAdminNode != nil {
|
||||
orgAdminNode.Url = "/admin"
|
||||
orgAdminNode.Text = "Administration"
|
||||
|
||||
generalNodeLinks := []*NavLink{}
|
||||
pluginsNodeLinks := []*NavLink{}
|
||||
accessNodeLinks := []*NavLink{}
|
||||
|
||||
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("upgrading"))
|
||||
if orgSettings := root.FindById("org-settings"); orgSettings != nil {
|
||||
orgSettings.Text = "Default preferences"
|
||||
generalNodeLinks = append(generalNodeLinks, orgSettings)
|
||||
}
|
||||
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("global-orgs"))
|
||||
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("server-settings"))
|
||||
|
||||
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugins"))
|
||||
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("datasources"))
|
||||
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("correlations"))
|
||||
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app"))
|
||||
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("recordedQueries")) // enterprise only
|
||||
|
||||
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("users"))
|
||||
if globalUsers := root.FindById("global-users"); globalUsers != nil {
|
||||
globalUsers.Text = "Users (All orgs)"
|
||||
accessNodeLinks = append(accessNodeLinks, globalUsers)
|
||||
}
|
||||
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("teams"))
|
||||
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("serviceaccounts"))
|
||||
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("apikeys"))
|
||||
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("standalone-plugin-page-/a/grafana-auth-app")) // Cloud Access Policies
|
||||
|
||||
generalNode := &NavLink{
|
||||
Text: "General",
|
||||
Id: NavIDAdminGeneral,
|
||||
Url: "/admin/general",
|
||||
Icon: "shield",
|
||||
Children: generalNodeLinks,
|
||||
}
|
||||
|
||||
pluginsNode := &NavLink{
|
||||
Text: "Plugins and data",
|
||||
Id: NavIDAdminPlugins,
|
||||
Url: "/admin/plugins",
|
||||
Icon: "shield",
|
||||
Children: pluginsNodeLinks,
|
||||
}
|
||||
|
||||
accessNode := &NavLink{
|
||||
Text: "Users and access",
|
||||
Id: NavIDAdminAccess,
|
||||
Url: "/admin/access",
|
||||
Icon: "shield",
|
||||
Children: accessNodeLinks,
|
||||
}
|
||||
|
||||
adminNodeLinks := []*NavLink{}
|
||||
|
||||
if len(generalNode.Children) > 0 {
|
||||
adminNodeLinks = append(adminNodeLinks, generalNode)
|
||||
}
|
||||
|
||||
if len(pluginsNode.Children) > 0 {
|
||||
adminNodeLinks = append(adminNodeLinks, pluginsNode)
|
||||
}
|
||||
|
||||
if len(accessNode.Children) > 0 {
|
||||
adminNodeLinks = append(adminNodeLinks, accessNode)
|
||||
}
|
||||
|
||||
if len(adminNodeLinks) > 0 {
|
||||
orgAdminNode.Children = adminNodeLinks
|
||||
} else {
|
||||
root.RemoveSection(orgAdminNode)
|
||||
}
|
||||
}
|
||||
|
||||
if serverAdminNode := root.FindById(NavIDAdmin); serverAdminNode != nil {
|
||||
root.RemoveSection(serverAdminNode)
|
||||
}
|
||||
}
|
||||
|
||||
func AppendIfNotNil(children []*NavLink, newChild *NavLink) []*NavLink {
|
||||
if newChild != nil {
|
||||
return append(children, newChild)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
func FindById(nodes []*NavLink, id string) *NavLink {
|
||||
for _, child := range nodes {
|
||||
if child.Id == id {
|
||||
|
||||
@@ -33,18 +33,20 @@ func TestNavTreeRoot(t *testing.T) {
|
||||
require.Equal(t, 2, len(treeRoot.Children))
|
||||
})
|
||||
|
||||
t.Run("Should move admin section into cfg and rename when topnav is enabled", func(t *testing.T) {
|
||||
t.Run("Should create 3 new sections in the Admin node when topnav is enabled", func(t *testing.T) {
|
||||
treeRoot := NavTreeRoot{
|
||||
Children: []*NavLink{
|
||||
{Id: NavIDCfg},
|
||||
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
|
||||
{Id: NavIDAdmin, Children: []*NavLink{{Id: "upgrading"}, {Id: "plugins"}, {Id: "teams"}}},
|
||||
},
|
||||
}
|
||||
|
||||
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
|
||||
|
||||
require.Equal(t, "Administration", treeRoot.Children[0].Text)
|
||||
require.Equal(t, NavIDAdmin, treeRoot.Children[0].Children[0].Id)
|
||||
require.Equal(t, NavIDAdminGeneral, treeRoot.Children[0].Children[0].Id)
|
||||
require.Equal(t, NavIDAdminPlugins, treeRoot.Children[0].Children[1].Id)
|
||||
require.Equal(t, NavIDAdminAccess, treeRoot.Children[0].Children[2].Id)
|
||||
})
|
||||
|
||||
t.Run("Should move reports into Dashboards", func(t *testing.T) {
|
||||
|
||||
@@ -166,16 +166,8 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink
|
||||
Children: adminNavLinks,
|
||||
}
|
||||
|
||||
if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagTopnav) {
|
||||
adminNode.SubTitle = "Manage server-wide settings and access to resources such as organizations, users, and licenses"
|
||||
}
|
||||
|
||||
if len(adminNavLinks) > 0 {
|
||||
if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagTopnav) {
|
||||
adminNode.Url = s.cfg.AppSubURL + "/admin/server"
|
||||
} else {
|
||||
adminNode.Url = adminNavLinks[0].Url
|
||||
}
|
||||
adminNode.Url = adminNavLinks[0].Url
|
||||
}
|
||||
|
||||
return adminNode
|
||||
|
||||
@@ -169,6 +169,12 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo
|
||||
}
|
||||
appLink.Children = childrenWithoutDefault
|
||||
|
||||
s.addPluginToSection(c, treeRoot, plugin, appLink)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) addPluginToSection(c *models.ReqContext, treeRoot *navtree.NavTreeRoot, plugin plugins.PluginDTO, appLink *navtree.NavLink) {
|
||||
// Handle moving apps into specific navtree sections
|
||||
alertingNode := treeRoot.FindById(navtree.NavIDAlerting)
|
||||
sectionID := navtree.NavIDApps
|
||||
@@ -182,7 +188,9 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo
|
||||
}
|
||||
}
|
||||
|
||||
if navNode := treeRoot.FindById(sectionID); navNode != nil {
|
||||
if sectionID == navtree.NavIDRoot {
|
||||
treeRoot.AddSection(appLink)
|
||||
} else if navNode := treeRoot.FindById(sectionID); navNode != nil {
|
||||
navNode.Children = append(navNode.Children, appLink)
|
||||
} else {
|
||||
switch sectionID {
|
||||
@@ -226,8 +234,6 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo
|
||||
s.log.Error("Plugin app nav id not found", "pluginId", plugin.ID, "navId", sectionID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) readNavigationSettings() {
|
||||
@@ -236,8 +242,9 @@ func (s *ServiceImpl) readNavigationSettings() {
|
||||
"grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 2, Text: "Synthetics"},
|
||||
"grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"},
|
||||
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incident"},
|
||||
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3},
|
||||
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"},
|
||||
"grafana-cloud-link-app": {SectionID: navtree.NavIDCfg},
|
||||
"grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightSavedItems + 1, Text: "Connections"},
|
||||
}
|
||||
|
||||
s.navigationAppPathConfig = map[string]NavigationAppConfig{
|
||||
|
||||
@@ -145,6 +145,30 @@ func TestAddAppLinks(t *testing.T) {
|
||||
require.Equal(t, "Page2", app1Node.Children[0].Text)
|
||||
})
|
||||
|
||||
// This can be done by using `[navigation.app_sections]` in the INI config
|
||||
t.Run("Should move apps that have root nav id configured to the root", func(t *testing.T) {
|
||||
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
|
||||
service.navigationAppConfig = map[string]NavigationAppConfig{
|
||||
"test-app1": {SectionID: navtree.NavIDRoot},
|
||||
}
|
||||
|
||||
treeRoot := navtree.NavTreeRoot{}
|
||||
|
||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if the plugin gets moved to the root
|
||||
require.Len(t, treeRoot.Children, 2)
|
||||
require.Equal(t, "plugin-page-test-app1", treeRoot.Children[0].Id)
|
||||
|
||||
// Check if it is not under the "Apps" section anymore
|
||||
appsNode := treeRoot.FindById(navtree.NavIDApps)
|
||||
require.NotNil(t, appsNode)
|
||||
require.Len(t, appsNode.Children, 2)
|
||||
require.Equal(t, "plugin-page-test-app2", appsNode.Children[0].Id)
|
||||
require.Equal(t, "plugin-page-test-app3", appsNode.Children[1].Id)
|
||||
})
|
||||
|
||||
// This can be done by using `[navigation.app_sections]` in the INI config
|
||||
t.Run("Should move apps that have specific nav id configured to correct section", func(t *testing.T) {
|
||||
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
|
||||
|
||||
@@ -73,9 +73,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
treeRoot := &navtree.NavTreeRoot{}
|
||||
|
||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
treeRoot.AddSection(s.getHomeNode(c, prefs))
|
||||
}
|
||||
treeRoot.AddSection(s.getHomeNode(c, prefs))
|
||||
|
||||
if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) {
|
||||
starredItemsLinks, err := s.buildStarredItemsNavLinks(c)
|
||||
@@ -224,7 +222,7 @@ func (s *ServiceImpl) getHomeNode(c *models.ReqContext, prefs *pref.Preference)
|
||||
}
|
||||
}
|
||||
|
||||
return &navtree.NavLink{
|
||||
homeNode := &navtree.NavLink{
|
||||
Text: "Home",
|
||||
Id: "home",
|
||||
Url: homeUrl,
|
||||
@@ -232,6 +230,10 @@ func (s *ServiceImpl) getHomeNode(c *models.ReqContext, prefs *pref.Preference)
|
||||
Section: navtree.NavSectionCore,
|
||||
SortWeight: navtree.WeightHome,
|
||||
}
|
||||
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
homeNode.HideFromMenu = true
|
||||
}
|
||||
return homeNode
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) {
|
||||
|
||||
@@ -104,10 +104,6 @@ type ProvisionedAlertRule struct {
|
||||
}
|
||||
|
||||
func (a *ProvisionedAlertRule) UpstreamModel() (models.AlertRule, error) {
|
||||
forDur, err := time.ParseDuration(a.For.String())
|
||||
if err != nil {
|
||||
return models.AlertRule{}, err
|
||||
}
|
||||
return models.AlertRule{
|
||||
ID: a.ID,
|
||||
UID: a.UID,
|
||||
@@ -120,7 +116,7 @@ func (a *ProvisionedAlertRule) UpstreamModel() (models.AlertRule, error) {
|
||||
Updated: a.Updated,
|
||||
NoDataState: a.NoDataState,
|
||||
ExecErrState: a.ExecErrState,
|
||||
For: forDur,
|
||||
For: time.Duration(a.For),
|
||||
Annotations: a.Annotations,
|
||||
Labels: a.Labels,
|
||||
}, nil
|
||||
|
||||
@@ -20,7 +20,7 @@ type Preference struct {
|
||||
Version int `db:"version"`
|
||||
HomeDashboardID int64 `xorm:"home_dashboard_id" db:"home_dashboard_id"`
|
||||
Timezone string `db:"timezone"`
|
||||
WeekStart string `db:"week_start"`
|
||||
WeekStart *string `db:"week_start"`
|
||||
Theme string `db:"theme"`
|
||||
Created time.Time `db:"created"`
|
||||
Updated time.Time `db:"updated"`
|
||||
|
||||
@@ -54,7 +54,7 @@ func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreference
|
||||
if p.Timezone != "" {
|
||||
res.Timezone = p.Timezone
|
||||
}
|
||||
if p.WeekStart != "" {
|
||||
if p.WeekStart != nil && *p.WeekStart != "" {
|
||||
res.WeekStart = p.WeekStart
|
||||
}
|
||||
if p.HomeDashboardID != 0 {
|
||||
@@ -108,7 +108,7 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err
|
||||
TeamID: cmd.TeamID,
|
||||
HomeDashboardID: cmd.HomeDashboardID,
|
||||
Timezone: cmd.Timezone,
|
||||
WeekStart: cmd.WeekStart,
|
||||
WeekStart: &cmd.WeekStart,
|
||||
Theme: cmd.Theme,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
@@ -125,7 +125,7 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err
|
||||
}
|
||||
|
||||
preference.Timezone = cmd.Timezone
|
||||
preference.WeekStart = cmd.WeekStart
|
||||
preference.WeekStart = &cmd.WeekStart
|
||||
preference.Theme = cmd.Theme
|
||||
preference.Updated = time.Now()
|
||||
preference.Version += 1
|
||||
@@ -200,7 +200,7 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e
|
||||
}
|
||||
|
||||
if cmd.WeekStart != nil {
|
||||
preference.WeekStart = *cmd.WeekStart
|
||||
preference.WeekStart = cmd.WeekStart
|
||||
}
|
||||
|
||||
if cmd.Theme != nil {
|
||||
@@ -232,7 +232,7 @@ func (s *Service) GetDefaults() *pref.Preference {
|
||||
defaults := &pref.Preference{
|
||||
Theme: s.cfg.DefaultTheme,
|
||||
Timezone: s.cfg.DateFormats.DefaultTimezone,
|
||||
WeekStart: s.cfg.DateFormats.DefaultWeekStart,
|
||||
WeekStart: &s.cfg.DateFormats.DefaultWeekStart,
|
||||
HomeDashboardID: 0,
|
||||
JSONData: &pref.PreferenceJSONData{},
|
||||
}
|
||||
|
||||
@@ -36,10 +36,12 @@ func TestGetDefaults(t *testing.T) {
|
||||
prefService.cfg.DefaultLocale = "en-US"
|
||||
prefService.cfg.DefaultTheme = "light"
|
||||
prefService.cfg.DateFormats.DefaultTimezone = "UTC"
|
||||
weekStart := ""
|
||||
|
||||
t.Run("GetDefaults", func(t *testing.T) {
|
||||
preference := prefService.GetDefaults()
|
||||
expected := &pref.Preference{
|
||||
WeekStart: &weekStart,
|
||||
Theme: "light",
|
||||
Timezone: "UTC",
|
||||
HomeDashboardID: 0,
|
||||
@@ -55,6 +57,7 @@ func TestGetDefaults(t *testing.T) {
|
||||
preference, err := prefService.GetWithDefaults(context.Background(), query)
|
||||
require.NoError(t, err)
|
||||
expected := &pref.Preference{
|
||||
WeekStart: &weekStart,
|
||||
Theme: "light",
|
||||
Timezone: "UTC",
|
||||
HomeDashboardID: 0,
|
||||
@@ -72,6 +75,7 @@ func TestGetDefaultsWithI18nFeatureFlag(t *testing.T) {
|
||||
cfg: setting.NewCfg(),
|
||||
features: featuremgmt.WithFeatures(featuremgmt.FlagInternationalization),
|
||||
}
|
||||
weekStart := ""
|
||||
prefService.cfg.DefaultLocale = "en-US"
|
||||
prefService.cfg.DefaultTheme = "light"
|
||||
prefService.cfg.DateFormats.DefaultTimezone = "UTC"
|
||||
@@ -79,6 +83,7 @@ func TestGetDefaultsWithI18nFeatureFlag(t *testing.T) {
|
||||
t.Run("GetDefaults", func(t *testing.T) {
|
||||
preference := prefService.GetDefaults()
|
||||
expected := &pref.Preference{
|
||||
WeekStart: &weekStart,
|
||||
Theme: "light",
|
||||
Timezone: "UTC",
|
||||
HomeDashboardID: 0,
|
||||
@@ -100,13 +105,15 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
|
||||
}
|
||||
prefService.cfg.DefaultLocale = "en-US"
|
||||
|
||||
weekStartOne := "1"
|
||||
weekStartTwo := "2"
|
||||
insertPrefs(t, prefService.store,
|
||||
pref.Preference{
|
||||
OrgID: 1,
|
||||
HomeDashboardID: 1,
|
||||
Theme: "dark",
|
||||
Timezone: "UTC",
|
||||
WeekStart: "1",
|
||||
WeekStart: &weekStartOne,
|
||||
JSONData: &pref.PreferenceJSONData{
|
||||
Locale: "en-GB",
|
||||
},
|
||||
@@ -117,7 +124,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
|
||||
HomeDashboardID: 4,
|
||||
Theme: "light",
|
||||
Timezone: "browser",
|
||||
WeekStart: "2",
|
||||
WeekStart: &weekStartTwo,
|
||||
JSONData: &pref.PreferenceJSONData{
|
||||
Locale: "en-AU",
|
||||
},
|
||||
@@ -131,7 +138,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
|
||||
expected := &pref.Preference{
|
||||
Theme: "light",
|
||||
Timezone: "browser",
|
||||
WeekStart: "2",
|
||||
WeekStart: &weekStartTwo,
|
||||
HomeDashboardID: 4,
|
||||
JSONData: &pref.PreferenceJSONData{
|
||||
Locale: "en-AU",
|
||||
@@ -150,7 +157,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
|
||||
expected := &pref.Preference{
|
||||
Theme: "dark",
|
||||
Timezone: "UTC",
|
||||
WeekStart: "1",
|
||||
WeekStart: &weekStartOne,
|
||||
HomeDashboardID: 1,
|
||||
JSONData: &pref.PreferenceJSONData{
|
||||
Locale: "en-GB",
|
||||
@@ -163,6 +170,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetDefaults_JSONData(t *testing.T) {
|
||||
weekStart := ""
|
||||
queryPreference := pref.QueryHistoryPreference{
|
||||
HomeTab: "hometab",
|
||||
}
|
||||
@@ -235,7 +243,8 @@ func TestGetDefaults_JSONData(t *testing.T) {
|
||||
preference, err := prefService.GetWithDefaults(context.Background(), query)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &pref.Preference{
|
||||
JSONData: &userPreferencesJsonData,
|
||||
WeekStart: &weekStart,
|
||||
JSONData: &userPreferencesJsonData,
|
||||
}, preference)
|
||||
})
|
||||
|
||||
@@ -262,6 +271,7 @@ func TestGetDefaults_JSONData(t *testing.T) {
|
||||
preference, err := prefService.GetWithDefaults(context.Background(), query)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &pref.Preference{
|
||||
WeekStart: &weekStart,
|
||||
JSONData: &pref.PreferenceJSONData{
|
||||
Locale: "en-GB",
|
||||
Navbar: userNavbarPreferences,
|
||||
@@ -300,12 +310,15 @@ func TestGetDefaults_JSONData(t *testing.T) {
|
||||
preference, err := prefService.GetWithDefaults(context.Background(), query)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &pref.Preference{
|
||||
JSONData: &team2PreferencesJsonData,
|
||||
JSONData: &team2PreferencesJsonData,
|
||||
WeekStart: &weekStart,
|
||||
}, preference)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetWithDefaults_teams(t *testing.T) {
|
||||
weekStartOne := "1"
|
||||
weekStartTwo := "2"
|
||||
prefService := &Service{
|
||||
store: newFake(),
|
||||
cfg: setting.NewCfg(),
|
||||
@@ -317,7 +330,7 @@ func TestGetWithDefaults_teams(t *testing.T) {
|
||||
HomeDashboardID: 1,
|
||||
Theme: "light",
|
||||
Timezone: "browser",
|
||||
WeekStart: "1",
|
||||
WeekStart: &weekStartOne,
|
||||
},
|
||||
pref.Preference{
|
||||
OrgID: 1,
|
||||
@@ -325,7 +338,7 @@ func TestGetWithDefaults_teams(t *testing.T) {
|
||||
HomeDashboardID: 3,
|
||||
Theme: "light",
|
||||
Timezone: "browser",
|
||||
WeekStart: "2",
|
||||
WeekStart: &weekStartTwo,
|
||||
},
|
||||
pref.Preference{
|
||||
OrgID: 1,
|
||||
@@ -333,7 +346,7 @@ func TestGetWithDefaults_teams(t *testing.T) {
|
||||
HomeDashboardID: 4,
|
||||
Theme: "light",
|
||||
Timezone: "browser",
|
||||
WeekStart: "2",
|
||||
WeekStart: &weekStartTwo,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -343,7 +356,7 @@ func TestGetWithDefaults_teams(t *testing.T) {
|
||||
expected := &pref.Preference{
|
||||
Theme: "light",
|
||||
Timezone: "browser",
|
||||
WeekStart: "2",
|
||||
WeekStart: &weekStartTwo,
|
||||
HomeDashboardID: 4,
|
||||
JSONData: &pref.PreferenceJSONData{},
|
||||
}
|
||||
@@ -399,7 +412,7 @@ func TestSave(t *testing.T) {
|
||||
assert.Equal(t, "dark", stored.Theme)
|
||||
assert.Equal(t, "browser", stored.Timezone)
|
||||
assert.EqualValues(t, 5, stored.HomeDashboardID)
|
||||
assert.Equal(t, "1", stored.WeekStart)
|
||||
assert.Equal(t, "1", *stored.WeekStart)
|
||||
assert.EqualValues(t, 0, stored.Version)
|
||||
})
|
||||
|
||||
@@ -421,7 +434,7 @@ func TestSave(t *testing.T) {
|
||||
assert.Empty(t, stored.Theme)
|
||||
assert.Equal(t, "UTC", stored.Timezone)
|
||||
assert.Zero(t, stored.HomeDashboardID)
|
||||
assert.Equal(t, "1", stored.WeekStart)
|
||||
assert.Equal(t, "1", *stored.WeekStart)
|
||||
assert.EqualValues(t, 1, stored.Version)
|
||||
})
|
||||
|
||||
@@ -440,7 +453,7 @@ func TestSave(t *testing.T) {
|
||||
assert.Equal(t, themeValue, stored.Theme)
|
||||
assert.Equal(t, "UTC", stored.Timezone)
|
||||
assert.Zero(t, stored.HomeDashboardID)
|
||||
assert.Equal(t, "1", stored.WeekStart)
|
||||
assert.Equal(t, "1", *stored.WeekStart)
|
||||
assert.EqualValues(t, 2, stored.Version)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ type getStore func(db.DB) store
|
||||
|
||||
func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) {
|
||||
t.Helper()
|
||||
|
||||
weekStartOne := "1"
|
||||
ss := db.InitTestDB(t)
|
||||
prefStore := fn(ss)
|
||||
orgNavbarPreferences := pref.NavbarPreference{
|
||||
@@ -123,7 +123,7 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) {
|
||||
Theme: "dark",
|
||||
Timezone: "browser",
|
||||
HomeDashboardID: 5,
|
||||
WeekStart: "1",
|
||||
WeekStart: &weekStartOne,
|
||||
JSONData: &pref.PreferenceJSONData{Navbar: orgNavbarPreferences},
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
@@ -135,7 +135,7 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) {
|
||||
Theme: "dark",
|
||||
HomeDashboardID: 5,
|
||||
Timezone: "browser",
|
||||
WeekStart: "1",
|
||||
WeekStart: &weekStartOne,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
JSONData: &pref.PreferenceJSONData{},
|
||||
@@ -149,7 +149,7 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) {
|
||||
Version: prefs[0].Version,
|
||||
HomeDashboardID: 5,
|
||||
Timezone: "browser",
|
||||
WeekStart: "1",
|
||||
WeekStart: &weekStartOne,
|
||||
Theme: "dark",
|
||||
JSONData: prefs[0].JSONData,
|
||||
Created: prefs[0].Created,
|
||||
|
||||
@@ -1,26 +1,54 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/values"
|
||||
)
|
||||
|
||||
type NotificiationPolicyV1 struct {
|
||||
OrgID values.Int64Value `json:"orgId" yaml:"orgId"`
|
||||
Policy definitions.Route `json:",inline" yaml:",inline"`
|
||||
OrgID values.Int64Value `json:"orgId" yaml:"orgId"`
|
||||
// We use JSONValue here, as we want to have interpolation the values.
|
||||
Policy values.JSONValue `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
func (v1 *NotificiationPolicyV1) mapToModel() NotificiationPolicy {
|
||||
func (v1 *NotificiationPolicyV1) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
err := v1.Policy.UnmarshalYAML(unmarshal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// As we also want to unmarshal the orgId and any other field that might be
|
||||
// added in the future we create an alias type that prevents recursion
|
||||
// and just uses the default marshler.
|
||||
type plain NotificiationPolicyV1
|
||||
return unmarshal((*plain)(v1))
|
||||
}
|
||||
|
||||
func (v1 *NotificiationPolicyV1) mapToModel() (NotificiationPolicy, error) {
|
||||
orgID := v1.OrgID.Value()
|
||||
if orgID < 1 {
|
||||
orgID = 1
|
||||
}
|
||||
// we don't need any further validation here as it's done by
|
||||
// the notification policy service
|
||||
var route definitions.Route
|
||||
// We need the string json representation, so we marshal the policy back
|
||||
// as a string and interpolate it at the same time.
|
||||
data, err := json.Marshal(v1.Policy.Value())
|
||||
if err != nil {
|
||||
return NotificiationPolicy{}, err
|
||||
}
|
||||
// Now we can take the interpolated string json represtenation of the policy
|
||||
// and unmarshal it in the concrete type.
|
||||
err = json.Unmarshal(data, &route)
|
||||
if err != nil {
|
||||
return NotificiationPolicy{}, err
|
||||
}
|
||||
// We don't need any further validation here as it's done by
|
||||
// the notification policy service.
|
||||
return NotificiationPolicy{
|
||||
OrgID: orgID,
|
||||
Policy: v1.Policy,
|
||||
}
|
||||
Policy: route,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type NotificiationPolicy struct {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user