From c9e83f63482976d317adcf917260ef3ca3c55db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 16 Mar 2026 14:29:56 +0100 Subject: [PATCH 01/24] feat: create grafana component --- src/components/grafana/builder.ts | 35 ++++++++ src/components/grafana/grafana.ts | 136 ++++++++++++++++++++++++++++++ src/components/grafana/index.ts | 2 + 3 files changed, 173 insertions(+) create mode 100644 src/components/grafana/builder.ts create mode 100644 src/components/grafana/grafana.ts diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts new file mode 100644 index 00000000..00a45124 --- /dev/null +++ b/src/components/grafana/builder.ts @@ -0,0 +1,35 @@ +import * as pulumi from '@pulumi/pulumi'; +import { Grafana } from './grafana'; + +export class GrafanaBuilder { + private name: string; + private prometheusConfig?: Grafana.PrometheusConfig; + private tags?: Grafana.Args['tags']; + + constructor(name: string) { + this.name = name; + } + + public withPrometheus(config: Grafana.PrometheusConfig): this { + this.prometheusConfig = config; + + return this; + } + + public withTags(tags: Grafana.Args['tags']): this { + this.tags = tags; + + return this; + } + + public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { + return new Grafana( + this.name, + { + prometheus: this.prometheusConfig, + tags: this.tags, + }, + opts, + ); + } +} diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts new file mode 100644 index 00000000..7b381c5f --- /dev/null +++ b/src/components/grafana/grafana.ts @@ -0,0 +1,136 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; + +const GRAFANA_CLOUD_AWS_ACCOUNT_ID = '008923505280'; + +export namespace Grafana { + export type PrometheusConfig = { + prometheusEndpoint: pulumi.Input; + region: pulumi.Input; + }; + + export type Args = { + prometheus?: PrometheusConfig; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; + }; +} + +export class Grafana extends pulumi.ComponentResource { + prometheusDataSource?: grafana.oss.DataSource; + + constructor( + name: string, + args: Grafana.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:grafana:Grafana', name, {}, opts); + + if (args.prometheus) { + const ampRole = this.createAmpRole(name, args.tags); + this.createPrometheusDataSource(name, args.prometheus, ampRole); + } + + this.registerOutputs(); + } + + private getStackSlug(): string { + const grafanaUrl = process.env.GRAFANA_URL; + + if (!grafanaUrl) { + throw new Error('GRAFANA_URL environment variable is not set.'); + } + + return new URL(grafanaUrl).hostname.split('.')[0]; + } + + private createAmpRole(name: string, tags: Grafana.Args['tags']) { + const stackSlug = this.getStackSlug(); + const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); + + const ampRole = new aws.iam.Role( + `${name}-amp-role`, + { + assumeRolePolicy: pulumi.jsonStringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + AWS: `arn:aws:iam::${GRAFANA_CLOUD_AWS_ACCOUNT_ID}:root`, + }, + Action: 'sts:AssumeRole', + Condition: { + StringEquals: { + 'sts:ExternalId': pulumi.output(grafanaStack).id, + }, + }, + }, + ], + }), + tags, + }, + { parent: this }, + ); + + new aws.iam.RolePolicy( + `${name}-amp-policy`, + { + role: ampRole.id, + policy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: [ + 'aps:GetSeries', + 'aps:GetLabels', + 'aps:GetMetricMetadata', + 'aps:QueryMetrics', + ], + Resource: '*', + }, + ], + }), + }, + { parent: this }, + ); + + return ampRole; + } + + private createPrometheusDataSource( + name: string, + config: Grafana.PrometheusConfig, + ampRole: aws.iam.Role, + ) { + const stackSlug = this.getStackSlug(); + + const plugin = new grafana.cloud.PluginInstallation( + `${name}-prometheus-plugin`, + { + stackSlug, + slug: 'grafana-amazonprometheus-datasource', + version: 'latest', + }, + { parent: this }, + ); + + this.prometheusDataSource = new grafana.oss.DataSource( + `${name}-prometheus-datasource`, + { + type: 'grafana-amazonprometheus-datasource', + url: config.prometheusEndpoint, + jsonDataEncoded: pulumi.jsonStringify({ + sigV4Auth: true, + sigV4AuthType: 'grafana_assume_role', + sigV4Region: config.region, + sigV4AssumeRoleArn: ampRole.arn, + }), + }, + { dependsOn: [plugin], parent: this }, + ); + } +} diff --git a/src/components/grafana/index.ts b/src/components/grafana/index.ts index e549b9ad..4009f0f7 100644 --- a/src/components/grafana/index.ts +++ b/src/components/grafana/index.ts @@ -1 +1,3 @@ export * as dashboard from './dashboards'; +export { Grafana } from './grafana'; +export { GrafanaBuilder } from './builder'; From 1df213564f56ea0b42bb3d52033e0573216a45c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 16 Mar 2026 14:37:57 +0100 Subject: [PATCH 02/24] docs: add grafana aws account hardcoded value comment --- src/components/grafana/grafana.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 7b381c5f..e9b90c27 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -2,6 +2,7 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; +// Fixed AWS account ID owned by Grafana Cloud, used to assume roles in customer accounts. const GRAFANA_CLOUD_AWS_ACCOUNT_ID = '008923505280'; export namespace Grafana { From ff2d228bae4a62954b9aca116b2981d62d83fd27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 17 Mar 2026 14:04:10 +0100 Subject: [PATCH 03/24] refactor: provider config value extraction --- src/components/grafana/grafana.ts | 71 ++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index e9b90c27..ef86707d 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -2,13 +2,14 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; -// Fixed AWS account ID owned by Grafana Cloud, used to assume roles in customer accounts. -const GRAFANA_CLOUD_AWS_ACCOUNT_ID = '008923505280'; +const awsConfig = new pulumi.Config('aws'); +const grafanaConfig = new pulumi.Config('grafana'); export namespace Grafana { export type PrometheusConfig = { prometheusEndpoint: pulumi.Input; - region: pulumi.Input; + region?: string; + prometheusPluginVersion?: string; }; export type Args = { @@ -20,6 +21,7 @@ export namespace Grafana { } export class Grafana extends pulumi.ComponentResource { + grafanaIamRole: aws.iam.Role; prometheusDataSource?: grafana.oss.DataSource; constructor( @@ -29,30 +31,34 @@ export class Grafana extends pulumi.ComponentResource { ) { super('studion:grafana:Grafana', name, {}, opts); + this.grafanaIamRole = this.createGrafanaIamRole(name, args); + if (args.prometheus) { - const ampRole = this.createAmpRole(name, args.tags); - this.createPrometheusDataSource(name, args.prometheus, ampRole); + this.createAmpRolePolicy(name, this.grafanaIamRole); + this.createPrometheusDataSource( + name, + args.prometheus, + this.grafanaIamRole, + ); } this.registerOutputs(); } - private getStackSlug(): string { - const grafanaUrl = process.env.GRAFANA_URL; - - if (!grafanaUrl) { - throw new Error('GRAFANA_URL environment variable is not set.'); + private createGrafanaIamRole(name: string, args: Grafana.Args) { + const grafanaAwsAccountId = + grafanaConfig.get('awsAccountId') ?? process.env.GRAFANA_AWS_ACCOUNT_ID; + if (!grafanaAwsAccountId) { + throw new Error( + 'Grafana AWS Account ID is not configured. Set it via Pulumi config (grafana:awsAccountId) or GRAFANA_AWS_ACCOUNT_ID env var.', + ); } - return new URL(grafanaUrl).hostname.split('.')[0]; - } - - private createAmpRole(name: string, tags: Grafana.Args['tags']) { const stackSlug = this.getStackSlug(); const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); - const ampRole = new aws.iam.Role( - `${name}-amp-role`, + const grafanaIamRole = new aws.iam.Role( + `${name}-grafana-iam-role`, { assumeRolePolicy: pulumi.jsonStringify({ Version: '2012-10-17', @@ -60,7 +66,7 @@ export class Grafana extends pulumi.ComponentResource { { Effect: 'Allow', Principal: { - AWS: `arn:aws:iam::${GRAFANA_CLOUD_AWS_ACCOUNT_ID}:root`, + AWS: `arn:aws:iam::${grafanaAwsAccountId}:root`, }, Action: 'sts:AssumeRole', Condition: { @@ -71,15 +77,31 @@ export class Grafana extends pulumi.ComponentResource { }, ], }), - tags, + tags: args.tags, }, { parent: this }, ); + return grafanaIamRole; + } + + private getStackSlug(): string { + const grafanaUrl = grafanaConfig.get('url') ?? process.env.GRAFANA_URL; + + if (!grafanaUrl) { + throw new Error( + 'Grafana URL is not configured. Set it via Pulumi config (grafana:url) or GRAFANA_URL env var.', + ); + } + + return new URL(grafanaUrl).hostname.split('.')[0]; + } + + private createAmpRolePolicy(name: string, grafanaIamRole: aws.iam.Role) { new aws.iam.RolePolicy( `${name}-amp-policy`, { - role: ampRole.id, + role: grafanaIamRole.id, policy: JSON.stringify({ Version: '2012-10-17', Statement: [ @@ -98,23 +120,22 @@ export class Grafana extends pulumi.ComponentResource { }, { parent: this }, ); - - return ampRole; } private createPrometheusDataSource( name: string, config: Grafana.PrometheusConfig, - ampRole: aws.iam.Role, + grafanaIamRole: aws.iam.Role, ) { const stackSlug = this.getStackSlug(); + const region = config.region ?? awsConfig.require('region'); const plugin = new grafana.cloud.PluginInstallation( `${name}-prometheus-plugin`, { stackSlug, slug: 'grafana-amazonprometheus-datasource', - version: 'latest', + version: config.prometheusPluginVersion ?? 'latest', }, { parent: this }, ); @@ -127,8 +148,8 @@ export class Grafana extends pulumi.ComponentResource { jsonDataEncoded: pulumi.jsonStringify({ sigV4Auth: true, sigV4AuthType: 'grafana_assume_role', - sigV4Region: config.region, - sigV4AssumeRoleArn: ampRole.arn, + sigV4Region: region, + sigV4AssumeRoleArn: grafanaIamRole.arn, }), }, { dependsOn: [plugin], parent: this }, From 02cb73428b8825433bcf468030a3b8ccc7e3544a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 17 Mar 2026 14:10:04 +0100 Subject: [PATCH 04/24] feat: remove tags prop from grafana builder and component --- src/components/grafana/builder.ts | 8 -------- src/components/grafana/grafana.ts | 6 ++---- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 00a45124..07568849 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -4,7 +4,6 @@ import { Grafana } from './grafana'; export class GrafanaBuilder { private name: string; private prometheusConfig?: Grafana.PrometheusConfig; - private tags?: Grafana.Args['tags']; constructor(name: string) { this.name = name; @@ -16,18 +15,11 @@ export class GrafanaBuilder { return this; } - public withTags(tags: Grafana.Args['tags']): this { - this.tags = tags; - - return this; - } - public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { return new Grafana( this.name, { prometheus: this.prometheusConfig, - tags: this.tags, }, opts, ); diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index ef86707d..35d22a69 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -1,6 +1,7 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; +import { commonTags } from '../../shared/common-tags'; const awsConfig = new pulumi.Config('aws'); const grafanaConfig = new pulumi.Config('grafana'); @@ -14,9 +15,6 @@ export namespace Grafana { export type Args = { prometheus?: PrometheusConfig; - tags?: pulumi.Input<{ - [key: string]: pulumi.Input; - }>; }; } @@ -77,7 +75,7 @@ export class Grafana extends pulumi.ComponentResource { }, ], }), - tags: args.tags, + tags: commonTags, }, { parent: this }, ); From 452c85c2ee22565fdd9682d8dfcd52ed36dd27c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 18 Mar 2026 11:03:29 +0100 Subject: [PATCH 05/24] refactor: rename grafana resources --- src/components/grafana/grafana.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 35d22a69..07c66b1a 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -10,7 +10,7 @@ export namespace Grafana { export type PrometheusConfig = { prometheusEndpoint: pulumi.Input; region?: string; - prometheusPluginVersion?: string; + pluginVersion?: string; }; export type Args = { @@ -133,14 +133,16 @@ export class Grafana extends pulumi.ComponentResource { { stackSlug, slug: 'grafana-amazonprometheus-datasource', - version: config.prometheusPluginVersion ?? 'latest', + version: config.pluginVersion ?? 'latest', }, { parent: this }, ); + const dataSourceName = `${name}-prometheus-datasource`; this.prometheusDataSource = new grafana.oss.DataSource( - `${name}-prometheus-datasource`, + dataSourceName, { + name: dataSourceName, type: 'grafana-amazonprometheus-datasource', url: config.prometheusEndpoint, jsonDataEncoded: pulumi.jsonStringify({ From fd465d2981ca9e52ab4a704f425e62935cc4fd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 18 Mar 2026 17:36:11 +0100 Subject: [PATCH 06/24] feat: grafana addDashboard builder method --- src/components/grafana/builder.ts | 9 + src/components/grafana/dashboards/panels.ts | 35 +- src/components/grafana/dashboards/types.ts | 54 +-- .../grafana/dashboards/web-server-slo.ts | 376 +++++++++--------- src/components/grafana/grafana.ts | 12 + 5 files changed, 262 insertions(+), 224 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 07568849..c0e99e5c 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -1,9 +1,11 @@ import * as pulumi from '@pulumi/pulumi'; import { Grafana } from './grafana'; +import { GrafanaDashboard } from './dashboards/types'; export class GrafanaBuilder { private name: string; private prometheusConfig?: Grafana.PrometheusConfig; + private dashboardConfigs: GrafanaDashboard.DashboardConfig[] = []; constructor(name: string) { this.name = name; @@ -15,11 +17,18 @@ export class GrafanaBuilder { return this; } + public addDashboard(config: GrafanaDashboard.DashboardConfig): this { + this.dashboardConfigs.push(config); + + return this; + } + public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { return new Grafana( this.name, { prometheus: this.prometheusConfig, + dashboards: this.dashboardConfigs, }, opts, ); diff --git a/src/components/grafana/dashboards/panels.ts b/src/components/grafana/dashboards/panels.ts index c410cd6c..58277823 100644 --- a/src/components/grafana/dashboards/panels.ts +++ b/src/components/grafana/dashboards/panels.ts @@ -1,4 +1,5 @@ -import { Grafana } from './types'; +import * as pulumi from '@pulumi/pulumi'; +import { GrafanaDashboard } from './types'; const percentageFieldConfig = { unit: 'percent', @@ -8,10 +9,10 @@ const percentageFieldConfig = { export function createStatPercentagePanel( title: string, - position: Grafana.Panel.Position, - dataSource: string, - metric: Grafana.Metric, -): Grafana.Panel { + position: GrafanaDashboard.PanelPosition, + dataSource: pulumi.Input, + metric: GrafanaDashboard.Metric, +): GrafanaDashboard.Panel { return { title, gridPos: position, @@ -41,10 +42,10 @@ export function createStatPercentagePanel( export function createTimeSeriesPercentagePanel( title: string, - position: Grafana.Panel.Position, - dataSource: string, - metric: Grafana.Metric, -): Grafana.Panel { + position: GrafanaDashboard.PanelPosition, + dataSource: pulumi.Input, + metric: GrafanaDashboard.Metric, +): GrafanaDashboard.Panel { return createTimeSeriesPanel( title, position, @@ -58,13 +59,13 @@ export function createTimeSeriesPercentagePanel( export function createTimeSeriesPanel( title: string, - position: Grafana.Panel.Position, - dataSource: string, - metric: Grafana.Metric, + position: GrafanaDashboard.PanelPosition, + dataSource: pulumi.Input, + metric: GrafanaDashboard.Metric, unit?: string, min?: number, max?: number, -): Grafana.Panel { +): GrafanaDashboard.Panel { return { title, type: 'timeseries', @@ -96,10 +97,10 @@ export function createTimeSeriesPanel( export function createBurnRatePanel( title: string, - position: Grafana.Panel.Position, - dataSource: string, - metric: Grafana.Metric, -): Grafana.Panel { + position: GrafanaDashboard.PanelPosition, + dataSource: pulumi.Input, + metric: GrafanaDashboard.Metric, +): GrafanaDashboard.Panel { return { type: 'stat', title, diff --git a/src/components/grafana/dashboards/types.ts b/src/components/grafana/dashboards/types.ts index 1a561bee..4d1687ec 100644 --- a/src/components/grafana/dashboards/types.ts +++ b/src/components/grafana/dashboards/types.ts @@ -1,32 +1,27 @@ import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; -// TODO: Should we prefix all namespaces with `Studion` -export namespace Grafana { - // TODO: Create SLO abstraction that enables configuring: - // - panels (long-window SLI, long-window error budget) - // - alerts (long-window burn, short-window burn) - export type Threshold = { - value: number | null; - color: string; - }; - export type Metric = { - label: string; - query: string; - thresholds: Threshold[]; +// TODO: Create SLO abstraction that enables configuring: +// - panels (long-window SLI, long-window error budget) +// - alerts (long-window burn, short-window burn) +export namespace GrafanaDashboard { + export type DataSources = { + prometheus?: pulumi.Output; }; + export interface DashboardConfig { + createResource(dataSources: DataSources): grafana.oss.Dashboard; + } + export type Args = { title: pulumi.Input; - provider: pulumi.Input; - tags: pulumi.Input[]>; }; export type Panel = { title: string; - gridPos: Panel.Position; + gridPos: PanelPosition; type: string; - datasource: string; + datasource: pulumi.Input; targets: { expr: string; legendFormat: string; @@ -62,12 +57,21 @@ export namespace Grafana { }; }; - export namespace Panel { - export type Position = { - x: number; - y: number; - w: number; - h: number; - }; - } + export type PanelPosition = { + x: number; + y: number; + w: number; + h: number; + }; + + export type Threshold = { + value: number | null; + color: string; + }; + + export type Metric = { + label: string; + query: string; + thresholds: Threshold[]; + }; } diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts index 94ae90ea..501f246b 100644 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ b/src/components/grafana/dashboards/web-server-slo.ts @@ -1,7 +1,7 @@ import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; import { queries as promQ } from '../../prometheus'; -import { Grafana } from './types'; +import { GrafanaDashboard } from './types'; import { createBurnRatePanel, createStatPercentagePanel, @@ -12,10 +12,11 @@ import { class WebServerSloDashboardBuilder { name: string; title: pulumi.Output; - panels: Grafana.Panel[] = []; - tags?: pulumi.Output; + private panelBuilders: (( + dataSources: GrafanaDashboard.DataSources, + ) => GrafanaDashboard.Panel[])[] = []; - constructor(name: string, args: Grafana.Args) { + constructor(name: string, args: GrafanaDashboard.Args) { this.name = name; this.title = pulumi.output(args.title); } @@ -23,41 +24,42 @@ class WebServerSloDashboardBuilder { withAvailability( target: number, window: promQ.TimeRange, - dataSource: string, prometheusNamespace: string, ): this { - const availabilityPercentage = promQ.getAvailabilityPercentageQuery( - prometheusNamespace, - window, - ); - const availabilityBurnRate = promQ.getBurnRateQuery( - promQ.getAvailabilityQuery(prometheusNamespace, '1h'), - target, - ); - - const availabilitySloPanel = createStatPercentagePanel( - 'Availability', - { x: 0, y: 0, w: 8, h: 8 }, - dataSource, - { - label: 'Availability', - query: availabilityPercentage, - thresholds: [], - }, - ); - const availabilityBurnRatePanel = createBurnRatePanel( - 'Availability Burn Rate', - { x: 0, y: 8, w: 8, h: 4 }, - dataSource, - { - label: 'Burn Rate', - query: availabilityBurnRate, - thresholds: [], - }, - ); - - this.panels.push(availabilitySloPanel, availabilityBurnRatePanel); - + this.panelBuilders.push(dataSource => { + const prometheusDataSource = this.requireDataSource( + dataSource, + 'prometheus', + ); + return [ + createStatPercentagePanel( + 'Availability', + { x: 0, y: 0, w: 8, h: 8 }, + prometheusDataSource, + { + label: 'Availability', + query: promQ.getAvailabilityPercentageQuery( + prometheusNamespace, + window, + ), + thresholds: [], + }, + ), + createBurnRatePanel( + 'Availability Burn Rate', + { x: 0, y: 8, w: 8, h: 4 }, + prometheusDataSource, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getAvailabilityQuery(prometheusNamespace, '1h'), + target, + ), + thresholds: [], + }, + ), + ]; + }); return this; } @@ -66,61 +68,57 @@ class WebServerSloDashboardBuilder { window: promQ.TimeRange, shortWindow: promQ.TimeRange, filter: string, - dataSource: string, prometheusNamespace: string, ): this { - const successRateSlo = promQ.getSuccessPercentageQuery( - prometheusNamespace, - window, - filter, - ); - const successRateBurnRate = promQ.getBurnRateQuery( - promQ.getSuccessRateQuery(prometheusNamespace, '1h', filter), - target, - ); - const successRate = promQ.getSuccessPercentageQuery( - prometheusNamespace, - shortWindow, - filter, - ); - - const successRateSloPanel = createStatPercentagePanel( - 'Success Rate', - { x: 8, y: 0, w: 8, h: 8 }, - dataSource, - { - label: 'Success Rate', - query: successRateSlo, - thresholds: [], - }, - ); - const successRatePanel = createTimeSeriesPercentagePanel( - 'HTTP Request Success Rate', - { x: 0, y: 16, w: 12, h: 8 }, - dataSource, - { - label: 'Success Rate', - query: successRate, - thresholds: [], - }, - ); - const successRateBurnRatePanel = createBurnRatePanel( - 'Success Rate Burn Rate', - { x: 8, y: 8, w: 8, h: 4 }, - dataSource, - { - label: 'Burn Rate', - query: successRateBurnRate, - thresholds: [], - }, - ); - - this.panels.push( - successRateSloPanel, - successRatePanel, - successRateBurnRatePanel, - ); - + this.panelBuilders.push(dataSource => { + const prometheusDataSource = this.requireDataSource( + dataSource, + 'prometheus', + ); + return [ + createStatPercentagePanel( + 'Success Rate', + { x: 8, y: 0, w: 8, h: 8 }, + prometheusDataSource, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + prometheusNamespace, + window, + filter, + ), + thresholds: [], + }, + ), + createTimeSeriesPercentagePanel( + 'HTTP Request Success Rate', + { x: 0, y: 16, w: 12, h: 8 }, + prometheusDataSource, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + prometheusNamespace, + shortWindow, + filter, + ), + thresholds: [], + }, + ), + createBurnRatePanel( + 'Success Rate Burn Rate', + { x: 8, y: 8, w: 8, h: 4 }, + prometheusDataSource, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getSuccessRateQuery(prometheusNamespace, '1h', filter), + target, + ), + thresholds: [], + }, + ), + ]; + }); return this; } @@ -130,104 +128,118 @@ class WebServerSloDashboardBuilder { window: promQ.TimeRange, shortWindow: promQ.TimeRange, filter: string, - dataSource: string, prometheusNamespace: string, ): this { - const latencySlo = promQ.getLatencyPercentageQuery( - prometheusNamespace, - window, - targetLatency, - filter, - ); - const latencyBurnRate = promQ.getBurnRateQuery( - promQ.getLatencyRateQuery(prometheusNamespace, '1h', targetLatency), - target, - ); - const percentileLatency = promQ.getPercentileLatencyQuery( - prometheusNamespace, - shortWindow, - target, - filter, - ); - const latencyBelowThreshold = promQ.getLatencyPercentageQuery( - prometheusNamespace, - shortWindow, - targetLatency, - filter, - ); - - const latencySloPanel = createStatPercentagePanel( - 'Request % below 250ms', - { x: 16, y: 0, w: 8, h: 8 }, - dataSource, - { - label: 'Request % below 250ms', - query: latencySlo, - thresholds: [], - }, - ); - const percentileLatencyPanel = createTimeSeriesPanel( - '99th Percentile Latency', - { x: 12, y: 16, w: 12, h: 8 }, - dataSource, - { - label: '99th Percentile Latency', - query: percentileLatency, - thresholds: [], - }, - 'ms', - ); - const latencyPercentagePanel = createTimeSeriesPercentagePanel( - 'Request percentage below 250ms', - { x: 0, y: 24, w: 12, h: 8 }, - dataSource, - { - label: 'Request percentage below 250ms', - query: latencyBelowThreshold, - thresholds: [], - }, - ); - const latencyBurnRatePanel = createBurnRatePanel( - 'Latency Burn Rate', - { x: 16, y: 8, w: 8, h: 4 }, - dataSource, - { - label: 'Burn Rate', - query: latencyBurnRate, - thresholds: [], - }, - ); - - this.panels.push( - latencySloPanel, - percentileLatencyPanel, - latencyPercentagePanel, - latencyBurnRatePanel, - ); + this.panelBuilders.push(dataSource => { + const prometheusDataSource = this.requireDataSource( + dataSource, + 'prometheus', + ); + return [ + createStatPercentagePanel( + 'Request % below 250ms', + { x: 16, y: 0, w: 8, h: 8 }, + prometheusDataSource, + { + label: 'Request % below 250ms', + query: promQ.getLatencyPercentageQuery( + prometheusNamespace, + window, + targetLatency, + filter, + ), + thresholds: [], + }, + ), + createTimeSeriesPanel( + '99th Percentile Latency', + { x: 12, y: 16, w: 12, h: 8 }, + prometheusDataSource, + { + label: '99th Percentile Latency', + query: promQ.getPercentileLatencyQuery( + prometheusNamespace, + shortWindow, + target, + filter, + ), + thresholds: [], + }, + 'ms', + ), + createTimeSeriesPercentagePanel( + 'Request percentage below 250ms', + { x: 0, y: 24, w: 12, h: 8 }, + prometheusDataSource, + { + label: 'Request percentage below 250ms', + query: promQ.getLatencyPercentageQuery( + prometheusNamespace, + shortWindow, + targetLatency, + filter, + ), + thresholds: [], + }, + ), + createBurnRatePanel( + 'Latency Burn Rate', + { x: 16, y: 8, w: 8, h: 4 }, + prometheusDataSource, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getLatencyRateQuery( + prometheusNamespace, + '1h', + targetLatency, + ), + target, + ), + thresholds: [], + }, + ), + ]; + }); + return this; + } + addPanel( + buildPanel: ( + dataSource: GrafanaDashboard.DataSources, + ) => GrafanaDashboard.Panel, + ): this { + this.panelBuilders.push(dataSource => [buildPanel(dataSource)]); return this; } - build( - provider: pulumi.Output, - ): pulumi.Output { - return pulumi - .all([this.title, this.panels, provider, this.tags]) - .apply(([title, panels, provider, tags]) => { - return new grafana.oss.Dashboard( - this.name, - { - configJson: JSON.stringify({ - title, - tags, - timezone: 'browser', - refresh: '10s', - panels, - }), - }, - { provider }, - ); - }); + build(): GrafanaDashboard.DashboardConfig { + const { name, title, panelBuilders } = this; + return { + createResource(dataSources) { + const panels = panelBuilders.flatMap(buildPanel => { + return buildPanel(dataSources); + }); + + return new grafana.oss.Dashboard(name, { + configJson: pulumi.jsonStringify({ + title, + timezone: 'browser', + refresh: '10s', + panels, + }), + }); + }, + }; + } + + private requireDataSource( + dataSource: GrafanaDashboard.DataSources, + key: keyof GrafanaDashboard.DataSources, + ): pulumi.Output { + if (!dataSource[key]) + throw new Error(`Missing required data source: ${String(key)}`); + return dataSource[key]!; } } diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 07c66b1a..35582f45 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -2,6 +2,7 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; import { commonTags } from '../../shared/common-tags'; +import { GrafanaDashboard } from './dashboards/types'; const awsConfig = new pulumi.Config('aws'); const grafanaConfig = new pulumi.Config('grafana'); @@ -15,12 +16,14 @@ export namespace Grafana { export type Args = { prometheus?: PrometheusConfig; + dashboards?: GrafanaDashboard.DashboardConfig[]; }; } export class Grafana extends pulumi.ComponentResource { grafanaIamRole: aws.iam.Role; prometheusDataSource?: grafana.oss.DataSource; + dashboards: grafana.oss.Dashboard[] = []; constructor( name: string, @@ -40,6 +43,15 @@ export class Grafana extends pulumi.ComponentResource { ); } + if (args.dashboards?.length) { + const dataSources = { + prometheus: this.prometheusDataSource?.name, + }; + this.dashboards = args.dashboards.map(dashboard => { + return dashboard.createResource(dataSources); + }); + } + this.registerOutputs(); } From 37e5dde676dda1512c6e1fe96baf7891a6f761e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 18 Mar 2026 17:42:30 +0100 Subject: [PATCH 07/24] refactor: config naming --- src/components/grafana/builder.ts | 2 +- src/components/grafana/grafana.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 07568849..c3418aec 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -19,7 +19,7 @@ export class GrafanaBuilder { return new Grafana( this.name, { - prometheus: this.prometheusConfig, + prometheusConfig: this.prometheusConfig, }, opts, ); diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 07c66b1a..0b5143cd 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -8,13 +8,13 @@ const grafanaConfig = new pulumi.Config('grafana'); export namespace Grafana { export type PrometheusConfig = { - prometheusEndpoint: pulumi.Input; + endpoint: pulumi.Input; region?: string; pluginVersion?: string; }; export type Args = { - prometheus?: PrometheusConfig; + prometheusConfig?: PrometheusConfig; }; } @@ -31,11 +31,11 @@ export class Grafana extends pulumi.ComponentResource { this.grafanaIamRole = this.createGrafanaIamRole(name, args); - if (args.prometheus) { + if (args.prometheusConfig) { this.createAmpRolePolicy(name, this.grafanaIamRole); this.createPrometheusDataSource( name, - args.prometheus, + args.prometheusConfig, this.grafanaIamRole, ); } @@ -144,7 +144,7 @@ export class Grafana extends pulumi.ComponentResource { { name: dataSourceName, type: 'grafana-amazonprometheus-datasource', - url: config.prometheusEndpoint, + url: config.endpoint, jsonDataEncoded: pulumi.jsonStringify({ sigV4Auth: true, sigV4AuthType: 'grafana_assume_role', From 509db81561707d477a9920a69e3781ebe205e243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 18 Mar 2026 17:48:53 +0100 Subject: [PATCH 08/24] feat: add name public prop to grafana component --- src/components/grafana/grafana.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 0b5143cd..92604e08 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -19,6 +19,7 @@ export namespace Grafana { } export class Grafana extends pulumi.ComponentResource { + name: string; grafanaIamRole: aws.iam.Role; prometheusDataSource?: grafana.oss.DataSource; @@ -29,12 +30,12 @@ export class Grafana extends pulumi.ComponentResource { ) { super('studion:grafana:Grafana', name, {}, opts); - this.grafanaIamRole = this.createGrafanaIamRole(name, args); + this.name = name; + this.grafanaIamRole = this.createGrafanaIamRole(); if (args.prometheusConfig) { - this.createAmpRolePolicy(name, this.grafanaIamRole); + this.createAmpRolePolicy(this.grafanaIamRole); this.createPrometheusDataSource( - name, args.prometheusConfig, this.grafanaIamRole, ); @@ -43,7 +44,7 @@ export class Grafana extends pulumi.ComponentResource { this.registerOutputs(); } - private createGrafanaIamRole(name: string, args: Grafana.Args) { + private createGrafanaIamRole() { const grafanaAwsAccountId = grafanaConfig.get('awsAccountId') ?? process.env.GRAFANA_AWS_ACCOUNT_ID; if (!grafanaAwsAccountId) { @@ -56,7 +57,7 @@ export class Grafana extends pulumi.ComponentResource { const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); const grafanaIamRole = new aws.iam.Role( - `${name}-grafana-iam-role`, + `${this.name}-grafana-iam-role`, { assumeRolePolicy: pulumi.jsonStringify({ Version: '2012-10-17', @@ -95,9 +96,9 @@ export class Grafana extends pulumi.ComponentResource { return new URL(grafanaUrl).hostname.split('.')[0]; } - private createAmpRolePolicy(name: string, grafanaIamRole: aws.iam.Role) { + private createAmpRolePolicy(grafanaIamRole: aws.iam.Role) { new aws.iam.RolePolicy( - `${name}-amp-policy`, + `${this.name}-amp-policy`, { role: grafanaIamRole.id, policy: JSON.stringify({ @@ -121,7 +122,6 @@ export class Grafana extends pulumi.ComponentResource { } private createPrometheusDataSource( - name: string, config: Grafana.PrometheusConfig, grafanaIamRole: aws.iam.Role, ) { @@ -129,7 +129,7 @@ export class Grafana extends pulumi.ComponentResource { const region = config.region ?? awsConfig.require('region'); const plugin = new grafana.cloud.PluginInstallation( - `${name}-prometheus-plugin`, + `${this.name}-prometheus-plugin`, { stackSlug, slug: 'grafana-amazonprometheus-datasource', @@ -138,7 +138,7 @@ export class Grafana extends pulumi.ComponentResource { { parent: this }, ); - const dataSourceName = `${name}-prometheus-datasource`; + const dataSourceName = `${this.name}-prometheus-datasource`; this.prometheusDataSource = new grafana.oss.DataSource( dataSourceName, { From 29e7167fbaa66a6da033def2ba27d68fc5a42d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 19 Mar 2026 17:13:43 +0100 Subject: [PATCH 09/24] feat: introduce grafana connections --- src/components/grafana/builder.ts | 21 ++- .../grafana/connections/amp-connection.ts | 100 ++++++++++++ .../grafana/connections/connection.ts | 79 ++++++++++ src/components/grafana/connections/index.ts | 2 + src/components/grafana/grafana.ts | 142 +----------------- src/components/grafana/index.ts | 1 + 6 files changed, 200 insertions(+), 145 deletions(-) create mode 100644 src/components/grafana/connections/amp-connection.ts create mode 100644 src/components/grafana/connections/connection.ts create mode 100644 src/components/grafana/connections/index.ts diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index c3418aec..8fa8339a 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -1,25 +1,32 @@ import * as pulumi from '@pulumi/pulumi'; +import { GrafanaConnection } from './connections'; import { Grafana } from './grafana'; export class GrafanaBuilder { - private name: string; - private prometheusConfig?: Grafana.PrometheusConfig; + private _name: string; + private connections: GrafanaConnection[] = []; constructor(name: string) { - this.name = name; + this._name = name; } - public withPrometheus(config: Grafana.PrometheusConfig): this { - this.prometheusConfig = config; + public addConnection(connection: GrafanaConnection): this { + this.connections.push(connection); return this; } public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { + if (!this.connections.length) { + throw new Error( + 'At least one connection is required. Call addConnection() before build().', + ); + } + return new Grafana( - this.name, + this._name, { - prometheusConfig: this.prometheusConfig, + connections: this.connections, }, opts, ); diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts new file mode 100644 index 00000000..ce3f8d30 --- /dev/null +++ b/src/components/grafana/connections/amp-connection.ts @@ -0,0 +1,100 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; +import { GrafanaConnection } from './connection'; + +const awsConfig = new pulumi.Config('aws'); + +export namespace AMPConnection { + export type Args = { + endpoint: pulumi.Input; + region?: string; + pluginVersion?: string; + }; +} + +export class AMPConnection extends GrafanaConnection { + readonly dataSource: grafana.oss.DataSource; + + constructor( + name: string, + args: AMPConnection.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:grafana:AMPConnection', name, opts); + + this.createAmpRolePolicy(name); + + const plugin = this.createPlugin(name, args.pluginVersion); + + this.dataSource = this.createDataSource(name, args, plugin); + + this.registerOutputs(); + } + + private createAmpRolePolicy(name: string) { + const policy = aws.iam.getPolicyDocumentOutput({ + statements: [ + { + effect: 'Allow', + actions: [ + 'aps:GetSeries', + 'aps:GetLabels', + 'aps:GetMetricMetadata', + 'aps:QueryMetrics', + ], + resources: ['*'], + }, + ], + }); + + new aws.iam.RolePolicy( + `${name}-amp-policy`, + { + role: this.iamRole.id, + policy: policy.json, + }, + { parent: this }, + ); + } + + private createPlugin( + name: string, + pluginVersion?: string, + ): grafana.cloud.PluginInstallation { + return new grafana.cloud.PluginInstallation( + `${name}-prometheus-plugin`, + { + stackSlug: this.getStackSlug(), + slug: 'grafana-amazonprometheus-datasource', + version: pluginVersion ?? 'latest', + }, + { parent: this }, + ); + } + + private createDataSource( + name: string, + args: AMPConnection.Args, + plugin: grafana.cloud.PluginInstallation, + ): grafana.oss.DataSource { + const region = args.region ?? awsConfig.require('region'); + const dataSourceName = `${name}-prometheus-datasource`; + + return new grafana.oss.DataSource( + dataSourceName, + { + name: dataSourceName, + type: 'grafana-amazonprometheus-datasource', + url: args.endpoint, + jsonDataEncoded: pulumi.jsonStringify({ + sigV4Auth: true, + sigV4AuthType: 'grafana_assume_role', + sigV4Region: region, + sigV4AssumeRoleArn: this.iamRole.arn, + }), + }, + { dependsOn: [plugin], parent: this }, + ); + } +} diff --git a/src/components/grafana/connections/connection.ts b/src/components/grafana/connections/connection.ts new file mode 100644 index 00000000..2d777593 --- /dev/null +++ b/src/components/grafana/connections/connection.ts @@ -0,0 +1,79 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; +import { commonTags } from '../../../shared/common-tags'; + +const grafanaConfig = new pulumi.Config('grafana'); + +export abstract class GrafanaConnection extends pulumi.ComponentResource { + abstract readonly dataSource: grafana.oss.DataSource; + + readonly iamRole: aws.iam.Role; + + constructor( + type: string, + name: string, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super(type, name, {}, opts); + + this.iamRole = this.createIamRole(name); + } + + protected getStackSlug(): string { + const grafanaUrl = grafanaConfig.get('url') ?? process.env.GRAFANA_URL; + + if (!grafanaUrl) { + throw new Error( + 'Grafana URL is not configured. Set it via Pulumi config (grafana:url) or GRAFANA_URL env var.', + ); + } + + return new URL(grafanaUrl).hostname.split('.')[0]; + } + + private createIamRole(name: string): aws.iam.Role { + const grafanaAwsAccountId = + grafanaConfig.get('awsAccountId') ?? process.env.GRAFANA_AWS_ACCOUNT_ID; + + if (!grafanaAwsAccountId) { + throw new Error( + 'Grafana AWS Account ID is not configured. Set it via Pulumi config (grafana:awsAccountId) or GRAFANA_AWS_ACCOUNT_ID env var.', + ); + } + + const stackSlug = this.getStackSlug(); + const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); + + const assumeRolePolicy = aws.iam.getPolicyDocumentOutput({ + statements: [ + { + effect: 'Allow', + principals: [ + { + type: 'AWS', + identifiers: [`arn:aws:iam::${grafanaAwsAccountId}:root`], + }, + ], + actions: ['sts:AssumeRole'], + conditions: [ + { + test: 'StringEquals', + variable: 'sts:ExternalId', + values: [pulumi.output(grafanaStack).id], + }, + ], + }, + ], + }); + + return new aws.iam.Role( + `${name}-grafana-iam-role`, + { + assumeRolePolicy: assumeRolePolicy.json, + tags: commonTags, + }, + { parent: this }, + ); + } +} diff --git a/src/components/grafana/connections/index.ts b/src/components/grafana/connections/index.ts new file mode 100644 index 00000000..8cf0d756 --- /dev/null +++ b/src/components/grafana/connections/index.ts @@ -0,0 +1,2 @@ +export { GrafanaConnection } from './connection'; +export { AMPConnection } from './amp-connection'; diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 92604e08..82e06645 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -1,27 +1,14 @@ -import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; -import * as grafana from '@pulumiverse/grafana'; -import { commonTags } from '../../shared/common-tags'; - -const awsConfig = new pulumi.Config('aws'); -const grafanaConfig = new pulumi.Config('grafana'); +import { GrafanaConnection } from './connections'; export namespace Grafana { - export type PrometheusConfig = { - endpoint: pulumi.Input; - region?: string; - pluginVersion?: string; - }; - export type Args = { - prometheusConfig?: PrometheusConfig; + connections: GrafanaConnection[]; }; } export class Grafana extends pulumi.ComponentResource { - name: string; - grafanaIamRole: aws.iam.Role; - prometheusDataSource?: grafana.oss.DataSource; + connections: GrafanaConnection[]; constructor( name: string, @@ -30,129 +17,8 @@ export class Grafana extends pulumi.ComponentResource { ) { super('studion:grafana:Grafana', name, {}, opts); - this.name = name; - this.grafanaIamRole = this.createGrafanaIamRole(); - - if (args.prometheusConfig) { - this.createAmpRolePolicy(this.grafanaIamRole); - this.createPrometheusDataSource( - args.prometheusConfig, - this.grafanaIamRole, - ); - } + this.connections = args.connections; this.registerOutputs(); } - - private createGrafanaIamRole() { - const grafanaAwsAccountId = - grafanaConfig.get('awsAccountId') ?? process.env.GRAFANA_AWS_ACCOUNT_ID; - if (!grafanaAwsAccountId) { - throw new Error( - 'Grafana AWS Account ID is not configured. Set it via Pulumi config (grafana:awsAccountId) or GRAFANA_AWS_ACCOUNT_ID env var.', - ); - } - - const stackSlug = this.getStackSlug(); - const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); - - const grafanaIamRole = new aws.iam.Role( - `${this.name}-grafana-iam-role`, - { - assumeRolePolicy: pulumi.jsonStringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: { - AWS: `arn:aws:iam::${grafanaAwsAccountId}:root`, - }, - Action: 'sts:AssumeRole', - Condition: { - StringEquals: { - 'sts:ExternalId': pulumi.output(grafanaStack).id, - }, - }, - }, - ], - }), - tags: commonTags, - }, - { parent: this }, - ); - - return grafanaIamRole; - } - - private getStackSlug(): string { - const grafanaUrl = grafanaConfig.get('url') ?? process.env.GRAFANA_URL; - - if (!grafanaUrl) { - throw new Error( - 'Grafana URL is not configured. Set it via Pulumi config (grafana:url) or GRAFANA_URL env var.', - ); - } - - return new URL(grafanaUrl).hostname.split('.')[0]; - } - - private createAmpRolePolicy(grafanaIamRole: aws.iam.Role) { - new aws.iam.RolePolicy( - `${this.name}-amp-policy`, - { - role: grafanaIamRole.id, - policy: JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Action: [ - 'aps:GetSeries', - 'aps:GetLabels', - 'aps:GetMetricMetadata', - 'aps:QueryMetrics', - ], - Resource: '*', - }, - ], - }), - }, - { parent: this }, - ); - } - - private createPrometheusDataSource( - config: Grafana.PrometheusConfig, - grafanaIamRole: aws.iam.Role, - ) { - const stackSlug = this.getStackSlug(); - const region = config.region ?? awsConfig.require('region'); - - const plugin = new grafana.cloud.PluginInstallation( - `${this.name}-prometheus-plugin`, - { - stackSlug, - slug: 'grafana-amazonprometheus-datasource', - version: config.pluginVersion ?? 'latest', - }, - { parent: this }, - ); - - const dataSourceName = `${this.name}-prometheus-datasource`; - this.prometheusDataSource = new grafana.oss.DataSource( - dataSourceName, - { - name: dataSourceName, - type: 'grafana-amazonprometheus-datasource', - url: config.endpoint, - jsonDataEncoded: pulumi.jsonStringify({ - sigV4Auth: true, - sigV4AuthType: 'grafana_assume_role', - sigV4Region: region, - sigV4AssumeRoleArn: grafanaIamRole.arn, - }), - }, - { dependsOn: [plugin], parent: this }, - ); - } } diff --git a/src/components/grafana/index.ts b/src/components/grafana/index.ts index 4009f0f7..ab5c8869 100644 --- a/src/components/grafana/index.ts +++ b/src/components/grafana/index.ts @@ -1,3 +1,4 @@ export * as dashboard from './dashboards'; +export { GrafanaConnection, AMPConnection } from './connections'; export { Grafana } from './grafana'; export { GrafanaBuilder } from './builder'; From 9aced940174c2bd1d30412933985895754c4281d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 19 Mar 2026 17:19:32 +0100 Subject: [PATCH 10/24] refactor: remove unnecessary lines --- src/components/grafana/builder.ts | 6 +++--- src/components/grafana/connections/amp-connection.ts | 1 - src/components/grafana/connections/connection.ts | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 8fa8339a..61b2c999 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -3,11 +3,11 @@ import { GrafanaConnection } from './connections'; import { Grafana } from './grafana'; export class GrafanaBuilder { - private _name: string; + private name: string; private connections: GrafanaConnection[] = []; constructor(name: string) { - this._name = name; + this.name = name; } public addConnection(connection: GrafanaConnection): this { @@ -24,7 +24,7 @@ export class GrafanaBuilder { } return new Grafana( - this._name, + this.name, { connections: this.connections, }, diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts index ce3f8d30..6618b483 100644 --- a/src/components/grafana/connections/amp-connection.ts +++ b/src/components/grafana/connections/amp-connection.ts @@ -26,7 +26,6 @@ export class AMPConnection extends GrafanaConnection { this.createAmpRolePolicy(name); const plugin = this.createPlugin(name, args.pluginVersion); - this.dataSource = this.createDataSource(name, args, plugin); this.registerOutputs(); diff --git a/src/components/grafana/connections/connection.ts b/src/components/grafana/connections/connection.ts index 2d777593..5ac9ffb6 100644 --- a/src/components/grafana/connections/connection.ts +++ b/src/components/grafana/connections/connection.ts @@ -7,7 +7,6 @@ const grafanaConfig = new pulumi.Config('grafana'); export abstract class GrafanaConnection extends pulumi.ComponentResource { abstract readonly dataSource: grafana.oss.DataSource; - readonly iamRole: aws.iam.Role; constructor( From b766efec063b480a667014c997795aa53432a871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 19 Mar 2026 17:54:23 +0100 Subject: [PATCH 11/24] refactor: method signatures --- .../grafana/connections/amp-connection.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts index 6618b483..0ae7d785 100644 --- a/src/components/grafana/connections/amp-connection.ts +++ b/src/components/grafana/connections/amp-connection.ts @@ -4,6 +4,7 @@ import * as grafana from '@pulumiverse/grafana'; import { GrafanaConnection } from './connection'; const awsConfig = new pulumi.Config('aws'); +const pluginName = 'grafana-amazonprometheus-datasource'; export namespace AMPConnection { export type Args = { @@ -14,7 +15,10 @@ export namespace AMPConnection { } export class AMPConnection extends GrafanaConnection { - readonly dataSource: grafana.oss.DataSource; + name: string; + dataSource: grafana.oss.DataSource; + plugin: grafana.cloud.PluginInstallation; + rolePolicy: aws.iam.RolePolicy; constructor( name: string, @@ -23,15 +27,21 @@ export class AMPConnection extends GrafanaConnection { ) { super('studion:grafana:AMPConnection', name, opts); - this.createAmpRolePolicy(name); + this.name = name; - const plugin = this.createPlugin(name, args.pluginVersion); - this.dataSource = this.createDataSource(name, args, plugin); + this.rolePolicy = this.createAmpRolePolicy(name); + this.plugin = this.createPlugin(name, args.pluginVersion); + this.dataSource = this.createDataSource( + name, + args.region, + args.endpoint, + this.plugin, + ); this.registerOutputs(); } - private createAmpRolePolicy(name: string) { + private createAmpRolePolicy(name: string): aws.iam.RolePolicy { const policy = aws.iam.getPolicyDocumentOutput({ statements: [ { @@ -47,7 +57,7 @@ export class AMPConnection extends GrafanaConnection { ], }); - new aws.iam.RolePolicy( + return new aws.iam.RolePolicy( `${name}-amp-policy`, { role: this.iamRole.id, @@ -59,13 +69,13 @@ export class AMPConnection extends GrafanaConnection { private createPlugin( name: string, - pluginVersion?: string, + pluginVersion?: AMPConnection.Args['pluginVersion'], ): grafana.cloud.PluginInstallation { return new grafana.cloud.PluginInstallation( - `${name}-prometheus-plugin`, + `${name}-amp-plugin`, { stackSlug: this.getStackSlug(), - slug: 'grafana-amazonprometheus-datasource', + slug: pluginName, version: pluginVersion ?? 'latest', }, { parent: this }, @@ -74,22 +84,23 @@ export class AMPConnection extends GrafanaConnection { private createDataSource( name: string, - args: AMPConnection.Args, + region: AMPConnection.Args['region'], + endpoint: AMPConnection.Args['endpoint'], plugin: grafana.cloud.PluginInstallation, ): grafana.oss.DataSource { - const region = args.region ?? awsConfig.require('region'); - const dataSourceName = `${name}-prometheus-datasource`; + const ampRegion = region ?? awsConfig.require('region'); + const dataSourceName = `${name}-amp-datasource`; return new grafana.oss.DataSource( dataSourceName, { name: dataSourceName, - type: 'grafana-amazonprometheus-datasource', - url: args.endpoint, + type: pluginName, + url: endpoint, jsonDataEncoded: pulumi.jsonStringify({ sigV4Auth: true, sigV4AuthType: 'grafana_assume_role', - sigV4Region: region, + sigV4Region: ampRegion, sigV4AssumeRoleArn: this.iamRole.arn, }), }, From 99376d5627c06e8a6679025e8017148ca43934ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 19 Mar 2026 17:56:54 +0100 Subject: [PATCH 12/24] feat: make grafana props readonly --- src/components/grafana/connections/amp-connection.ts | 8 ++++---- src/components/grafana/grafana.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts index 0ae7d785..1d20a94a 100644 --- a/src/components/grafana/connections/amp-connection.ts +++ b/src/components/grafana/connections/amp-connection.ts @@ -15,10 +15,10 @@ export namespace AMPConnection { } export class AMPConnection extends GrafanaConnection { - name: string; - dataSource: grafana.oss.DataSource; - plugin: grafana.cloud.PluginInstallation; - rolePolicy: aws.iam.RolePolicy; + readonly name: string; + readonly dataSource: grafana.oss.DataSource; + readonly plugin: grafana.cloud.PluginInstallation; + readonly rolePolicy: aws.iam.RolePolicy; constructor( name: string, diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 82e06645..a5b3a052 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -8,7 +8,7 @@ export namespace Grafana { } export class Grafana extends pulumi.ComponentResource { - connections: GrafanaConnection[]; + readonly connections: GrafanaConnection[]; constructor( name: string, From 631ded88a3f985f9e3c415ca9d90e7fbfd7dac66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 19 Mar 2026 18:03:18 +0100 Subject: [PATCH 13/24] feat: add name prop to grafana component --- src/components/grafana/grafana.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index a5b3a052..46fab221 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -8,6 +8,7 @@ export namespace Grafana { } export class Grafana extends pulumi.ComponentResource { + readonly name: string; readonly connections: GrafanaConnection[]; constructor( @@ -17,6 +18,7 @@ export class Grafana extends pulumi.ComponentResource { ) { super('studion:grafana:Grafana', name, {}, opts); + this.name = name; this.connections = args.connections; this.registerOutputs(); From 8cec8154e33dee1efd920def812e46d32c7071b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 25 Mar 2026 09:45:31 +0100 Subject: [PATCH 14/24] refactor: method signatures --- src/components/grafana/builder.ts | 23 +++++++---- .../grafana/connections/amp-connection.ts | 40 ++++++++----------- .../grafana/connections/connection.ts | 35 +++++++++------- src/components/grafana/grafana.ts | 11 +++-- 4 files changed, 59 insertions(+), 50 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 61b2c999..2756dbbf 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -1,32 +1,39 @@ import * as pulumi from '@pulumi/pulumi'; -import { GrafanaConnection } from './connections'; +import { AMPConnection, GrafanaConnection } from './connections'; import { Grafana } from './grafana'; export class GrafanaBuilder { - private name: string; - private connections: GrafanaConnection[] = []; + private readonly name: string; + private readonly connectionBuilders: GrafanaConnection.ConnectionBuilder[] = + []; constructor(name: string) { this.name = name; } - public addConnection(connection: GrafanaConnection): this { - this.connections.push(connection); + public addAmp(name: string, args: AMPConnection.Args): this { + this.connectionBuilders.push(opts => new AMPConnection(name, args, opts)); + + return this; + } + + public addConnection(builder: GrafanaConnection.ConnectionBuilder): this { + this.connectionBuilders.push(builder); return this; } public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { - if (!this.connections.length) { + if (!this.connectionBuilders.length) { throw new Error( - 'At least one connection is required. Call addConnection() before build().', + 'At least one connection is required. Call addAmp() or addConnection() before build().', ); } return new Grafana( this.name, { - connections: this.connections, + connectionBuilders: this.connectionBuilders, }, opts, ); diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts index 1d20a94a..5879db49 100644 --- a/src/components/grafana/connections/amp-connection.ts +++ b/src/components/grafana/connections/amp-connection.ts @@ -7,7 +7,7 @@ const awsConfig = new pulumi.Config('aws'); const pluginName = 'grafana-amazonprometheus-datasource'; export namespace AMPConnection { - export type Args = { + export type Args = GrafanaConnection.Args & { endpoint: pulumi.Input; region?: string; pluginVersion?: string; @@ -15,33 +15,28 @@ export namespace AMPConnection { } export class AMPConnection extends GrafanaConnection { - readonly name: string; - readonly dataSource: grafana.oss.DataSource; - readonly plugin: grafana.cloud.PluginInstallation; - readonly rolePolicy: aws.iam.RolePolicy; + public readonly name: string; + public readonly dataSource: grafana.oss.DataSource; + public readonly plugin: grafana.cloud.PluginInstallation; + public readonly rolePolicy: aws.iam.RolePolicy; constructor( name: string, args: AMPConnection.Args, opts: pulumi.ComponentResourceOptions = {}, ) { - super('studion:grafana:AMPConnection', name, opts); + super('studion:grafana:AMPConnection', name, args, opts); this.name = name; - this.rolePolicy = this.createAmpRolePolicy(name); - this.plugin = this.createPlugin(name, args.pluginVersion); - this.dataSource = this.createDataSource( - name, - args.region, - args.endpoint, - this.plugin, - ); + this.rolePolicy = this.createAmpRolePolicy(); + this.plugin = this.createPlugin(args.pluginVersion); + this.dataSource = this.createDataSource(args.region, args.endpoint); this.registerOutputs(); } - private createAmpRolePolicy(name: string): aws.iam.RolePolicy { + private createAmpRolePolicy(): aws.iam.RolePolicy { const policy = aws.iam.getPolicyDocumentOutput({ statements: [ { @@ -58,9 +53,9 @@ export class AMPConnection extends GrafanaConnection { }); return new aws.iam.RolePolicy( - `${name}-amp-policy`, + `${this.name}-amp-policy`, { - role: this.iamRole.id, + role: this.role.id, policy: policy.json, }, { parent: this }, @@ -68,11 +63,10 @@ export class AMPConnection extends GrafanaConnection { } private createPlugin( - name: string, pluginVersion?: AMPConnection.Args['pluginVersion'], ): grafana.cloud.PluginInstallation { return new grafana.cloud.PluginInstallation( - `${name}-amp-plugin`, + `${this.name}-amp-plugin`, { stackSlug: this.getStackSlug(), slug: pluginName, @@ -83,13 +77,11 @@ export class AMPConnection extends GrafanaConnection { } private createDataSource( - name: string, region: AMPConnection.Args['region'], endpoint: AMPConnection.Args['endpoint'], - plugin: grafana.cloud.PluginInstallation, ): grafana.oss.DataSource { const ampRegion = region ?? awsConfig.require('region'); - const dataSourceName = `${name}-amp-datasource`; + const dataSourceName = `${this.name}-amp-datasource`; return new grafana.oss.DataSource( dataSourceName, @@ -101,10 +93,10 @@ export class AMPConnection extends GrafanaConnection { sigV4Auth: true, sigV4AuthType: 'grafana_assume_role', sigV4Region: ampRegion, - sigV4AssumeRoleArn: this.iamRole.arn, + sigV4AssumeRoleArn: this.role.arn, }), }, - { dependsOn: [plugin], parent: this }, + { dependsOn: [this.plugin], parent: this }, ); } } diff --git a/src/components/grafana/connections/connection.ts b/src/components/grafana/connections/connection.ts index 5ac9ffb6..6ac5e424 100644 --- a/src/components/grafana/connections/connection.ts +++ b/src/components/grafana/connections/connection.ts @@ -5,18 +5,34 @@ import { commonTags } from '../../../shared/common-tags'; const grafanaConfig = new pulumi.Config('grafana'); +export namespace GrafanaConnection { + export type Args = { + grafanaAwsAccountId: string; + }; + + export type ConnectionBuilder = ( + opts: pulumi.ComponentResourceOptions, + ) => GrafanaConnection; +} + export abstract class GrafanaConnection extends pulumi.ComponentResource { - abstract readonly dataSource: grafana.oss.DataSource; - readonly iamRole: aws.iam.Role; + public readonly name: string; + public readonly role: aws.iam.Role; + public abstract readonly dataSource: grafana.oss.DataSource; constructor( type: string, name: string, + args: GrafanaConnection.Args, opts: pulumi.ComponentResourceOptions = {}, ) { super(type, name, {}, opts); - this.iamRole = this.createIamRole(name); + this.name = name; + + this.role = this.createIamRole(args.grafanaAwsAccountId); + + this.registerOutputs(); } protected getStackSlug(): string { @@ -31,16 +47,7 @@ export abstract class GrafanaConnection extends pulumi.ComponentResource { return new URL(grafanaUrl).hostname.split('.')[0]; } - private createIamRole(name: string): aws.iam.Role { - const grafanaAwsAccountId = - grafanaConfig.get('awsAccountId') ?? process.env.GRAFANA_AWS_ACCOUNT_ID; - - if (!grafanaAwsAccountId) { - throw new Error( - 'Grafana AWS Account ID is not configured. Set it via Pulumi config (grafana:awsAccountId) or GRAFANA_AWS_ACCOUNT_ID env var.', - ); - } - + private createIamRole(grafanaAwsAccountId: string): aws.iam.Role { const stackSlug = this.getStackSlug(); const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); @@ -67,7 +74,7 @@ export abstract class GrafanaConnection extends pulumi.ComponentResource { }); return new aws.iam.Role( - `${name}-grafana-iam-role`, + `${this.name}-grafana-iam-role`, { assumeRolePolicy: assumeRolePolicy.json, tags: commonTags, diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 46fab221..dbed90b6 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -3,13 +3,13 @@ import { GrafanaConnection } from './connections'; export namespace Grafana { export type Args = { - connections: GrafanaConnection[]; + connectionBuilders: GrafanaConnection.ConnectionBuilder[]; }; } export class Grafana extends pulumi.ComponentResource { - readonly name: string; - readonly connections: GrafanaConnection[]; + public readonly name: string; + public readonly connections: GrafanaConnection[]; constructor( name: string, @@ -19,7 +19,10 @@ export class Grafana extends pulumi.ComponentResource { super('studion:grafana:Grafana', name, {}, opts); this.name = name; - this.connections = args.connections; + + this.connections = args.connectionBuilders.map(buildConnection => + buildConnection({ parent: this }), + ); this.registerOutputs(); } From 1626551282e5d0c9432132fabb0772d053933791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 25 Mar 2026 16:23:17 +0100 Subject: [PATCH 15/24] refactor: method signatures --- src/components/grafana/builder.ts | 2 +- .../grafana/connections/amp-connection.ts | 28 +++++++++++++------ .../grafana/connections/connection.ts | 8 +++--- src/components/grafana/grafana.ts | 4 +-- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 2756dbbf..9e07939e 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -26,7 +26,7 @@ export class GrafanaBuilder { public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { if (!this.connectionBuilders.length) { throw new Error( - 'At least one connection is required. Call addAmp() or addConnection() before build().', + 'At least one connection is required. Call addConnection() to add custom connection or use one of existing connection builders.', ); } diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts index 5879db49..1afd0240 100644 --- a/src/components/grafana/connections/amp-connection.ts +++ b/src/components/grafana/connections/amp-connection.ts @@ -1,6 +1,7 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; +import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; import { GrafanaConnection } from './connection'; const awsConfig = new pulumi.Config('aws'); @@ -14,6 +15,11 @@ export namespace AMPConnection { }; } +const defaults = { + pluginVersion: 'latest', + region: awsConfig.require('region'), +}; + export class AMPConnection extends GrafanaConnection { public readonly name: string; public readonly dataSource: grafana.oss.DataSource; @@ -27,16 +33,21 @@ export class AMPConnection extends GrafanaConnection { ) { super('studion:grafana:AMPConnection', name, args, opts); + const argsWithDefaults = mergeWithDefaults(defaults, args); + this.name = name; - this.rolePolicy = this.createAmpRolePolicy(); - this.plugin = this.createPlugin(args.pluginVersion); - this.dataSource = this.createDataSource(args.region, args.endpoint); + this.rolePolicy = this.createRolePolicy(); + this.plugin = this.createPlugin(argsWithDefaults.pluginVersion); + this.dataSource = this.createDataSource( + argsWithDefaults.region, + argsWithDefaults.endpoint, + ); this.registerOutputs(); } - private createAmpRolePolicy(): aws.iam.RolePolicy { + private createRolePolicy(): aws.iam.RolePolicy { const policy = aws.iam.getPolicyDocumentOutput({ statements: [ { @@ -63,24 +74,23 @@ export class AMPConnection extends GrafanaConnection { } private createPlugin( - pluginVersion?: AMPConnection.Args['pluginVersion'], + pluginVersion: string, ): grafana.cloud.PluginInstallation { return new grafana.cloud.PluginInstallation( `${this.name}-amp-plugin`, { stackSlug: this.getStackSlug(), slug: pluginName, - version: pluginVersion ?? 'latest', + version: pluginVersion, }, { parent: this }, ); } private createDataSource( - region: AMPConnection.Args['region'], + region: string, endpoint: AMPConnection.Args['endpoint'], ): grafana.oss.DataSource { - const ampRegion = region ?? awsConfig.require('region'); const dataSourceName = `${this.name}-amp-datasource`; return new grafana.oss.DataSource( @@ -92,7 +102,7 @@ export class AMPConnection extends GrafanaConnection { jsonDataEncoded: pulumi.jsonStringify({ sigV4Auth: true, sigV4AuthType: 'grafana_assume_role', - sigV4Region: ampRegion, + sigV4Region: region, sigV4AssumeRoleArn: this.role.arn, }), }, diff --git a/src/components/grafana/connections/connection.ts b/src/components/grafana/connections/connection.ts index 6ac5e424..98efc36a 100644 --- a/src/components/grafana/connections/connection.ts +++ b/src/components/grafana/connections/connection.ts @@ -7,7 +7,7 @@ const grafanaConfig = new pulumi.Config('grafana'); export namespace GrafanaConnection { export type Args = { - grafanaAwsAccountId: string; + awsAccountId: string; }; export type ConnectionBuilder = ( @@ -30,7 +30,7 @@ export abstract class GrafanaConnection extends pulumi.ComponentResource { this.name = name; - this.role = this.createIamRole(args.grafanaAwsAccountId); + this.role = this.createIamRole(args.awsAccountId); this.registerOutputs(); } @@ -47,7 +47,7 @@ export abstract class GrafanaConnection extends pulumi.ComponentResource { return new URL(grafanaUrl).hostname.split('.')[0]; } - private createIamRole(grafanaAwsAccountId: string): aws.iam.Role { + private createIamRole(awsAccountId: string): aws.iam.Role { const stackSlug = this.getStackSlug(); const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); @@ -58,7 +58,7 @@ export abstract class GrafanaConnection extends pulumi.ComponentResource { principals: [ { type: 'AWS', - identifiers: [`arn:aws:iam::${grafanaAwsAccountId}:root`], + identifiers: [`arn:aws:iam::${awsAccountId}:root`], }, ], actions: ['sts:AssumeRole'], diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index dbed90b6..a81bdf5d 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -20,8 +20,8 @@ export class Grafana extends pulumi.ComponentResource { this.name = name; - this.connections = args.connectionBuilders.map(buildConnection => - buildConnection({ parent: this }), + this.connections = args.connectionBuilders.map(build => + build({ parent: this }), ); this.registerOutputs(); From fa0dd95fb28984637b0dfb1ab1e4e3578357e5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Fri, 27 Mar 2026 11:00:04 +0100 Subject: [PATCH 16/24] feat: generic dashboard builder --- src/components/grafana/builder.ts | 8 +- .../grafana/dashboards/dashboard-builder.ts | 42 +++ src/components/grafana/dashboards/index.ts | 4 +- src/components/grafana/dashboards/types.ts | 78 +---- .../grafana/dashboards/web-server-slo.ts | 294 ++++-------------- src/components/grafana/grafana.ts | 27 +- src/components/grafana/index.ts | 1 + src/components/grafana/panels/availability.ts | 48 +++ .../panels.ts => panels/helpers.ts} | 37 ++- src/components/grafana/panels/index.ts | 5 + src/components/grafana/panels/latency.ts | 113 +++++++ src/components/grafana/panels/success-rate.ts | 76 +++++ src/components/grafana/panels/types.ts | 62 ++++ 13 files changed, 457 insertions(+), 338 deletions(-) create mode 100644 src/components/grafana/dashboards/dashboard-builder.ts create mode 100644 src/components/grafana/panels/availability.ts rename src/components/grafana/{dashboards/panels.ts => panels/helpers.ts} (78%) create mode 100644 src/components/grafana/panels/index.ts create mode 100644 src/components/grafana/panels/latency.ts create mode 100644 src/components/grafana/panels/success-rate.ts create mode 100644 src/components/grafana/panels/types.ts diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index ae9d9c5f..e746994d 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -7,7 +7,7 @@ export class GrafanaBuilder { private readonly name: string; private readonly connectionBuilders: GrafanaConnection.ConnectionBuilder[] = []; - private readonly dashboardConfigs: GrafanaDashboard.DashboardConfig[] = []; + private readonly dashboardBuilders: GrafanaDashboard.DashboardBuilder[] = []; constructor(name: string) { this.name = name; @@ -25,8 +25,8 @@ export class GrafanaBuilder { return this; } - public addDashboard(config: GrafanaDashboard.DashboardConfig): this { - this.dashboardConfigs.push(config); + public addDashboard(builder: GrafanaDashboard.DashboardBuilder): this { + this.dashboardBuilders.push(builder); return this; } @@ -42,7 +42,7 @@ export class GrafanaBuilder { this.name, { connectionBuilders: this.connectionBuilders, - dashboards: this.dashboardConfigs, + dashboardBuilders: this.dashboardBuilders, }, opts, ); diff --git a/src/components/grafana/dashboards/dashboard-builder.ts b/src/components/grafana/dashboards/dashboard-builder.ts new file mode 100644 index 00000000..9255197e --- /dev/null +++ b/src/components/grafana/dashboards/dashboard-builder.ts @@ -0,0 +1,42 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; +import { GrafanaConnection } from '../connections'; +import { GrafanaDashboard } from './types'; +import { Panel, PanelBuilder } from '../panels/types'; + +export class DashboardBuilder { + private title: pulumi.Input; + private panelBuilders: PanelBuilder[] = []; + + constructor(args: { title: pulumi.Input }) { + this.title = args.title; + } + + addPanel(builder: PanelBuilder): this { + this.panelBuilders.push(builder); + return this; + } + + build(connections: GrafanaConnection[]): GrafanaDashboard.DashboardConfig { + const { title, panelBuilders } = this; + const panels = panelBuilders.map(build => build(connections)); + + return { + createResource(name, folder, opts) { + return new grafana.oss.Dashboard( + name, + { + folder: folder?.uid, + configJson: pulumi.jsonStringify({ + title, + timezone: 'browser', + refresh: '10s', + panels, + }), + }, + { parent: folder, ...opts }, + ); + }, + }; + } +} diff --git a/src/components/grafana/dashboards/index.ts b/src/components/grafana/dashboards/index.ts index 6e0be25c..71fbf804 100644 --- a/src/components/grafana/dashboards/index.ts +++ b/src/components/grafana/dashboards/index.ts @@ -1,2 +1,2 @@ -export { default as WebServerSloDashboardBuilder } from './web-server-slo'; -export * as panel from './panels'; +export { DashboardBuilder } from './dashboard-builder'; +export { createWebServerSloDashboard } from './web-server-slo'; diff --git a/src/components/grafana/dashboards/types.ts b/src/components/grafana/dashboards/types.ts index 4d1687ec..f32a07f8 100644 --- a/src/components/grafana/dashboards/types.ts +++ b/src/components/grafana/dashboards/types.ts @@ -1,77 +1,21 @@ import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; +import { GrafanaConnection } from '../connections'; -// TODO: Create SLO abstraction that enables configuring: -// - panels (long-window SLI, long-window error budget) -// - alerts (long-window burn, short-window burn) export namespace GrafanaDashboard { - export type DataSources = { - prometheus?: pulumi.Output; - }; - - export interface DashboardConfig { - createResource(dataSources: DataSources): grafana.oss.Dashboard; - } - export type Args = { title: pulumi.Input; }; - export type Panel = { - title: string; - gridPos: PanelPosition; - type: string; - datasource: pulumi.Input; - targets: { - expr: string; - legendFormat: string; - }[]; - fieldConfig: { - defaults: { - unit?: string; - min?: number; - max?: number; - color?: { - mode: string; - }; - thresholds?: { - mode: string; - steps: Threshold[]; - }; - custom?: { - lineInterpolation?: string; - spanNulls: boolean; - }; - }; - }; - options?: { - colorMode?: string; - graphMode?: string; - justifyMode?: string; - textMode?: string; - reduceOptions?: { - calcs?: string[]; - fields?: string; - values?: boolean; - }; - }; - }; - - export type PanelPosition = { - x: number; - y: number; - w: number; - h: number; - }; - - export type Threshold = { - value: number | null; - color: string; - }; + export interface DashboardConfig { + createResource( + name: string, + folder?: grafana.oss.Folder, + opts?: pulumi.ComponentResourceOptions, + ): grafana.oss.Dashboard; + } - export type Metric = { - label: string; - query: string; - thresholds: Threshold[]; - }; + export type DashboardBuilder = ( + connections: GrafanaConnection[], + ) => DashboardConfig; } diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts index 501f246b..16caaf5d 100644 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ b/src/components/grafana/dashboards/web-server-slo.ts @@ -1,246 +1,56 @@ import * as pulumi from '@pulumi/pulumi'; -import * as grafana from '@pulumiverse/grafana'; -import { queries as promQ } from '../../prometheus'; +import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; +import { GrafanaConnection } from '../connections'; import { GrafanaDashboard } from './types'; +import { DashboardBuilder } from './dashboard-builder'; +import { queries as promQ } from '../../prometheus'; import { - createBurnRatePanel, - createStatPercentagePanel, - createTimeSeriesPanel, - createTimeSeriesPercentagePanel, -} from './panels'; - -class WebServerSloDashboardBuilder { - name: string; - title: pulumi.Output; - private panelBuilders: (( - dataSources: GrafanaDashboard.DataSources, - ) => GrafanaDashboard.Panel[])[] = []; - - constructor(name: string, args: GrafanaDashboard.Args) { - this.name = name; - this.title = pulumi.output(args.title); - } - - withAvailability( - target: number, - window: promQ.TimeRange, - prometheusNamespace: string, - ): this { - this.panelBuilders.push(dataSource => { - const prometheusDataSource = this.requireDataSource( - dataSource, - 'prometheus', - ); - return [ - createStatPercentagePanel( - 'Availability', - { x: 0, y: 0, w: 8, h: 8 }, - prometheusDataSource, - { - label: 'Availability', - query: promQ.getAvailabilityPercentageQuery( - prometheusNamespace, - window, - ), - thresholds: [], - }, - ), - createBurnRatePanel( - 'Availability Burn Rate', - { x: 0, y: 8, w: 8, h: 4 }, - prometheusDataSource, - { - label: 'Burn Rate', - query: promQ.getBurnRateQuery( - promQ.getAvailabilityQuery(prometheusNamespace, '1h'), - target, - ), - thresholds: [], - }, - ), - ]; - }); - return this; - } - - withSuccessRate( - target: number, - window: promQ.TimeRange, - shortWindow: promQ.TimeRange, - filter: string, - prometheusNamespace: string, - ): this { - this.panelBuilders.push(dataSource => { - const prometheusDataSource = this.requireDataSource( - dataSource, - 'prometheus', - ); - return [ - createStatPercentagePanel( - 'Success Rate', - { x: 8, y: 0, w: 8, h: 8 }, - prometheusDataSource, - { - label: 'Success Rate', - query: promQ.getSuccessPercentageQuery( - prometheusNamespace, - window, - filter, - ), - thresholds: [], - }, - ), - createTimeSeriesPercentagePanel( - 'HTTP Request Success Rate', - { x: 0, y: 16, w: 12, h: 8 }, - prometheusDataSource, - { - label: 'Success Rate', - query: promQ.getSuccessPercentageQuery( - prometheusNamespace, - shortWindow, - filter, - ), - thresholds: [], - }, - ), - createBurnRatePanel( - 'Success Rate Burn Rate', - { x: 8, y: 8, w: 8, h: 4 }, - prometheusDataSource, - { - label: 'Burn Rate', - query: promQ.getBurnRateQuery( - promQ.getSuccessRateQuery(prometheusNamespace, '1h', filter), - target, - ), - thresholds: [], - }, - ), - ]; - }); - return this; - } - - withLatency( - target: number, - targetLatency: number, - window: promQ.TimeRange, - shortWindow: promQ.TimeRange, - filter: string, - prometheusNamespace: string, - ): this { - this.panelBuilders.push(dataSource => { - const prometheusDataSource = this.requireDataSource( - dataSource, - 'prometheus', - ); - return [ - createStatPercentagePanel( - 'Request % below 250ms', - { x: 16, y: 0, w: 8, h: 8 }, - prometheusDataSource, - { - label: 'Request % below 250ms', - query: promQ.getLatencyPercentageQuery( - prometheusNamespace, - window, - targetLatency, - filter, - ), - thresholds: [], - }, - ), - createTimeSeriesPanel( - '99th Percentile Latency', - { x: 12, y: 16, w: 12, h: 8 }, - prometheusDataSource, - { - label: '99th Percentile Latency', - query: promQ.getPercentileLatencyQuery( - prometheusNamespace, - shortWindow, - target, - filter, - ), - thresholds: [], - }, - 'ms', - ), - createTimeSeriesPercentagePanel( - 'Request percentage below 250ms', - { x: 0, y: 24, w: 12, h: 8 }, - prometheusDataSource, - { - label: 'Request percentage below 250ms', - query: promQ.getLatencyPercentageQuery( - prometheusNamespace, - shortWindow, - targetLatency, - filter, - ), - thresholds: [], - }, - ), - createBurnRatePanel( - 'Latency Burn Rate', - { x: 16, y: 8, w: 8, h: 4 }, - prometheusDataSource, - { - label: 'Burn Rate', - query: promQ.getBurnRateQuery( - promQ.getLatencyRateQuery( - prometheusNamespace, - '1h', - targetLatency, - ), - target, - ), - thresholds: [], - }, - ), - ]; - }); - return this; - } - - addPanel( - buildPanel: ( - dataSource: GrafanaDashboard.DataSources, - ) => GrafanaDashboard.Panel, - ): this { - this.panelBuilders.push(dataSource => [buildPanel(dataSource)]); - return this; - } - - build(): GrafanaDashboard.DashboardConfig { - const { name, title, panelBuilders } = this; - return { - createResource(dataSources) { - const panels = panelBuilders.flatMap(buildPanel => { - return buildPanel(dataSources); - }); - - return new grafana.oss.Dashboard(name, { - configJson: pulumi.jsonStringify({ - title, - timezone: 'browser', - refresh: '10s', - panels, - }), - }); - }, - }; - } - - private requireDataSource( - dataSource: GrafanaDashboard.DataSources, - key: keyof GrafanaDashboard.DataSources, - ): pulumi.Output { - if (!dataSource[key]) - throw new Error(`Missing required data source: ${String(key)}`); - return dataSource[key]!; - } + createAvailabilityPanel, + createAvailabilityBurnRatePanel, +} from '../panels/availability'; +import { + createSuccessRatePanel, + createSuccessRateTimeSeriesPanel, + createSuccessRateBurnRatePanel, +} from '../panels/success-rate'; +import { + createLatencyPanel, + createLatencyPercentilePanel, + createLatencyPercentagePanel, + createLatencyBurnRatePanel, +} from '../panels/latency'; + +const defaults = { + target: 0.99, + window: '30d' as promQ.TimeRange, + shortWindow: '5m' as promQ.TimeRange, + targetLatency: 250, +}; + +export function createWebServerSloDashboard( + connections: GrafanaConnection[], + config: { + title: pulumi.Input; + namespace: string; + filter: string; + target?: number; + window?: promQ.TimeRange; + shortWindow?: promQ.TimeRange; + targetLatency?: number; + }, +): GrafanaDashboard.DashboardConfig { + const argsWithDefaults = mergeWithDefaults(defaults, config); + return new DashboardBuilder({ title: argsWithDefaults.title }) + .addPanel(conns => createAvailabilityPanel(conns, argsWithDefaults)) + .addPanel(conns => createAvailabilityBurnRatePanel(conns, argsWithDefaults)) + .addPanel(conns => createSuccessRatePanel(conns, argsWithDefaults)) + .addPanel(conns => + createSuccessRateTimeSeriesPanel(conns, argsWithDefaults), + ) + .addPanel(conns => createSuccessRateBurnRatePanel(conns, argsWithDefaults)) + .addPanel(conns => createLatencyPanel(conns, argsWithDefaults)) + .addPanel(conns => createLatencyPercentilePanel(conns, argsWithDefaults)) + .addPanel(conns => createLatencyPercentagePanel(conns, argsWithDefaults)) + .addPanel(conns => createLatencyBurnRatePanel(conns, argsWithDefaults)) + .build(connections); } - -export default WebServerSloDashboardBuilder; diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 716a9105..8f3ea984 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -6,14 +6,14 @@ import { GrafanaConnection } from './connections'; export namespace Grafana { export type Args = { connectionBuilders: GrafanaConnection.ConnectionBuilder[]; - dashboards?: GrafanaDashboard.DashboardConfig[]; + dashboardBuilders?: GrafanaDashboard.DashboardBuilder[]; }; } export class Grafana extends pulumi.ComponentResource { public readonly name: string; public readonly connections: GrafanaConnection[]; - dashboards: grafana.oss.Dashboard[] = []; + public readonly dashboards: grafana.oss.Dashboard[]; constructor( name: string, @@ -28,14 +28,21 @@ export class Grafana extends pulumi.ComponentResource { build({ parent: this }), ); - // if (args.dashboards?.length) { - // const dataSources = { - // prometheus: this.prometheusDataSource?.name, - // }; - // this.dashboards = args.dashboards.map(dashboard => { - // return dashboard.createResource(dataSources); - // }); - // } + const folder = new grafana.oss.Folder( + `${name}-folder`, + { title: name }, + { parent: this }, + ); + + const dashboardConfigs = (args.dashboardBuilders ?? []).map(factory => + factory(this.connections), + ); + + this.dashboards = dashboardConfigs.map((config, i) => + config.createResource(`${name}-dashboard-${i}`, folder, { + parent: folder, + }), + ); this.registerOutputs(); } diff --git a/src/components/grafana/index.ts b/src/components/grafana/index.ts index ab5c8869..8b550ec0 100644 --- a/src/components/grafana/index.ts +++ b/src/components/grafana/index.ts @@ -1,4 +1,5 @@ export * as dashboard from './dashboards'; +export * as panels from './panels'; export { GrafanaConnection, AMPConnection } from './connections'; export { Grafana } from './grafana'; export { GrafanaBuilder } from './builder'; diff --git a/src/components/grafana/panels/availability.ts b/src/components/grafana/panels/availability.ts new file mode 100644 index 00000000..983ce345 --- /dev/null +++ b/src/components/grafana/panels/availability.ts @@ -0,0 +1,48 @@ +import { queries as promQ } from '../../prometheus'; +import { GrafanaConnection, AMPConnection } from '../connections'; +import { Panel } from './types'; +import { + createStatPercentagePanel, + createBurnRatePanel, + requireConnection, +} from './helpers'; + +export function createAvailabilityPanel( + connections: GrafanaConnection[], + config: { target: number; window: promQ.TimeRange; namespace: string }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createStatPercentagePanel( + 'Availability', + { x: 0, y: 0, w: 8, h: 8 }, + ds, + { + label: 'Availability', + query: promQ.getAvailabilityPercentageQuery( + config.namespace, + config.window, + ), + thresholds: [], + }, + ); +} + +export function createAvailabilityBurnRatePanel( + connections: GrafanaConnection[], + config: { target: number; window: promQ.TimeRange; namespace: string }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createBurnRatePanel( + 'Availability Burn Rate', + { x: 0, y: 8, w: 8, h: 4 }, + ds, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getAvailabilityQuery(config.namespace, '1h'), + config.target, + ), + thresholds: [], + }, + ); +} diff --git a/src/components/grafana/dashboards/panels.ts b/src/components/grafana/panels/helpers.ts similarity index 78% rename from src/components/grafana/dashboards/panels.ts rename to src/components/grafana/panels/helpers.ts index 58277823..4245e02a 100644 --- a/src/components/grafana/dashboards/panels.ts +++ b/src/components/grafana/panels/helpers.ts @@ -1,5 +1,6 @@ import * as pulumi from '@pulumi/pulumi'; -import { GrafanaDashboard } from './types'; +import { GrafanaConnection } from '../connections'; +import { Panel, PanelPosition, Metric } from './types'; const percentageFieldConfig = { unit: 'percent', @@ -9,10 +10,10 @@ const percentageFieldConfig = { export function createStatPercentagePanel( title: string, - position: GrafanaDashboard.PanelPosition, + position: PanelPosition, dataSource: pulumi.Input, - metric: GrafanaDashboard.Metric, -): GrafanaDashboard.Panel { + metric: Metric, +): Panel { return { title, gridPos: position, @@ -42,10 +43,10 @@ export function createStatPercentagePanel( export function createTimeSeriesPercentagePanel( title: string, - position: GrafanaDashboard.PanelPosition, + position: PanelPosition, dataSource: pulumi.Input, - metric: GrafanaDashboard.Metric, -): GrafanaDashboard.Panel { + metric: Metric, +): Panel { return createTimeSeriesPanel( title, position, @@ -59,13 +60,13 @@ export function createTimeSeriesPercentagePanel( export function createTimeSeriesPanel( title: string, - position: GrafanaDashboard.PanelPosition, + position: PanelPosition, dataSource: pulumi.Input, - metric: GrafanaDashboard.Metric, + metric: Metric, unit?: string, min?: number, max?: number, -): GrafanaDashboard.Panel { +): Panel { return { title, type: 'timeseries', @@ -97,10 +98,10 @@ export function createTimeSeriesPanel( export function createBurnRatePanel( title: string, - position: GrafanaDashboard.PanelPosition, + position: PanelPosition, dataSource: pulumi.Input, - metric: GrafanaDashboard.Metric, -): GrafanaDashboard.Panel { + metric: Metric, +): Panel { return { type: 'stat', title, @@ -137,3 +138,13 @@ export function createBurnRatePanel( }, }; } + +export function requireConnection( + connections: GrafanaConnection[], + ConnectionType: new (...args: any[]) => T, +): T { + const conn = connections.find(c => c instanceof ConnectionType); + if (!conn) + throw new Error(`Required connection ${ConnectionType.name} not found`); + return conn as T; +} diff --git a/src/components/grafana/panels/index.ts b/src/components/grafana/panels/index.ts new file mode 100644 index 00000000..bbee81b7 --- /dev/null +++ b/src/components/grafana/panels/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './helpers'; +export * from './availability'; +export * from './success-rate'; +export * from './latency'; diff --git a/src/components/grafana/panels/latency.ts b/src/components/grafana/panels/latency.ts new file mode 100644 index 00000000..60d9afe6 --- /dev/null +++ b/src/components/grafana/panels/latency.ts @@ -0,0 +1,113 @@ +import { queries as promQ } from '../../prometheus'; +import { GrafanaConnection, AMPConnection } from '../connections'; +import { Panel } from './types'; +import { + createStatPercentagePanel, + createTimeSeriesPanel, + createTimeSeriesPercentagePanel, + createBurnRatePanel, + requireConnection, +} from './helpers'; + +export function createLatencyPanel( + connections: GrafanaConnection[], + config: { + target: number; + window: promQ.TimeRange; + targetLatency: number; + filter: string; + namespace: string; + }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createStatPercentagePanel( + 'Request % below 250ms', + { x: 16, y: 0, w: 8, h: 8 }, + ds, + { + label: 'Request % below 250ms', + query: promQ.getLatencyPercentageQuery( + config.namespace, + config.window, + config.targetLatency, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createLatencyPercentilePanel( + connections: GrafanaConnection[], + config: { + target: number; + shortWindow: promQ.TimeRange; + filter: string; + namespace: string; + }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createTimeSeriesPanel( + '99th Percentile Latency', + { x: 12, y: 16, w: 12, h: 8 }, + ds, + { + label: '99th Percentile Latency', + query: promQ.getPercentileLatencyQuery( + config.namespace, + config.shortWindow, + config.target, + config.filter, + ), + thresholds: [], + }, + 'ms', + ); +} + +export function createLatencyPercentagePanel( + connections: GrafanaConnection[], + config: { + targetLatency: number; + shortWindow: promQ.TimeRange; + filter: string; + namespace: string; + }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createTimeSeriesPercentagePanel( + 'Request percentage below 250ms', + { x: 0, y: 24, w: 12, h: 8 }, + ds, + { + label: 'Request percentage below 250ms', + query: promQ.getLatencyPercentageQuery( + config.namespace, + config.shortWindow, + config.targetLatency, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createLatencyBurnRatePanel( + connections: GrafanaConnection[], + config: { target: number; targetLatency: number; namespace: string }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createBurnRatePanel( + 'Latency Burn Rate', + { x: 16, y: 8, w: 8, h: 4 }, + ds, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getLatencyRateQuery(config.namespace, '1h', config.targetLatency), + config.target, + ), + thresholds: [], + }, + ); +} diff --git a/src/components/grafana/panels/success-rate.ts b/src/components/grafana/panels/success-rate.ts new file mode 100644 index 00000000..8a562fd8 --- /dev/null +++ b/src/components/grafana/panels/success-rate.ts @@ -0,0 +1,76 @@ +import { queries as promQ } from '../../prometheus'; +import { GrafanaConnection, AMPConnection } from '../connections'; +import { Panel } from './types'; +import { + createStatPercentagePanel, + createTimeSeriesPercentagePanel, + createBurnRatePanel, + requireConnection, +} from './helpers'; + +export function createSuccessRatePanel( + connections: GrafanaConnection[], + config: { + target: number; + window: promQ.TimeRange; + filter: string; + namespace: string; + }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createStatPercentagePanel( + 'Success Rate', + { x: 8, y: 0, w: 8, h: 8 }, + ds, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + config.namespace, + config.window, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createSuccessRateTimeSeriesPanel( + connections: GrafanaConnection[], + config: { shortWindow: promQ.TimeRange; filter: string; namespace: string }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createTimeSeriesPercentagePanel( + 'HTTP Request Success Rate', + { x: 0, y: 16, w: 12, h: 8 }, + ds, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + config.namespace, + config.shortWindow, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createSuccessRateBurnRatePanel( + connections: GrafanaConnection[], + config: { target: number; filter: string; namespace: string }, +): Panel { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createBurnRatePanel( + 'Success Rate Burn Rate', + { x: 8, y: 8, w: 8, h: 4 }, + ds, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getSuccessRateQuery(config.namespace, '1h', config.filter), + config.target, + ), + thresholds: [], + }, + ); +} diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts new file mode 100644 index 00000000..952f7122 --- /dev/null +++ b/src/components/grafana/panels/types.ts @@ -0,0 +1,62 @@ +import * as pulumi from '@pulumi/pulumi'; +import { GrafanaConnection } from '../connections'; + +export type Panel = { + title: string; + gridPos: PanelPosition; + type: string; + datasource: pulumi.Input; + targets: { + expr: string; + legendFormat: string; + }[]; + fieldConfig: { + defaults: { + unit?: string; + min?: number; + max?: number; + color?: { + mode: string; + }; + thresholds?: { + mode: string; + steps: Threshold[]; + }; + custom?: { + lineInterpolation?: string; + spanNulls: boolean; + }; + }; + }; + options?: { + colorMode?: string; + graphMode?: string; + justifyMode?: string; + textMode?: string; + reduceOptions?: { + calcs?: string[]; + fields?: string; + values?: boolean; + }; + }; +}; + +export type PanelPosition = { + x: number; + y: number; + w: number; + h: number; +}; + +export type Threshold = { + value: number | null; + color: string; +}; + +export type Metric = { + label: string; + query: string; + thresholds: Threshold[]; +}; + +export type PanelBuilder = (connections: GrafanaConnection[]) => Panel; From fde5f48c9b9736c25da0e4d36d929e56e24f4c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 11:09:06 +0200 Subject: [PATCH 17/24] refactor: dashboard build configuration --- src/components/grafana/builder.ts | 14 +++++++--- .../{dashboard-builder.ts => builder.ts} | 18 ++++++++----- src/components/grafana/dashboards/index.ts | 2 +- src/components/grafana/dashboards/types.ts | 5 +--- .../grafana/dashboards/web-server-slo.ts | 26 ++++++++---------- src/components/grafana/grafana.ts | 27 ++++++++++--------- 6 files changed, 49 insertions(+), 43 deletions(-) rename src/components/grafana/dashboards/{dashboard-builder.ts => builder.ts} (65%) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 756880fa..689785bb 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -12,7 +12,7 @@ export class GrafanaBuilder { private readonly name: string; private readonly connectionBuilders: GrafanaConnection.ConnectionBuilder[] = []; - private readonly dashboardBuilders: GrafanaDashboard.DashboardBuilder[] = []; + private readonly dashboards: GrafanaDashboard.DashboardConfig[] = []; constructor(name: string) { this.name = name; @@ -47,8 +47,8 @@ export class GrafanaBuilder { return this; } - public addDashboard(builder: GrafanaDashboard.DashboardBuilder): this { - this.dashboardBuilders.push(builder); + public addDashboard(dashboard: GrafanaDashboard.DashboardConfig): this { + this.dashboards.push(dashboard); return this; } @@ -60,11 +60,17 @@ export class GrafanaBuilder { ); } + if (!this.dashboards.length) { + throw new Error( + 'At least one dashboard is required. Call addDashboard() to add a dashboard.', + ); + } + return new Grafana( this.name, { connectionBuilders: this.connectionBuilders, - dashboardBuilders: this.dashboardBuilders, + dashboardBuilders: this.dashboards, }, opts, ); diff --git a/src/components/grafana/dashboards/dashboard-builder.ts b/src/components/grafana/dashboards/builder.ts similarity index 65% rename from src/components/grafana/dashboards/dashboard-builder.ts rename to src/components/grafana/dashboards/builder.ts index 9255197e..3d45e4ff 100644 --- a/src/components/grafana/dashboards/dashboard-builder.ts +++ b/src/components/grafana/dashboards/builder.ts @@ -1,12 +1,11 @@ import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; -import { GrafanaConnection } from '../connections'; import { GrafanaDashboard } from './types'; -import { Panel, PanelBuilder } from '../panels/types'; +import { PanelBuilder } from '../panels/types'; export class DashboardBuilder { private title: pulumi.Input; - private panelBuilders: PanelBuilder[] = []; + private readonly panelBuilders: PanelBuilder[] = []; constructor(args: { title: pulumi.Input }) { this.title = args.title; @@ -14,15 +13,22 @@ export class DashboardBuilder { addPanel(builder: PanelBuilder): this { this.panelBuilders.push(builder); + return this; } - build(connections: GrafanaConnection[]): GrafanaDashboard.DashboardConfig { + build(): GrafanaDashboard.DashboardConfig { + if (!this.panelBuilders.length) { + throw new Error( + 'At least one panel is required. Call addPanel() to add a panel.', + ); + } + const { title, panelBuilders } = this; - const panels = panelBuilders.map(build => build(connections)); return { - createResource(name, folder, opts) { + createResource(name, connections, folder, opts) { + const panels = panelBuilders.map(build => build(connections)); return new grafana.oss.Dashboard( name, { diff --git a/src/components/grafana/dashboards/index.ts b/src/components/grafana/dashboards/index.ts index 71fbf804..623b2bb5 100644 --- a/src/components/grafana/dashboards/index.ts +++ b/src/components/grafana/dashboards/index.ts @@ -1,2 +1,2 @@ -export { DashboardBuilder } from './dashboard-builder'; +export { DashboardBuilder } from './builder'; export { createWebServerSloDashboard } from './web-server-slo'; diff --git a/src/components/grafana/dashboards/types.ts b/src/components/grafana/dashboards/types.ts index f32a07f8..59f0741a 100644 --- a/src/components/grafana/dashboards/types.ts +++ b/src/components/grafana/dashboards/types.ts @@ -10,12 +10,9 @@ export namespace GrafanaDashboard { export interface DashboardConfig { createResource( name: string, + connections: GrafanaConnection[], folder?: grafana.oss.Folder, opts?: pulumi.ComponentResourceOptions, ): grafana.oss.Dashboard; } - - export type DashboardBuilder = ( - connections: GrafanaConnection[], - ) => DashboardConfig; } diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts index 16caaf5d..511d5c83 100644 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ b/src/components/grafana/dashboards/web-server-slo.ts @@ -1,8 +1,7 @@ import * as pulumi from '@pulumi/pulumi'; import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; -import { GrafanaConnection } from '../connections'; import { GrafanaDashboard } from './types'; -import { DashboardBuilder } from './dashboard-builder'; +import { DashboardBuilder } from './builder'; import { queries as promQ } from '../../prometheus'; import { createAvailabilityPanel, @@ -27,18 +26,15 @@ const defaults = { targetLatency: 250, }; -export function createWebServerSloDashboard( - connections: GrafanaConnection[], - config: { - title: pulumi.Input; - namespace: string; - filter: string; - target?: number; - window?: promQ.TimeRange; - shortWindow?: promQ.TimeRange; - targetLatency?: number; - }, -): GrafanaDashboard.DashboardConfig { +export function createWebServerSloDashboard(config: { + title: pulumi.Input; + namespace: string; + filter: string; + target?: number; + window?: promQ.TimeRange; + shortWindow?: promQ.TimeRange; + targetLatency?: number; +}): GrafanaDashboard.DashboardConfig { const argsWithDefaults = mergeWithDefaults(defaults, config); return new DashboardBuilder({ title: argsWithDefaults.title }) .addPanel(conns => createAvailabilityPanel(conns, argsWithDefaults)) @@ -52,5 +48,5 @@ export function createWebServerSloDashboard( .addPanel(conns => createLatencyPercentilePanel(conns, argsWithDefaults)) .addPanel(conns => createLatencyPercentagePanel(conns, argsWithDefaults)) .addPanel(conns => createLatencyBurnRatePanel(conns, argsWithDefaults)) - .build(connections); + .build(); } diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 8f3ea984..49d0c0d9 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -6,7 +6,7 @@ import { GrafanaConnection } from './connections'; export namespace Grafana { export type Args = { connectionBuilders: GrafanaConnection.ConnectionBuilder[]; - dashboardBuilders?: GrafanaDashboard.DashboardBuilder[]; + dashboardBuilders: GrafanaDashboard.DashboardConfig[]; }; } @@ -24,9 +24,9 @@ export class Grafana extends pulumi.ComponentResource { this.name = name; - this.connections = args.connectionBuilders.map(build => - build({ parent: this }), - ); + this.connections = args.connectionBuilders.map(build => { + return build({ parent: this }); + }); const folder = new grafana.oss.Folder( `${name}-folder`, @@ -34,15 +34,16 @@ export class Grafana extends pulumi.ComponentResource { { parent: this }, ); - const dashboardConfigs = (args.dashboardBuilders ?? []).map(factory => - factory(this.connections), - ); - - this.dashboards = dashboardConfigs.map((config, i) => - config.createResource(`${name}-dashboard-${i}`, folder, { - parent: folder, - }), - ); + this.dashboards = args.dashboardBuilders.map((build, i) => { + return build.createResource( + `${name}-dashboard-${i}`, + this.connections, + folder, + { + parent: folder, + }, + ); + }); this.registerOutputs(); } From 21a9b465a054f3683deb317c97983ce5b745ed8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 11:18:23 +0200 Subject: [PATCH 18/24] refactor: dashboard builder --- src/components/grafana/dashboards/builder.ts | 12 +++++++----- src/components/grafana/dashboards/types.ts | 3 +-- src/components/grafana/dashboards/web-server-slo.ts | 7 ++++--- src/components/grafana/grafana.ts | 11 ++--------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/components/grafana/dashboards/builder.ts b/src/components/grafana/dashboards/builder.ts index 3d45e4ff..2c01e1f5 100644 --- a/src/components/grafana/dashboards/builder.ts +++ b/src/components/grafana/dashboards/builder.ts @@ -4,11 +4,13 @@ import { GrafanaDashboard } from './types'; import { PanelBuilder } from '../panels/types'; export class DashboardBuilder { - private title: pulumi.Input; + private readonly name: string; + private readonly title: string; private readonly panelBuilders: PanelBuilder[] = []; - constructor(args: { title: pulumi.Input }) { - this.title = args.title; + constructor(name: string, title: string) { + this.name = name; + this.title = title; } addPanel(builder: PanelBuilder): this { @@ -24,10 +26,10 @@ export class DashboardBuilder { ); } - const { title, panelBuilders } = this; + const { name, title, panelBuilders } = this; return { - createResource(name, connections, folder, opts) { + createResource(connections, folder, opts) { const panels = panelBuilders.map(build => build(connections)); return new grafana.oss.Dashboard( name, diff --git a/src/components/grafana/dashboards/types.ts b/src/components/grafana/dashboards/types.ts index 59f0741a..ef858997 100644 --- a/src/components/grafana/dashboards/types.ts +++ b/src/components/grafana/dashboards/types.ts @@ -4,12 +4,11 @@ import { GrafanaConnection } from '../connections'; export namespace GrafanaDashboard { export type Args = { - title: pulumi.Input; + title: string; }; export interface DashboardConfig { createResource( - name: string, connections: GrafanaConnection[], folder?: grafana.oss.Folder, opts?: pulumi.ComponentResourceOptions, diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts index 511d5c83..7c1e6231 100644 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ b/src/components/grafana/dashboards/web-server-slo.ts @@ -1,4 +1,3 @@ -import * as pulumi from '@pulumi/pulumi'; import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; import { GrafanaDashboard } from './types'; import { DashboardBuilder } from './builder'; @@ -26,8 +25,10 @@ const defaults = { targetLatency: 250, }; +// TODO: rename to prometheusNamespace export function createWebServerSloDashboard(config: { - title: pulumi.Input; + name: string; + title: string; namespace: string; filter: string; target?: number; @@ -36,7 +37,7 @@ export function createWebServerSloDashboard(config: { targetLatency?: number; }): GrafanaDashboard.DashboardConfig { const argsWithDefaults = mergeWithDefaults(defaults, config); - return new DashboardBuilder({ title: argsWithDefaults.title }) + return new DashboardBuilder(config.name, argsWithDefaults.title) .addPanel(conns => createAvailabilityPanel(conns, argsWithDefaults)) .addPanel(conns => createAvailabilityBurnRatePanel(conns, argsWithDefaults)) .addPanel(conns => createSuccessRatePanel(conns, argsWithDefaults)) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 49d0c0d9..298e0568 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -34,15 +34,8 @@ export class Grafana extends pulumi.ComponentResource { { parent: this }, ); - this.dashboards = args.dashboardBuilders.map((build, i) => { - return build.createResource( - `${name}-dashboard-${i}`, - this.connections, - folder, - { - parent: folder, - }, - ); + this.dashboards = args.dashboardBuilders.map(build => { + return build.createResource(this.connections, folder, { parent: folder }); }); this.registerOutputs(); From 4d46c9b0d5f9ec117067bf851cd6a052cfa2fd7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 12:05:03 +0200 Subject: [PATCH 19/24] feat: add dashboard builder default configuration --- src/components/grafana/builder.ts | 17 +++-- .../grafana/connections/connection.ts | 2 +- src/components/grafana/dashboards/builder.ts | 65 +++++++++++++------ src/components/grafana/dashboards/index.ts | 2 +- src/components/grafana/dashboards/types.ts | 17 ----- .../grafana/dashboards/web-server-slo.ts | 7 +- src/components/grafana/grafana.ts | 10 +-- 7 files changed, 63 insertions(+), 57 deletions(-) delete mode 100644 src/components/grafana/dashboards/types.ts diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 689785bb..1dc5aa6c 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -6,13 +6,12 @@ import { XRayConnection, } from './connections'; import { Grafana } from './grafana'; -import { GrafanaDashboard } from './dashboards/types'; +import type { GrafanaDashboardBuilder } from './dashboards/builder'; export class GrafanaBuilder { private readonly name: string; - private readonly connectionBuilders: GrafanaConnection.ConnectionBuilder[] = - []; - private readonly dashboards: GrafanaDashboard.DashboardConfig[] = []; + private readonly connectionBuilders: GrafanaConnection.Builder[] = []; + private readonly dashboardBuilders: GrafanaDashboardBuilder.Dashboard[] = []; constructor(name: string) { this.name = name; @@ -41,14 +40,14 @@ export class GrafanaBuilder { return this; } - public addConnection(builder: GrafanaConnection.ConnectionBuilder): this { + public addConnection(builder: GrafanaConnection.Builder): this { this.connectionBuilders.push(builder); return this; } - public addDashboard(dashboard: GrafanaDashboard.DashboardConfig): this { - this.dashboards.push(dashboard); + public addDashboard(dashboard: GrafanaDashboardBuilder.Dashboard): this { + this.dashboardBuilders.push(dashboard); return this; } @@ -60,7 +59,7 @@ export class GrafanaBuilder { ); } - if (!this.dashboards.length) { + if (!this.dashboardBuilders.length) { throw new Error( 'At least one dashboard is required. Call addDashboard() to add a dashboard.', ); @@ -70,7 +69,7 @@ export class GrafanaBuilder { this.name, { connectionBuilders: this.connectionBuilders, - dashboardBuilders: this.dashboards, + dashboardBuilders: this.dashboardBuilders, }, opts, ); diff --git a/src/components/grafana/connections/connection.ts b/src/components/grafana/connections/connection.ts index 98efc36a..fea8e342 100644 --- a/src/components/grafana/connections/connection.ts +++ b/src/components/grafana/connections/connection.ts @@ -10,7 +10,7 @@ export namespace GrafanaConnection { awsAccountId: string; }; - export type ConnectionBuilder = ( + export type Builder = ( opts: pulumi.ComponentResourceOptions, ) => GrafanaConnection; } diff --git a/src/components/grafana/dashboards/builder.ts b/src/components/grafana/dashboards/builder.ts index 2c01e1f5..da79b56a 100644 --- a/src/components/grafana/dashboards/builder.ts +++ b/src/components/grafana/dashboards/builder.ts @@ -1,25 +1,51 @@ import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; -import { GrafanaDashboard } from './types'; +import { GrafanaConnection } from '../connections'; import { PanelBuilder } from '../panels/types'; +import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; -export class DashboardBuilder { +export namespace GrafanaDashboardBuilder { + export type Config = { + timezone?: string; + refresh?: string; + }; + + export type Dashboard = ( + connections: GrafanaConnection[], + folder?: grafana.oss.Folder, + opts?: pulumi.ComponentResourceOptions, + ) => grafana.oss.Dashboard; +} + +const defaults = { + timezone: 'browser', + refresh: '10s', +}; + +export class GrafanaDashboardBuilder { private readonly name: string; private readonly title: string; private readonly panelBuilders: PanelBuilder[] = []; + private configuration: GrafanaDashboardBuilder.Config = {}; constructor(name: string, title: string) { this.name = name; this.title = title; } + withConfig(options: GrafanaDashboardBuilder.Config): this { + this.configuration = options; + + return this; + } + addPanel(builder: PanelBuilder): this { this.panelBuilders.push(builder); return this; } - build(): GrafanaDashboard.DashboardConfig { + build(): GrafanaDashboardBuilder.Dashboard { if (!this.panelBuilders.length) { throw new Error( 'At least one panel is required. Call addPanel() to add a panel.', @@ -27,24 +53,23 @@ export class DashboardBuilder { } const { name, title, panelBuilders } = this; + const options = mergeWithDefaults(defaults, this.configuration); - return { - createResource(connections, folder, opts) { - const panels = panelBuilders.map(build => build(connections)); - return new grafana.oss.Dashboard( - name, - { - folder: folder?.uid, - configJson: pulumi.jsonStringify({ - title, - timezone: 'browser', - refresh: '10s', - panels, - }), - }, - { parent: folder, ...opts }, - ); - }, + return (connections, folder, opts) => { + const panels = panelBuilders.map(build => build(connections)); + return new grafana.oss.Dashboard( + name, + { + folder: folder?.uid, + configJson: pulumi.jsonStringify({ + title, + timezone: options.timezone, + refresh: options.refresh, + panels, + }), + }, + opts, + ); }; } } diff --git a/src/components/grafana/dashboards/index.ts b/src/components/grafana/dashboards/index.ts index 623b2bb5..86b63470 100644 --- a/src/components/grafana/dashboards/index.ts +++ b/src/components/grafana/dashboards/index.ts @@ -1,2 +1,2 @@ -export { DashboardBuilder } from './builder'; +export { GrafanaDashboardBuilder as DashboardBuilder } from './builder'; export { createWebServerSloDashboard } from './web-server-slo'; diff --git a/src/components/grafana/dashboards/types.ts b/src/components/grafana/dashboards/types.ts deleted file mode 100644 index ef858997..00000000 --- a/src/components/grafana/dashboards/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as pulumi from '@pulumi/pulumi'; -import * as grafana from '@pulumiverse/grafana'; -import { GrafanaConnection } from '../connections'; - -export namespace GrafanaDashboard { - export type Args = { - title: string; - }; - - export interface DashboardConfig { - createResource( - connections: GrafanaConnection[], - folder?: grafana.oss.Folder, - opts?: pulumi.ComponentResourceOptions, - ): grafana.oss.Dashboard; - } -} diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts index 7c1e6231..67d8ddeb 100644 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ b/src/components/grafana/dashboards/web-server-slo.ts @@ -1,6 +1,5 @@ import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; -import { GrafanaDashboard } from './types'; -import { DashboardBuilder } from './builder'; +import { GrafanaDashboardBuilder } from './builder'; import { queries as promQ } from '../../prometheus'; import { createAvailabilityPanel, @@ -35,9 +34,9 @@ export function createWebServerSloDashboard(config: { window?: promQ.TimeRange; shortWindow?: promQ.TimeRange; targetLatency?: number; -}): GrafanaDashboard.DashboardConfig { +}): GrafanaDashboardBuilder.Dashboard { const argsWithDefaults = mergeWithDefaults(defaults, config); - return new DashboardBuilder(config.name, argsWithDefaults.title) + return new GrafanaDashboardBuilder(config.name, argsWithDefaults.title) .addPanel(conns => createAvailabilityPanel(conns, argsWithDefaults)) .addPanel(conns => createAvailabilityBurnRatePanel(conns, argsWithDefaults)) .addPanel(conns => createSuccessRatePanel(conns, argsWithDefaults)) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 298e0568..8685065b 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -1,12 +1,12 @@ import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; -import { GrafanaDashboard } from './dashboards/types'; +import type { GrafanaDashboardBuilder } from './dashboards/builder'; import { GrafanaConnection } from './connections'; export namespace Grafana { export type Args = { - connectionBuilders: GrafanaConnection.ConnectionBuilder[]; - dashboardBuilders: GrafanaDashboard.DashboardConfig[]; + connectionBuilders: GrafanaConnection.Builder[]; + dashboardBuilders: GrafanaDashboardBuilder.Dashboard[]; }; } @@ -29,13 +29,13 @@ export class Grafana extends pulumi.ComponentResource { }); const folder = new grafana.oss.Folder( - `${name}-folder`, + name, { title: name }, { parent: this }, ); this.dashboards = args.dashboardBuilders.map(build => { - return build.createResource(this.connections, folder, { parent: folder }); + return build(this.connections, folder, { parent: folder }); }); this.registerOutputs(); From 813775e4142127216737c6f1c352d85f4b8bf260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 12:13:21 +0200 Subject: [PATCH 20/24] refactor: panel export type --- .../grafana/dashboards/web-server-slo.ts | 27 ++- src/components/grafana/panels/availability.ts | 82 ++++---- src/components/grafana/panels/latency.ts | 198 +++++++++--------- src/components/grafana/panels/success-rate.ts | 135 ++++++------ 4 files changed, 229 insertions(+), 213 deletions(-) diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts index 67d8ddeb..a00d99c7 100644 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ b/src/components/grafana/dashboards/web-server-slo.ts @@ -19,16 +19,15 @@ import { const defaults = { target: 0.99, - window: '30d' as promQ.TimeRange, - shortWindow: '5m' as promQ.TimeRange, + window: '30d', + shortWindow: '5m', targetLatency: 250, }; -// TODO: rename to prometheusNamespace export function createWebServerSloDashboard(config: { name: string; title: string; - namespace: string; + prometheusNamespace: string; filter: string; target?: number; window?: promQ.TimeRange; @@ -37,16 +36,14 @@ export function createWebServerSloDashboard(config: { }): GrafanaDashboardBuilder.Dashboard { const argsWithDefaults = mergeWithDefaults(defaults, config); return new GrafanaDashboardBuilder(config.name, argsWithDefaults.title) - .addPanel(conns => createAvailabilityPanel(conns, argsWithDefaults)) - .addPanel(conns => createAvailabilityBurnRatePanel(conns, argsWithDefaults)) - .addPanel(conns => createSuccessRatePanel(conns, argsWithDefaults)) - .addPanel(conns => - createSuccessRateTimeSeriesPanel(conns, argsWithDefaults), - ) - .addPanel(conns => createSuccessRateBurnRatePanel(conns, argsWithDefaults)) - .addPanel(conns => createLatencyPanel(conns, argsWithDefaults)) - .addPanel(conns => createLatencyPercentilePanel(conns, argsWithDefaults)) - .addPanel(conns => createLatencyPercentagePanel(conns, argsWithDefaults)) - .addPanel(conns => createLatencyBurnRatePanel(conns, argsWithDefaults)) + .addPanel(createAvailabilityPanel(argsWithDefaults)) + .addPanel(createAvailabilityBurnRatePanel(argsWithDefaults)) + .addPanel(createSuccessRatePanel(argsWithDefaults)) + .addPanel(createSuccessRateTimeSeriesPanel(argsWithDefaults)) + .addPanel(createSuccessRateBurnRatePanel(argsWithDefaults)) + .addPanel(createLatencyPanel(argsWithDefaults)) + .addPanel(createLatencyPercentilePanel(argsWithDefaults)) + .addPanel(createLatencyPercentagePanel(argsWithDefaults)) + .addPanel(createLatencyBurnRatePanel(argsWithDefaults)) .build(); } diff --git a/src/components/grafana/panels/availability.ts b/src/components/grafana/panels/availability.ts index 983ce345..3ea4eacd 100644 --- a/src/components/grafana/panels/availability.ts +++ b/src/components/grafana/panels/availability.ts @@ -1,48 +1,54 @@ import { queries as promQ } from '../../prometheus'; -import { GrafanaConnection, AMPConnection } from '../connections'; -import { Panel } from './types'; +import { AMPConnection } from '../connections'; +import { PanelBuilder } from './types'; import { createStatPercentagePanel, createBurnRatePanel, requireConnection, } from './helpers'; -export function createAvailabilityPanel( - connections: GrafanaConnection[], - config: { target: number; window: promQ.TimeRange; namespace: string }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createStatPercentagePanel( - 'Availability', - { x: 0, y: 0, w: 8, h: 8 }, - ds, - { - label: 'Availability', - query: promQ.getAvailabilityPercentageQuery( - config.namespace, - config.window, - ), - thresholds: [], - }, - ); +export function createAvailabilityPanel(config: { + target: number; + window: promQ.TimeRange; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createStatPercentagePanel( + 'Availability', + { x: 0, y: 0, w: 8, h: 8 }, + ds, + { + label: 'Availability', + query: promQ.getAvailabilityPercentageQuery( + config.prometheusNamespace, + config.window, + ), + thresholds: [], + }, + ); + }; } -export function createAvailabilityBurnRatePanel( - connections: GrafanaConnection[], - config: { target: number; window: promQ.TimeRange; namespace: string }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createBurnRatePanel( - 'Availability Burn Rate', - { x: 0, y: 8, w: 8, h: 4 }, - ds, - { - label: 'Burn Rate', - query: promQ.getBurnRateQuery( - promQ.getAvailabilityQuery(config.namespace, '1h'), - config.target, - ), - thresholds: [], - }, - ); +export function createAvailabilityBurnRatePanel(config: { + target: number; + window: promQ.TimeRange; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createBurnRatePanel( + 'Availability Burn Rate', + { x: 0, y: 8, w: 8, h: 4 }, + ds, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getAvailabilityQuery(config.prometheusNamespace, '1h'), + config.target, + ), + thresholds: [], + }, + ); + }; } diff --git a/src/components/grafana/panels/latency.ts b/src/components/grafana/panels/latency.ts index 60d9afe6..d398f782 100644 --- a/src/components/grafana/panels/latency.ts +++ b/src/components/grafana/panels/latency.ts @@ -1,6 +1,6 @@ import { queries as promQ } from '../../prometheus'; -import { GrafanaConnection, AMPConnection } from '../connections'; -import { Panel } from './types'; +import { AMPConnection } from '../connections'; +import { PanelBuilder } from './types'; import { createStatPercentagePanel, createTimeSeriesPanel, @@ -9,105 +9,109 @@ import { requireConnection, } from './helpers'; -export function createLatencyPanel( - connections: GrafanaConnection[], - config: { - target: number; - window: promQ.TimeRange; - targetLatency: number; - filter: string; - namespace: string; - }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createStatPercentagePanel( - 'Request % below 250ms', - { x: 16, y: 0, w: 8, h: 8 }, - ds, - { - label: 'Request % below 250ms', - query: promQ.getLatencyPercentageQuery( - config.namespace, - config.window, - config.targetLatency, - config.filter, - ), - thresholds: [], - }, - ); +export function createLatencyPanel(config: { + target: number; + window: promQ.TimeRange; + targetLatency: number; + filter: string; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createStatPercentagePanel( + 'Request % below 250ms', + { x: 16, y: 0, w: 8, h: 8 }, + ds, + { + label: 'Request % below 250ms', + query: promQ.getLatencyPercentageQuery( + config.prometheusNamespace, + config.window, + config.targetLatency, + config.filter, + ), + thresholds: [], + }, + ); + }; } -export function createLatencyPercentilePanel( - connections: GrafanaConnection[], - config: { - target: number; - shortWindow: promQ.TimeRange; - filter: string; - namespace: string; - }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createTimeSeriesPanel( - '99th Percentile Latency', - { x: 12, y: 16, w: 12, h: 8 }, - ds, - { - label: '99th Percentile Latency', - query: promQ.getPercentileLatencyQuery( - config.namespace, - config.shortWindow, - config.target, - config.filter, - ), - thresholds: [], - }, - 'ms', - ); +export function createLatencyPercentilePanel(config: { + target: number; + shortWindow: promQ.TimeRange; + filter: string; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createTimeSeriesPanel( + '99th Percentile Latency', + { x: 12, y: 16, w: 12, h: 8 }, + ds, + { + label: '99th Percentile Latency', + query: promQ.getPercentileLatencyQuery( + config.prometheusNamespace, + config.shortWindow, + config.target, + config.filter, + ), + thresholds: [], + }, + 'ms', + ); + }; } -export function createLatencyPercentagePanel( - connections: GrafanaConnection[], - config: { - targetLatency: number; - shortWindow: promQ.TimeRange; - filter: string; - namespace: string; - }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createTimeSeriesPercentagePanel( - 'Request percentage below 250ms', - { x: 0, y: 24, w: 12, h: 8 }, - ds, - { - label: 'Request percentage below 250ms', - query: promQ.getLatencyPercentageQuery( - config.namespace, - config.shortWindow, - config.targetLatency, - config.filter, - ), - thresholds: [], - }, - ); +export function createLatencyPercentagePanel(config: { + targetLatency: number; + shortWindow: promQ.TimeRange; + filter: string; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createTimeSeriesPercentagePanel( + 'Request percentage below 250ms', + { x: 0, y: 24, w: 12, h: 8 }, + ds, + { + label: 'Request percentage below 250ms', + query: promQ.getLatencyPercentageQuery( + config.prometheusNamespace, + config.shortWindow, + config.targetLatency, + config.filter, + ), + thresholds: [], + }, + ); + }; } -export function createLatencyBurnRatePanel( - connections: GrafanaConnection[], - config: { target: number; targetLatency: number; namespace: string }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createBurnRatePanel( - 'Latency Burn Rate', - { x: 16, y: 8, w: 8, h: 4 }, - ds, - { - label: 'Burn Rate', - query: promQ.getBurnRateQuery( - promQ.getLatencyRateQuery(config.namespace, '1h', config.targetLatency), - config.target, - ), - thresholds: [], - }, - ); +export function createLatencyBurnRatePanel(config: { + target: number; + targetLatency: number; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createBurnRatePanel( + 'Latency Burn Rate', + { x: 16, y: 8, w: 8, h: 4 }, + ds, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getLatencyRateQuery( + config.prometheusNamespace, + '1h', + config.targetLatency, + ), + config.target, + ), + thresholds: [], + }, + ); + }; } diff --git a/src/components/grafana/panels/success-rate.ts b/src/components/grafana/panels/success-rate.ts index 8a562fd8..07e08e18 100644 --- a/src/components/grafana/panels/success-rate.ts +++ b/src/components/grafana/panels/success-rate.ts @@ -1,6 +1,6 @@ import { queries as promQ } from '../../prometheus'; -import { GrafanaConnection, AMPConnection } from '../connections'; -import { Panel } from './types'; +import { AMPConnection } from '../connections'; +import { PanelBuilder } from './types'; import { createStatPercentagePanel, createTimeSeriesPercentagePanel, @@ -8,69 +8,78 @@ import { requireConnection, } from './helpers'; -export function createSuccessRatePanel( - connections: GrafanaConnection[], - config: { - target: number; - window: promQ.TimeRange; - filter: string; - namespace: string; - }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createStatPercentagePanel( - 'Success Rate', - { x: 8, y: 0, w: 8, h: 8 }, - ds, - { - label: 'Success Rate', - query: promQ.getSuccessPercentageQuery( - config.namespace, - config.window, - config.filter, - ), - thresholds: [], - }, - ); +export function createSuccessRatePanel(config: { + target: number; + window: promQ.TimeRange; + filter: string; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createStatPercentagePanel( + 'Success Rate', + { x: 8, y: 0, w: 8, h: 8 }, + ds, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + config.prometheusNamespace, + config.window, + config.filter, + ), + thresholds: [], + }, + ); + }; } -export function createSuccessRateTimeSeriesPanel( - connections: GrafanaConnection[], - config: { shortWindow: promQ.TimeRange; filter: string; namespace: string }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createTimeSeriesPercentagePanel( - 'HTTP Request Success Rate', - { x: 0, y: 16, w: 12, h: 8 }, - ds, - { - label: 'Success Rate', - query: promQ.getSuccessPercentageQuery( - config.namespace, - config.shortWindow, - config.filter, - ), - thresholds: [], - }, - ); +export function createSuccessRateTimeSeriesPanel(config: { + shortWindow: promQ.TimeRange; + filter: string; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createTimeSeriesPercentagePanel( + 'HTTP Request Success Rate', + { x: 0, y: 16, w: 12, h: 8 }, + ds, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + config.prometheusNamespace, + config.shortWindow, + config.filter, + ), + thresholds: [], + }, + ); + }; } -export function createSuccessRateBurnRatePanel( - connections: GrafanaConnection[], - config: { target: number; filter: string; namespace: string }, -): Panel { - const ds = requireConnection(connections, AMPConnection).dataSource.name; - return createBurnRatePanel( - 'Success Rate Burn Rate', - { x: 8, y: 8, w: 8, h: 4 }, - ds, - { - label: 'Burn Rate', - query: promQ.getBurnRateQuery( - promQ.getSuccessRateQuery(config.namespace, '1h', config.filter), - config.target, - ), - thresholds: [], - }, - ); +export function createSuccessRateBurnRatePanel(config: { + target: number; + filter: string; + prometheusNamespace: string; +}): PanelBuilder { + return connections => { + const ds = requireConnection(connections, AMPConnection).dataSource.name; + return createBurnRatePanel( + 'Success Rate Burn Rate', + { x: 8, y: 8, w: 8, h: 4 }, + ds, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getSuccessRateQuery( + config.prometheusNamespace, + '1h', + config.filter, + ), + config.target, + ), + thresholds: [], + }, + ); + }; } From a3898745a3db981a5d7fa2d77fb837c735080841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 12:17:20 +0200 Subject: [PATCH 21/24] refactor: panel types --- src/components/grafana/panels/helpers.ts | 10 +++++----- src/components/grafana/panels/types.ts | 16 +++++++++------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/components/grafana/panels/helpers.ts b/src/components/grafana/panels/helpers.ts index 4245e02a..30cee1c8 100644 --- a/src/components/grafana/panels/helpers.ts +++ b/src/components/grafana/panels/helpers.ts @@ -1,6 +1,6 @@ import * as pulumi from '@pulumi/pulumi'; import { GrafanaConnection } from '../connections'; -import { Panel, PanelPosition, Metric } from './types'; +import { Panel, Metric } from './types'; const percentageFieldConfig = { unit: 'percent', @@ -10,7 +10,7 @@ const percentageFieldConfig = { export function createStatPercentagePanel( title: string, - position: PanelPosition, + position: Panel.Position, dataSource: pulumi.Input, metric: Metric, ): Panel { @@ -43,7 +43,7 @@ export function createStatPercentagePanel( export function createTimeSeriesPercentagePanel( title: string, - position: PanelPosition, + position: Panel.Position, dataSource: pulumi.Input, metric: Metric, ): Panel { @@ -60,7 +60,7 @@ export function createTimeSeriesPercentagePanel( export function createTimeSeriesPanel( title: string, - position: PanelPosition, + position: Panel.Position, dataSource: pulumi.Input, metric: Metric, unit?: string, @@ -98,7 +98,7 @@ export function createTimeSeriesPanel( export function createBurnRatePanel( title: string, - position: PanelPosition, + position: Panel.Position, dataSource: pulumi.Input, metric: Metric, ): Panel { diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts index 952f7122..59ad0fca 100644 --- a/src/components/grafana/panels/types.ts +++ b/src/components/grafana/panels/types.ts @@ -3,7 +3,7 @@ import { GrafanaConnection } from '../connections'; export type Panel = { title: string; - gridPos: PanelPosition; + gridPos: Panel.Position; type: string; datasource: pulumi.Input; targets: { @@ -41,12 +41,14 @@ export type Panel = { }; }; -export type PanelPosition = { - x: number; - y: number; - w: number; - h: number; -}; +export namespace Panel { + export type Position = { + x: number; + y: number; + w: number; + h: number; + }; +} export type Threshold = { value: number | null; From 0c6d1236e8f0c7254dbba4c9040f03f2775e6e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 12:21:03 +0200 Subject: [PATCH 22/24] refactor: rename prometheusNamespace to ampNamespace --- .../grafana/dashboards/web-server-slo.ts | 2 +- src/components/grafana/panels/availability.ts | 8 ++++---- src/components/grafana/panels/latency.ts | 16 ++++++++-------- src/components/grafana/panels/success-rate.ts | 16 ++++++---------- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts index a00d99c7..c7056f36 100644 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ b/src/components/grafana/dashboards/web-server-slo.ts @@ -27,7 +27,7 @@ const defaults = { export function createWebServerSloDashboard(config: { name: string; title: string; - prometheusNamespace: string; + ampNamespace: string; filter: string; target?: number; window?: promQ.TimeRange; diff --git a/src/components/grafana/panels/availability.ts b/src/components/grafana/panels/availability.ts index 3ea4eacd..92ba1083 100644 --- a/src/components/grafana/panels/availability.ts +++ b/src/components/grafana/panels/availability.ts @@ -10,7 +10,7 @@ import { export function createAvailabilityPanel(config: { target: number; window: promQ.TimeRange; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -21,7 +21,7 @@ export function createAvailabilityPanel(config: { { label: 'Availability', query: promQ.getAvailabilityPercentageQuery( - config.prometheusNamespace, + config.ampNamespace, config.window, ), thresholds: [], @@ -33,7 +33,7 @@ export function createAvailabilityPanel(config: { export function createAvailabilityBurnRatePanel(config: { target: number; window: promQ.TimeRange; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -44,7 +44,7 @@ export function createAvailabilityBurnRatePanel(config: { { label: 'Burn Rate', query: promQ.getBurnRateQuery( - promQ.getAvailabilityQuery(config.prometheusNamespace, '1h'), + promQ.getAvailabilityQuery(config.ampNamespace, '1h'), config.target, ), thresholds: [], diff --git a/src/components/grafana/panels/latency.ts b/src/components/grafana/panels/latency.ts index d398f782..1af643e1 100644 --- a/src/components/grafana/panels/latency.ts +++ b/src/components/grafana/panels/latency.ts @@ -14,7 +14,7 @@ export function createLatencyPanel(config: { window: promQ.TimeRange; targetLatency: number; filter: string; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -25,7 +25,7 @@ export function createLatencyPanel(config: { { label: 'Request % below 250ms', query: promQ.getLatencyPercentageQuery( - config.prometheusNamespace, + config.ampNamespace, config.window, config.targetLatency, config.filter, @@ -40,7 +40,7 @@ export function createLatencyPercentilePanel(config: { target: number; shortWindow: promQ.TimeRange; filter: string; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -51,7 +51,7 @@ export function createLatencyPercentilePanel(config: { { label: '99th Percentile Latency', query: promQ.getPercentileLatencyQuery( - config.prometheusNamespace, + config.ampNamespace, config.shortWindow, config.target, config.filter, @@ -67,7 +67,7 @@ export function createLatencyPercentagePanel(config: { targetLatency: number; shortWindow: promQ.TimeRange; filter: string; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -78,7 +78,7 @@ export function createLatencyPercentagePanel(config: { { label: 'Request percentage below 250ms', query: promQ.getLatencyPercentageQuery( - config.prometheusNamespace, + config.ampNamespace, config.shortWindow, config.targetLatency, config.filter, @@ -92,7 +92,7 @@ export function createLatencyPercentagePanel(config: { export function createLatencyBurnRatePanel(config: { target: number; targetLatency: number; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -104,7 +104,7 @@ export function createLatencyBurnRatePanel(config: { label: 'Burn Rate', query: promQ.getBurnRateQuery( promQ.getLatencyRateQuery( - config.prometheusNamespace, + config.ampNamespace, '1h', config.targetLatency, ), diff --git a/src/components/grafana/panels/success-rate.ts b/src/components/grafana/panels/success-rate.ts index 07e08e18..904830c5 100644 --- a/src/components/grafana/panels/success-rate.ts +++ b/src/components/grafana/panels/success-rate.ts @@ -12,7 +12,7 @@ export function createSuccessRatePanel(config: { target: number; window: promQ.TimeRange; filter: string; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -23,7 +23,7 @@ export function createSuccessRatePanel(config: { { label: 'Success Rate', query: promQ.getSuccessPercentageQuery( - config.prometheusNamespace, + config.ampNamespace, config.window, config.filter, ), @@ -36,7 +36,7 @@ export function createSuccessRatePanel(config: { export function createSuccessRateTimeSeriesPanel(config: { shortWindow: promQ.TimeRange; filter: string; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -47,7 +47,7 @@ export function createSuccessRateTimeSeriesPanel(config: { { label: 'Success Rate', query: promQ.getSuccessPercentageQuery( - config.prometheusNamespace, + config.ampNamespace, config.shortWindow, config.filter, ), @@ -60,7 +60,7 @@ export function createSuccessRateTimeSeriesPanel(config: { export function createSuccessRateBurnRatePanel(config: { target: number; filter: string; - prometheusNamespace: string; + ampNamespace: string; }): PanelBuilder { return connections => { const ds = requireConnection(connections, AMPConnection).dataSource.name; @@ -71,11 +71,7 @@ export function createSuccessRateBurnRatePanel(config: { { label: 'Burn Rate', query: promQ.getBurnRateQuery( - promQ.getSuccessRateQuery( - config.prometheusNamespace, - '1h', - config.filter, - ), + promQ.getSuccessRateQuery(config.ampNamespace, '1h', config.filter), config.target, ), thresholds: [], From e522d6ff0996ce019b443536f5d38701687ea5b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 12:57:25 +0200 Subject: [PATCH 23/24] refactor: panel types --- src/components/grafana/panels/helpers.ts | 10 +++++----- src/components/grafana/panels/types.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/grafana/panels/helpers.ts b/src/components/grafana/panels/helpers.ts index 30cee1c8..8ac9ecb3 100644 --- a/src/components/grafana/panels/helpers.ts +++ b/src/components/grafana/panels/helpers.ts @@ -141,10 +141,10 @@ export function createBurnRatePanel( export function requireConnection( connections: GrafanaConnection[], - ConnectionType: new (...args: any[]) => T, + connectionType: new (...args: any[]) => T, ): T { - const conn = connections.find(c => c instanceof ConnectionType); - if (!conn) - throw new Error(`Required connection ${ConnectionType.name} not found`); - return conn as T; + const connection = connections.find(c => c instanceof connectionType); + if (!connection) + throw new Error(`Required connection ${connectionType.name} not found`); + return connection as T; } diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts index 59ad0fca..83f5fed1 100644 --- a/src/components/grafana/panels/types.ts +++ b/src/components/grafana/panels/types.ts @@ -50,15 +50,15 @@ export namespace Panel { }; } -export type Threshold = { - value: number | null; - color: string; -}; - export type Metric = { label: string; query: string; thresholds: Threshold[]; }; +export type Threshold = { + value: number | null; + color: string; +}; + export type PanelBuilder = (connections: GrafanaConnection[]) => Panel; From 3049a67e1668f6c4e5135503d3eb361f72ab646d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 30 Mar 2026 15:48:58 +0200 Subject: [PATCH 24/24] refactor: error msg --- src/components/grafana/builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 1dc5aa6c..718dd667 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -55,7 +55,7 @@ export class GrafanaBuilder { public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { if (!this.connectionBuilders.length) { throw new Error( - 'At least one connection is required. Call addConnection() to add custom connection or use one of existing connection builders.', + 'At least one connection is required. Call addConnection() to add custom connection or use one of existing connection builders.', ); }