diff --git a/backend/plugins/grafana_irm/README.md b/backend/plugins/grafana_irm/README.md new file mode 100644 index 00000000000..71c697af03b --- /dev/null +++ b/backend/plugins/grafana_irm/README.md @@ -0,0 +1,159 @@ + + +# Grafana IRM Plugin + +## Summary + +The `grafana_irm` plugin connects Apache DevLake to **Grafana Cloud Incident Response & Management (IRM)**. It ingests incident records, assignments, and labels into DevLake and transforms them into standardized domain models (`Issue`, `IssueAssignee`, `IssueLabel`, and `BoardIssue`) to calculate key DORA metrics: + +- **Change Failure Rate (CFR)** +- **Mean Time to Recovery / Failed Deployment Recovery Time (MTTR)** + +This plugin follows the established incident-management pattern used by DevLake plugins like `pagerduty` and `incidentio`. + +--- + +## What It Collects + +### Source API Endpoints + +Grafana IRM uses a JSON-RPC HTTP POST API under `/api/plugins/grafana-irm-app/resources/api/v1/`: + +| RPC Method | Purpose | +| :--- | :--- | +| `IncidentsService.QueryIncidents` | Query incident list with pagination, date bounds, and drill filters | +| `IncidentsService.GetIncident` | Fetch details for open / active incidents during the refresh pass | + +### Database Tables + +#### Tool Layer +- `_tool_grafana_irm_connections`: Connection configurations +- `_tool_grafana_irm_scopes`: Data scopes (connection-wide incident feed) +- `_tool_grafana_irm_scope_configs`: Scope transformation configurations +- `_tool_grafana_irm_incidents`: Incident metadata, severities, durations, and timestamps +- `_tool_grafana_irm_incident_labels`: Key-value labels attached to incidents +- `_tool_grafana_irm_incident_assignments`: Role-based incident responder assignments + +#### Domain Layer +- `issues`: Standardized issue entity (`type = 'INCIDENT'`) +- `board_issues`: Mapping of incidents to the connection scope board +- `issue_labels`: Incident labels (useful for team/service breakdown in SQL/Grafana) +- `issue_assignees`: Incident assignees and roles (commander, investigator, etc.) + +--- + +## Data Flow + +```mermaid +flowchart LR + API["Grafana Cloud IRM API\n(JSON-RPC POST)"] + RAW[("_raw_grafana_irm_incidents")] + TOOL[("_tool_grafana_irm_incidents\n_tool_grafana_irm_incident_labels\n_tool_grafana_irm_incident_assignments")] + DOMAIN[("Domain Layer\nissues\nissue_labels\nissue_assignees\nboard_issues")] + DORA["DORA Dashboards\n(CFR, MTTR)"] + + API -->|collectIncidents| RAW + RAW -->|extractIncidents| TOOL + TOOL -->|convertIncidents| DOMAIN + DOMAIN --> DORA +``` + +### Pipeline Subtasks + +The plugin executes three subtasks in order: +1. `collectIncidents`: Pulls new and updated incidents from Grafana Cloud using cursor pagination and re-fetches unresolved incidents. +2. `extractIncidents`: Extracts raw JSON payloads into tool-layer tables. +3. `convertIncidents`: Transforms tool records into standardized DevLake domain tables for DORA analysis. + +--- + +## Collection Strategy & Sync Architecture + +1. **Incremental Collection**: + - Uses query DSL date ranges: `isdrill:false or(declared:, resolved:,)`. + - Filters out drill/simulation incidents server-side to avoid polluting production DORA metrics. +2. **Unified `FinalizableApiCollector`**: + - Implements both new incident listing and open-incident refresh within a single `FinalizableApiCollector` subtask. + - On full syncs, the refresh phase is bypassed by design to prevent raw table clobbering. + - On "Re-transform Data", the entire collector is cleanly bypassed without dropping previously resolved incidents. +3. **Smart Skip for Unchanged Incidents**: + - When refreshing open incidents, the collector passes `X-Devlake-Known-Modified` headers. + - If the remote incident has not changed since the last sync, insertion into the raw table is skipped to prevent table bloating. + +--- + +## Setup & Configuration + +### Prerequisites + +1. An active **Grafana Cloud** stack with IRM (Incident Response & Management) enabled. +2. A **Grafana Cloud Service Account Token** with permissions to read incidents (`Incident: Read` or viewer/editor role on the IRM app). + > **Note:** On brand-new stacks, open the IRM app in your Grafana Cloud web UI at least once before creating API connections to trigger internal org provisioning. + +### Step 1 — Create Connection + +1. In DevLake Config UI (`http://localhost:4000`), navigate to **Connections** → **Add Connection** → select **Grafana IRM**. +2. Configure the following fields: + - **Connection Name**: A memorable name for your stack (e.g. `Production Grafana IRM`). + - **Grafana Cloud Stack URL**: The base URL of your Grafana Cloud instance (e.g. `https://mycompany.grafana.net/`). + - **Service Account Token**: The bearer token generated from Grafana Cloud (e.g. `glsa_...`). + - **Rate Limit**: Optional hourly request limit (defaults to `3,600` requests/hour). +3. Click **Test Connection**. Once verified, click **Save Connection**. + +### Step 2 — Add Data Scope + +1. Click **Add Data Scope** for the saved connection. +2. Select the **All Incidents** scope checkbox. + - Grafana IRM manages incidents globally across the stack; selecting "All Incidents" ingests the organization's incident stream. +3. Click **Save Scope**. + +### Step 3 — Collect Data in a Project + +1. Navigate to **Projects** from the sidebar and open your target project (or create a new one). +2. Under **Project Metrics**, ensure **DORA Metrics** is enabled. +3. Click **Add Connection** and select your Grafana IRM connection with the **All Incidents** scope. +4. Configure your sync frequency and historical date range, then click **Save**. +5. Trigger or wait for the blueprint pipeline run to complete. + +--- + +## Domain Mapping Reference + +| Grafana IRM Field | DevLake Domain Field (`issues`) | Notes | +| :--- | :--- | :--- | +| `incidentID` | `original_id` | Unique incident ID | +| `title` | `title` | Incident title / summary | +| `status` | `status`, `original_status` | Mapped to `DONE` (`resolved`) or `IN_PROGRESS` (`active`) | +| `severity` | `severity`, `priority` | Critical, Major, Minor | +| `createdTime` / `incidentStart` | `created_date` | Incident declaration time | +| `closedTime` / `incidentEnd` | `resolution_date` | Incident resolution timestamp (empty if unresolved) | +| `durationSeconds` | `lead_time_minutes` | Converted to minutes for MTTR calculations | +| `labels[]` | `issue_labels` (join table) | Ingested as key-value label rows (`label_key:label`) | +| `assignments[]` | `issue_assignees` (join table) | User assignments and roles (e.g. `commander`) | +| `isDrill` | *Filtered out* | Filtered server-side (`isdrill:false`) | + +--- + +## Troubleshooting + +| Issue | Cause & Resolution | +| :--- | :--- | +| **401 Unauthorized** on Test Connection | Invalid token, expired token, or token lacks IRM access. Verify service account permissions in Grafana Cloud. | +| **User not found / Org not found** | Brand new stack where IRM was never opened. Log in to Grafana Cloud and click into the IRM application once to complete initial org setup. | +| **Rate Limit (429)** | Exceeded Grafana Cloud API limits. Adjust `rateLimitPerHour` in connection settings or wait for rate limit window reset. | +| **Missing incidents in DORA metrics** | Check that deployments are configured in DevLake so incidents can be correlated with deployments. Also verify whether the incidents were drills (`isDrill: true`), which are intentionally excluded. | diff --git a/backend/plugins/grafana_irm/api/blueprint_v200.go b/backend/plugins/grafana_irm/api/blueprint_v200.go new file mode 100644 index 00000000000..f17b0b9ddc5 --- /dev/null +++ b/backend/plugins/grafana_irm/api/blueprint_v200.go @@ -0,0 +1,103 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/core/utils" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/helpers/srvhelper" + "github.com/apache/devlake/plugins/grafana_irm/models" + "github.com/apache/devlake/plugins/grafana_irm/tasks" +) + +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + connection, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + plan, err := makePipelinePlanV200(subtaskMetas, scopeDetails, connection) + if err != nil { + return nil, nil, err + } + scopes, err := makeScopesV200(scopeDetails, connection) + return plan, scopes, err +} + +func makePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.GrafanaIrmScope, models.GrafanaIrmScopeConfig], + connection *models.GrafanaIrmConnection, +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + stage := plan[i] + if stage == nil { + stage = coreModels.PipelineStage{} + } + + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + task, err := api.MakePipelinePlanTask( + "grafana_irm", + subtaskMetas, + scopeConfig.Entities, + tasks.GrafanaIrmOptions{ + ConnectionId: connection.ID, + ScopeId: scope.Id, + }, + ) + if err != nil { + return nil, err + } + stage = append(stage, task) + plan[i] = stage + } + + return plan, nil +} + +func makeScopesV200( + scopeDetails []*srvhelper.ScopeDetail[models.GrafanaIrmScope, models.GrafanaIrmScopeConfig], + connection *models.GrafanaIrmConnection, +) ([]plugin.Scope, errors.Error) { + scopes := make([]plugin.Scope, 0, len(scopeDetails)) + + idgen := didgen.NewDomainIdGenerator(&models.GrafanaIrmScope{}) + for _, scopeDetail := range scopeDetails { + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + id := idgen.Generate(connection.ID, scope.Id) + + if utils.StringsContains(scopeConfig.Entities, plugin.DOMAIN_TYPE_TICKET) { + scopes = append(scopes, ticket.NewBoard(id, scope.Name)) + } + } + + return scopes, nil +} diff --git a/backend/plugins/grafana_irm/api/connection_api.go b/backend/plugins/grafana_irm/api/connection_api.go new file mode 100644 index 00000000000..086426a8b67 --- /dev/null +++ b/backend/plugins/grafana_irm/api/connection_api.go @@ -0,0 +1,193 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/models" +) + +// testConnection calls IncidentsService.QueryIncidents with limit:1 and an +// empty queryString — the cheapest call that's valid on every stack (even one +// with zero incidents) and exercises real auth, per grafana_irm_plan.md §3.4 +// (OrderDirection is required; queryString "" matches everything). Mirrors +// incidentio's testConnection pattern (api/connection_api.go there), adapted +// from a REST GET to this API's JSON-RPC POST shape. +func testConnection(ctx context.Context, connection models.GrafanaIrmConn) (*plugin.ApiResourceOutput, errors.Error) { + if vld != nil { + if err := vld.Struct(connection); err != nil { + return nil, errors.BadInput.New(fmt.Sprintf("Validation failed: %s", err.Error())) + } + } + apiClient, err := api.NewApiClientFromConnection(ctx, basicRes, &connection) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "no such host") || strings.Contains(errMsg, "Failed to resolve DNS") { + return nil, errors.BadInput.New(fmt.Sprintf("Failed to resolve hostname for '%s'. Please check your Grafana Cloud URL for typos (e.g. https://.grafana.net/).", connection.Endpoint)) + } + if strings.Contains(errMsg, "Invalid URL") || strings.Contains(errMsg, "scheme") { + return nil, errors.BadInput.New(fmt.Sprintf("Invalid endpoint URL '%s': please ensure it starts with https:// (e.g. https://.grafana.net/).", connection.Endpoint)) + } + if strings.Contains(errMsg, "Failed to connect") || strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "i/o timeout") { + return nil, errors.BadInput.New(fmt.Sprintf("Failed to connect to '%s' (connection timed out or refused). Please check that the URL is spelled correctly (e.g. https://.grafana.net/) and verify your network or proxy settings.", connection.Endpoint)) + } + if idx := strings.Index(errMsg, " Wraps:"); idx != -1 { + errMsg = errMsg[:idx] + } + return nil, errors.BadInput.New(fmt.Sprintf("Invalid endpoint URL '%s': %s", connection.Endpoint, errMsg)) + } + body := map[string]interface{}{ + "query": map[string]interface{}{ + "limit": 1, + "orderDirection": "ASC", + "queryString": "", + }, + } + response, err := apiClient.Post("api/plugins/grafana-irm-app/resources/api/v1/IncidentsService.QueryIncidents", nil, body, nil) + if err != nil { + errMsg := err.Error() + if idx := strings.Index(errMsg, " Wraps:"); idx != -1 { + errMsg = errMsg[:idx] + } + if strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "i/o timeout") { + return nil, errors.BadInput.New(fmt.Sprintf("Request to Grafana IRM API timed out at '%s'. Please check that your stack URL is spelled correctly and verify your network connection.", connection.Endpoint)) + } + return nil, errors.BadInput.New(fmt.Sprintf("Failed to reach Grafana IRM API at '%s': %s", connection.Endpoint, errMsg)) + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, errors.BadInput.New("Authentication failed: invalid Service Account token or insufficient permissions for Grafana IRM.") + } + if response.StatusCode == http.StatusNotFound { + return nil, errors.BadInput.New(fmt.Sprintf("Grafana IRM app endpoint not found (HTTP 404) at '%s'. Please ensure the endpoint is your Grafana Cloud stack base URL (e.g. https://.grafana.net/).", connection.Endpoint)) + } + if response.StatusCode == http.StatusOK { + return &plugin.ApiResourceOutput{Body: nil, Status: http.StatusOK}, nil + } + return &plugin.ApiResourceOutput{Body: nil, Status: response.StatusCode}, errors.BadInput.New(fmt.Sprintf("Connection test failed with HTTP status %d. Please verify your stack URL and token.", response.StatusCode)) +} + +// TestConnection test grafana_irm connection +// @Summary test grafana_irm connection +// @Description Test Grafana IRM Connection +// @Tags plugins/grafana_irm +// @Param body body models.GrafanaIrmConn true "json body" +// @Success 200 {object} shared.ApiBody "Success" +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/grafana_irm/test [POST] +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var connection models.GrafanaIrmConn + err := api.Decode(input.Body, &connection, vld) + if err != nil { + return nil, err + } + testConnectionResult, testConnectionErr := testConnection(context.TODO(), connection) + if testConnectionErr != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, testConnectionErr) + } + return testConnectionResult, nil +} + +// TestExistingConnection test grafana_irm connection +// @Summary test grafana_irm connection +// @Description Test Grafana IRM Connection +// @Tags plugins/grafana_irm +// @Param connectionId path int true "connection ID" +// @Success 200 {object} shared.ApiBody "Success" +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/test [POST] +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.GetMergedConnection(input) + if err != nil { + return nil, errors.BadInput.Wrap(err, "find connection from db") + } + if err := api.DecodeMapStruct(input.Body, connection, false); err != nil { + return nil, err + } + testConnectionResult, testConnectionErr := testConnection(context.TODO(), connection.GrafanaIrmConn) + if testConnectionErr != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, testConnectionErr) + } + return testConnectionResult, nil +} + +// @Summary create grafana_irm connection +// @Description Create Grafana IRM connection +// @Tags plugins/grafana_irm +// @Param body body models.GrafanaIrmConnection true "json body" +// @Success 200 {object} models.GrafanaIrmConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/grafana_irm/connections [POST] +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Post(input) +} + +// @Summary patch grafana_irm connection +// @Description Patch Grafana IRM connection +// @Tags plugins/grafana_irm +// @Param body body models.GrafanaIrmConnection true "json body" +// @Success 200 {object} models.GrafanaIrmConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId} [PATCH] +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Patch(input) +} + +// @Summary delete grafana_irm connection +// @Description Delete Grafana IRM connection +// @Tags plugins/grafana_irm +// @Success 200 {object} models.GrafanaIrmConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 409 {object} services.BlueprintProjectPairs "References exist to this connection" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId} [DELETE] +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Delete(input) +} + +// @Summary list grafana_irm connections +// @Description List Grafana IRM connections +// @Tags plugins/grafana_irm +// @Success 200 {object} models.GrafanaIrmConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/grafana_irm/connections [GET] +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetAll(input) +} + +// @Summary get grafana_irm connection +// @Description Get Grafana IRM connection +// @Tags plugins/grafana_irm +// @Success 200 {object} models.GrafanaIrmConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId} [GET] +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetDetail(input) +} diff --git a/backend/plugins/grafana_irm/api/connection_api_test.go b/backend/plugins/grafana_irm/api/connection_api_test.go new file mode 100644 index 00000000000..6609233a6e4 --- /dev/null +++ b/backend/plugins/grafana_irm/api/connection_api_test.go @@ -0,0 +1,141 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/apache/devlake/core/config" + "github.com/apache/devlake/helpers/pluginhelper/api" + implcontext "github.com/apache/devlake/impls/context" + "github.com/apache/devlake/impls/logruslog" + "github.com/apache/devlake/plugins/grafana_irm/models" + "github.com/go-playground/validator/v10" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func initTestDeps(t *testing.T) { + t.Helper() + basicRes = implcontext.NewDefaultBasicRes(config.GetConfig(), logruslog.Global, nil) + vld = validator.New() +} + +func TestTestConnection_DnsFailure(t *testing.T) { + initTestDeps(t) + conn := models.GrafanaIrmConn{ + RestConnection: api.RestConnection{ + Endpoint: "https://maroonmacaron2191.grafannet/", + }, + GrafanaIrmAccessToken: models.GrafanaIrmAccessToken{ + Token: "dummy-token", + }, + } + _, err := testConnection(context.Background(), conn) + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to resolve hostname for 'https://maroonmacaron2191.grafannet/'") + assert.Contains(t, err.Error(), "Please check your Grafana Cloud URL for typos") + // Verify internal cockroachdb wrap dumps are not present + assert.NotContains(t, err.Error(), "Wraps:") + assert.NotContains(t, err.Error(), "*hintdetail.withDetail") +} + +func TestTestConnection_Success(t *testing.T) { + initTestDeps(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/plugins/grafana-irm-app/resources/api/v1/IncidentsService.QueryIncidents", r.URL.Path) + assert.Equal(t, "Bearer valid-token", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"incidents":[]}`)) + })) + defer ts.Close() + + conn := models.GrafanaIrmConn{ + RestConnection: api.RestConnection{ + Endpoint: ts.URL + "/", + }, + GrafanaIrmAccessToken: models.GrafanaIrmAccessToken{ + Token: "valid-token", + }, + } + out, err := testConnection(context.Background(), conn) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, out.Status) +} + +func TestTestConnection_Unauthorized(t *testing.T) { + initTestDeps(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer ts.Close() + + conn := models.GrafanaIrmConn{ + RestConnection: api.RestConnection{ + Endpoint: ts.URL + "/", + }, + GrafanaIrmAccessToken: models.GrafanaIrmAccessToken{ + Token: "invalid-token", + }, + } + _, err := testConnection(context.Background(), conn) + require.Error(t, err) + assert.Contains(t, err.Error(), "Authentication failed: invalid Service Account token") +} + +func TestTestConnection_NotFound(t *testing.T) { + initTestDeps(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + conn := models.GrafanaIrmConn{ + RestConnection: api.RestConnection{ + Endpoint: ts.URL + "/", + }, + GrafanaIrmAccessToken: models.GrafanaIrmAccessToken{ + Token: "valid-token", + }, + } + _, err := testConnection(context.Background(), conn) + require.Error(t, err) + assert.Contains(t, err.Error(), "Grafana IRM app endpoint not found (HTTP 404)") +} + +func TestTestConnection_ConnectRefused(t *testing.T) { + initTestDeps(t) + // Connect to an unreachable local port to trigger connection refused immediately + conn := models.GrafanaIrmConn{ + RestConnection: api.RestConnection{ + Endpoint: "http://127.0.0.1:54321/", + }, + GrafanaIrmAccessToken: models.GrafanaIrmAccessToken{ + Token: "valid-token", + }, + } + _, err := testConnection(context.Background(), conn) + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to connect to 'http://127.0.0.1:54321/'") + assert.Contains(t, err.Error(), "Please check that the URL is spelled correctly") + assert.NotContains(t, err.Error(), "Wraps:") + assert.NotContains(t, err.Error(), "*hintdetail.withDetail") +} diff --git a/backend/plugins/grafana_irm/api/init.go b/backend/plugins/grafana_irm/api/init.go new file mode 100644 index 00000000000..72b5b595a67 --- /dev/null +++ b/backend/plugins/grafana_irm/api/init.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/models" + "github.com/go-playground/validator/v10" +) + +var vld *validator.Validate +var basicRes context.BasicRes + +var dsHelper *api.DsHelper[models.GrafanaIrmConnection, models.GrafanaIrmScope, models.GrafanaIrmScopeConfig] +var raProxy *api.DsRemoteApiProxyHelper[models.GrafanaIrmConnection] +var raScopeList *api.DsRemoteApiScopeListHelper[models.GrafanaIrmConnection, models.GrafanaIrmScope, GrafanaIrmRemotePagination] +var raScopeSearch *api.DsRemoteApiScopeSearchHelper[models.GrafanaIrmConnection, models.GrafanaIrmScope] + +func Init(br context.BasicRes, p plugin.PluginMeta) { + vld = validator.New() + basicRes = br + dsHelper = api.NewDataSourceHelper[ + models.GrafanaIrmConnection, models.GrafanaIrmScope, models.GrafanaIrmScopeConfig, + ]( + br, + p.Name(), + []string{"name"}, + func(c models.GrafanaIrmConnection) models.GrafanaIrmConnection { + return c.Sanitize() + }, + nil, + nil, + ) + // The Grafana Incident API has no remote-listable service/team resource + // (grafana_irm_plan.md §4), but config-ui's data-scope picker has no + // manual-entry flow to fall back to either — so these list/search a + // single synthetic "whole connection" scope (§4.3) rather than a real + // remote list. + raProxy = api.NewDsRemoteApiProxyHelper[models.GrafanaIrmConnection](dsHelper.ConnApi.ModelApiHelper) + raScopeList = api.NewDsRemoteApiScopeListHelper[models.GrafanaIrmConnection, models.GrafanaIrmScope, GrafanaIrmRemotePagination](raProxy, listGrafanaIrmRemoteScopes) + raScopeSearch = api.NewDsRemoteApiScopeSearchHelper[models.GrafanaIrmConnection, models.GrafanaIrmScope](raProxy, searchGrafanaIrmRemoteScopes) +} diff --git a/backend/plugins/grafana_irm/api/remote_api.go b/backend/plugins/grafana_irm/api/remote_api.go new file mode 100644 index 00000000000..2cacb6f11b0 --- /dev/null +++ b/backend/plugins/grafana_irm/api/remote_api.go @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models" + "github.com/apache/devlake/plugins/grafana_irm/models" +) + +// GrafanaIrmRemotePagination is unused: there is only ever one scope to list, +// so there is never a next page. +type GrafanaIrmRemotePagination struct{} + +// defaultScope is the single synthetic scope every Grafana IRM connection +// has, covering its whole real-incident stream (grafana_irm_plan.md §4.2/ +// §4.3): the API has no listable service/team resource to browse, and the +// originating feature request only ever asked for one org-wide incident +// feed, so there is nothing else to list. +func defaultScope() dsmodels.DsRemoteApiScopeListEntry[models.GrafanaIrmScope] { + return dsmodels.DsRemoteApiScopeListEntry[models.GrafanaIrmScope]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: "default", + Name: "All Incidents", + FullName: "All Incidents", + Data: &models.GrafanaIrmScope{ + Id: "default", + Name: "All Incidents", + }, + } +} + +func listGrafanaIrmRemoteScopes( + _ *models.GrafanaIrmConnection, + _ plugin.ApiClient, + _ string, + _ GrafanaIrmRemotePagination, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.GrafanaIrmScope], + nextPage *GrafanaIrmRemotePagination, + err errors.Error, +) { + return []dsmodels.DsRemoteApiScopeListEntry[models.GrafanaIrmScope]{defaultScope()}, nil, nil +} + +func searchGrafanaIrmRemoteScopes( + _ plugin.ApiClient, + params *dsmodels.DsRemoteApiScopeSearchParams, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.GrafanaIrmScope], + err errors.Error, +) { + entry := defaultScope() + if params.Search != "" && !strings.Contains(strings.ToLower(entry.Name), strings.ToLower(params.Search)) { + return nil, nil + } + return []dsmodels.DsRemoteApiScopeListEntry[models.GrafanaIrmScope]{entry}, nil +} + +// RemoteScopes lists the single synthetic scope available on this connection +// @Summary list the available scope for this connection +// @Description Grafana IRM has exactly one scope per connection, covering its whole incident stream +// @Tags plugins/grafana_irm +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param groupId query string false "group ID" +// @Param pageToken query string false "page Token" +// @Success 200 {object} RemoteScopesOutput +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/remote-scopes [GET] +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeList.Get(input) +} + +// SearchRemoteScopes searches the single synthetic scope available on this connection +// @Summary search the available scope for this connection +// @Description Grafana IRM has exactly one scope per connection, covering its whole incident stream +// @Tags plugins/grafana_irm +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param search query string false "search" +// @Param page query int false "page number" +// @Param pageSize query int false "page size per page" +// @Success 200 {object} SearchRemoteScopesOutput +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/search-remote-scopes [GET] +func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeSearch.Get(input) +} diff --git a/backend/plugins/grafana_irm/api/remote_api_test.go b/backend/plugins/grafana_irm/api/remote_api_test.go new file mode 100644 index 00000000000..089e59b3b64 --- /dev/null +++ b/backend/plugins/grafana_irm/api/remote_api_test.go @@ -0,0 +1,53 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models" +) + +func TestListGrafanaIrmRemoteScopes_ReturnsExactlyOneScopeNoNextPage(t *testing.T) { + children, nextPage, err := listGrafanaIrmRemoteScopes(nil, nil, "", GrafanaIrmRemotePagination{}) + require.NoError(t, err) + require.Len(t, children, 1) + assert.Equal(t, "default", children[0].Id) + assert.Nil(t, nextPage) +} + +func TestSearchGrafanaIrmRemoteScopes_EmptySearchMatches(t *testing.T) { + children, err := searchGrafanaIrmRemoteScopes(nil, &dsmodels.DsRemoteApiScopeSearchParams{}) + require.NoError(t, err) + require.Len(t, children, 1) +} + +func TestSearchGrafanaIrmRemoteScopes_MatchingSearchTermMatches(t *testing.T) { + children, err := searchGrafanaIrmRemoteScopes(nil, &dsmodels.DsRemoteApiScopeSearchParams{Search: "incidents"}) + require.NoError(t, err) + require.Len(t, children, 1) +} + +func TestSearchGrafanaIrmRemoteScopes_NonMatchingSearchTermExcludes(t *testing.T) { + children, err := searchGrafanaIrmRemoteScopes(nil, &dsmodels.DsRemoteApiScopeSearchParams{Search: "nonexistent"}) + require.NoError(t, err) + assert.Len(t, children, 0) +} diff --git a/backend/plugins/grafana_irm/api/scope_api.go b/backend/plugins/grafana_irm/api/scope_api.go new file mode 100644 index 00000000000..8fbdc9a29cb --- /dev/null +++ b/backend/plugins/grafana_irm/api/scope_api.go @@ -0,0 +1,107 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/models" +) + +type PutScopesReqBody api.PutScopesReqBody[models.GrafanaIrmScope] +type ScopeDetail api.ScopeDetail[models.GrafanaIrmScope, models.GrafanaIrmScopeConfig] + +// PutScopes create or update grafana_irm scopes +// @Summary create or update grafana_irm scopes +// @Description Create or update Grafana IRM scopes +// @Tags plugins/grafana_irm +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scope body ScopeReq true "json" +// @Success 200 {object} []ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/scopes [PUT] +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} + +// PatchScope patch to grafana_irm scope +// @Summary patch to grafana_irm scope +// @Description patch to Grafana IRM scope +// @Tags plugins/grafana_irm +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Param scope body models.GrafanaIrmScope true "json" +// @Success 200 {object} models.GrafanaIrmScope +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/scopes/{scopeId} [PATCH] +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Patch(input) +} + +// GetScopeList get grafana_irm scopes +// @Summary get grafana_irm scopes +// @Description get Grafana IRM scopes +// @Tags plugins/grafana_irm +// @Param connectionId path int true "connection ID" +// @Param searchTerm query string false "search term for scope name" +// @Param pageSize query int false "page size, default 50" +// @Param page query int false "page size, default 1" +// @Param blueprints query bool false "also return blueprints using these scopes as part of the payload" +// @Success 200 {object} []ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/scopes/ [GET] +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +// GetScope get one grafana_irm scope +// @Summary get one grafana_irm scope +// @Description get one Grafana IRM scope +// @Tags plugins/grafana_irm +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Param blueprints query bool false "also return blueprints using this scope as part of the payload" +// @Success 200 {object} ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/scopes/{scopeId} [GET] +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +// DeleteScope delete plugin data associated with the scope and optionally the scope itself +// @Summary delete plugin data associated with the scope and optionally the scope itself +// @Description delete data associated with plugin scope +// @Tags plugins/grafana_irm +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Param delete_data_only query bool false "Only delete the scope data, not the scope itself" +// @Success 200 +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 409 {object} api.ScopeRefDoc "References exist to this scope" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/scopes/{scopeId} [DELETE] +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Delete(input) +} diff --git a/backend/plugins/grafana_irm/api/scope_state_api.go b/backend/plugins/grafana_irm/api/scope_state_api.go new file mode 100644 index 00000000000..c385149ee89 --- /dev/null +++ b/backend/plugins/grafana_irm/api/scope_state_api.go @@ -0,0 +1,37 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +// GetScopeLatestSyncState get one grafana_irm scope's latest sync state +// @Summary get one grafana_irm scope's latest sync state +// @Description get one Grafana IRM scope's latest sync state +// @Tags plugins/grafana_irm +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Success 200 {object} []models.LatestSyncState +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/grafana_irm/connections/{connectionId}/scopes/{scopeId}/latest-sync-state [GET] +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/grafana_irm/api/swagger.go b/backend/plugins/grafana_irm/api/swagger.go new file mode 100644 index 00000000000..00ecc3828c7 --- /dev/null +++ b/backend/plugins/grafana_irm/api/swagger.go @@ -0,0 +1,32 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/plugins/grafana_irm/tasks" +) + +type GrafanaIrmTaskOptions tasks.GrafanaIrmOptions + +// @Summary grafana_irm task options for pipelines +// @Description This is a dummy API to demonstrate the available task options for grafana_irm pipelines +// @Tags plugins/grafana_irm +// @Accept application/json +// @Param pipeline body GrafanaIrmTaskOptions true "json" +// @Router /pipelines/grafana_irm/pipeline-task [post] +func _() {} diff --git a/backend/plugins/grafana_irm/e2e/incident_test.go b/backend/plugins/grafana_irm/e2e/incident_test.go new file mode 100644 index 00000000000..abe46c89c94 --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/incident_test.go @@ -0,0 +1,134 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/helpers/e2ehelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/impl" + "github.com/apache/devlake/plugins/grafana_irm/models" + "github.com/apache/devlake/plugins/grafana_irm/tasks" +) + +// The raw fixture is not hand-authored: it's the exact (compacted) JSON +// captured live from a real Grafana Cloud dev stack — the four real +// (non-drill) incidents present there as of 2026-09-20. See +// grafana_irm_plan.md §9: +// - `4`: active, Major, one label (team_name:platform), no assignment +// - `5`: resolved, Minor, no labels, no assignment +// - `6`: active, Critical, two labels (service_name:orders-api, +// team_name:platform), one real role assignment (commander) +// - `7`: resolved, Minor, one label (team_name:payments, a different team +// than 4/6), no assignment +// +// This mix exercises every field the extractor/converter touch: multiple +// severities, both statuses (with a real resolution timestamp), zero/one/two +// labels, and a real (non-placeholder) assignment — not just the "happy path" +// of a single unlabeled, unassigned incident. +func TestIncidentDataFlow(t *testing.T) { + var plugin impl.GrafanaIrm + dataflowTester := e2ehelper.NewDataFlowTester(t, "grafana_irm", plugin) + + options := tasks.GrafanaIrmOptions{ + ConnectionId: 1, + ScopeId: "default", + ScopeConfig: &models.GrafanaIrmScopeConfig{}, + } + taskData := &tasks.GrafanaIrmTaskData{ + Options: &options, + Connection: &models.GrafanaIrmConnection{ + GrafanaIrmConn: models.GrafanaIrmConn{ + RestConnection: helper.RestConnection{ + Endpoint: "https://grafana-irm-plugin-dev.grafana.net/", + }, + }, + }, + } + + // import raw data table + dataflowTester.ImportCsvIntoRawTable( + "./raw_tables/_raw_grafana_irm_incidents.csv", + "_raw_grafana_irm_incidents", + ) + + // verify extraction + dataflowTester.FlushTabler(&models.Incident{}) + dataflowTester.FlushTabler(&models.IncidentLabel{}) + dataflowTester.FlushTabler(&models.IncidentAssignment{}) + dataflowTester.Subtask(tasks.ExtractIncidentsMeta, taskData) + dataflowTester.VerifyTableWithOptions( + models.Incident{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_grafana_irm_incidents.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + models.IncidentLabel{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_grafana_irm_incident_labels.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + models.IncidentAssignment{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_grafana_irm_incident_assignments.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }, + ) + + // verify conversion + dataflowTester.FlushTabler(&ticket.Issue{}) + dataflowTester.FlushTabler(&ticket.BoardIssue{}) + dataflowTester.FlushTabler(&ticket.IssueLabel{}) + dataflowTester.FlushTabler(&ticket.IssueAssignee{}) + dataflowTester.Subtask(tasks.ConvertIncidentsMeta, taskData) + dataflowTester.VerifyTableWithOptions( + ticket.Issue{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/issues.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + ticket.BoardIssue{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/board_issues.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + ticket.IssueLabel{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/issue_labels.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + ticket.IssueAssignee{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/issue_assignees.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }, + ) +} diff --git a/backend/plugins/grafana_irm/e2e/raw_tables/_raw_grafana_irm_incidents.csv b/backend/plugins/grafana_irm/e2e/raw_tables/_raw_grafana_irm_incidents.csv new file mode 100644 index 00000000000..413007599fd --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/raw_tables/_raw_grafana_irm_incidents.csv @@ -0,0 +1,5 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""default""}","{""incidentID"":""4"",""refs"":[],""severity"":""Major"",""labels"":[{""key"":""team_name"",""label"":""platform"",""description"":"""",""colorHex"":""""}],""isDrill"":false,""incidentType"":""internal"",""createdTime"":""2026-09-19T11:32:53.254258Z"",""modifiedTime"":""2026-09-19T11:32:54.999376Z"",""createdByUser"":{""userID"":""grafana-incident:user-6aae6b80244e1a8626a0a593"",""name"":""Service Account: grafana-irm-plugin-dev"",""photoURL"":""https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro""},""closedTime"":"""",""durationSeconds"":62947,""status"":""active"",""title"":""[DevLake seed] API gateway latency degradation"",""overviewURL"":""/a/grafana-irm-app/incidents/4/devlake-seed-api-gateway-latency-degradation"",""incidentMembership"":{""assignments"":[{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0}],""totalAssignments"":0,""totalParticipants"":0},""taskList"":{""tasks"":[],""todoCount"":0,""doneCount"":0},""summary"":"""",""incidentStart"":""2026-09-19T11:32:53Z"",""incidentEnd"":"""",""incidentChannels"":[]}",,null,2026-09-20T05:10:00.000+00:00 +2,"{""ConnectionId"":1,""ScopeId"":""default""}","{""incidentID"":""5"",""refs"":[],""severity"":""Minor"",""labels"":[],""isDrill"":false,""incidentType"":""internal"",""createdTime"":""2026-09-19T11:33:10.948822Z"",""modifiedTime"":""2026-09-19T11:33:27.648602Z"",""createdByUser"":{""userID"":""grafana-incident:user-6aae6b80244e1a8626a0a593"",""name"":""Service Account: grafana-irm-plugin-dev"",""photoURL"":""https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro""},""closedTime"":""2026-09-19T11:33:27.17626Z"",""durationSeconds"":17,""status"":""resolved"",""title"":""[DevLake seed] Cache cluster node failure"",""overviewURL"":""/a/grafana-irm-app/incidents/5/devlake-seed-cache-cluster-node-failure"",""incidentMembership"":{""assignments"":[{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0}],""totalAssignments"":0,""totalParticipants"":0},""taskList"":{""tasks"":[],""todoCount"":0,""doneCount"":0},""summary"":"""",""incidentStart"":""2026-09-19T11:33:10Z"",""incidentEnd"":""2026-09-19T11:33:27.17626Z"",""incidentChannels"":[]}",,null,2026-09-20T05:10:00.000+00:00 +3,"{""ConnectionId"":1,""ScopeId"":""default""}","{""incidentID"":""6"",""refs"":[],""severity"":""Critical"",""labels"":[{""key"":""service_name"",""label"":""orders-api"",""description"":"""",""colorHex"":""""},{""key"":""team_name"",""label"":""platform"",""description"":"""",""colorHex"":""""}],""isDrill"":false,""incidentType"":""internal"",""createdTime"":""2026-09-20T05:01:00.946089Z"",""modifiedTime"":""2026-09-20T05:01:15.377404Z"",""createdByUser"":{""userID"":""grafana-incident:user-6aae6b80244e1a8626a0a593"",""name"":""Service Account: grafana-irm-plugin-dev"",""photoURL"":""https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro""},""closedTime"":"""",""durationSeconds"":63,""status"":""active"",""title"":""[DevLake seed] Database connection pool exhaustion"",""overviewURL"":""/a/grafana-irm-app/incidents/6/devlake-seed-database-connection-pool-exhaustion"",""incidentMembership"":{""assignments"":[{""user"":{""userID"":""grafana-incident:user-6aae6b80244e1a8626a0a593"",""name"":""Service Account: grafana-irm-plugin-dev"",""photoURL"":""https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro""},""role"":{""roleID"":9576,""orgID"":""1836253"",""name"":""commander"",""description"":""Owns the incident (has their full-time attention)"",""important"":true,""mandatory"":true,""archived"":false,""createdAt"":""2026-09-19T11:00:21Z"",""updatedAt"":""""},""roleID"":9576},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0}],""totalAssignments"":1,""totalParticipants"":0},""taskList"":{""tasks"":[],""todoCount"":0,""doneCount"":0},""summary"":"""",""incidentStart"":""2026-09-20T05:01:00Z"",""incidentEnd"":"""",""incidentChannels"":[]}",,null,2026-09-20T05:10:00.000+00:00 +4,"{""ConnectionId"":1,""ScopeId"":""default""}","{""incidentID"":""7"",""refs"":[],""severity"":""Minor"",""labels"":[{""key"":""team_name"",""label"":""payments"",""description"":"""",""colorHex"":""""}],""isDrill"":false,""incidentType"":""internal"",""createdTime"":""2026-09-20T05:01:28.559648Z"",""modifiedTime"":""2026-09-20T05:01:43.133798Z"",""createdByUser"":{""userID"":""grafana-incident:user-6aae6b80244e1a8626a0a593"",""name"":""Service Account: grafana-irm-plugin-dev"",""photoURL"":""https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro""},""closedTime"":""2026-09-20T05:01:42.71276Z"",""durationSeconds"":14,""status"":""resolved"",""title"":""[DevLake seed] Payment webhook retries failing"",""overviewURL"":""/a/grafana-irm-app/incidents/7/devlake-seed-payment-webhook-retries-failing"",""incidentMembership"":{""assignments"":[{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0},{""user"":{""userID"":"""",""name"":"""",""photoURL"":""""},""role"":{""roleID"":0,""orgID"":"""",""name"":"""",""description"":"""",""important"":false,""mandatory"":false,""archived"":false,""createdAt"":"""",""updatedAt"":""""},""roleID"":0}],""totalAssignments"":0,""totalParticipants"":0},""taskList"":{""tasks"":[],""todoCount"":0,""doneCount"":0},""summary"":"""",""incidentStart"":""2026-09-20T05:01:28Z"",""incidentEnd"":""2026-09-20T05:01:42.71276Z"",""incidentChannels"":[]}",,null,2026-09-20T05:10:00.000+00:00 diff --git a/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incident_assignments.csv b/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incident_assignments.csv new file mode 100644 index 00000000000..bd8ab1123ad --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incident_assignments.csv @@ -0,0 +1,2 @@ +connection_id,incident_id,user_id,role_name,user_name +1,6,grafana-incident:user-6aae6b80244e1a8626a0a593,commander,Service Account: grafana-irm-plugin-dev diff --git a/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incident_labels.csv b/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incident_labels.csv new file mode 100644 index 00000000000..6600480d29a --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incident_labels.csv @@ -0,0 +1,5 @@ +connection_id,incident_id,label_key,label +1,4,team_name,platform +1,6,service_name,orders-api +1,6,team_name,platform +1,7,team_name,payments diff --git a/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incidents.csv b/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incidents.csv new file mode 100644 index 00000000000..f24a5fa1e73 --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/snapshot_tables/_tool_grafana_irm_incidents.csv @@ -0,0 +1,5 @@ +connection_id,id,title,url,status,severity,created_date,updated_date,resolved_date +1,4,[DevLake seed] API gateway latency degradation,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/4/devlake-seed-api-gateway-latency-degradation,active,Major,2026-09-19T11:32:53.254+00:00,2026-09-19T11:32:54.999+00:00, +1,5,[DevLake seed] Cache cluster node failure,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/5/devlake-seed-cache-cluster-node-failure,resolved,Minor,2026-09-19T11:33:10.949+00:00,2026-09-19T11:33:27.649+00:00,2026-09-19T11:33:27.176+00:00 +1,6,[DevLake seed] Database connection pool exhaustion,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/6/devlake-seed-database-connection-pool-exhaustion,active,Critical,2026-09-20T05:01:00.946+00:00,2026-09-20T05:01:15.377+00:00, +1,7,[DevLake seed] Payment webhook retries failing,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/7/devlake-seed-payment-webhook-retries-failing,resolved,Minor,2026-09-20T05:01:28.560+00:00,2026-09-20T05:01:43.134+00:00,2026-09-20T05:01:42.713+00:00 diff --git a/backend/plugins/grafana_irm/e2e/snapshot_tables/board_issues.csv b/backend/plugins/grafana_irm/e2e/snapshot_tables/board_issues.csv new file mode 100644 index 00000000000..a3e5652d292 --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/snapshot_tables/board_issues.csv @@ -0,0 +1,5 @@ +board_id,issue_id +grafana_irm:GrafanaIrmScope:1:default,grafana_irm:Incident:1:4 +grafana_irm:GrafanaIrmScope:1:default,grafana_irm:Incident:1:5 +grafana_irm:GrafanaIrmScope:1:default,grafana_irm:Incident:1:6 +grafana_irm:GrafanaIrmScope:1:default,grafana_irm:Incident:1:7 diff --git a/backend/plugins/grafana_irm/e2e/snapshot_tables/issue_assignees.csv b/backend/plugins/grafana_irm/e2e/snapshot_tables/issue_assignees.csv new file mode 100644 index 00000000000..1f444c10a94 --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/snapshot_tables/issue_assignees.csv @@ -0,0 +1,2 @@ +issue_id,assignee_id,assignee_name +grafana_irm:Incident:1:6,grafana-incident:user-6aae6b80244e1a8626a0a593,Service Account: grafana-irm-plugin-dev diff --git a/backend/plugins/grafana_irm/e2e/snapshot_tables/issue_labels.csv b/backend/plugins/grafana_irm/e2e/snapshot_tables/issue_labels.csv new file mode 100644 index 00000000000..d509a716144 --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/snapshot_tables/issue_labels.csv @@ -0,0 +1,5 @@ +issue_id,label_name +grafana_irm:Incident:1:4,team_name:platform +grafana_irm:Incident:1:6,service_name:orders-api +grafana_irm:Incident:1:6,team_name:platform +grafana_irm:Incident:1:7,team_name:payments diff --git a/backend/plugins/grafana_irm/e2e/snapshot_tables/issues.csv b/backend/plugins/grafana_irm/e2e/snapshot_tables/issues.csv new file mode 100644 index 00000000000..138f725c9af --- /dev/null +++ b/backend/plugins/grafana_irm/e2e/snapshot_tables/issues.csv @@ -0,0 +1,5 @@ +id,url,icon_url,issue_key,title,description,epic_key,type,original_type,status,original_status,story_point,resolution_date,created_date,updated_date,lead_time_minutes,original_estimate_minutes,time_spent_minutes,time_remaining_minutes,creator_id,creator_name,assignee_id,assignee_name,parent_issue_id,priority,severity,urgency,component,original_project,is_subtask,due_date,fix_versions +grafana_irm:Incident:1:4,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/4/devlake-seed-api-gateway-latency-degradation,,4,[DevLake seed] API gateway latency degradation,,,INCIDENT,,IN_PROGRESS,active,,,2026-09-19T11:32:53.254+00:00,2026-09-19T11:32:54.999+00:00,,,,,,,,,,,Major,,,,0,, +grafana_irm:Incident:1:5,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/5/devlake-seed-cache-cluster-node-failure,,5,[DevLake seed] Cache cluster node failure,,,INCIDENT,,DONE,resolved,,2026-09-19T11:33:27.176+00:00,2026-09-19T11:33:10.949+00:00,2026-09-19T11:33:27.649+00:00,0,,,,,,,,,,Minor,,,,0,, +grafana_irm:Incident:1:6,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/6/devlake-seed-database-connection-pool-exhaustion,,6,[DevLake seed] Database connection pool exhaustion,,,INCIDENT,,IN_PROGRESS,active,,,2026-09-20T05:01:00.946+00:00,2026-09-20T05:01:15.377+00:00,,,,,,,,,,,Critical,,,,0,, +grafana_irm:Incident:1:7,https://grafana-irm-plugin-dev.grafana.net/a/grafana-irm-app/incidents/7/devlake-seed-payment-webhook-retries-failing,,7,[DevLake seed] Payment webhook retries failing,,,INCIDENT,,DONE,resolved,,2026-09-20T05:01:42.713+00:00,2026-09-20T05:01:28.560+00:00,2026-09-20T05:01:43.134+00:00,0,,,,,,,,,,Minor,,,,0,, diff --git a/backend/plugins/grafana_irm/grafana_irm.go b/backend/plugins/grafana_irm/grafana_irm.go new file mode 100644 index 00000000000..2b43ee51bed --- /dev/null +++ b/backend/plugins/grafana_irm/grafana_irm.go @@ -0,0 +1,38 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/plugins/grafana_irm/impl" + "github.com/spf13/cobra" +) + +// PluginEntry Export a variable named PluginEntry for Framework to search and load +var PluginEntry impl.GrafanaIrm //nolint + +// standalone mode for debugging +func main() { + cmd := &cobra.Command{Use: "grafana_irm"} + timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "collect data that are created after specified time, ie 2006-01-02T15:04:05Z") + + cmd.Run = func(cmd *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{}, *timeAfter) + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/grafana_irm/impl/impl.go b/backend/plugins/grafana_irm/impl/impl.go new file mode 100644 index 00000000000..d7517ad277a --- /dev/null +++ b/backend/plugins/grafana_irm/impl/impl.go @@ -0,0 +1,221 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "fmt" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/api" + "github.com/apache/devlake/plugins/grafana_irm/models" + "github.com/apache/devlake/plugins/grafana_irm/models/migrationscripts" + "github.com/apache/devlake/plugins/grafana_irm/tasks" +) + +// make sure interface is implemented + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginTask + plugin.PluginApi + plugin.PluginModel + plugin.DataSourcePluginBlueprintV200 + plugin.CloseablePluginTask + plugin.PluginSource +} = (*GrafanaIrm)(nil) + +type GrafanaIrm struct{} + +func (p GrafanaIrm) Description() string { + return "collect Grafana IRM incident data" +} + +func (p GrafanaIrm) Name() string { + return "grafana_irm" +} + +func (p GrafanaIrm) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p GrafanaIrm) Connection() dal.Tabler { + return &models.GrafanaIrmConnection{} +} + +func (p GrafanaIrm) Scope() plugin.ToolLayerScope { + return &models.GrafanaIrmScope{} +} + +func (p GrafanaIrm) ScopeConfig() dal.Tabler { + return &models.GrafanaIrmScopeConfig{} +} + +func (p GrafanaIrm) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + tasks.CollectIncidentsMeta, + tasks.ExtractIncidentsMeta, + tasks.ConvertIncidentsMeta, + } +} + +func (p GrafanaIrm) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.GrafanaIrmConnection{}, + &models.GrafanaIrmScope{}, + &models.GrafanaIrmScopeConfig{}, + &models.Incident{}, + &models.IncidentLabel{}, + &models.IncidentAssignment{}, + } +} + +func (p GrafanaIrm) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + op, err := tasks.DecodeAndValidateTaskOptions(options) + if err != nil { + return nil, err + } + connectionHelper := helper.NewConnectionHelper( + taskCtx, + nil, + p.Name(), + ) + connection := &models.GrafanaIrmConnection{} + err = connectionHelper.FirstById(connection, op.ConnectionId) + if err != nil { + return nil, errors.Default.Wrap(err, "unable to get Grafana IRM connection by the given connection ID") + } + + if err := loadScopeConfig(taskCtx, op); err != nil { + return nil, err + } + + client, err := helper.NewApiClientFromConnection(taskCtx.GetContext(), taskCtx, connection) + if err != nil { + return nil, err + } + asyncClient, err := helper.CreateAsyncApiClient(taskCtx, client, nil) + if err != nil { + return nil, err + } + return &tasks.GrafanaIrmTaskData{ + Options: op, + Client: asyncClient, + Connection: connection, + }, nil +} + +// loadScopeConfig resolves op.ScopeConfig, which decides which incidents this +// scope covers (see models.GrafanaIrmScopeConfig). Options may carry it +// inline (advanced mode), or only a scope config id, or neither — in which +// case it's read from the scope row. A scope with no config at all is left +// with a zero-value config, which means "no label filter": the scope covers +// every real incident on the connection. +func loadScopeConfig(taskCtx plugin.TaskContext, op *tasks.GrafanaIrmOptions) errors.Error { + if op.ScopeConfig != nil { + return nil + } + db := taskCtx.GetDal() + if op.ScopeConfigId == 0 && op.ScopeId != "" { + scope := &models.GrafanaIrmScope{} + err := db.First(scope, dal.Where("connection_id = ? AND id = ?", op.ConnectionId, op.ScopeId)) + if err != nil && !db.IsErrorNotFound(err) { + return errors.Default.Wrap(err, "unable to get Grafana IRM scope") + } + if err == nil { + op.ScopeConfigId = scope.ScopeConfigId + } + } + scopeConfig := &models.GrafanaIrmScopeConfig{} + if op.ScopeConfigId != 0 { + err := db.First(scopeConfig, dal.Where("id = ?", op.ScopeConfigId)) + if err != nil && !db.IsErrorNotFound(err) { + return errors.Default.Wrap(err, "unable to get Grafana IRM scope config") + } + } + op.ScopeConfig = scopeConfig + return nil +} + +// RootPkgPath information lost when compiled as plugin(.so) +func (p GrafanaIrm) RootPkgPath() string { + return "github.com/apache/devlake/plugins/grafana_irm" +} + +func (p GrafanaIrm) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p GrafanaIrm) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/search-remote-scopes": { + "GET": api.SearchRemoteScopes, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scopes/:scopeId": { + "GET": api.GetScope, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes/:scopeId/latest-sync-state": { + "GET": api.GetScopeLatestSyncState, + }, + } +} + +func (p GrafanaIrm) MakeDataSourcePipelinePlanV200( + connectionId uint64, + scopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes) +} + +func (p GrafanaIrm) Close(taskCtx plugin.TaskContext) errors.Error { + _, ok := taskCtx.GetData().(*tasks.GrafanaIrmTaskData) + if !ok { + return errors.Default.New(fmt.Sprintf("GetData failed when try to close %+v", taskCtx)) + } + return nil +} diff --git a/backend/plugins/grafana_irm/models/connection.go b/backend/plugins/grafana_irm/models/connection.go new file mode 100644 index 00000000000..c2cf5ab6a9f --- /dev/null +++ b/backend/plugins/grafana_irm/models/connection.go @@ -0,0 +1,75 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "fmt" + "net/http" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/utils" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// GrafanaIrmAccessToken authenticates against the Grafana Incident API using +// a Grafana Cloud Service Account token (see grafana_irm_plan.md §3). +type GrafanaIrmAccessToken helper.AccessToken + +func (at *GrafanaIrmAccessToken) SetupAuthentication(request *http.Request) errors.Error { + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.Token)) + return nil +} + +type GrafanaIrmConn struct { + helper.RestConnection `mapstructure:",squash"` + GrafanaIrmAccessToken `mapstructure:",squash"` +} + +func (connection GrafanaIrmConn) Sanitize() GrafanaIrmConn { + connection.Token = utils.SanitizeString(connection.Token) + return connection +} + +type GrafanaIrmConnection struct { + helper.BaseConnection `mapstructure:",squash"` + GrafanaIrmConn `mapstructure:",squash"` +} + +// MergeFromRequest preserves the existing token when an incoming PATCH body +// omits it or echoes the sanitized form back (see incidentio's Connection +// for the same pattern). +func (connection *GrafanaIrmConnection) MergeFromRequest(target *GrafanaIrmConnection, body map[string]interface{}) error { + token := target.Token + if err := helper.DecodeMapStruct(body, target, true); err != nil { + return err + } + modifiedToken := target.Token + if modifiedToken == "" || modifiedToken == utils.SanitizeString(token) { + target.Token = token + } + return nil +} + +func (GrafanaIrmConnection) TableName() string { + return "_tool_grafana_irm_connections" +} + +func (connection GrafanaIrmConnection) Sanitize() GrafanaIrmConnection { + connection.Token = utils.SanitizeString(connection.Token) + return connection +} diff --git a/backend/plugins/grafana_irm/models/incident.go b/backend/plugins/grafana_irm/models/incident.go new file mode 100644 index 00000000000..1d18e6c89f4 --- /dev/null +++ b/backend/plugins/grafana_irm/models/incident.go @@ -0,0 +1,49 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// Incident is the tool-layer row for a Grafana IRM incident. Status is the +// raw wire value ("active"/"resolved" as verified live, see +// grafana_irm_plan.md §3.4) and is what the collector's refresh pass +// (tasks/incidents_collector.go) filters on. CreatedDate/ResolvedDate use +// CreatedTime/ClosedTime rather than IncidentStart/IncidentEnd per the +// resolved open decision in §9 (they mirror each other exactly under normal +// API-driven resolution, so either pair works). +type Incident struct { + common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Title string + // Url is the incident's absolute overview URL. OverviewURL on the wire + // is relative (verified live, see §3.4) and must be prefixed with the + // connection's endpoint by the extractor before being stored here. + Url string + Status string `gorm:"index;type:varchar(255)"` + Severity string + CreatedDate time.Time + UpdatedDate time.Time + ResolvedDate *time.Time +} + +func (Incident) TableName() string { return "_tool_grafana_irm_incidents" } diff --git a/backend/plugins/grafana_irm/models/incident_assignment.go b/backend/plugins/grafana_irm/models/incident_assignment.go new file mode 100644 index 00000000000..498e9e23c08 --- /dev/null +++ b/backend/plugins/grafana_irm/models/incident_assignment.go @@ -0,0 +1,38 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// IncidentAssignment is one real, filled role assignment on an incident. +// IncidentMembership.Assignments on the wire is a fixed-size array of role +// *slots*, most of them empty placeholders with User.UserID == "" (verified +// live, see grafana_irm_plan.md §3.4/§9) — only entries with a real assigned +// user are ever extracted into this table. +type IncidentAssignment struct { + common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + IncidentId string `gorm:"primaryKey;autoIncrement:false"` + UserId string `gorm:"primaryKey;type:varchar(255)"` + RoleName string `gorm:"primaryKey;type:varchar(255)"` + UserName string +} + +func (IncidentAssignment) TableName() string { return "_tool_grafana_irm_incident_assignments" } diff --git a/backend/plugins/grafana_irm/models/incident_label.go b/backend/plugins/grafana_irm/models/incident_label.go new file mode 100644 index 00000000000..fdff83a4ca1 --- /dev/null +++ b/backend/plugins/grafana_irm/models/incident_label.go @@ -0,0 +1,39 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// IncidentLabel is one key/label pair from an incident's Labels[] (see +// grafana_irm_plan.md §3.2/§5) — the only team/service grouping signal the +// API exposes (§4). +type IncidentLabel struct { + common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + IncidentId string `gorm:"primaryKey;autoIncrement:false"` + // Key is stored as column `label_key`, not `key`: `key` is a reserved + // word in MySQL (index syntax) and breaks any raw SQL that references it + // unquoted — caught live running the e2e test (see grafana_irm_plan.md + // §12), not by any local check. + Key string `gorm:"column:label_key;primaryKey;type:varchar(255)"` + Label string +} + +func (IncidentLabel) TableName() string { return "_tool_grafana_irm_incident_labels" } diff --git a/backend/plugins/grafana_irm/models/migrationscripts/20260918_add_init_tables.go b/backend/plugins/grafana_irm/models/migrationscripts/20260918_add_init_tables.go new file mode 100644 index 00000000000..109ba06a521 --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/20260918_add_init_tables.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" + "github.com/apache/devlake/plugins/grafana_irm/models/migrationscripts/archived" +) + +type addInitTables struct{} + +func (*addInitTables) Up(baseRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(baseRes, + &archived.Connection{}, + &archived.Scope{}, + &archived.ScopeConfig{}, + &archived.Incident{}, + &archived.IncidentLabel{}, + &archived.IncidentAssignment{}, + ) +} + +func (*addInitTables) Version() uint64 { + return 20260918000001 +} + +func (*addInitTables) Name() string { + return "grafana_irm init schemas" +} diff --git a/backend/plugins/grafana_irm/models/migrationscripts/archived/connection.go b/backend/plugins/grafana_irm/models/migrationscripts/archived/connection.go new file mode 100644 index 00000000000..e1cc591fa17 --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/archived/connection.go @@ -0,0 +1,35 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/devlake/core/models/migrationscripts/archived" +) + +type Connection struct { + archived.Model + Name string `gorm:"type:varchar(100);uniqueIndex" json:"name" validate:"required"` + Endpoint string `mapstructure:"endpoint" validate:"required" json:"endpoint"` + Proxy string `mapstructure:"proxy" json:"proxy"` + RateLimitPerHour int `comment:"api request rate limit per hour" json:"rateLimitPerHour"` + Token string `mapstructure:"token" env:"GRAFANA_IRM_AUTH" validate:"required" encrypt:"yes"` +} + +func (Connection) TableName() string { + return "_tool_grafana_irm_connections" +} diff --git a/backend/plugins/grafana_irm/models/migrationscripts/archived/incident.go b/backend/plugins/grafana_irm/models/migrationscripts/archived/incident.go new file mode 100644 index 00000000000..53bfd867d59 --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/archived/incident.go @@ -0,0 +1,41 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "time" + + "github.com/apache/devlake/core/models/migrationscripts/archived" +) + +type Incident struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Title string + Url string + Status string + Severity string + CreatedDate time.Time + UpdatedDate time.Time + ResolvedDate *time.Time +} + +func (Incident) TableName() string { + return "_tool_grafana_irm_incidents" +} diff --git a/backend/plugins/grafana_irm/models/migrationscripts/archived/incident_assignment.go b/backend/plugins/grafana_irm/models/migrationscripts/archived/incident_assignment.go new file mode 100644 index 00000000000..ff754f57d55 --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/archived/incident_assignment.go @@ -0,0 +1,35 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/devlake/core/models/migrationscripts/archived" +) + +type IncidentAssignment struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + IncidentId string `gorm:"primaryKey;autoIncrement:false"` + UserId string `gorm:"primaryKey"` + RoleName string `gorm:"primaryKey"` + UserName string +} + +func (IncidentAssignment) TableName() string { + return "_tool_grafana_irm_incident_assignments" +} diff --git a/backend/plugins/grafana_irm/models/migrationscripts/archived/incident_label.go b/backend/plugins/grafana_irm/models/migrationscripts/archived/incident_label.go new file mode 100644 index 00000000000..894f6b84b9e --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/archived/incident_label.go @@ -0,0 +1,34 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/devlake/core/models/migrationscripts/archived" +) + +type IncidentLabel struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + IncidentId string `gorm:"primaryKey;autoIncrement:false"` + Key string `gorm:"column:label_key;primaryKey"` + Label string +} + +func (IncidentLabel) TableName() string { + return "_tool_grafana_irm_incident_labels" +} diff --git a/backend/plugins/grafana_irm/models/migrationscripts/archived/scope.go b/backend/plugins/grafana_irm/models/migrationscripts/archived/scope.go new file mode 100644 index 00000000000..1873dd501af --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/archived/scope.go @@ -0,0 +1,36 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/devlake/core/models/migrationscripts/archived" +) + +// ScopeConfigId mirrors the column that live `models.GrafanaIrmScope` gets +// via embedded `common.Scope`; the archived `NoPKModel` doesn't include it. +type Scope struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty" mapstructure:"scopeConfigId,omitempty"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Name string +} + +func (Scope) TableName() string { + return "_tool_grafana_irm_scopes" +} diff --git a/backend/plugins/grafana_irm/models/migrationscripts/archived/scope_config.go b/backend/plugins/grafana_irm/models/migrationscripts/archived/scope_config.go new file mode 100644 index 00000000000..e4b32ca23a7 --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/archived/scope_config.go @@ -0,0 +1,35 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/devlake/core/models/migrationscripts/archived" +) + +// ConnectionId and Name come from `common.ScopeConfig` on the live model; +// the archived `archived.ScopeConfig` base only carries Model + Entities, so +// declare them explicitly (same pattern as incidentio's archived ScopeConfig). +type ScopeConfig struct { + archived.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + ConnectionId uint64 `json:"connectionId" gorm:"index" validate:"required" mapstructure:"connectionId,omitempty"` + Name string `mapstructure:"name" json:"name" gorm:"type:varchar(255);uniqueIndex" validate:"required"` +} + +func (ScopeConfig) TableName() string { + return "_tool_grafana_irm_scope_configs" +} diff --git a/backend/plugins/grafana_irm/models/migrationscripts/register.go b/backend/plugins/grafana_irm/models/migrationscripts/register.go new file mode 100644 index 00000000000..20d2a37ecb7 --- /dev/null +++ b/backend/plugins/grafana_irm/models/migrationscripts/register.go @@ -0,0 +1,29 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/plugin" +) + +// All returns all the migration scripts for the grafana_irm plugin +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + new(addInitTables), + } +} diff --git a/backend/plugins/grafana_irm/models/raw/incident.go b/backend/plugins/grafana_irm/models/raw/incident.go new file mode 100644 index 00000000000..c92a517d768 --- /dev/null +++ b/backend/plugins/grafana_irm/models/raw/incident.go @@ -0,0 +1,65 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package raw + +// Incident is the wire shape of a single object in +// IncidentsService.QueryIncidents'/GetIncident's `incident`/`incidents[]` +// field, verified live against a real Grafana Cloud stack (see +// grafana_irm_plan.md §3.2/§3.4/§9). Only fields the extractor actually maps +// are declared here; CreatedTime/ModifiedTime/ClosedTime are plain strings, +// not time.Time, because ClosedTime comes back as a literal empty string +// `""` on an open incident (verified live) rather than null or omitted. +type Incident struct { + IncidentID string `json:"incidentID"` + Title string `json:"title"` + Status string `json:"status"` + Severity string `json:"severity"` + Labels []IncidentLabel `json:"labels"` + CreatedTime string `json:"createdTime"` + ModifiedTime string `json:"modifiedTime"` + ClosedTime string `json:"closedTime"` + OverviewURL string `json:"overviewURL"` + IncidentMembership IncidentMembership `json:"incidentMembership"` +} + +type IncidentLabel struct { + Key string `json:"key"` + Label string `json:"label"` +} + +type IncidentMembership struct { + Assignments []Assignment `json:"assignments"` +} + +// Assignment is one entry of IncidentMembership.Assignments. On the wire +// this array is a fixed-size list of role *slots*, most of them empty +// placeholders with User.UserID == "" (verified live, see +// grafana_irm_plan.md §3.4/§9) — the extractor filters on that. +type Assignment struct { + User UserPreview `json:"user"` + Role Role `json:"role"` +} + +type UserPreview struct { + UserID string `json:"userID"` + Name string `json:"name"` +} + +type Role struct { + Name string `json:"name"` +} diff --git a/backend/plugins/grafana_irm/models/scope.go b/backend/plugins/grafana_irm/models/scope.go new file mode 100644 index 00000000000..90d6db2b7f7 --- /dev/null +++ b/backend/plugins/grafana_irm/models/scope.go @@ -0,0 +1,65 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" +) + +type GrafanaIrmParams struct { + ConnectionId uint64 + ScopeId string +} + +// GrafanaIrmScope is the connection-level scope described in +// grafana_irm_plan.md §4: the Grafana Incident API has no remote-listable +// "service"/"team" resource to scope by (unlike incidentio's incident +// types), so a scope here is a user-named grouping the operator creates by +// hand rather than one picked from a remote list. Any label-filter or other +// per-scope collection behavior is deferred until that logic is designed. +type GrafanaIrmScope struct { + common.Scope `mapstructure:",squash"` + Id string `json:"id" mapstructure:"id" gorm:"primaryKey;autoIncrement:false"` + Name string `json:"name" mapstructure:"name"` +} + +func (s GrafanaIrmScope) ScopeId() string { + return s.Id +} + +func (s GrafanaIrmScope) ScopeName() string { + return s.Name +} + +func (s GrafanaIrmScope) ScopeFullName() string { + return s.Name +} + +func (s GrafanaIrmScope) ScopeParams() interface{} { + return &GrafanaIrmParams{ + ConnectionId: s.ConnectionId, + ScopeId: s.Id, + } +} + +func (s GrafanaIrmScope) TableName() string { + return "_tool_grafana_irm_scopes" +} + +var _ plugin.ToolLayerScope = (*GrafanaIrmScope)(nil) diff --git a/backend/plugins/grafana_irm/models/scope_config.go b/backend/plugins/grafana_irm/models/scope_config.go new file mode 100644 index 00000000000..222d8d84076 --- /dev/null +++ b/backend/plugins/grafana_irm/models/scope_config.go @@ -0,0 +1,36 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// GrafanaIrmScopeConfig carries only the standard entity-selection fields +// (common.ScopeConfig) — same shape as incidentio's/pagerduty's. See +// grafana_irm_plan.md §4.2: a connection has exactly one scope covering its +// whole incident stream (there is no listable service/team resource to slice +// by, and the originating feature request never asked for per-team +// filtering), so there is nothing scope-specific left to configure here. +type GrafanaIrmScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` +} + +func (GrafanaIrmScopeConfig) TableName() string { + return "_tool_grafana_irm_scope_configs" +} diff --git a/backend/plugins/grafana_irm/tasks/incidents_collector.go b/backend/plugins/grafana_irm/tasks/incidents_collector.go new file mode 100644 index 00000000000..d9854dcdf61 --- /dev/null +++ b/backend/plugins/grafana_irm/tasks/incidents_collector.go @@ -0,0 +1,304 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/models" + "github.com/apache/devlake/plugins/grafana_irm/models/raw" +) + +const RAW_INCIDENTS_TABLE = "grafana_irm_incidents" + +const incidentsPageSize = 100 + +var _ plugin.SubTaskEntryPoint = CollectIncidents + +// queryIncidentsResponse is the envelope IncidentsService.QueryIncidents +// returns; verified live, see grafana_irm_plan.md §3.1/§10.1. +type queryIncidentsResponse struct { + Incidents []json.RawMessage `json:"incidents"` + Cursor struct { + NextValue string `json:"nextValue"` + HasMore bool `json:"hasMore"` + } `json:"cursor"` +} + +// getIncidentResponse is IncidentsService.GetIncident's envelope. +type getIncidentResponse struct { + Incident json.RawMessage `json:"incident"` +} + +// simplifiedIncident is the minimal shape read back from our own tool table +// to drive the "unfinished details" half's input iterator below. UpdatedDate +// is our last-known modifiedTime for this incident, used to skip +// re-inserting a raw row when GetIncident shows nothing has actually changed +// (see unfinishedDetailsHeader/incidentUnchanged below). +type simplifiedIncident struct { + Id string + UpdatedDate time.Time +} + +// knownModifiedHeader carries simplifiedIncident.UpdatedDate from +// unfinishedDetailsHeader (set on the outgoing request) through to that +// half's ResponseParser (read off the matching response) so the two can be +// compared per-request. This is smuggled through an HTTP header rather than +// shared state because ResponseParser only receives *http.Response, not the +// request that produced it or its originating input — but net/http +// guarantees res.Request is the exact request that was sent, headers +// included, which makes this correlation safe under this collector's +// concurrent workers (each request/response pair carries its own copy, +// nothing is shared across goroutines). Sent to Grafana's API too; harmless, +// since it silently ignores headers it doesn't recognize (verified live, +// same as it does for unrecognized JSON fields — see grafana_irm_plan.md +// §14.3). +const knownModifiedHeader = "X-Devlake-Known-Modified" + +var CollectIncidentsMeta = plugin.SubTaskMeta{ + Name: "collectIncidents", + EntryPoint: CollectIncidents, + EnabledByDefault: true, + Description: "Collect Grafana IRM incidents: new/recently-changed by list, plus a refresh of still-open ones a date-range list can't catch", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, + ProductTables: []string{RAW_INCIDENTS_TABLE}, +} + +// CollectIncidents is a single FinalizableApiCollector subtask (grafana_irm_plan.md §15; +// replaces the earlier two-subtask CollectIncidents+RefreshOpenIncidents split, which caused +// three real bugs — §14.1/§14.2/§14.4 — all traced to having two independent ApiCollectors +// each independently deciding whether to wipe the raw table, with no coordination between +// them). This mirrors pagerduty's CollectUnfinishedDetails pattern, the framework's own +// blessed answer to this exact "list new/changed, then separately refresh still-open ones" +// shape: +// - CollectNewRecordsByList: the main pass. `IncidentsQuery.DateFrom`/`DateTo` were verified +// live to have no filtering effect at all, so incremental filtering goes through +// `queryString`'s `declared:`/`resolved:` date-range syntax instead — verified live to +// combine correctly with a bare `isdrill:false` term via `or(...)` (§10.1). Real incidents +// only; drills are never synced (§11). +// - CollectUnfinishedDetails: `queryString` only supports `declared:`/`started:`/`resolved:`/ +// `ended:` date ranges (verified live; `modified:`/`updated:` are not valid properties +// there), so a status change, label edit, or role assignment on an incident that's already +// synced and still open would never be picked up by the list pass alone. This re-fetches +// (via GetIncident) every incident our own tool table still has as non-resolved from a +// prior sync — but per NewStatefulApiCollectorForFinalizableEntity's own implementation, +// it is never even constructed when the run isn't incremental (a full sync), because the +// list pass's own unrestricted query already covers everything then. That's exactly what +// §14.4 needed and didn't have under the old two-subtask design. +func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*GrafanaIrmTaskData) + db := taskCtx.GetDal() + + args := api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_INCIDENTS_TABLE, + } + + // FinalizableApiCollectorCommonArgs.RequestBody doesn't receive + // createdAfter the way Query/Header do (grafana_irm_plan.md §15.2 — an + // asymmetry nothing else needed until now, not a deliberate constraint: + // every other FinalizableApiCollector plugin filters via Query on a REST + // GET, never RequestBody). Since createdAfter is a single constant for + // the whole run, not a per-request value, we read it ourselves via a + // second, independent, read-only CollectorStateManager against the same + // (table, params) state row NewStatefulApiCollectorForFinalizableEntity + // will also read below. Safe because NewCollectorStateManager only + // reads — state is only ever written in .Close() — and this instance's + // Close() is never called; only the framework's own manager (returned + // below, closed via collector.Execute()) does that. + rawDataSubTask, err := api.NewRawDataSubTask(args) + if err != nil { + return err + } + stateManager, err := api.NewCollectorStateManager(taskCtx, taskCtx.TaskContext().SyncPolicy(), rawDataSubTask.GetTable(), rawDataSubTask.GetParams()) + if err != nil { + return err + } + queryString := buildIncidentsQueryString(stateManager.GetSince(), stateManager.GetUntil()) + + var lastCursor string + var lastHasMore bool + + collector, err := api.NewStatefulApiCollectorForFinalizableEntity(api.FinalizableApiCollectorArgs{ + RawDataSubTaskArgs: args, + ApiClient: data.Client, + CollectNewRecordsByList: api.FinalizableApiCollectorListArgs{ + PageSize: incidentsPageSize, + GetNextPageCustomData: func(prevReqData *api.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) { + // lastCursor/lastHasMore are set in ResponseParser below and read + // from that closure rather than prevPageResponse.Body here: the + // body is a single-read stream and is already drained by the time + // this hook fires (same constraint incidentio's collector notes). + if !lastHasMore || lastCursor == "" { + return nil, api.ErrFinishCollect + } + return lastCursor, nil + }, + FinalizableApiCollectorCommonArgs: api.FinalizableApiCollectorCommonArgs{ + Method: http.MethodPost, + UrlTemplate: "api/plugins/grafana-irm-app/resources/api/v1/IncidentsService.QueryIncidents", + RequestBody: func(reqData *api.RequestData) map[string]interface{} { + query := map[string]interface{}{ + "limit": reqData.Pager.Size, + "orderDirection": "ASC", + "queryString": queryString, + } + body := map[string]interface{}{"query": query} + if cursor, ok := reqData.CustomData.(string); ok && cursor != "" { + body["cursor"] = map[string]interface{}{"nextValue": cursor} + } + return body + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + envelope := &queryIncidentsResponse{} + if err := api.UnmarshalResponse(res, envelope); err != nil { + return nil, err + } + lastCursor = envelope.Cursor.NextValue + lastHasMore = envelope.Cursor.HasMore + return envelope.Incidents, nil + }, + }, + }, + CollectUnfinishedDetails: &api.FinalizableApiCollectorDetailArgs{ + FinalizableApiCollectorCommonArgs: api.FinalizableApiCollectorCommonArgs{ + Method: http.MethodPost, + UrlTemplate: "api/plugins/grafana-irm-app/resources/api/v1/IncidentsService.GetIncident", + RequestBody: unfinishedDetailsRequestBody, + Header: unfinishedDetailsHeader, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + envelope := &getIncidentResponse{} + if err := api.UnmarshalResponse(res, envelope); err != nil { + return nil, err + } + incident := &raw.Incident{} + if err := errors.Convert(json.Unmarshal(envelope.Incident, incident)); err != nil { + return nil, err + } + if incidentUnchanged(incident.ModifiedTime, res.Request.Header.Get(knownModifiedHeader)) { + return []json.RawMessage{}, nil + } + return []json.RawMessage{envelope.Incident}, nil + }, + }, + // Deliberately connection-wide, NOT filtered to this scope's label: the + // tool tables are shared across a connection's scopes, and this pass is + // what keeps every open incident's labels current. That matters because + // scope membership is recomputed from those labels at convert time — if + // an incident is relabelled out of this scope, neither scope's list pass + // would re-fetch it (relabelling changes no declared/resolved date), so + // this refresh is the only thing that notices. + BuildInputIterator: func() (api.Iterator, errors.Error) { + cursor, err := db.Cursor( + dal.Select("id, updated_date"), + dal.From(&models.Incident{}), + dal.Where("connection_id = ? AND status != ?", data.Options.ConnectionId, "resolved"), + ) + if err != nil { + return nil, err + } + return api.NewDalCursorIterator(db, cursor, reflect.TypeOf(simplifiedIncident{})) + }, + }, + }) + if err != nil { + return err + } + return collector.Execute() +} + +// buildIncidentsQueryString implements the incremental-sync recipe verified +// live against the real API (grafana_irm_plan.md §10.1/§10.2). since/until +// come from the framework's own collector state tracking; a nil since means +// a full sync, so no date restriction is applied. A connection has exactly +// one scope covering its whole incident stream (§4.2), so there is no +// per-scope filter term to add here. +func buildIncidentsQueryString(since, until *time.Time) string { + terms := []string{"isdrill:false"} + if since != nil && until != nil { + from := since.UTC().Format(time.RFC3339) + to := until.UTC().Format(time.RFC3339) + terms = append(terms, fmt.Sprintf("or(declared:%s,%s resolved:%s,%s)", from, to, from, to)) + } + return strings.Join(terms, " ") +} + +// unfinishedDetailsRequestBody reads the current input row off reqData. +// NewDalCursorIterator (used by CollectUnfinishedDetails' BuildInputIterator +// above) hands back *simplifiedIncident, not simplifiedIncident: it +// constructs each row via reflect.New, which always yields a pointer. +// Asserting the value type here panicked at runtime with "interface +// conversion: interface {} is *tasks.simplifiedIncident, not +// tasks.simplifiedIncident" the first time this path ran on a live pipeline +// with an unresolved incident already synced (§14.1) — neither unit nor e2e +// tests exercised this iterator before that. +func unfinishedDetailsRequestBody(reqData *api.RequestData) map[string]interface{} { + input := reqData.Input.(*simplifiedIncident) + return map[string]interface{}{"incidentID": input.Id} +} + +// unfinishedDetailsHeader stamps this request with the incident's last-known +// modifiedTime, so ResponseParser can tell whether GetIncident's response +// actually changed anything (see knownModifiedHeader's doc comment). +// FinalizableApiCollectorCommonArgs.Header receives createdAfter (unlike +// RequestBody — see CollectIncidents' doc comment on that asymmetry); it's +// unused here since this comparison only cares about this one incident's own +// prior state, not the run's overall time window. +func unfinishedDetailsHeader(reqData *api.RequestData, _ *time.Time) (http.Header, errors.Error) { + input := reqData.Input.(*simplifiedIncident) + h := http.Header{} + h.Set(knownModifiedHeader, input.UpdatedDate.UTC().Format(time.RFC3339Nano)) + return h, nil +} + +// incidentUnchanged reports whether fetchedModifiedTime (the wire value from +// a fresh GetIncident call, RFC3339 with sub-second precision) represents the +// same instant as knownModified (this incident's last-known UpdatedDate, +// round-tripped through unfinishedDetailsHeader). Comparison truncates both +// sides to millisecond precision: UpdatedDate has already lost precision +// below that through MySQL's datetime(3) column (see models/incident.go), so +// comparing at full nanosecond precision would report "changed" every time +// even when nothing was. +// +// Fails safe: any parse failure (a missing/malformed header, or a malformed +// fetchedModifiedTime) returns false — "treat as changed, keep the row" — +// never "assume unchanged" on a header this function can't make sense of. +func incidentUnchanged(fetchedModifiedTime string, knownModified string) bool { + if knownModified == "" { + return false + } + fetched, err := parseIncidentTime(fetchedModifiedTime) + if err != nil { + return false + } + known, parseErr := time.Parse(time.RFC3339Nano, knownModified) + if parseErr != nil { + return false + } + return fetched.Truncate(time.Millisecond).Equal(known.Truncate(time.Millisecond)) +} diff --git a/backend/plugins/grafana_irm/tasks/incidents_collector_test.go b/backend/plugins/grafana_irm/tasks/incidents_collector_test.go new file mode 100644 index 00000000000..c841151eccd --- /dev/null +++ b/backend/plugins/grafana_irm/tasks/incidents_collector_test.go @@ -0,0 +1,149 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// The expected strings below are the exact query shapes verified live against +// a real stack (see grafana_irm_plan.md §10.1): `isdrill:false`, and the +// `or(...)` date-range group added for an incremental sync. +func TestBuildIncidentsQueryString(t *testing.T) { + since := time.Date(2026, 9, 19, 11, 2, 0, 0, time.UTC) + until := time.Date(2026, 9, 19, 23, 59, 59, 0, time.UTC) + + cases := []struct { + name string + since *time.Time + until *time.Time + expected string + }{ + { + name: "full sync", + expected: "isdrill:false", + }, + { + name: "incremental", + since: &since, + until: &until, + expected: "isdrill:false or(declared:2026-09-19T11:02:00Z,2026-09-19T23:59:59Z resolved:2026-09-19T11:02:00Z,2026-09-19T23:59:59Z)", + }, + { + name: "a since with no until falls back to full sync", + since: &since, + expected: "isdrill:false", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, buildIncidentsQueryString(tc.since, tc.until)) + }) + } +} + +// Regression test for a real panic hit on a live pipeline run: +// NewDalCursorIterator (see CollectIncidents' CollectUnfinishedDetails half) +// hands back *simplifiedIncident, not simplifiedIncident — reflect.New always +// yields a pointer — so asserting the value type here panicked with +// "interface conversion: interface {} is *tasks.simplifiedIncident, not +// tasks.simplifiedIncident" the first time this path actually ran against a +// connection with an unresolved incident already synced (§14.1). Neither the +// unit tests nor the e2e fixtures exercised this iterator before that. +func TestUnfinishedDetailsRequestBody(t *testing.T) { + reqData := &api.RequestData{Input: &simplifiedIncident{Id: "42"}} + body := unfinishedDetailsRequestBody(reqData) + assert.Equal(t, map[string]interface{}{"incidentID": "42"}, body) +} + +// unfinishedDetailsHeader carries simplifiedIncident.UpdatedDate onto the +// outgoing request so ResponseParser can compare it against the freshly +// fetched modifiedTime (see incidentUnchanged below); this is what +// incidentUnchanged actually reads back off res.Request.Header. +func TestUnfinishedDetailsHeader(t *testing.T) { + updated := time.Date(2026, 9, 20, 5, 1, 15, 377000000, time.UTC) + reqData := &api.RequestData{Input: &simplifiedIncident{Id: "6", UpdatedDate: updated}} + header, err := unfinishedDetailsHeader(reqData, nil) + assert.NoError(t, err) + assert.Equal(t, "2026-09-20T05:01:15.377Z", header.Get(knownModifiedHeader)) +} + +// incidentUnchanged is the actual fix for grafana_irm_plan.md §14.3: without +// it, the "unfinished details" pass inserts a fresh raw row for every +// currently-open incident on every single pipeline run, regardless of +// whether anything changed, since Grafana IRM has no "modified since" filter +// to ask for only what's new (re-confirmed live and against the docs, see +// §14.3). +func TestIncidentUnchanged(t *testing.T) { + cases := []struct { + name string + fetchedModified string + knownModified string + expectUnchanged bool + }{ + { + name: "identical instant, same precision", + fetchedModified: "2026-09-20T05:01:15.377000Z", + knownModified: "2026-09-20T05:01:15.377Z", + expectUnchanged: true, + }, + { + name: "same instant, fetched has extra sub-millisecond precision " + + "MySQL's datetime(3) already dropped — must still count as unchanged", + fetchedModified: "2026-09-20T05:01:15.377404Z", + knownModified: "2026-09-20T05:01:15.377Z", + expectUnchanged: true, + }, + { + name: "genuinely different instant", + fetchedModified: "2026-09-20T05:05:00.000Z", + knownModified: "2026-09-20T05:01:15.377Z", + expectUnchanged: false, + }, + { + name: "empty header (never set, or a bug) fails safe to changed", + fetchedModified: "2026-09-20T05:01:15.377Z", + knownModified: "", + expectUnchanged: false, + }, + { + name: "malformed header fails safe to changed", + fetchedModified: "2026-09-20T05:01:15.377Z", + knownModified: "not-a-timestamp", + expectUnchanged: false, + }, + { + name: "malformed fetched time fails safe to changed", + fetchedModified: "not-a-timestamp", + knownModified: "2026-09-20T05:01:15.377Z", + expectUnchanged: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expectUnchanged, incidentUnchanged(tc.fetchedModified, tc.knownModified)) + }) + } +} diff --git a/backend/plugins/grafana_irm/tasks/incidents_converter.go b/backend/plugins/grafana_irm/tasks/incidents_converter.go new file mode 100644 index 00000000000..5ac9998766e --- /dev/null +++ b/backend/plugins/grafana_irm/tasks/incidents_converter.go @@ -0,0 +1,185 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "reflect" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/models" +) + +var _ plugin.SubTaskEntryPoint = ConvertIncidents + +var ConvertIncidentsMeta = plugin.SubTaskMeta{ + Name: "convertIncidents", + EntryPoint: ConvertIncidents, + EnabledByDefault: true, + Description: "Convert Grafana IRM incidents into domain-layer ticket issues", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +// ConvertIncidents maps tool-layer Incident rows to domain ticket.Issue rows +// (Type: INCIDENT), plus their labels and real assignments as IssueLabel/ +// IssueAssignee rows. A connection has exactly one scope covering its whole +// incident stream (§4.2 — Grafana IRM has no listable service/team resource, +// and the originating feature request never asked for per-team filtering), +// so every incident collected for this connection converts unconditionally. +func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*GrafanaIrmTaskData) + logger := taskCtx.GetLogger() + + cursor, err := db.Cursor( + dal.From(&models.Incident{}), + dal.Where("connection_id = ?", data.Options.ConnectionId), + ) + if err != nil { + return err + } + defer cursor.Close() + + idGen := didgen.NewDomainIdGenerator(&models.Incident{}) + boardId := didgen.NewDomainIdGenerator(&models.GrafanaIrmScope{}). + Generate(data.Options.ConnectionId, data.Options.ScopeId) + + scope := &models.GrafanaIrmScope{} + scopeName := "All Incidents" + if err := db.First(scope, dal.Where("connection_id = ? AND id = ?", data.Options.ConnectionId, data.Options.ScopeId)); err == nil && scope.Name != "" { + scopeName = scope.Name + } + domainBoard := &ticket.Board{ + DomainEntity: domainlayer.DomainEntity{Id: boardId}, + Name: scopeName, + } + if err := db.CreateOrUpdate(domainBoard); err != nil { + return err + } + + converter, err := api.NewDataConverter(api.DataConverterArgs{ + RawDataSubTaskArgs: api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_INCIDENTS_TABLE, + }, + InputRowType: reflect.TypeOf(models.Incident{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + incident := inputRow.(*models.Incident) + + var labels []models.IncidentLabel + if err := db.All(&labels, + dal.From(&models.IncidentLabel{}), + dal.Where("connection_id = ? AND incident_id = ?", data.Options.ConnectionId, incident.Id), + ); err != nil { + return nil, err + } + + status, known := mapIncidentStatus(incident.Status) + if !known { + logger.Warn(nil, "unknown grafana irm incident status: %s", incident.Status) + } + + domainIssueId := idGen.Generate(data.Options.ConnectionId, incident.Id) + createdDate := incident.CreatedDate + updatedDate := incident.UpdatedDate + + domainIssue := &ticket.Issue{ + DomainEntity: domainlayer.DomainEntity{Id: domainIssueId}, + Url: incident.Url, + IssueKey: incident.Id, + Title: incident.Title, + Type: ticket.INCIDENT, + Status: status, + OriginalStatus: incident.Status, + Severity: incident.Severity, + ResolutionDate: incident.ResolvedDate, + CreatedDate: &createdDate, + UpdatedDate: &updatedDate, + LeadTimeMinutes: computeLeadTimeMinutes(incident.CreatedDate, incident.ResolvedDate), + } + + result := []interface{}{ + domainIssue, + &ticket.BoardIssue{BoardId: boardId, IssueId: domainIssueId}, + } + + for _, label := range labels { + result = append(result, &ticket.IssueLabel{ + IssueId: domainIssueId, + LabelName: label.Key + ":" + label.Label, + }) + } + + var assignments []models.IncidentAssignment + if err := db.All(&assignments, + dal.From(&models.IncidentAssignment{}), + dal.Where("connection_id = ? AND incident_id = ?", data.Options.ConnectionId, incident.Id), + ); err != nil { + return nil, err + } + for _, assignment := range assignments { + result = append(result, &ticket.IssueAssignee{ + IssueId: domainIssueId, + AssigneeId: assignment.UserId, + AssigneeName: assignment.UserName, + }) + } + + return result, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} + +// mapIncidentStatus mirrors incidentio's soft-fallback approach: an unknown +// status logs a warning instead of failing the pipeline, since Grafana IRM +// statuses could in principle be extended beyond the two values ("active", +// "resolved") verified live so far (see grafana_irm_plan.md §3.4/§9). +func mapIncidentStatus(status string) (mapped string, known bool) { + switch status { + case "active": + return ticket.IN_PROGRESS, true + case "resolved": + return ticket.DONE, true + default: + return ticket.IN_PROGRESS, false + } +} + +// computeLeadTimeMinutes guards against a resolved timestamp preceding the +// created timestamp the same way incidentio's converter does: keep the +// resolution date but drop the (meaningless) lead time rather than let a +// negative duration wrap to a huge garbage uint. +func computeLeadTimeMinutes(created time.Time, resolved *time.Time) *uint { + if resolved == nil || resolved.Before(created) { + return nil + } + minutes := uint(resolved.Sub(created).Minutes()) + return &minutes +} diff --git a/backend/plugins/grafana_irm/tasks/incidents_extractor.go b/backend/plugins/grafana_irm/tasks/incidents_extractor.go new file mode 100644 index 00000000000..2d4cb0b8aa1 --- /dev/null +++ b/backend/plugins/grafana_irm/tasks/incidents_extractor.go @@ -0,0 +1,162 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strings" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/models" + "github.com/apache/devlake/plugins/grafana_irm/models/raw" +) + +var _ plugin.SubTaskEntryPoint = ExtractIncidents + +var ExtractIncidentsMeta = plugin.SubTaskMeta{ + Name: "extractIncidents", + EntryPoint: ExtractIncidents, + EnabledByDefault: true, + Description: "Extract Grafana IRM incidents", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, + ProductTables: []string{ + models.Incident{}.TableName(), + models.IncidentLabel{}.TableName(), + models.IncidentAssignment{}.TableName(), + }, +} + +func ExtractIncidents(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*GrafanaIrmTaskData) + endpoint := strings.TrimSuffix(data.Connection.GetEndpoint(), "/") + + extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{ + RawDataSubTaskArgs: api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_INCIDENTS_TABLE, + }, + Extract: func(row *api.RawData) ([]interface{}, errors.Error) { + return extractIncident(row.Data, data.Options, endpoint) + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} + +func extractIncident(rawData []byte, op *GrafanaIrmOptions, endpoint string) ([]interface{}, errors.Error) { + rawIncident := &raw.Incident{} + if err := errors.Convert(json.Unmarshal(rawData, rawIncident)); err != nil { + return nil, err + } + + createdDate, err := parseIncidentTime(rawIncident.CreatedTime) + if err != nil { + return nil, err + } + updatedDate, err := parseIncidentTime(rawIncident.ModifiedTime) + if err != nil { + return nil, err + } + resolvedDate, err := parseOptionalIncidentTime(rawIncident.ClosedTime) + if err != nil { + return nil, err + } + + incident := &models.Incident{ + ConnectionId: op.ConnectionId, + Id: rawIncident.IncidentID, + Title: rawIncident.Title, + Url: resolveIncidentUrl(endpoint, rawIncident.OverviewURL), + Status: rawIncident.Status, + Severity: rawIncident.Severity, + CreatedDate: createdDate, + UpdatedDate: updatedDate, + ResolvedDate: resolvedDate, + } + + result := []interface{}{incident} + + for _, label := range rawIncident.Labels { + result = append(result, &models.IncidentLabel{ + ConnectionId: op.ConnectionId, + IncidentId: rawIncident.IncidentID, + Key: label.Key, + Label: label.Label, + }) + } + + // IncidentMembership.Assignments is a fixed-size array of role *slots*, + // most of them empty placeholders (verified live, see + // grafana_irm_plan.md §3.4/§9) — only keep entries with a real user. + for _, assignment := range rawIncident.IncidentMembership.Assignments { + if assignment.User.UserID == "" { + continue + } + result = append(result, &models.IncidentAssignment{ + ConnectionId: op.ConnectionId, + IncidentId: rawIncident.IncidentID, + UserId: assignment.User.UserID, + UserName: assignment.User.Name, + RoleName: assignment.Role.Name, + }) + } + + return result, nil +} + +// resolveIncidentUrl prefixes the wire's relative overviewURL with the +// connection's endpoint (verified live: overviewURL is relative, e.g. +// "/a/grafana-irm-app/incidents/1/some-slug" — see grafana_irm_plan.md §3.4). +func resolveIncidentUrl(endpoint, overviewURL string) string { + if overviewURL == "" { + return "" + } + return endpoint + overviewURL +} + +// parseIncidentTime parses a required RFC3339 timestamp field. Go's +// time.Parse accepts arbitrary-precision fractional seconds even though the +// RFC3339 layout constant doesn't declare one, which real responses use +// (e.g. "2026-09-19T11:07:18.515762775Z", verified live). +func parseIncidentTime(value string) (time.Time, errors.Error) { + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, errors.Convert(err) + } + return t, nil +} + +// parseOptionalIncidentTime handles fields that come back as a literal empty +// string when unset (verified live: ClosedTime on an open incident is `""`, +// not null or omitted — see grafana_irm_plan.md §3.4). +func parseOptionalIncidentTime(value string) (*time.Time, errors.Error) { + if value == "" { + return nil, nil + } + t, err := parseIncidentTime(value) + if err != nil { + return nil, err + } + return &t, nil +} diff --git a/backend/plugins/grafana_irm/tasks/incidents_extractor_test.go b/backend/plugins/grafana_irm/tasks/incidents_extractor_test.go new file mode 100644 index 00000000000..41a2d7ebb59 --- /dev/null +++ b/backend/plugins/grafana_irm/tasks/incidents_extractor_test.go @@ -0,0 +1,93 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/devlake/plugins/grafana_irm/models" +) + +// These fixtures are not hand-authored: they're the exact (compacted) JSON +// captured live from a real Grafana Cloud dev stack — incident `6` (active, +// two real labels, one real role assignment among the placeholder slots) as +// of 2026-09-20, and incident `5` (resolved, no labels, no assignments) as of +// 2026-09-19. See grafana_irm_plan.md §9. + +const activeWithAssignmentJSON = `{"incidentID": "6", "refs": [], "severity": "Critical", "labels": [{"key": "service_name", "label": "orders-api", "description": "", "colorHex": ""}, {"key": "team_name", "label": "platform", "description": "", "colorHex": ""}], "isDrill": false, "incidentType": "internal", "createdTime": "2026-09-20T05:01:00.946089Z", "modifiedTime": "2026-09-20T05:01:15.377404Z", "createdByUser": {"userID": "grafana-incident:user-6aae6b80244e1a8626a0a593", "name": "Service Account: grafana-irm-plugin-dev", "photoURL": "https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro"}, "closedTime": "", "durationSeconds": 63, "status": "active", "title": "[DevLake seed] Database connection pool exhaustion", "overviewURL": "/a/grafana-irm-app/incidents/6/devlake-seed-database-connection-pool-exhaustion", "incidentMembership": {"assignments": [{"user": {"userID": "grafana-incident:user-6aae6b80244e1a8626a0a593", "name": "Service Account: grafana-irm-plugin-dev", "photoURL": "https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro"}, "role": {"roleID": 9576, "orgID": "1836253", "name": "commander", "description": "Owns the incident (has their full-time attention)", "important": true, "mandatory": true, "archived": false, "createdAt": "2026-09-19T11:00:21Z", "updatedAt": ""}, "roleID": 9576}, {"user": {"userID": "", "name": "", "photoURL": ""}, "role": {"roleID": 0, "orgID": "", "name": "", "description": "", "important": false, "mandatory": false, "archived": false, "createdAt": "", "updatedAt": ""}, "roleID": 0}, {"user": {"userID": "", "name": "", "photoURL": ""}, "role": {"roleID": 0, "orgID": "", "name": "", "description": "", "important": false, "mandatory": false, "archived": false, "createdAt": "", "updatedAt": ""}, "roleID": 0}], "totalAssignments": 1, "totalParticipants": 0}, "taskList": {"tasks": [], "todoCount": 0, "doneCount": 0}, "summary": "", "incidentStart": "2026-09-20T05:01:00Z", "incidentEnd": "", "incidentChannels": []}` + +const resolvedNoExtrasJSON = `{"incidentID": "5", "refs": [], "severity": "Minor", "labels": [], "isDrill": false, "incidentType": "internal", "createdTime": "2026-09-19T11:33:10.948822Z", "modifiedTime": "2026-09-19T11:33:27.648602Z", "createdByUser": {"userID": "grafana-incident:user-6aae6b80244e1a8626a0a593", "name": "Service Account: grafana-irm-plugin-dev", "photoURL": "https://www.gravatar.com/avatar/e244d010798c52b18bb2009c179dac20?s=512&d=retro"}, "closedTime": "2026-09-19T11:33:27.17626Z", "durationSeconds": 17, "status": "resolved", "title": "[DevLake seed] Cache cluster node failure", "overviewURL": "/a/grafana-irm-app/incidents/5/devlake-seed-cache-cluster-node-failure", "incidentMembership": {"assignments": [{"user": {"userID": "", "name": "", "photoURL": ""}, "role": {"roleID": 0, "orgID": "", "name": "", "description": "", "important": false, "mandatory": false, "archived": false, "createdAt": "", "updatedAt": ""}, "roleID": 0}], "totalAssignments": 0, "totalParticipants": 0}, "taskList": {"tasks": [], "todoCount": 0, "doneCount": 0}, "summary": "", "incidentStart": "2026-09-19T11:33:10Z", "incidentEnd": "2026-09-19T11:33:27.17626Z", "incidentChannels": []}` + +func newTestOptions() *GrafanaIrmOptions { + return &GrafanaIrmOptions{ConnectionId: 1} +} + +func TestExtractIncident_ActiveWithLabelsAndAssignment(t *testing.T) { + op := newTestOptions() + results, err := extractIncident([]byte(activeWithAssignmentJSON), op, "https://mystack.grafana.net") + require.NoError(t, err) + // 1 incident + 2 labels + 1 assignment (9 empty placeholder slots filtered out) + require.Len(t, results, 4) + + incident := results[0].(*models.Incident) + assert.Equal(t, uint64(1), incident.ConnectionId) + assert.Equal(t, "6", incident.Id) + assert.Equal(t, "[DevLake seed] Database connection pool exhaustion", incident.Title) + assert.Equal(t, "https://mystack.grafana.net/a/grafana-irm-app/incidents/6/devlake-seed-database-connection-pool-exhaustion", incident.Url) + assert.Equal(t, "active", incident.Status) + assert.Equal(t, "Critical", incident.Severity) + assert.True(t, incident.CreatedDate.Equal(mustParseTime(t, "2026-09-20T05:01:00.946089Z"))) + assert.True(t, incident.UpdatedDate.Equal(mustParseTime(t, "2026-09-20T05:01:15.377404Z"))) + assert.Nil(t, incident.ResolvedDate) + + label1 := results[1].(*models.IncidentLabel) + assert.Equal(t, "service_name", label1.Key) + assert.Equal(t, "orders-api", label1.Label) + label2 := results[2].(*models.IncidentLabel) + assert.Equal(t, "team_name", label2.Key) + assert.Equal(t, "platform", label2.Label) + + assignment := results[3].(*models.IncidentAssignment) + assert.Equal(t, "grafana-incident:user-6aae6b80244e1a8626a0a593", assignment.UserId) + assert.Equal(t, "Service Account: grafana-irm-plugin-dev", assignment.UserName) + assert.Equal(t, "commander", assignment.RoleName) +} + +func TestExtractIncident_ResolvedNoLabelsNoAssignments(t *testing.T) { + op := newTestOptions() + results, err := extractIncident([]byte(resolvedNoExtrasJSON), op, "https://mystack.grafana.net") + require.NoError(t, err) + require.Len(t, results, 1) + + incident := results[0].(*models.Incident) + assert.Equal(t, "5", incident.Id) + assert.Equal(t, "resolved", incident.Status) + require.NotNil(t, incident.ResolvedDate) + assert.True(t, incident.ResolvedDate.Equal(mustParseTime(t, "2026-09-19T11:33:27.17626Z"))) +} + +func mustParseTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, value) + require.NoError(t, err) + return parsed +} diff --git a/backend/plugins/grafana_irm/tasks/task_data.go b/backend/plugins/grafana_irm/tasks/task_data.go new file mode 100644 index 00000000000..ea8f7bc7d1b --- /dev/null +++ b/backend/plugins/grafana_irm/tasks/task_data.go @@ -0,0 +1,76 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/grafana_irm/models" +) + +// GrafanaIrmOptions carries the plumbing options every subtask needs. +type GrafanaIrmOptions struct { + ConnectionId uint64 `json:"connectionId" mapstructure:"connectionId,omitempty"` + ScopeId string `json:"scopeId,omitempty" mapstructure:"scopeId,omitempty"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty" mapstructure:"scopeConfigId,omitempty"` + ScopeConfig *models.GrafanaIrmScopeConfig `json:"scopeConfig,omitempty" mapstructure:"scopeConfig,omitempty"` +} + +type GrafanaIrmTaskData struct { + Options *GrafanaIrmOptions + Client api.RateLimitedApiClient + // Connection is needed by the extractor to build an absolute Issue.Url + // from the wire's relative `overviewURL` (verified live, see + // grafana_irm_plan.md §3.4). + Connection *models.GrafanaIrmConnection +} + +func (p *GrafanaIrmOptions) GetParams() any { + return models.GrafanaIrmParams{ + ConnectionId: p.ConnectionId, + ScopeId: p.ScopeId, + } +} + +func DecodeAndValidateTaskOptions(options map[string]interface{}) (*GrafanaIrmOptions, errors.Error) { + op, err := DecodeTaskOptions(options) + if err != nil { + return nil, err + } + err = ValidateTaskOptions(op) + if err != nil { + return nil, err + } + return op, nil +} + +func DecodeTaskOptions(options map[string]interface{}) (*GrafanaIrmOptions, errors.Error) { + var op GrafanaIrmOptions + err := api.Decode(options, &op, nil) + if err != nil { + return nil, err + } + return &op, nil +} + +func ValidateTaskOptions(op *GrafanaIrmOptions) errors.Error { + if op.ConnectionId == 0 { + return errors.BadInput.New("connectionId is invalid") + } + return nil +} diff --git a/backend/plugins/schema_e2e/migration_schema_test.go b/backend/plugins/schema_e2e/migration_schema_test.go index ae1392bfe5b..af33d0482a1 100644 --- a/backend/plugins/schema_e2e/migration_schema_test.go +++ b/backend/plugins/schema_e2e/migration_schema_test.go @@ -65,6 +65,7 @@ import ( github "github.com/apache/devlake/plugins/github/impl" githubGraphql "github.com/apache/devlake/plugins/github_graphql/impl" gitlab "github.com/apache/devlake/plugins/gitlab/impl" + grafanaIrm "github.com/apache/devlake/plugins/grafana_irm/impl" icla "github.com/apache/devlake/plugins/icla/impl" incidentio "github.com/apache/devlake/plugins/incidentio/impl" issueTrace "github.com/apache/devlake/plugins/issue_trace/impl" @@ -116,6 +117,7 @@ func allGoPlugins() []plugin.PluginMeta { github.Github{}, githubGraphql.GithubGraphql{}, gitlab.Gitlab{}, + grafanaIrm.GrafanaIrm{}, icla.Icla{}, incidentio.Incidentio{}, issueTrace.IssueTrace{}, diff --git a/backend/plugins/table_info_test.go b/backend/plugins/table_info_test.go index c73b0e1e620..f63107f1db4 100644 --- a/backend/plugins/table_info_test.go +++ b/backend/plugins/table_info_test.go @@ -41,6 +41,7 @@ import ( github "github.com/apache/devlake/plugins/github/impl" githubGraphql "github.com/apache/devlake/plugins/github_graphql/impl" gitlab "github.com/apache/devlake/plugins/gitlab/impl" + grafanaIrm "github.com/apache/devlake/plugins/grafana_irm/impl" icla "github.com/apache/devlake/plugins/icla/impl" incidentio "github.com/apache/devlake/plugins/incidentio/impl" issueTrace "github.com/apache/devlake/plugins/issue_trace/impl" @@ -87,6 +88,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("github/models", github.Github{}.GetTablesInfo) checker.FeedIn("github_graphql", githubGraphql.GithubGraphql{}.GetTablesInfo) checker.FeedIn("gitlab/models", gitlab.Gitlab{}.GetTablesInfo) + checker.FeedIn("grafana_irm/models", grafanaIrm.GrafanaIrm{}.GetTablesInfo) checker.FeedIn("icla/models", icla.Icla{}.GetTablesInfo) checker.FeedIn("incidentio/models", incidentio.Incidentio{}.GetTablesInfo) checker.FeedIn("jenkins/models", jenkins.Jenkins{}.GetTablesInfo) diff --git a/backend/test/e2e/services/server_startup_test.go b/backend/test/e2e/services/server_startup_test.go index 0d0eaf3a823..75de813460a 100644 --- a/backend/test/e2e/services/server_startup_test.go +++ b/backend/test/e2e/services/server_startup_test.go @@ -32,6 +32,7 @@ import ( github "github.com/apache/devlake/plugins/github/impl" githubGraphql "github.com/apache/devlake/plugins/github_graphql/impl" gitlab "github.com/apache/devlake/plugins/gitlab/impl" + grafanaIrm "github.com/apache/devlake/plugins/grafana_irm/impl" icla "github.com/apache/devlake/plugins/icla/impl" incidentio "github.com/apache/devlake/plugins/incidentio/impl" jenkins "github.com/apache/devlake/plugins/jenkins/impl" @@ -72,6 +73,7 @@ func loadGoPlugins() []plugin.PluginMeta { github.Github{}, githubGraphql.GithubGraphql{}, gitlab.Gitlab{}, + grafanaIrm.GrafanaIrm{}, icla.Icla{}, incidentio.Incidentio{}, jenkins.Jenkins{}, diff --git a/config-ui/src/plugins/register/grafana_irm/assets/icon.svg b/config-ui/src/plugins/register/grafana_irm/assets/icon.svg new file mode 100644 index 00000000000..e31f78f04b2 --- /dev/null +++ b/config-ui/src/plugins/register/grafana_irm/assets/icon.svg @@ -0,0 +1,67 @@ + + + + + + + + + + \ No newline at end of file diff --git a/config-ui/src/plugins/register/grafana_irm/config.tsx b/config-ui/src/plugins/register/grafana_irm/config.tsx new file mode 100644 index 00000000000..4d5bb9ad69d --- /dev/null +++ b/config-ui/src/plugins/register/grafana_irm/config.tsx @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg'; + +export const GrafanaIrmConfig: IPluginConfig = { + plugin: 'grafana_irm', + name: 'Grafana IRM', + icon: () => , + sort: 21, + isBeta: true, + connection: { + docLink: 'https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/reference/incident-api/get-started/', + initialValues: {}, + fields: [ + 'name', + { + key: 'endpoint', + label: 'Grafana Cloud Stack URL', + subLabel: 'The base URL of your Grafana Cloud stack, e.g. https://mystack.grafana.net/', + placeholder: 'https://mystack.grafana.net/', + }, + { + key: 'token', + label: 'Service Account Token', + subLabel: 'A Grafana Cloud service account token with access to the IRM app.', + }, + 'proxy', + { + key: 'rateLimitPerHour', + subLabel: + 'By default, DevLake uses 3,600 requests/hour for data collection for Grafana IRM. But you can adjust the collection speed by setting up your desirable rate limit.', + defaultValue: 3600, + }, + ], + }, + dataScope: { + title: 'Incidents', + }, +}; diff --git a/config-ui/src/plugins/register/grafana_irm/index.ts b/config-ui/src/plugins/register/grafana_irm/index.ts new file mode 100644 index 00000000000..de415db39ab --- /dev/null +++ b/config-ui/src/plugins/register/grafana_irm/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config'; diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts index 94bc90e3c4d..943b61f8dc4 100644 --- a/config-ui/src/plugins/register/index.ts +++ b/config-ui/src/plugins/register/index.ts @@ -31,6 +31,7 @@ import { CursorConfig } from './cursor'; import { GitHubConfig } from './github'; import { GhCopilotConfig } from './gh-copilot'; import { GitLabConfig } from './gitlab'; +import { GrafanaIrmConfig } from './grafana_irm'; import { IncidentioConfig } from './incidentio'; import { JenkinsConfig } from './jenkins'; import { JiraConfig } from './jira'; @@ -63,6 +64,7 @@ export const pluginConfigs: IPluginConfig[] = [ GitHubConfig, GhCopilotConfig, GitLabConfig, + GrafanaIrmConfig, IncidentioConfig, JenkinsConfig, JiraConfig, diff --git a/grafana/dashboards/grafana-irm.json b/grafana/dashboards/grafana-irm.json new file mode 100644 index 00000000000..0f8cff4d03a --- /dev/null +++ b/grafana/dashboards/grafana-irm.json @@ -0,0 +1,1352 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [ + { + "asDropdown": false, + "icon": "bolt", + "includeVars": false, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Homepage", + "tooltip": "", + "type": "link", + "url": "/grafana/d/Lv1XbLHnk/data-specific-dashboards-homepage" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": true, + "tags": [ + "Data Source Specific Dashboard" + ], + "targetBlank": false, + "title": "Metric dashboards", + "tooltip": "", + "type": "dashboards", + "url": "" + } + ], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 13, + "x": 0, + "y": 0 + }, + "id": 128, + "links": [ + { + "targetBlank": true, + "title": "Grafana IRM", + "url": "https://devlake.apache.org/docs/Plugins/grafana_irm" + } + ], + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "- Use Cases: This dashboard shows the incident data from Grafana IRM.\n- Data Source Required: Grafana IRM", + "mode": "markdown" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Dashboard Introduction", + "type": "text" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 126, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "1. Incident Resolution Status", + "type": "row" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 114, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \r\n count(distinct i.id) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 116, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \r\n count(distinct i.id) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Resolved Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "custom.filterable", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 4 + }, + "id": 131, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \n b.name as scope,\n i.issue_key,\n i.title,\n i.original_status as status,\n i.severity,\n i.created_date,\n i.resolution_date,\n round((i.lead_time_minutes/1440),1) as lead_time_days,\n i.url\nfrom issues i\n join board_issues bi on i.id = bi.issue_id\n join boards b on bi.board_id = b.id\nwhere \n (i.created_date)\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "List of Incidents [Created in Selected Time Range]", + "type": "table" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 117, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select\r\n count(distinct i.id) as total_count,\r\n count(distinct case when i.original_status = 'resolved' then i.id else null end) as resolved_count\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})\r\n)\r\n\r\nselect \r\n now() as time,\r\n 1.0 * resolved_count/total_count as requirement_delivery_rate\r\nfrom _requirements", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate [Incidents created in the selected time range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Resolution Rate(%)", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 12, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 10 + }, + "id": 121, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select\r\n DATE_ADD(date(i.created_date), INTERVAL -DAYOFMONTH(date(i.created_date))+1 DAY) as time,\r\n 1.0 * count(distinct case when i.original_status = 'resolved' then i.id else null end)/count(distinct i.id) as resolved_rate\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})\r\n group by 1\r\n)\r\n\r\nselect\r\n time,\r\n resolved_rate\r\nfrom _requirements\r\norder by time", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate over Time [Incidents Created in Selected Time Range]", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 110, + "panels": [], + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "2. Mean Time to Resolve (MTTR)", + "type": "row" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 17 + }, + "id": 12, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "/^value$/", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "select \r\n avg(lead_time_minutes/1440) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "MTTR [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 17 + }, + "id": 13, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _ranks as(\r\n select \r\n i.lead_time_minutes,\r\n percent_rank() over (order by lead_time_minutes asc) as ranks\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n)\r\n\r\nselect\r\n max(lead_time_minutes/1440) as value\r\nfrom _ranks\r\nwhere \r\n ranks <= 0.8", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "80% Incidents' MTTR are less than # [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Incident Age(days)", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 17 + }, + "id": 17, + "interval": "", + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "barRadius": 0, + "barWidth": 0.5, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "text": { + "valueSize": 12 + }, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select \r\n DATE_ADD(date(i.resolution_date), INTERVAL -DAYOFMONTH(date(i.resolution_date))+1 DAY) as time,\r\n avg(lead_time_minutes/1440) as mean_incident_age\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n group by 1\r\n)\r\n\r\nselect \r\n date_format(time,'%M %Y') as month,\r\n mean_incident_age\r\nfrom _requirements\r\norder by time asc", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Mean MTTR [Incidents Resolved in Select Time Range]", + "type": "barchart" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "1. The cumulative distribution of MTTR\n2. Each point refers to the percent rank of a distinct duration to resolve incidents.", + "fill": 0, + "fillGradient": 4, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 23 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 8, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "nullPointMode": "null", + "options": { + "alertThreshold": false + }, + "percentage": false, + "pluginVersion": "13.0.2", + "pointradius": 0.5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _ranks as(\r\n select \r\n round(i.lead_time_minutes/1440) as lead_time_day\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n order by lead_time_day asc\r\n)\r\n\r\nselect \r\n now() as time,\r\n lpad(concat(lead_time_day,'d'), 4, ' ') as metric,\r\n percent_rank() over (order by lead_time_day asc) as value\r\nfrom _ranks\r\norder by lead_time_day asc", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "thresholds": [ + { + "$$hashKey": "object:469", + "colorMode": "ok", + "fill": true, + "line": true, + "op": "lt", + "value": 0.8, + "yaxis": "right" + } + ], + "timeRegions": [], + "title": "Cumulative Distribution of MTTR [Incidents Resolved in Select Time Range]", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "transformations": [], + "type": "graph", + "xaxis": { + "mode": "series", + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "$$hashKey": "object:76", + "format": "percentunit", + "label": "Percent Rank (%)", + "logBase": 1, + "max": "1.2", + "show": true + }, + { + "$$hashKey": "object:77", + "format": "short", + "logBase": 1, + "show": false + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 29 + }, + "id": 130, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "
\n\nThis dashboard is created based on this [data schema](https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema). Want to add more metrics? Please follow the [guide](https://devlake.apache.org/docs/Configuration/Dashboards/GrafanaUserGuide).", + "mode": "markdown" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "type": "text" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "Data Source Dashboard", + "Stable Data Sources" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "definition": "select concat(name, '--', id) from boards where id like 'grafana_irm%'", + "hide": 0, + "includeAll": true, + "label": "Choose Board", + "multi": true, + "name": "board_id", + "options": [], + "query": "select concat(name, '--', id) from boards where id like 'grafana_irm%'", + "refresh": 1, + "regex": "/^(?.*)--(?.*)$/", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6M", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Grafana IRM", + "uid": "grafana-irm-dashboard", + "version": 2, + "weekStart": "" +} \ No newline at end of file diff --git a/grafana/dashboards/mysql/grafana-irm.json b/grafana/dashboards/mysql/grafana-irm.json new file mode 100644 index 00000000000..0f8cff4d03a --- /dev/null +++ b/grafana/dashboards/mysql/grafana-irm.json @@ -0,0 +1,1352 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [ + { + "asDropdown": false, + "icon": "bolt", + "includeVars": false, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Homepage", + "tooltip": "", + "type": "link", + "url": "/grafana/d/Lv1XbLHnk/data-specific-dashboards-homepage" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": true, + "tags": [ + "Data Source Specific Dashboard" + ], + "targetBlank": false, + "title": "Metric dashboards", + "tooltip": "", + "type": "dashboards", + "url": "" + } + ], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 13, + "x": 0, + "y": 0 + }, + "id": 128, + "links": [ + { + "targetBlank": true, + "title": "Grafana IRM", + "url": "https://devlake.apache.org/docs/Plugins/grafana_irm" + } + ], + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "- Use Cases: This dashboard shows the incident data from Grafana IRM.\n- Data Source Required: Grafana IRM", + "mode": "markdown" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Dashboard Introduction", + "type": "text" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 126, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "1. Incident Resolution Status", + "type": "row" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 114, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \r\n count(distinct i.id) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 116, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \r\n count(distinct i.id) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Resolved Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "custom.filterable", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 4 + }, + "id": 131, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \n b.name as scope,\n i.issue_key,\n i.title,\n i.original_status as status,\n i.severity,\n i.created_date,\n i.resolution_date,\n round((i.lead_time_minutes/1440),1) as lead_time_days,\n i.url\nfrom issues i\n join board_issues bi on i.id = bi.issue_id\n join boards b on bi.board_id = b.id\nwhere \n (i.created_date)\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "List of Incidents [Created in Selected Time Range]", + "type": "table" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 117, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select\r\n count(distinct i.id) as total_count,\r\n count(distinct case when i.original_status = 'resolved' then i.id else null end) as resolved_count\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})\r\n)\r\n\r\nselect \r\n now() as time,\r\n 1.0 * resolved_count/total_count as requirement_delivery_rate\r\nfrom _requirements", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate [Incidents created in the selected time range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Resolution Rate(%)", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 12, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 10 + }, + "id": 121, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select\r\n DATE_ADD(date(i.created_date), INTERVAL -DAYOFMONTH(date(i.created_date))+1 DAY) as time,\r\n 1.0 * count(distinct case when i.original_status = 'resolved' then i.id else null end)/count(distinct i.id) as resolved_rate\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})\r\n group by 1\r\n)\r\n\r\nselect\r\n time,\r\n resolved_rate\r\nfrom _requirements\r\norder by time", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate over Time [Incidents Created in Selected Time Range]", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 110, + "panels": [], + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "2. Mean Time to Resolve (MTTR)", + "type": "row" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 17 + }, + "id": 12, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "/^value$/", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "select \r\n avg(lead_time_minutes/1440) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "MTTR [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 17 + }, + "id": 13, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _ranks as(\r\n select \r\n i.lead_time_minutes,\r\n percent_rank() over (order by lead_time_minutes asc) as ranks\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n)\r\n\r\nselect\r\n max(lead_time_minutes/1440) as value\r\nfrom _ranks\r\nwhere \r\n ranks <= 0.8", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "80% Incidents' MTTR are less than # [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Incident Age(days)", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 17 + }, + "id": 17, + "interval": "", + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "barRadius": 0, + "barWidth": 0.5, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "text": { + "valueSize": 12 + }, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select \r\n DATE_ADD(date(i.resolution_date), INTERVAL -DAYOFMONTH(date(i.resolution_date))+1 DAY) as time,\r\n avg(lead_time_minutes/1440) as mean_incident_age\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n group by 1\r\n)\r\n\r\nselect \r\n date_format(time,'%M %Y') as month,\r\n mean_incident_age\r\nfrom _requirements\r\norder by time asc", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Mean MTTR [Incidents Resolved in Select Time Range]", + "type": "barchart" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "1. The cumulative distribution of MTTR\n2. Each point refers to the percent rank of a distinct duration to resolve incidents.", + "fill": 0, + "fillGradient": 4, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 23 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 8, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "nullPointMode": "null", + "options": { + "alertThreshold": false + }, + "percentage": false, + "pluginVersion": "13.0.2", + "pointradius": 0.5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _ranks as(\r\n select \r\n round(i.lead_time_minutes/1440) as lead_time_day\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n order by lead_time_day asc\r\n)\r\n\r\nselect \r\n now() as time,\r\n lpad(concat(lead_time_day,'d'), 4, ' ') as metric,\r\n percent_rank() over (order by lead_time_day asc) as value\r\nfrom _ranks\r\norder by lead_time_day asc", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "thresholds": [ + { + "$$hashKey": "object:469", + "colorMode": "ok", + "fill": true, + "line": true, + "op": "lt", + "value": 0.8, + "yaxis": "right" + } + ], + "timeRegions": [], + "title": "Cumulative Distribution of MTTR [Incidents Resolved in Select Time Range]", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "transformations": [], + "type": "graph", + "xaxis": { + "mode": "series", + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "$$hashKey": "object:76", + "format": "percentunit", + "label": "Percent Rank (%)", + "logBase": 1, + "max": "1.2", + "show": true + }, + { + "$$hashKey": "object:77", + "format": "short", + "logBase": 1, + "show": false + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 29 + }, + "id": 130, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "
\n\nThis dashboard is created based on this [data schema](https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema). Want to add more metrics? Please follow the [guide](https://devlake.apache.org/docs/Configuration/Dashboards/GrafanaUserGuide).", + "mode": "markdown" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "type": "text" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "Data Source Dashboard", + "Stable Data Sources" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "definition": "select concat(name, '--', id) from boards where id like 'grafana_irm%'", + "hide": 0, + "includeAll": true, + "label": "Choose Board", + "multi": true, + "name": "board_id", + "options": [], + "query": "select concat(name, '--', id) from boards where id like 'grafana_irm%'", + "refresh": 1, + "regex": "/^(?.*)--(?.*)$/", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6M", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Grafana IRM", + "uid": "grafana-irm-dashboard", + "version": 2, + "weekStart": "" +} \ No newline at end of file diff --git a/grafana/dashboards/postgresql/grafana-irm.json b/grafana/dashboards/postgresql/grafana-irm.json new file mode 100644 index 00000000000..7821c7ebc46 --- /dev/null +++ b/grafana/dashboards/postgresql/grafana-irm.json @@ -0,0 +1,1352 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [ + { + "asDropdown": false, + "icon": "bolt", + "includeVars": false, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Homepage", + "tooltip": "", + "type": "link", + "url": "/grafana/d/Lv1XbLHnk/data-specific-dashboards-homepage" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": true, + "tags": [ + "Data Source Specific Dashboard" + ], + "targetBlank": false, + "title": "Metric dashboards", + "tooltip": "", + "type": "dashboards", + "url": "" + } + ], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 13, + "x": 0, + "y": 0 + }, + "id": 128, + "links": [ + { + "targetBlank": true, + "title": "Grafana IRM", + "url": "https://devlake.apache.org/docs/Plugins/grafana_irm" + } + ], + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "- Use Cases: This dashboard shows the incident data from Grafana IRM.\n- Data Source Required: Grafana IRM", + "mode": "markdown" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Dashboard Introduction", + "type": "text" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 126, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "1. Incident Resolution Status", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 114, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "SELECT COUNT(DISTINCT i.id) AS value FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE $__timeFilter(i.created_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[]))", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 116, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "SELECT COUNT(DISTINCT i.id) AS value FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE i.original_status = 'resolved' AND $__timeFilter(i.created_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[]))", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Resolved Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "custom.filterable", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 4 + }, + "id": 131, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "SELECT b.name AS scope, i.issue_key, i.title, i.original_status AS status, i.severity, i.created_date, i.resolution_date, ROUND(CAST((CAST(i.lead_time_minutes AS NUMERIC) / NULLIF(1440, 0)) AS DECIMAL), 1) AS lead_time_days, i.url FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id JOIN boards AS b ON bi.board_id = b.id WHERE (i.created_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[]))", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "List of Incidents [Created in Selected Time Range]", + "type": "table" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 117, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "WITH _requirements AS (SELECT COUNT(DISTINCT i.id) AS total_count, COUNT(DISTINCT CASE WHEN i.original_status = 'resolved' THEN i.id ELSE NULL END) AS resolved_count FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE $__timeFilter(i.created_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[]))) SELECT NOW() AS time, CAST(1.0 * resolved_count AS NUMERIC) / NULLIF(total_count, 0) AS requirement_delivery_rate FROM _requirements", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate [Incidents created in the selected time range]", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Resolution Rate(%)", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 12, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 10 + }, + "id": 121, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "WITH _requirements AS (SELECT CAST(i.created_date AS DATE) + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST(i.created_date AS DATE)) + 1) AS time, CAST(1.0 * COUNT(DISTINCT CASE WHEN i.original_status = 'resolved' THEN i.id ELSE NULL END) AS NUMERIC) / NULLIF(COUNT(DISTINCT i.id), 0) AS resolved_rate FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE $__timeFilter(i.created_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[])) GROUP BY 1) SELECT time, resolved_rate FROM _requirements ORDER BY time NULLS FIRST", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate over Time [Incidents Created in Selected Time Range]", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 110, + "panels": [], + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "2. Mean Time to Resolve (MTTR)", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 17 + }, + "id": 12, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "/^value$/", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT AVG(CAST(lead_time_minutes AS NUMERIC) / NULLIF(1440, 0)) AS value FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE i.original_status = 'resolved' AND $__timeFilter(i.resolution_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[]))", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "MTTR [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 17 + }, + "id": 13, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "WITH _ranks AS (SELECT i.lead_time_minutes, PERCENT_RANK() OVER (ORDER BY lead_time_minutes ASC NULLS FIRST) AS ranks FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE i.original_status = 'resolved' AND $__timeFilter(i.resolution_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[]))) SELECT MAX(CAST(lead_time_minutes AS NUMERIC) / NULLIF(1440, 0)) AS value FROM _ranks WHERE ranks <= 0.8", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "80% Incidents' MTTR are less than # [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Incident Age(days)", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 17 + }, + "id": 17, + "interval": "", + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "barRadius": 0, + "barWidth": 0.5, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "text": { + "valueSize": 12 + }, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "WITH _requirements AS (SELECT CAST(i.resolution_date AS DATE) + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST(i.resolution_date AS DATE)) + 1) AS time, AVG(CAST(lead_time_minutes AS NUMERIC) / NULLIF(1440, 0)) AS mean_incident_age FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE i.original_status = 'resolved' AND $__timeFilter(i.resolution_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[])) GROUP BY 1) SELECT TO_CHAR(CAST(time AS TIMESTAMP), 'FMMonth YYYY') AS month, mean_incident_age FROM _requirements ORDER BY time ASC NULLS FIRST", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Mean MTTR [Incidents Resolved in Select Time Range]", + "type": "barchart" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "1. The cumulative distribution of MTTR\n2. Each point refers to the percent rank of a distinct duration to resolve incidents.", + "fill": 0, + "fillGradient": 4, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 23 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 8, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "nullPointMode": "null", + "options": { + "alertThreshold": false + }, + "percentage": false, + "pluginVersion": "13.0.2", + "pointradius": 0.5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "WITH _ranks AS (SELECT ROUND(CAST(i.lead_time_minutes AS NUMERIC) / NULLIF(1440, 0)) AS lead_time_day FROM issues AS i JOIN board_issues AS bi ON i.id = bi.issue_id WHERE i.original_status = 'resolved' AND $__timeFilter(i.resolution_date) AND ('${board_id:csv}' = '' OR bi.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[])) ORDER BY lead_time_day ASC NULLS FIRST) SELECT NOW() AS time, LPAD(lead_time_day || 'd', 4, ' ') AS metric, PERCENT_RANK() OVER (ORDER BY lead_time_day ASC NULLS FIRST) AS value FROM _ranks ORDER BY lead_time_day ASC NULLS FIRST", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "thresholds": [ + { + "$$hashKey": "object:469", + "colorMode": "ok", + "fill": true, + "line": true, + "op": "lt", + "value": 0.8, + "yaxis": "right" + } + ], + "timeRegions": [], + "title": "Cumulative Distribution of MTTR [Incidents Resolved in Select Time Range]", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "transformations": [], + "type": "graph", + "xaxis": { + "mode": "series", + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "$$hashKey": "object:76", + "format": "percentunit", + "label": "Percent Rank (%)", + "logBase": 1, + "max": "1.2", + "show": true + }, + { + "$$hashKey": "object:77", + "format": "short", + "logBase": 1, + "show": false + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 29 + }, + "id": 130, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "
\n\nThis dashboard is created based on this [data schema](https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema). Want to add more metrics? Please follow the [guide](https://devlake.apache.org/docs/Configuration/Dashboards/GrafanaUserGuide).", + "mode": "markdown" + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "type": "text" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "Data Source Dashboard", + "Stable Data Sources" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "definition": "SELECT name || '--' || id FROM boards WHERE id::text LIKE 'grafana_irm%'", + "hide": 0, + "includeAll": true, + "label": "Choose Board", + "multi": true, + "name": "board_id", + "options": [], + "query": "SELECT name || '--' || id FROM boards WHERE id::text LIKE 'grafana_irm%'", + "refresh": 1, + "regex": "/^(?.*)--(?.*)$/", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6M", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Grafana IRM", + "uid": "grafana-irm-dashboard-pg", + "version": 2, + "weekStart": "" +} \ No newline at end of file