diff --git a/README.md b/README.md index b9a3885e8..fbdfb9d63 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Grafana plugin for Backstage -The Grafana plugin is a frontend plugin that lists Grafana alerts and dashboards. It includes two components that can be integrated into Backstage: +The Grafana plugin is a frontend plugin that lists Grafana alerts and dashboards. It supports multiple Grafana hosts configuration. +It includes several components that can be integrated into Backstage: * The `EntityGrafanaDashboardsCard` component which can display dashboards for a specific entity * The `EntityGrafanaAlertsCard` component which can display recent alerts for a specific entity diff --git a/config.d.ts b/config.d.ts index d7951e95b..8fdf810fe 100644 --- a/config.d.ts +++ b/config.d.ts @@ -15,24 +15,54 @@ */ export interface Config { - grafana: { - /** - * Domain used by users to access Grafana web UI. - * Example: https://monitoring.eu.my-company.com/ - * @visibility frontend - */ - domain: string; + grafana: { + /** + * Domain used by users to access Grafana web UI. + * Example: https://monitoring.eu.my-company.com/ + * @visibility frontend + */ + domain?: string; - /** - * Path to use for requests via the proxy, defaults to /grafana/api - * @visibility frontend - */ - proxyPath?: string; + /** + * Path to use for requests via the proxy, defaults to /grafana/api + * @visibility frontend + */ + proxyPath?: string; - /** - * Is Grafana using unified alerting? - * @visibility frontend - */ - unifiedAlerting?: boolean; - } -} \ No newline at end of file + /** + * Is Grafana using unified alerting? + * @visibility frontend + */ + unifiedAlerting?: boolean; + + /** + * List of the grafana hosts + * @visibility frontend + */ + hosts: { + /** + * Unique ID of the grafana host. This value should be used in the catalog Yaml files to match grafana/source-id. + * @visibility frontend + */ + id: string; + /** + * Domain used by users to access Grafana web UI. + * Example: https://monitoring.eu.my-company.com/ + * @visibility frontend + */ + domain: string; + + /** + * Path to use for requests via the proxy, defaults to /grafana/api + * @visibility frontend + */ + proxyPath?: string; + + /** + * Is Grafana using unified alerting? + * @visibility frontend + */ + unifiedAlerting?: boolean; + }[]; + }; +} diff --git a/docs/alerts-on-component-page.md b/docs/alerts-on-component-page.md index fdae4e83a..4e1dfba5b 100644 --- a/docs/alerts-on-component-page.md +++ b/docs/alerts-on-component-page.md @@ -41,6 +41,7 @@ If Grafana's [Unified Alerting](https://grafana.com/blog/2021/06/14/the-new-unif ```yaml annotations: + # grafana/source-id: 'my-instance-id' # use in case of multiple Grafana instances grafana/alert-label-selector: "service=awesome-service" ``` @@ -52,7 +53,8 @@ If Grafana's [Unified Alerting](https://grafana.com/blog/2021/06/14/the-new-unif ```yaml annotations: + # grafana/source-id: 'my-instance-id' # use in case of multiple Grafana instances grafana/tag-selector: "my-tag" ``` -The `EntityGrafanaAlertsCard` component will then display alerts matching the given tag. \ No newline at end of file +The `EntityGrafanaAlertsCard` component will then display alerts matching the given tag. diff --git a/docs/dashboards-on-component-page.md b/docs/dashboards-on-component-page.md index 7736b61cb..57a7e59bd 100644 --- a/docs/dashboards-on-component-page.md +++ b/docs/dashboards-on-component-page.md @@ -40,6 +40,7 @@ The following selector will return dashboards that have a `my-service` or a `my- ```yml annotations: + # grafana/source-id: 'my-instance-id' # use in case of multiple Grafana instances grafana/dashboard-selector: "(tags @> 'my-service' || tags @> 'my-service-slo') && tags @> 'generated'" ``` @@ -67,5 +68,6 @@ Note that the `tags @> "my-service"` selector can be simplified as: ```yaml annotations: + # grafana/source-id: 'my-instance-id' # use in case of multiple Grafana instances grafana/dashboard-selector: my-service ``` diff --git a/docs/setup.md b/docs/setup.md index 3d16d705c..9cdd18f5f 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -10,11 +10,12 @@ Configure the plugin in `app-config.yaml`. The proxy endpoint described below wi to authenticate with Grafana without exposing your API key to users. [Create an API key](https://grafana.com/docs/grafana/latest/http_api/auth/#create-api-token) if you don't already have one. `Viewer` access will be enough. +#### Minimal configuration for a single instance: ```yaml # app-config.yaml proxy: '/grafana/api': - # May be a public or an internal DNS + # Maybe a public or an internal DNS target: https://grafana.host/ headers: Authorization: Bearer ${GRAFANA_TOKEN} @@ -22,14 +23,66 @@ proxy: grafana: # Publicly accessible domain domain: https://monitoring.company.com - + + # Path to use for requests via the proxy, defaults to /grafana/api + # proxyPath: '/grafana/api' + # Is unified alerting enabled in Grafana? # See: https://grafana.com/blog/2021/06/14/the-new-unified-alerting-system-for-grafana-everything-you-need-to-know/ # Optional. Default: false unifiedAlerting: false ``` -Expose the plugin to Backstage: +You don't need to specify `grafana/source-id` annotation in Catalog yaml files in this case. It's matched to the value `default`. + +#### Multiple Grafana instances +If you need to use multiple Grafana instances, use `hosts` field. You also need to define a proxy for each host: + +```yaml +# app-config.yaml +proxy: + '/grafana/api': + # Maybe a public or an internal DNS + target: https://grafana.host/ + headers: + Authorization: Bearer ${GRAFANA_TOKEN} + '/grafana2/api': + # Maybe a public or an internal DNS + target: https://grafana2.host/ + headers: + Authorization: Bearer ${GRAFANA2_TOKEN} + +grafana: + hosts: + - id: 'default' #unique host identifier used in Catalog Yaml annotation `grafana/source-id` + + # Publicly accessible domain + domain: https://monitoring.company.com + + # Path to use for requests via the proxy, defaults to /grafana/api + proxyPath: '/grafana/api' + + # Is unified alerting enabled in Grafana? + # See: https://grafana.com/blog/2021/06/14/the-new-unified-alerting-system-for-grafana-everything-you-need-to-know/ + # Optional. Default: false + unifiedAlerting: false + + - id: 'my-second-instance' #unique host identifier used in Catalog Yaml annotation `grafana/source-id` + + # Publicly accessible domain + domain: https://monitoring2.company.com + + # Path to use for requests via the proxy, defaults to /grafana/api + proxyPath: '/grafana2/api' + + # Is unified alerting enabled in Grafana? + # See: https://grafana.com/blog/2021/06/14/the-new-unified-alerting-system-for-grafana-everything-you-need-to-know/ + # Optional. Default: false + unifiedAlerting: false +``` + + +### Expose the plugin to Backstage: ```ts // packages/app/src/plugins.tsx @@ -39,4 +92,4 @@ Expose the plugin to Backstage: export { grafanaPlugin } from '@k-phoen/backstage-plugin-grafana'; ``` -That's it! You can now update your entities pages to [display alerts](alerts-on-component-page.md) or [dashboards](dashboards-on-component-page.md) related to them. \ No newline at end of file +That's it! You can now update your entities pages to [display alerts](alerts-on-component-page.md) or [dashboards](dashboards-on-component-page.md) related to them. diff --git a/package.json b/package.json index 738d21727..28b5ecba1 100644 --- a/package.json +++ b/package.json @@ -30,23 +30,22 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/catalog-model": "^1.2.1", - "@backstage/core-components": "^0.12.5", - "@backstage/core-plugin-api": "^1.5.0", - "@backstage/plugin-catalog-react": "^1.4.0", + "@backstage/core-components": "^0.13.4", + "@backstage/core-plugin-api": "^1.5.3", + "@backstage/plugin-catalog-react": "^1.8.3", "@material-ui/core": "^4.12.2", "@material-ui/lab": "4.0.0-alpha.57", "jsep": "^1.3.8", "react-use": "^17.2.4" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.2.0", "react-dom": "^16.13.1 || ^17.0.0" }, "devDependencies": { - "@backstage/cli": "^0.22.5", - "@backstage/core-app-api": "^1.6.0", - "@backstage/dev-utils": "^1.0.13", + "@backstage/cli": "^0.22.12", + "@backstage/core-app-api": "^1.9.1", + "@backstage/dev-utils": "^1.0.20", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/src/api.ts b/src/api.ts index 6dfa29a81..c43f9dceb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,13 +14,18 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + createApiRef, + DiscoveryApi, + IdentityApi, +} from '@backstage/core-plugin-api'; import { QueryEvaluator } from './query'; import { Alert, Dashboard } from './types'; export interface GrafanaApi { - listDashboards(query: string): Promise; - alertsForSelector(selector: string): Promise; + listDashboards(query: string, sourceId?: string): Promise; + + alertsForSelector(selector: string, sourceId?: string): Promise; } interface AlertRuleGroupConfig { @@ -46,31 +51,61 @@ interface AlertRule { grafana_alert: UnifiedGrafanaAlert; } +type UnifiedAlertState = + | 'Normal' + | 'Pending' + | 'Alerting' + | 'NoData' + | 'Error' + | 'n/a'; + +interface AlertInstance { + labels: Record; + state: UnifiedAlertState; +} + +interface AlertsData { + data: { alerts: AlertInstance[] }; +} + export const grafanaApiRef = createApiRef({ id: 'plugin.grafana.service', }); -export type Options = { - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - +export type GrafanaHost = { + /** + * Host unique identifier + */ + id: string; /** * Domain used by users to access Grafana web UI. * Example: https://monitoring.my-company.com/ */ domain: string; - /** * Path to use for requests via the proxy, defaults to /grafana/api */ proxyPath?: string; + + /** + * Is Grafana using unified alerting? Default false. + * @visibility frontend + */ + unifiedAlerting?: boolean; +}; + +export type Options = { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + + hosts: GrafanaHost[]; }; -const DEFAULT_PROXY_PATH = '/grafana/api'; +export const DEFAULT_PROXY_PATH = '/grafana/api'; const isSingleWord = (input: string): boolean => { return input.match(/^[\w-]+$/g) !== null; -} +}; class Client { private readonly discoveryApi: DiscoveryApi; @@ -78,10 +113,10 @@ class Client { private readonly proxyPath: string; private readonly queryEvaluator: QueryEvaluator; - constructor(opts: Options) { + constructor(opts: Options, proxyPath: string) { this.discoveryApi = opts.discoveryApi; this.identityApi = opts.identityApi; - this.proxyPath = opts.proxyPath ?? DEFAULT_PROXY_PATH; + this.proxyPath = proxyPath; this.queryEvaluator = new QueryEvaluator(); } @@ -105,23 +140,31 @@ class Client { return this.dashboardsForQuery(domain, query); } - async dashboardsForQuery(domain: string, query: string): Promise { + async dashboardsForQuery( + domain: string, + query: string, + ): Promise { const parsedQuery = this.queryEvaluator.parse(query); const response = await this.fetch(`/api/search?type=dash-db`); const allDashboards = this.fullyQualifiedDashboardURLs(domain, response); - return allDashboards.filter((dashboard) => { + return allDashboards.filter(dashboard => { return this.queryEvaluator.evaluate(parsedQuery, dashboard) === true; }); } async dashboardsByTag(domain: string, tag: string): Promise { - const response = await this.fetch(`/api/search?type=dash-db&tag=${tag}`); + const response = await this.fetch( + `/api/search?type=dash-db&tag=${tag}`, + ); return this.fullyQualifiedDashboardURLs(domain, response); } - private fullyQualifiedDashboardURLs(domain: string, dashboards: Dashboard[]): Dashboard[] { + private fullyQualifiedDashboardURLs( + domain: string, + dashboards: Dashboard[], + ): Dashboard[] { return dashboards.map(dashboard => ({ ...dashboard, url: domain + dashboard.url, @@ -143,63 +186,197 @@ class Client { headers: { ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), - } + }, }; } } +export type GrafanaClientHost = { + host: GrafanaHost; + client: Client; +}; + +function initClients(opts: Options): Map { + const clients = new Map(); + + opts.hosts.map(host => { + clients.set(host.id, { + host: host, + client: new Client(opts, host.proxyPath ?? DEFAULT_PROXY_PATH), + }); + }); + return clients; +} + export class GrafanaApiClient implements GrafanaApi { - private readonly domain: string; - private readonly client: Client; + private readonly clients: Map; constructor(opts: Options) { - this.domain = opts.domain; - this.client = new Client(opts); + this.clients = initClients(opts); } - async listDashboards(query: string): Promise { - return this.client.listDashboards(this.domain, query); + async listDashboards(query: string, sourceId?: string): Promise { + const sourceIdent = sourceId === undefined || sourceId === '' ? 'default' : sourceId; + const grafanaClientHost = this.clients.get(sourceIdent); + if (grafanaClientHost === undefined) { + throw new Error( + `Grafana host id '${sourceIdent}' was not found. Check the grafana plugin configuration and/or grafana/source-id annotation value.`, + ); + } + return grafanaClientHost.client.listDashboards( + grafanaClientHost.host.domain, + query, + ); } - async alertsForSelector(dashboardTag: string): Promise { - const response = await this.client.fetch(`/api/alerts?dashboardTag=${dashboardTag}`); + async alertsForSelector( + selector: string, + sourceId?: string, + ): Promise { + const sourceIdent = sourceId === undefined || sourceId === '' ? 'default' : sourceId; + const grafanaClientHost = this.clients.get(sourceIdent); + + if (grafanaClientHost === undefined) { + throw new Error( + `Grafana host id '${sourceIdent}' was not found. Check the grafana plugin configuration and/or grafana/source-id annotation value.`, + ); + } - return response.map(alert => ( - { - name: alert.name, - state: alert.state, - url: `${this.domain}${alert.url}?panelId=${alert.panelId}&fullscreen&refresh=30s`, - } - )); - } -} + const domain = grafanaClientHost.host.domain; + const client = grafanaClientHost.client; -export class UnifiedAlertingGrafanaApiClient implements GrafanaApi { - private readonly domain: string; - private readonly client: Client; + if (!grafanaClientHost.host.unifiedAlerting) { + // not unified alerting... - constructor(opts: Options) { - this.domain = opts.domain; - this.client = new Client(opts); - } + const response = await client.fetch( + `/api/alerts?dashboardTag=${selector}`, + ); + + return response.map(alert => ({ + name: alert.name, + state: alert.state, + url: `${domain}${alert.url}?panelId=${alert.panelId}&fullscreen&refresh=30s`, + })); + } - async listDashboards(query: string): Promise { - return this.client.listDashboards(this.domain, query); + // unified alerting + + const alertsRuleResponse = client.fetch>( + '/api/ruler/grafana/api/v1/rules', + ); + + const alertsResponse = client.fetch( + '/api/prometheus/grafana/api/v1/alerts', + ); + + return Promise.all([alertsRuleResponse, alertsResponse]).then(responses => { + const response = responses[0] as Record; + const alertsResponse = responses[1] as AlertsData; + + const rules = Object.values(response) + .flat() + .map(ruleGroup => ruleGroup.rules) + .flat(); + const labelSelectors = selector.split(','); + + return labelSelectors + .map(selector => { + const [label, labelValue] = selector.split('='); + + const matchingRules = rules.filter( + rule => rule.labels && rule.labels[label] === labelValue, + ); + const alertInstances = alertsResponse.data.alerts.filter( + alertInstance => alertInstance.labels[label] === labelValue, + ); + + return matchingRules.map(rule => { + const matchingAlertInstances = alertInstances.filter( + alertInstance => + alertInstance.labels.alertname === rule.grafana_alert.title, + ); + + const aggregatedAlertStates = this.getAggregatedAlertStates( + matchingAlertInstances, + ); + + return { + name: rule.grafana_alert.title, + url: `${domain}/alerting/grafana/${rule.grafana_alert.uid}/view`, + state: this.getState( + aggregatedAlertStates, + matchingAlertInstances.length, + ), + }; + }); + }) + .flat(); + }); } - async alertsForSelector(selector: string): Promise { - const response = await this.client.fetch>('/api/ruler/grafana/api/v1/rules'); - const rules = Object.values(response).flat().map(ruleGroup => ruleGroup.rules).flat(); - const [label, labelValue] = selector.split('='); + private getState( + states: { + normal: number; + pending: number; + alerting: number; + noData: number; + error: number; + invalid: number; + }, + totalAlerts: number, + ): UnifiedAlertState { + if (states.alerting > 0) { + return 'Alerting'; + } else if (states.error > 0) { + return 'Error'; + } else if (states.pending > 0) { + return 'Pending'; + } + if (states.noData === totalAlerts) { + return 'NoData'; + } else if ( + states.normal === totalAlerts || + states.normal + states.noData === totalAlerts + ) { + return 'Normal'; + } - const matchingRules = rules.filter(rule => rule.labels && rule.labels[label] === labelValue); + return 'n/a'; + } - return matchingRules.map(rule => { - return { - name: rule.grafana_alert.title, - url: `${this.domain}/alerting/grafana/${rule.grafana_alert.uid}/view`, - state: "n/a", - }; - }) + private getAggregatedAlertStates(matchingAlertInstances: AlertInstance[]) { + return matchingAlertInstances.reduce( + (previous, alert) => { + switch (alert.state) { + case 'Normal': + previous.normal += 1; + break; + case 'Pending': + previous.pending += 1; + break; + case 'Alerting': + previous.alerting += 1; + break; + case 'NoData': + previous.noData += 1; + break; + case 'Error': + previous.error += 1; + break; + default: + previous.invalid += 1; + } + + return previous; + }, + { + normal: 0, + pending: 0, + alerting: 0, + noData: 0, + error: 0, + invalid: 0, + }, + ); } } diff --git a/src/components/AlertsCard/AlertsCard.tsx b/src/components/AlertsCard/AlertsCard.tsx index def33a079..b0cc1c3d7 100644 --- a/src/components/AlertsCard/AlertsCard.tsx +++ b/src/components/AlertsCard/AlertsCard.tsx @@ -18,36 +18,56 @@ import React from 'react'; import { Progress, TableColumn, Table, StatusOK, StatusPending, StatusWarning, StatusError, StatusAborted, MissingAnnotationEmptyState, Link } from '@backstage/core-components'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { grafanaApiRef } from '../../api'; import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; -import { Alert as GrafanaAlert } from '../../types'; -import { GRAFANA_ANNOTATION_TAG_SELECTOR, GRAFANA_ANNOTATION_ALERT_LABEL_SELECTOR, isAlertSelectorAvailable, isDashboardSelectorAvailable, tagSelectorFromEntity, alertSelectorFromEntity } from '../grafanaData'; +import {Alert as GrafanaAlert, AlertsCardOpts} from '../../types'; +import { + GRAFANA_ANNOTATION_TAG_SELECTOR, + GRAFANA_ANNOTATION_ALERT_LABEL_SELECTOR, + isAlertSelectorAvailable, + isDashboardSelectorAvailable, + tagSelectorFromEntity, + alertSelectorFromEntity, + grafanaSourceIdFromEntity +} from '../grafanaData'; +import {useApi} from "@backstage/core-plugin-api"; +import {Tooltip} from "@material-ui/core"; const AlertStatusBadge = ({ alert }: { alert: GrafanaAlert }) => { let statusElmt: React.ReactElement; + let tooltipTitle: string; switch (alert.state) { case "ok": + case "Normal": statusElmt = ; + tooltipTitle = "Status OK" break; case "paused": statusElmt = ; + tooltipTitle = "Status Pending" break; case "no_data": case "pending": + case "Pending": + case "NoData": statusElmt = ; + tooltipTitle = "Status Warning" break; case "alerting": + case "Alerting": + case "Error": statusElmt = ; + tooltipTitle = "Status Error" break; default: statusElmt = ; + tooltipTitle = "Status Aborted" } return ( -
{statusElmt}
+
{statusElmt}
); }; @@ -61,9 +81,10 @@ export const AlertsTable = ({alerts, opts}: {alerts: GrafanaAlert[], opts: Alert }, ]; - if (opts.showState) { + if (opts.showState) { columns.push({ title: 'State', + cellStyle: {textAlign: 'center'}, render: (row: GrafanaAlert): React.ReactNode => , }); } @@ -88,11 +109,10 @@ export const AlertsTable = ({alerts, opts}: {alerts: GrafanaAlert[], opts: Alert const Alerts = ({entity, opts}: {entity: Entity, opts: AlertsCardOpts}) => { const grafanaApi = useApi(grafanaApiRef); - const configApi = useApi(configApiRef); - const unifiedAlertingEnabled = configApi.getOptionalBoolean('grafana.unifiedAlerting') || false; - const alertSelector = unifiedAlertingEnabled ? alertSelectorFromEntity(entity) : tagSelectorFromEntity(entity); - const { value, loading, error } = useAsync(async () => await grafanaApi.alertsForSelector(alertSelector)); + const alertSelector = isAlertSelectorAvailable(entity) ? alertSelectorFromEntity(entity) : tagSelectorFromEntity(entity); + + const { value, loading, error } = useAsync(async () => await grafanaApi.alertsForSelector(alertSelector, grafanaSourceIdFromEntity(entity))); if (loading) { return ; @@ -103,19 +123,10 @@ const Alerts = ({entity, opts}: {entity: Entity, opts: AlertsCardOpts}) => { return ; }; -export type AlertsCardOpts = { - paged?: boolean; - searchable?: boolean; - pageSize?: number; - sortable?: boolean; - title?: string; - showState?: boolean; -}; - -export const AlertsCard = (opts?: AlertsCardOpts) => { +export const AlertsCard = (opts: AlertsCardOpts) => { const { entity } = useEntity(); - const configApi = useApi(configApiRef); - const unifiedAlertingEnabled = configApi.getOptionalBoolean('grafana.unifiedAlerting') || false; + + const unifiedAlertingEnabled = isAlertSelectorAvailable(entity) || false; if (!unifiedAlertingEnabled && !isDashboardSelectorAvailable(entity)) { return ; @@ -125,7 +136,14 @@ export const AlertsCard = (opts?: AlertsCardOpts) => { return ; } - const finalOpts = {...opts, ...{showState: opts?.showState && !unifiedAlertingEnabled}}; + return ; +}; - return ; +AlertsCard.defaultProps = { + paged: false, + searchable: false, + pageSize: 5, + sortable: false, + title: 'Alerts', + showState: true }; diff --git a/src/components/DashboardsCard/DashboardsCard.tsx b/src/components/DashboardsCard/DashboardsCard.tsx index 5d94f4228..c5d125556 100644 --- a/src/components/DashboardsCard/DashboardsCard.tsx +++ b/src/components/DashboardsCard/DashboardsCard.tsx @@ -24,7 +24,12 @@ import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { Tooltip } from '@material-ui/core'; import { Dashboard } from '../../types'; -import { dashboardSelectorFromEntity, GRAFANA_ANNOTATION_DASHBOARD_SELECTOR, isDashboardSelectorAvailable } from '../grafanaData'; +import { + dashboardSelectorFromEntity, + GRAFANA_ANNOTATION_DASHBOARD_SELECTOR, + grafanaSourceIdFromEntity, + isDashboardSelectorAvailable +} from '../grafanaData'; export const DashboardsTable = ({entity, dashboards, opts}: {entity: Entity, dashboards: Dashboard[], opts: DashboardCardOpts}) => { const columns: TableColumn[] = [ @@ -66,7 +71,7 @@ export const DashboardsTable = ({entity, dashboards, opts}: {entity: Entity, das const Dashboards = ({entity, opts}: {entity: Entity, opts: DashboardCardOpts}) => { const grafanaApi = useApi(grafanaApiRef); - const { value, loading, error } = useAsync(async () => await grafanaApi.listDashboards(dashboardSelectorFromEntity(entity))); + const { value, loading, error } = useAsync(async () => await grafanaApi.listDashboards(dashboardSelectorFromEntity(entity), grafanaSourceIdFromEntity(entity))); if (loading) { return ; diff --git a/src/components/grafanaData.ts b/src/components/grafanaData.ts index 8e727d631..ad574e2b9 100644 --- a/src/components/grafanaData.ts +++ b/src/components/grafanaData.ts @@ -18,15 +18,17 @@ import { Entity } from '@backstage/catalog-model'; // @deprecated Use GRAFANA_ANNOTATION_DASHBOARD_SELECTOR instead. export const GRAFANA_ANNOTATION_TAG_SELECTOR = 'grafana/tag-selector'; +export const GRAFANA_ANNOTATION_SOURCE_ID = 'grafana/source-id'; export const GRAFANA_ANNOTATION_DASHBOARD_SELECTOR = 'grafana/dashboard-selector'; export const GRAFANA_ANNOTATION_ALERT_LABEL_SELECTOR = 'grafana/alert-label-selector'; export const GRAFANA_ANNOTATION_OVERVIEW_DASHBOARD = 'grafana/overview-dashboard'; -export const isDashboardSelectorAvailable = (entity: Entity) => entity?.metadata.annotations?.[GRAFANA_ANNOTATION_DASHBOARD_SELECTOR] || entity?.metadata.annotations?.[GRAFANA_ANNOTATION_TAG_SELECTOR]; +export const isDashboardSelectorAvailable = (entity: Entity) => Boolean(entity?.metadata.annotations?.[GRAFANA_ANNOTATION_DASHBOARD_SELECTOR] || entity?.metadata.annotations?.[GRAFANA_ANNOTATION_TAG_SELECTOR]); export const isAlertSelectorAvailable = (entity: Entity) => Boolean(entity?.metadata.annotations?.[GRAFANA_ANNOTATION_ALERT_LABEL_SELECTOR]); export const isOverviewDashboardAvailable = (entity: Entity) => Boolean(entity?.metadata.annotations?.[GRAFANA_ANNOTATION_OVERVIEW_DASHBOARD]); export const dashboardSelectorFromEntity = (entity: Entity) => entity?.metadata.annotations?.[GRAFANA_ANNOTATION_DASHBOARD_SELECTOR] ?? entity?.metadata.annotations?.[GRAFANA_ANNOTATION_TAG_SELECTOR] ?? ''; +export const grafanaSourceIdFromEntity = (entity: Entity) => entity?.metadata.annotations?.[GRAFANA_ANNOTATION_SOURCE_ID]; export const alertSelectorFromEntity = (entity: Entity) => entity?.metadata.annotations?.[GRAFANA_ANNOTATION_ALERT_LABEL_SELECTOR] ?? ''; export const overviewDashboardFromEntity = (entity: Entity) => entity?.metadata.annotations?.[GRAFANA_ANNOTATION_OVERVIEW_DASHBOARD] ?? ''; diff --git a/src/index.ts b/src/index.ts index e760054ce..6d165b29f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export { alertSelectorFromEntity, overviewDashboardFromEntity, GRAFANA_ANNOTATION_DASHBOARD_SELECTOR, + GRAFANA_ANNOTATION_SOURCE_ID, GRAFANA_ANNOTATION_ALERT_LABEL_SELECTOR, GRAFANA_ANNOTATION_TAG_SELECTOR, GRAFANA_ANNOTATION_OVERVIEW_DASHBOARD, diff --git a/src/plugin.ts b/src/plugin.ts index ec199ddb8..2607e1641 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -15,7 +15,7 @@ */ import { configApiRef, createApiFactory, createComponentExtension, createPlugin, discoveryApiRef, identityApiRef } from '@backstage/core-plugin-api'; -import { UnifiedAlertingGrafanaApiClient, grafanaApiRef, GrafanaApiClient } from './api'; +import {grafanaApiRef, GrafanaApiClient, GrafanaHost, DEFAULT_PROXY_PATH} from './api'; export const grafanaPlugin = createPlugin({ id: 'grafana', @@ -24,24 +24,39 @@ export const grafanaPlugin = createPlugin({ api: grafanaApiRef, deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef, configApi: configApiRef }, factory: ({ discoveryApi, identityApi, configApi }) => { - const unifiedAlertingEnabled = configApi.getOptionalBoolean('grafana.unifiedAlerting') || false; + const hosts: GrafanaHost[] = configApi.getOptional('grafana.hosts') || []; + const domain = configApi.getOptionalString('grafana.domain'); - if (!unifiedAlertingEnabled) { - return new GrafanaApiClient({ - discoveryApi: discoveryApi, - identityApi: identityApi, - domain: configApi.getString('grafana.domain'), - proxyPath: configApi.getOptionalString('grafana.proxyPath'), - }); + // let's do some config validations: + if (!domain && !hosts) { + throw new Error("At least `grafana.domain` or `grafana.hosts` must be defined") } - return new UnifiedAlertingGrafanaApiClient({ + hosts.forEach(host => { + if (!host.domain) { + throw new Error("Each `grafana.hosts.domain` must be defined in the configuration") + } + if (!host.id) { + throw new Error("Each `grafana.hosts.id` must be defined in the configuration") + } + }); + + //for backward compatibility + if (domain) { + hosts.push({ + id: 'default', + domain: domain, + proxyPath: configApi.getOptionalString('grafana.proxyPath') ?? DEFAULT_PROXY_PATH, + unifiedAlerting: configApi.getOptionalBoolean('grafana.unifiedAlerting') + }) + } + + return new GrafanaApiClient({ discoveryApi: discoveryApi, identityApi: identityApi, - domain: configApi.getString('grafana.domain'), - proxyPath: configApi.getOptionalString('grafana.proxyPath'), + hosts: hosts, }); - }, + } }), ], }); diff --git a/src/types.ts b/src/types.ts index 14581ea81..2f234b788 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,3 +27,12 @@ export interface Alert { state: string; url: string; } + +export interface AlertsCardOpts { + paged?: boolean; + searchable?: boolean; + pageSize?: number; + sortable?: boolean; + title?: string; + showState?: boolean; +}