This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 1cbd8357e feat(sonarqube): collect historical project metrics and 
Grafana trends (#8983)
1cbd8357e is described below

commit 1cbd8357e87cbc25c5566f58f3070640e85f294f
Author: Joshua Smith <[email protected]>
AuthorDate: Wed Jul 15 02:53:49 2026 -0600

    feat(sonarqube): collect historical project metrics and Grafana trends 
(#8983)
    
    * feat(sonarqube): add project-level metrics history collection
    
    Add collector, extractor, and convertor subtasks that pull historical
    metric snapshots from the SonarQube measures/search_history and
    project_analyses/search APIs into a new cq_project_metrics_history
    domain table, enabling trend analysis for coverage, bugs, code smells,
    complexity, vulnerabilities, and other quality indicators over time.
    
    Signed-off-by: Joshua Smith <[email protected]>
    
    * fix(sonarqube): Require user token at test
    
      * Reject Global and Project analysis tokens on SonarQube Server before 
authentication validate runs.
      * Skip prefix validation for SonarCloud endpoints where token prefixes 
differ.
    
    * feat(grafana): add historical trends to SonarQube dashboards
    
      * Add collapsed "Historical Trends" row with coverage, bugs, 
vulnerabilities, code smells, and duplication time-series panels to both Server 
and Cloud dashboards
      * Panels query cq_project_metrics_history and honor the Grafana time 
picker via $__timeFilter
    
    Signed-off-by: Joshua Smith <[email protected]>
    
    * style(sonarqube): Fix gofmt formatting in code quality files
    
      * Align struct fields and ApiCollectorArgs literals to satisfy 
golangci-lint gofmt checks.
      * Updates cq_project_metrics_history and SonarQube collector tasks 
flagged in CI.
    
    ---------
    
    Signed-off-by: Joshua Smith <[email protected]>
---
 .../codequality/cq_project_metrics_history.go      |  46 ++++
 .../models/domainlayer/domaininfo/domaininfo.go    |   1 +
 .../20260707_add_cq_project_metrics_history.go}    |  45 ++--
 .../archived/cq_project_metrics_history.go}        |  45 ++--
 backend/core/models/migrationscripts/register.go   |   1 +
 backend/plugins/sonarqube/api/connection_api.go    |   3 +
 backend/plugins/sonarqube/impl/impl.go             |   7 +
 backend/plugins/sonarqube/models/connection.go     |  42 +++-
 .../plugins/sonarqube/models/connection_test.go    |  92 +++++++
 .../20260707_add_project_metrics_history.go        |  77 ++++++
 .../sonarqube/models/migrationscripts/register.go  |   1 +
 .../register.go => sonarqube_project_analyses.go}  |  43 ++--
 ...ter.go => sonarqube_project_metrics_history.go} |  40 ++-
 .../sonarqube/tasks/project_analyses_collector.go  |  92 +++++++
 .../sonarqube/tasks/project_analyses_extractor.go  |  84 +++++++
 .../tasks/project_metrics_history_collector.go     |  94 +++++++
 .../tasks/project_metrics_history_convertor.go     | 158 ++++++++++++
 .../tasks/project_metrics_history_extractor.go     |  89 +++++++
 grafana/dashboards/mysql/Sonarqube.json            | 261 +++++++++++++++++++-
 grafana/dashboards/mysql/sonar-qube-cloud.json     | 261 +++++++++++++++++++-
 grafana/dashboards/postgresql/Sonarqube.json       | 269 ++++++++++++++++++++-
 .../dashboards/postgresql/sonar-qube-cloud.json    | 269 ++++++++++++++++++++-
 22 files changed, 1919 insertions(+), 101 deletions(-)

diff --git 
a/backend/core/models/domainlayer/codequality/cq_project_metrics_history.go 
b/backend/core/models/domainlayer/codequality/cq_project_metrics_history.go
new file mode 100644
index 000000000..90f64ce66
--- /dev/null
+++ b/backend/core/models/domainlayer/codequality/cq_project_metrics_history.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 codequality
+
+import (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/models/domainlayer"
+)
+
+type CqProjectMetricsHistory struct {
+       domainlayer.DomainEntity
+       ProjectKey             string    `gorm:"index;type:varchar(500)"`
+       AnalysisDate           time.Time `gorm:"index"`
+       Coverage               *float64
+       Ncloc                  *int
+       Bugs                   *int
+       ReliabilityRating      string `gorm:"type:varchar(5)"`
+       CodeSmells             *int
+       SqaleRating            string `gorm:"type:varchar(5)"`
+       Complexity             *int
+       CognitiveComplexity    *int
+       Vulnerabilities        *int
+       SecurityRating         string `gorm:"type:varchar(5)"`
+       SecurityHotspots       *int
+       DuplicatedLinesDensity *float64
+}
+
+func (CqProjectMetricsHistory) TableName() string {
+       return "cq_project_metrics_history"
+}
diff --git a/backend/core/models/domainlayer/domaininfo/domaininfo.go 
b/backend/core/models/domainlayer/domaininfo/domaininfo.go
index b2cc5431f..b88289e8d 100644
--- a/backend/core/models/domainlayer/domaininfo/domaininfo.go
+++ b/backend/core/models/domainlayer/domaininfo/domaininfo.go
@@ -56,6 +56,7 @@ func GetDomainTablesInfo() []dal.Tabler {
                &codequality.CqIssue{},
                &codequality.CqIssueImpact{},
                &codequality.CqProject{},
+               &codequality.CqProjectMetricsHistory{},
                // crossdomain
                &crossdomain.Account{},
                &crossdomain.BoardRepo{},
diff --git a/backend/plugins/sonarqube/models/migrationscripts/register.go 
b/backend/core/models/migrationscripts/20260707_add_cq_project_metrics_history.go
similarity index 52%
copy from backend/plugins/sonarqube/models/migrationscripts/register.go
copy to 
backend/core/models/migrationscripts/20260707_add_cq_project_metrics_history.go
index 7c48de842..de66ae829 100644
--- a/backend/plugins/sonarqube/models/migrationscripts/register.go
+++ 
b/backend/core/models/migrationscripts/20260707_add_cq_project_metrics_history.go
@@ -17,27 +17,26 @@ limitations under the License.
 
 package migrationscripts
 
-import "github.com/apache/incubator-devlake/core/plugin"
-
-// All return all the migration scripts
-func All() []plugin.MigrationScript {
-       return []plugin.MigrationScript{
-               new(addInitTables),
-               new(modifyCharacterSet),
-               new(expandProjectKey20230206),
-               new(addRawParamTableForScope),
-               new(addScopeConfigIdToProject),
-               new(modifyFileMetricsKeyLength),
-               new(modifyComponentLength),
-               new(addSonarQubeScopeConfig20231214),
-               new(modifyCommitCharacterType),
-               new(modifyCommitCharacterType0508),
-               new(updateSonarQubeScopeConfig20240614),
-               new(modifyNameLength),
-               new(changeIssueComponentType),
-               new(increaseProjectKeyLength),
-               new(addOrgToConn),
-               new(addIssueImpacts),
-               new(extendSonarqubeFieldSize),
-       }
+import (
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+       "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type addCqProjectMetricsHistory struct{}
+
+func (u *addCqProjectMetricsHistory) Up(basicRes context.BasicRes) 
errors.Error {
+       return migrationhelper.AutoMigrateTables(
+               basicRes,
+               &archived.CqProjectMetricsHistory{},
+       )
+}
+
+func (*addCqProjectMetricsHistory) Version() uint64 {
+       return 20260707153201
+}
+
+func (*addCqProjectMetricsHistory) Name() string {
+       return "add cq_project_metrics_history domain table"
 }
diff --git a/backend/plugins/sonarqube/models/migrationscripts/register.go 
b/backend/core/models/migrationscripts/archived/cq_project_metrics_history.go
similarity index 51%
copy from backend/plugins/sonarqube/models/migrationscripts/register.go
copy to 
backend/core/models/migrationscripts/archived/cq_project_metrics_history.go
index 7c48de842..934627368 100644
--- a/backend/plugins/sonarqube/models/migrationscripts/register.go
+++ 
b/backend/core/models/migrationscripts/archived/cq_project_metrics_history.go
@@ -15,29 +15,28 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 */
 
-package migrationscripts
+package archived
 
-import "github.com/apache/incubator-devlake/core/plugin"
+import "time"
 
-// All return all the migration scripts
-func All() []plugin.MigrationScript {
-       return []plugin.MigrationScript{
-               new(addInitTables),
-               new(modifyCharacterSet),
-               new(expandProjectKey20230206),
-               new(addRawParamTableForScope),
-               new(addScopeConfigIdToProject),
-               new(modifyFileMetricsKeyLength),
-               new(modifyComponentLength),
-               new(addSonarQubeScopeConfig20231214),
-               new(modifyCommitCharacterType),
-               new(modifyCommitCharacterType0508),
-               new(updateSonarQubeScopeConfig20240614),
-               new(modifyNameLength),
-               new(changeIssueComponentType),
-               new(increaseProjectKeyLength),
-               new(addOrgToConn),
-               new(addIssueImpacts),
-               new(extendSonarqubeFieldSize),
-       }
+type CqProjectMetricsHistory struct {
+       DomainEntity
+       ProjectKey             string    `gorm:"index;type:varchar(500)"`
+       AnalysisDate           time.Time `gorm:"index"`
+       Coverage               *float64
+       Ncloc                  *int
+       Bugs                   *int
+       ReliabilityRating      string `gorm:"type:varchar(5)"`
+       CodeSmells             *int
+       SqaleRating            string `gorm:"type:varchar(5)"`
+       Complexity             *int
+       CognitiveComplexity    *int
+       Vulnerabilities        *int
+       SecurityRating         string `gorm:"type:varchar(5)"`
+       SecurityHotspots       *int
+       DuplicatedLinesDensity *float64
+}
+
+func (CqProjectMetricsHistory) TableName() string {
+       return "cq_project_metrics_history"
 }
diff --git a/backend/core/models/migrationscripts/register.go 
b/backend/core/models/migrationscripts/register.go
index b4596154e..76f2e9683 100644
--- a/backend/core/models/migrationscripts/register.go
+++ b/backend/core/models/migrationscripts/register.go
@@ -145,5 +145,6 @@ func All() []plugin.MigrationScript {
                new(modifyCicdDeploymentsToText),
                new(increaseCqIssuesProjectKeyLength),
                new(addAuthSessions),
+               new(addCqProjectMetricsHistory),
        }
 }
diff --git a/backend/plugins/sonarqube/api/connection_api.go 
b/backend/plugins/sonarqube/api/connection_api.go
index feb1905a6..d387e5d46 100644
--- a/backend/plugins/sonarqube/api/connection_api.go
+++ b/backend/plugins/sonarqube/api/connection_api.go
@@ -44,6 +44,9 @@ func testConnection(ctx context.Context, connection 
models.SonarqubeConn) (*plug
                        return nil, errors.Default.Wrap(err, "error validating 
target")
                }
        }
+       if err := connection.ValidateUserTokenPrefix(); err != nil {
+               return nil, err
+       }
        apiClient, err := api.NewApiClientFromConnection(ctx, basicRes, 
&connection)
        if err != nil {
                return nil, err
diff --git a/backend/plugins/sonarqube/impl/impl.go 
b/backend/plugins/sonarqube/impl/impl.go
index a6d6bd258..85ee668ee 100644
--- a/backend/plugins/sonarqube/impl/impl.go
+++ b/backend/plugins/sonarqube/impl/impl.go
@@ -86,6 +86,8 @@ func (p Sonarqube) GetTablesInfo() []dal.Tabler {
                &models.SonarqubeFileMetrics{},
                &models.SonarqubeAccount{},
                &models.SonarqubeScopeConfig{},
+               &models.SonarqubeProjectMetricsHistory{},
+               &models.SonarqubeProjectAnalysis{},
        }
 }
 
@@ -108,6 +110,11 @@ func (p Sonarqube) SubTaskMetas() []plugin.SubTaskMeta {
                tasks.ConvertHotspotsMeta,
                tasks.ConvertFileMetricsMeta,
                tasks.ConvertAccountsMeta,
+               tasks.CollectProjectMetricsHistoryMeta,
+               tasks.ExtractProjectMetricsHistoryMeta,
+               tasks.CollectProjectAnalysesMeta,
+               tasks.ExtractProjectAnalysesMeta,
+               tasks.ConvertProjectMetricsHistoryMeta,
        }
 }
 
diff --git a/backend/plugins/sonarqube/models/connection.go 
b/backend/plugins/sonarqube/models/connection.go
index 66c32f57c..0bbe124d5 100644
--- a/backend/plugins/sonarqube/models/connection.go
+++ b/backend/plugins/sonarqube/models/connection.go
@@ -21,6 +21,7 @@ import (
        "encoding/base64"
        "fmt"
        "net/http"
+       "strings"
 
        "github.com/apache/incubator-devlake/core/utils"
 
@@ -29,6 +30,12 @@ import (
        helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
 )
 
+const (
+       userTokenPrefix            = "squ_"
+       globalAnalysisTokenPrefix  = "sqa_"
+       projectAnalysisTokenPrefix = "sqp_"
+)
+
 type SonarqubeAccessToken helper.AccessToken
 
 // SetupAuthentication sets up the HTTP Request Authentication
@@ -92,7 +99,40 @@ func (connection *SonarqubeConnection) 
MergeFromRequest(target *SonarqubeConnect
        return nil
 }
 
-func (connection *SonarqubeConnection) IsCloud() bool {
+// ValidateUserTokenPrefix ensures the token is a SonarQube User token on 
Server
+// instances. Global (sqa_) and Project (sqp_) analysis tokens authenticate but
+// cannot call read APIs such as measures/component_tree.
+func (connection SonarqubeConn) ValidateUserTokenPrefix() errors.Error {
+       if connection.IsCloud() {
+               return nil
+       }
+       token := strings.TrimSpace(connection.Token)
+       if token == "" {
+               return errors.BadInput.New("token is required")
+       }
+       switch {
+       case strings.HasPrefix(token, globalAnalysisTokenPrefix):
+               return errors.BadInput.New(
+                       "DevLake requires a User token (squ_ prefix). " +
+                               "Global Analysis tokens (sqa_) can push scan 
results but cannot read project metrics via the Web API. " +
+                               "Create a User token under My Account > 
Security in SonarQube.",
+               )
+       case strings.HasPrefix(token, projectAnalysisTokenPrefix):
+               return errors.BadInput.New(
+                       "DevLake requires a User token (squ_ prefix). " +
+                               "Project Analysis tokens (sqp_) can push scan 
results but cannot read project metrics via the Web API. " +
+                               "Create a User token under My Account > 
Security in SonarQube.",
+               )
+       case !strings.HasPrefix(token, userTokenPrefix):
+               return errors.BadInput.New(
+                       "DevLake requires a User token (squ_ prefix) for 
SonarQube Server. " +
+                               "Create one under My Account > Security in 
SonarQube.",
+               )
+       }
+       return nil
+}
+
+func (connection SonarqubeConn) IsCloud() bool {
        return connection.Endpoint == "https://sonarcloud.io/api/";
 }
 
diff --git a/backend/plugins/sonarqube/models/connection_test.go 
b/backend/plugins/sonarqube/models/connection_test.go
new file mode 100644
index 000000000..e92284ad5
--- /dev/null
+++ b/backend/plugins/sonarqube/models/connection_test.go
@@ -0,0 +1,92 @@
+/*
+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 (
+       "testing"
+
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+func TestValidateUserTokenPrefix(t *testing.T) {
+       t.Parallel()
+
+       serverEndpoint := "https://rad-sonar.example.com/api/";
+       cloudEndpoint := "https://sonarcloud.io/api/";
+
+       tests := []struct {
+               name    string
+               conn    SonarqubeConn
+               wantErr bool
+       }{
+               {
+                       name: "user token on server",
+                       conn: SonarqubeConn{
+                               RestConnection:       
helper.RestConnection{Endpoint: serverEndpoint},
+                               SonarqubeAccessToken: 
SonarqubeAccessToken{Token: "squ_abc123"},
+                       },
+               },
+               {
+                       name: "global analysis token on server",
+                       conn: SonarqubeConn{
+                               RestConnection:       
helper.RestConnection{Endpoint: serverEndpoint},
+                               SonarqubeAccessToken: 
SonarqubeAccessToken{Token: "sqa_abc123"},
+                       },
+                       wantErr: true,
+               },
+               {
+                       name: "project analysis token on server",
+                       conn: SonarqubeConn{
+                               RestConnection:       
helper.RestConnection{Endpoint: serverEndpoint},
+                               SonarqubeAccessToken: 
SonarqubeAccessToken{Token: "sqp_abc123"},
+                       },
+                       wantErr: true,
+               },
+               {
+                       name: "unknown prefix on server",
+                       conn: SonarqubeConn{
+                               RestConnection:       
helper.RestConnection{Endpoint: serverEndpoint},
+                               SonarqubeAccessToken: 
SonarqubeAccessToken{Token: "legacy-token"},
+                       },
+                       wantErr: true,
+               },
+               {
+                       name: "sonarcloud skips prefix check",
+                       conn: SonarqubeConn{
+                               RestConnection:       
helper.RestConnection{Endpoint: cloudEndpoint},
+                               SonarqubeAccessToken: 
SonarqubeAccessToken{Token: "sqa_abc123"},
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+                       err := tt.conn.ValidateUserTokenPrefix()
+                       if tt.wantErr {
+                               if err == nil {
+                                       t.Fatal("expected error, got nil")
+                               }
+                               return
+                       }
+                       if err != nil {
+                               t.Fatalf("unexpected error: %v", err)
+                       }
+               })
+       }
+}
diff --git 
a/backend/plugins/sonarqube/models/migrationscripts/20260707_add_project_metrics_history.go
 
b/backend/plugins/sonarqube/models/migrationscripts/20260707_add_project_metrics_history.go
new file mode 100644
index 000000000..d060f4631
--- /dev/null
+++ 
b/backend/plugins/sonarqube/models/migrationscripts/20260707_add_project_metrics_history.go
@@ -0,0 +1,77 @@
+/*
+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 (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+var _ plugin.MigrationScript = (*addProjectMetricsHistory)(nil)
+
+type projectMetricsHistory20260707 struct {
+       ConnectionId uint64    `gorm:"primaryKey"`
+       ProjectKey   string    `gorm:"primaryKey;type:varchar(255)"`
+       AnalysisDate time.Time `gorm:"primaryKey"`
+       MetricKey    string    `gorm:"primaryKey;type:varchar(100)"`
+       MetricValue  string    `gorm:"type:varchar(50)"`
+       archived.NoPKModel
+}
+
+func (projectMetricsHistory20260707) TableName() string {
+       return "_tool_sonarqube_project_metrics_history"
+}
+
+type projectAnalyses20260707 struct {
+       ConnectionId   uint64    `gorm:"primaryKey"`
+       ProjectKey     string    `gorm:"primaryKey;type:varchar(255)"`
+       AnalysisKey    string    `gorm:"primaryKey;type:varchar(255)"`
+       AnalysisDate   time.Time `gorm:"index"`
+       ProjectVersion string    `gorm:"type:varchar(255)"`
+       Revision       string    `gorm:"type:varchar(255)"`
+       BuildString    string    `gorm:"type:varchar(255)"`
+       DetectedCI     string    `gorm:"type:varchar(100)"`
+       archived.NoPKModel
+}
+
+func (projectAnalyses20260707) TableName() string {
+       return "_tool_sonarqube_project_analyses"
+}
+
+type addProjectMetricsHistory struct{}
+
+func (script *addProjectMetricsHistory) Up(basicRes context.BasicRes) 
errors.Error {
+       return migrationhelper.AutoMigrateTables(
+               basicRes,
+               &projectMetricsHistory20260707{},
+               &projectAnalyses20260707{},
+       )
+}
+
+func (*addProjectMetricsHistory) Version() uint64 {
+       return 20260707153200
+}
+
+func (*addProjectMetricsHistory) Name() string {
+       return "add project_metrics_history and project_analyses tables"
+}
diff --git a/backend/plugins/sonarqube/models/migrationscripts/register.go 
b/backend/plugins/sonarqube/models/migrationscripts/register.go
index 7c48de842..113341921 100644
--- a/backend/plugins/sonarqube/models/migrationscripts/register.go
+++ b/backend/plugins/sonarqube/models/migrationscripts/register.go
@@ -39,5 +39,6 @@ func All() []plugin.MigrationScript {
                new(addOrgToConn),
                new(addIssueImpacts),
                new(extendSonarqubeFieldSize),
+               new(addProjectMetricsHistory),
        }
 }
diff --git a/backend/plugins/sonarqube/models/migrationscripts/register.go 
b/backend/plugins/sonarqube/models/sonarqube_project_analyses.go
similarity index 51%
copy from backend/plugins/sonarqube/models/migrationscripts/register.go
copy to backend/plugins/sonarqube/models/sonarqube_project_analyses.go
index 7c48de842..55dac5ff7 100644
--- a/backend/plugins/sonarqube/models/migrationscripts/register.go
+++ b/backend/plugins/sonarqube/models/sonarqube_project_analyses.go
@@ -15,29 +15,26 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 */
 
-package migrationscripts
+package models
 
-import "github.com/apache/incubator-devlake/core/plugin"
+import (
+       "time"
 
-// All return all the migration scripts
-func All() []plugin.MigrationScript {
-       return []plugin.MigrationScript{
-               new(addInitTables),
-               new(modifyCharacterSet),
-               new(expandProjectKey20230206),
-               new(addRawParamTableForScope),
-               new(addScopeConfigIdToProject),
-               new(modifyFileMetricsKeyLength),
-               new(modifyComponentLength),
-               new(addSonarQubeScopeConfig20231214),
-               new(modifyCommitCharacterType),
-               new(modifyCommitCharacterType0508),
-               new(updateSonarQubeScopeConfig20240614),
-               new(modifyNameLength),
-               new(changeIssueComponentType),
-               new(increaseProjectKeyLength),
-               new(addOrgToConn),
-               new(addIssueImpacts),
-               new(extendSonarqubeFieldSize),
-       }
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+type SonarqubeProjectAnalysis struct {
+       ConnectionId   uint64    `gorm:"primaryKey"`
+       ProjectKey     string    `gorm:"primaryKey;type:varchar(255)"`
+       AnalysisKey    string    `gorm:"primaryKey;type:varchar(255)"`
+       AnalysisDate   time.Time `gorm:"index"`
+       ProjectVersion string    `gorm:"type:varchar(255)"`
+       Revision       string    `gorm:"type:varchar(255)"`
+       BuildString    string    `gorm:"type:varchar(255)"`
+       DetectedCI     string    `gorm:"type:varchar(100)"`
+       common.NoPKModel
+}
+
+func (SonarqubeProjectAnalysis) TableName() string {
+       return "_tool_sonarqube_project_analyses"
 }
diff --git a/backend/plugins/sonarqube/models/migrationscripts/register.go 
b/backend/plugins/sonarqube/models/sonarqube_project_metrics_history.go
similarity index 51%
copy from backend/plugins/sonarqube/models/migrationscripts/register.go
copy to backend/plugins/sonarqube/models/sonarqube_project_metrics_history.go
index 7c48de842..ebd555252 100644
--- a/backend/plugins/sonarqube/models/migrationscripts/register.go
+++ b/backend/plugins/sonarqube/models/sonarqube_project_metrics_history.go
@@ -15,29 +15,23 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 */
 
-package migrationscripts
+package models
 
-import "github.com/apache/incubator-devlake/core/plugin"
+import (
+       "time"
 
-// All return all the migration scripts
-func All() []plugin.MigrationScript {
-       return []plugin.MigrationScript{
-               new(addInitTables),
-               new(modifyCharacterSet),
-               new(expandProjectKey20230206),
-               new(addRawParamTableForScope),
-               new(addScopeConfigIdToProject),
-               new(modifyFileMetricsKeyLength),
-               new(modifyComponentLength),
-               new(addSonarQubeScopeConfig20231214),
-               new(modifyCommitCharacterType),
-               new(modifyCommitCharacterType0508),
-               new(updateSonarQubeScopeConfig20240614),
-               new(modifyNameLength),
-               new(changeIssueComponentType),
-               new(increaseProjectKeyLength),
-               new(addOrgToConn),
-               new(addIssueImpacts),
-               new(extendSonarqubeFieldSize),
-       }
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+type SonarqubeProjectMetricsHistory struct {
+       ConnectionId uint64    `gorm:"primaryKey"`
+       ProjectKey   string    `gorm:"primaryKey;type:varchar(255)"`
+       AnalysisDate time.Time `gorm:"primaryKey"`
+       MetricKey    string    `gorm:"primaryKey;type:varchar(100)"`
+       MetricValue  string    `gorm:"type:varchar(50)"`
+       common.NoPKModel
+}
+
+func (SonarqubeProjectMetricsHistory) TableName() string {
+       return "_tool_sonarqube_project_metrics_history"
 }
diff --git a/backend/plugins/sonarqube/tasks/project_analyses_collector.go 
b/backend/plugins/sonarqube/tasks/project_analyses_collector.go
new file mode 100644
index 000000000..37682fd7c
--- /dev/null
+++ b/backend/plugins/sonarqube/tasks/project_analyses_collector.go
@@ -0,0 +1,92 @@
+/*
+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"
+       "net/url"
+
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/plugins/sonarqube/models"
+)
+
+const RAW_PROJECT_ANALYSES_TABLE = "sonarqube_api_project_analyses"
+
+var _ plugin.SubTaskEntryPoint = CollectProjectAnalyses
+
+func CollectProjectAnalyses(taskCtx plugin.SubTaskContext) errors.Error {
+       logger := taskCtx.GetLogger()
+       logger.Info("collect project analyses")
+
+       data := taskCtx.GetData().(*SonarqubeTaskData)
+       apiCollector, err := 
helper.NewStatefulApiCollector(helper.RawDataSubTaskArgs{
+               Ctx: taskCtx,
+               Params: models.SonarqubeApiParams{
+                       ConnectionId: data.Options.ConnectionId,
+                       ProjectKey:   data.Options.ProjectKey,
+               },
+               Table: RAW_PROJECT_ANALYSES_TABLE,
+       })
+       if err != nil {
+               return err
+       }
+
+       err = apiCollector.InitCollector(helper.ApiCollectorArgs{
+               ApiClient:   data.ApiClient,
+               PageSize:    500,
+               UrlTemplate: "project_analyses/search",
+               Query: func(reqData *helper.RequestData) (url.Values, 
errors.Error) {
+                       query := url.Values{}
+                       query.Set("project", data.Options.ProjectKey)
+                       query.Set("ps", fmt.Sprintf("%v", reqData.Pager.Size))
+                       query.Set("p", fmt.Sprintf("%v", reqData.Pager.Page))
+                       if apiCollector.GetSince() != nil {
+                               query.Set("from", 
apiCollector.GetSince().UTC().Format("2006-01-02"))
+                       }
+                       return query, nil
+               },
+               GetTotalPages: GetTotalPagesFromResponse,
+               ResponseParser: func(res *http.Response) ([]json.RawMessage, 
errors.Error) {
+                       var resData struct {
+                               Analyses []json.RawMessage `json:"analyses"`
+                       }
+                       err := helper.UnmarshalResponse(res, &resData)
+                       if err != nil {
+                               return nil, err
+                       }
+                       return resData.Analyses, nil
+               },
+       })
+       if err != nil {
+               return err
+       }
+
+       return apiCollector.Execute()
+}
+
+var CollectProjectAnalysesMeta = plugin.SubTaskMeta{
+       Name:             "CollectProjectAnalyses",
+       EntryPoint:       CollectProjectAnalyses,
+       EnabledByDefault: true,
+       Description:      "Collect project analysis metadata from SonarQube 
project_analyses/search API",
+       DomainTypes:      []string{plugin.DOMAIN_TYPE_CODE_QUALITY},
+}
diff --git a/backend/plugins/sonarqube/tasks/project_analyses_extractor.go 
b/backend/plugins/sonarqube/tasks/project_analyses_extractor.go
new file mode 100644
index 000000000..ac10d8d6e
--- /dev/null
+++ b/backend/plugins/sonarqube/tasks/project_analyses_extractor.go
@@ -0,0 +1,84 @@
+/*
+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"
+       "time"
+
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/plugins/sonarqube/models"
+)
+
+var _ plugin.SubTaskEntryPoint = ExtractProjectAnalyses
+
+type projectAnalysisResponse struct {
+       Key            string `json:"key"`
+       Date           string `json:"date"`
+       ProjectVersion string `json:"projectVersion"`
+       Revision       string `json:"revision"`
+       BuildString    string `json:"buildString"`
+       DetectedCI     string `json:"detectedCI"`
+}
+
+func ExtractProjectAnalyses(taskCtx plugin.SubTaskContext) errors.Error {
+       rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, 
RAW_PROJECT_ANALYSES_TABLE)
+
+       extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+               RawDataSubTaskArgs: *rawDataSubTaskArgs,
+               Extract: func(resData *helper.RawData) ([]interface{}, 
errors.Error) {
+                       body := &projectAnalysisResponse{}
+                       err := errors.Convert(json.Unmarshal(resData.Data, 
body))
+                       if err != nil {
+                               return nil, err
+                       }
+
+                       analysisDate, parseErr := 
time.Parse("2006-01-02T15:04:05-0700", body.Date)
+                       if parseErr != nil {
+                               return nil, 
errors.Default.Wrap(errors.Convert(parseErr), "failed to parse analysis date")
+                       }
+
+                       analysis := &models.SonarqubeProjectAnalysis{
+                               ConnectionId:   data.Options.ConnectionId,
+                               ProjectKey:     data.Options.ProjectKey,
+                               AnalysisKey:    body.Key,
+                               AnalysisDate:   analysisDate,
+                               ProjectVersion: body.ProjectVersion,
+                               Revision:       body.Revision,
+                               BuildString:    body.BuildString,
+                               DetectedCI:     body.DetectedCI,
+                       }
+                       return []interface{}{analysis}, nil
+               },
+       })
+       if err != nil {
+               return err
+       }
+
+       return extractor.Execute()
+}
+
+var ExtractProjectAnalysesMeta = plugin.SubTaskMeta{
+       Name:             "ExtractProjectAnalyses",
+       EntryPoint:       ExtractProjectAnalyses,
+       EnabledByDefault: true,
+       Description:      "Extract raw project analyses data into tool layer 
table",
+       DomainTypes:      []string{plugin.DOMAIN_TYPE_CODE_QUALITY},
+}
diff --git 
a/backend/plugins/sonarqube/tasks/project_metrics_history_collector.go 
b/backend/plugins/sonarqube/tasks/project_metrics_history_collector.go
new file mode 100644
index 000000000..fa2b02633
--- /dev/null
+++ b/backend/plugins/sonarqube/tasks/project_metrics_history_collector.go
@@ -0,0 +1,94 @@
+/*
+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"
+       "net/url"
+
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/plugins/sonarqube/models"
+)
+
+const RAW_PROJECT_METRICS_HISTORY_TABLE = 
"sonarqube_api_project_metrics_history"
+
+const metricsToCollect = 
"coverage,ncloc,bugs,vulnerabilities,code_smells,security_hotspots," +
+       
"duplicated_lines_density,sqale_rating,reliability_rating,security_rating,complexity,cognitive_complexity"
+
+var _ plugin.SubTaskEntryPoint = CollectProjectMetricsHistory
+
+func CollectProjectMetricsHistory(taskCtx plugin.SubTaskContext) errors.Error {
+       logger := taskCtx.GetLogger()
+       logger.Info("collect project metrics history")
+
+       data := taskCtx.GetData().(*SonarqubeTaskData)
+       apiCollector, err := 
helper.NewStatefulApiCollector(helper.RawDataSubTaskArgs{
+               Ctx: taskCtx,
+               Params: models.SonarqubeApiParams{
+                       ConnectionId: data.Options.ConnectionId,
+                       ProjectKey:   data.Options.ProjectKey,
+               },
+               Table: RAW_PROJECT_METRICS_HISTORY_TABLE,
+       })
+       if err != nil {
+               return err
+       }
+
+       err = apiCollector.InitCollector(helper.ApiCollectorArgs{
+               ApiClient:   data.ApiClient,
+               PageSize:    1000,
+               UrlTemplate: "measures/search_history",
+               Query: func(reqData *helper.RequestData) (url.Values, 
errors.Error) {
+                       query := url.Values{}
+                       query.Set("component", data.Options.ProjectKey)
+                       query.Set("metrics", metricsToCollect)
+                       query.Set("ps", fmt.Sprintf("%v", reqData.Pager.Size))
+                       query.Set("p", fmt.Sprintf("%v", reqData.Pager.Page))
+                       if apiCollector.GetSince() != nil {
+                               query.Set("from", 
apiCollector.GetSince().UTC().Format("2006-01-02"))
+                       }
+                       return query, nil
+               },
+               GetTotalPages: GetTotalPagesFromResponse,
+               ResponseParser: func(res *http.Response) ([]json.RawMessage, 
errors.Error) {
+                       var body json.RawMessage
+                       err := helper.UnmarshalResponse(res, &body)
+                       if err != nil {
+                               return nil, err
+                       }
+                       return []json.RawMessage{body}, nil
+               },
+       })
+       if err != nil {
+               return err
+       }
+
+       return apiCollector.Execute()
+}
+
+var CollectProjectMetricsHistoryMeta = plugin.SubTaskMeta{
+       Name:             "CollectProjectMetricsHistory",
+       EntryPoint:       CollectProjectMetricsHistory,
+       EnabledByDefault: true,
+       Description:      "Collect project-level metric history from SonarQube 
measures/search_history API",
+       DomainTypes:      []string{plugin.DOMAIN_TYPE_CODE_QUALITY},
+}
diff --git 
a/backend/plugins/sonarqube/tasks/project_metrics_history_convertor.go 
b/backend/plugins/sonarqube/tasks/project_metrics_history_convertor.go
new file mode 100644
index 000000000..bf59aa1fe
--- /dev/null
+++ b/backend/plugins/sonarqube/tasks/project_metrics_history_convertor.go
@@ -0,0 +1,158 @@
+/*
+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 (
+       "fmt"
+       "reflect"
+       "strconv"
+       "time"
+
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/models/domainlayer"
+       
"github.com/apache/incubator-devlake/core/models/domainlayer/codequality"
+       "github.com/apache/incubator-devlake/core/models/domainlayer/didgen"
+       "github.com/apache/incubator-devlake/core/plugin"
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       sonarqubeModels 
"github.com/apache/incubator-devlake/plugins/sonarqube/models"
+)
+
+var ConvertProjectMetricsHistoryMeta = plugin.SubTaskMeta{
+       Name:             "convertProjectMetricsHistory",
+       EntryPoint:       ConvertProjectMetricsHistory,
+       EnabledByDefault: true,
+       Description:      "Convert tool layer project metrics history into 
domain layer table cq_project_metrics_history",
+       DomainTypes:      []string{plugin.DOMAIN_TYPE_CODE_QUALITY},
+}
+
+func ConvertProjectMetricsHistory(taskCtx plugin.SubTaskContext) errors.Error {
+       db := taskCtx.GetDal()
+       _, data := CreateRawDataSubTaskArgs(taskCtx, 
RAW_PROJECT_METRICS_HISTORY_TABLE)
+
+       // Query all narrow metric rows ordered so we can group-pivot them
+       cursor, err := db.Cursor(
+               dal.From(sonarqubeModels.SonarqubeProjectMetricsHistory{}),
+               dal.Where("connection_id = ? AND project_key = ?", 
data.Options.ConnectionId, data.Options.ProjectKey),
+               dal.Orderby("analysis_date"),
+       )
+       if err != nil {
+               return err
+       }
+       defer cursor.Close()
+
+       projectIdGen := 
didgen.NewDomainIdGenerator(&sonarqubeModels.SonarqubeProject{})
+       domainProjectKey := projectIdGen.Generate(data.Options.ConnectionId, 
data.Options.ProjectKey)
+
+       batchSave, err := helper.NewBatchSave(taskCtx, 
reflect.TypeOf(&codequality.CqProjectMetricsHistory{}), 200)
+       if err != nil {
+               return err
+       }
+       defer batchSave.Close()
+
+       // Group narrow rows by analysis_date, pivot into wide domain rows
+       var currentDate *time.Time
+       var currentDomain *codequality.CqProjectMetricsHistory
+
+       flushCurrent := func() errors.Error {
+               if currentDomain != nil {
+                       return batchSave.Add(currentDomain)
+               }
+               return nil
+       }
+
+       for cursor.Next() {
+               row := &sonarqubeModels.SonarqubeProjectMetricsHistory{}
+               err = db.Fetch(cursor, row)
+               if err != nil {
+                       return err
+               }
+
+               if currentDate == nil || !currentDate.Equal(row.AnalysisDate) {
+                       if flushErr := flushCurrent(); flushErr != nil {
+                               return flushErr
+                       }
+                       domainId := fmt.Sprintf("%s:%s",
+                               domainProjectKey,
+                               
row.AnalysisDate.UTC().Format("2006-01-02T15:04:05Z"),
+                       )
+                       currentDomain = &codequality.CqProjectMetricsHistory{
+                               DomainEntity: domainlayer.DomainEntity{Id: 
domainId},
+                               ProjectKey:   domainProjectKey,
+                               AnalysisDate: row.AnalysisDate,
+                       }
+                       t := row.AnalysisDate
+                       currentDate = &t
+               }
+
+               applyMetricValue(currentDomain, row.MetricKey, row.MetricValue)
+       }
+
+       if flushErr := flushCurrent(); flushErr != nil {
+               return flushErr
+       }
+
+       return batchSave.Close()
+}
+
+func applyMetricValue(d *codequality.CqProjectMetricsHistory, metricKey, value 
string) {
+       switch metricKey {
+       case "coverage":
+               if v, err := strconv.ParseFloat(value, 64); err == nil {
+                       d.Coverage = &v
+               }
+       case "ncloc":
+               if v, err := strconv.Atoi(value); err == nil {
+                       d.Ncloc = &v
+               }
+       case "bugs":
+               if v, err := strconv.Atoi(value); err == nil {
+                       d.Bugs = &v
+               }
+       case "reliability_rating":
+               d.ReliabilityRating = alphabetMap[value]
+       case "code_smells":
+               if v, err := strconv.Atoi(value); err == nil {
+                       d.CodeSmells = &v
+               }
+       case "sqale_rating":
+               d.SqaleRating = alphabetMap[value]
+       case "complexity":
+               if v, err := strconv.Atoi(value); err == nil {
+                       d.Complexity = &v
+               }
+       case "cognitive_complexity":
+               if v, err := strconv.Atoi(value); err == nil {
+                       d.CognitiveComplexity = &v
+               }
+       case "vulnerabilities":
+               if v, err := strconv.Atoi(value); err == nil {
+                       d.Vulnerabilities = &v
+               }
+       case "security_rating":
+               d.SecurityRating = alphabetMap[value]
+       case "security_hotspots":
+               if v, err := strconv.Atoi(value); err == nil {
+                       d.SecurityHotspots = &v
+               }
+       case "duplicated_lines_density":
+               if v, err := strconv.ParseFloat(value, 64); err == nil {
+                       d.DuplicatedLinesDensity = &v
+               }
+       }
+}
diff --git 
a/backend/plugins/sonarqube/tasks/project_metrics_history_extractor.go 
b/backend/plugins/sonarqube/tasks/project_metrics_history_extractor.go
new file mode 100644
index 000000000..430ff196b
--- /dev/null
+++ b/backend/plugins/sonarqube/tasks/project_metrics_history_extractor.go
@@ -0,0 +1,89 @@
+/*
+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"
+       "time"
+
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/plugins/sonarqube/models"
+)
+
+var _ plugin.SubTaskEntryPoint = ExtractProjectMetricsHistory
+
+type metricsHistoryResponse struct {
+       Measures []struct {
+               Metric  string `json:"metric"`
+               History []struct {
+                       Date  string `json:"date"`
+                       Value string `json:"value"`
+               } `json:"history"`
+       } `json:"measures"`
+}
+
+func ExtractProjectMetricsHistory(taskCtx plugin.SubTaskContext) errors.Error {
+       rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, 
RAW_PROJECT_METRICS_HISTORY_TABLE)
+
+       extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+               RawDataSubTaskArgs: *rawDataSubTaskArgs,
+               Extract: func(resData *helper.RawData) ([]interface{}, 
errors.Error) {
+                       body := &metricsHistoryResponse{}
+                       err := errors.Convert(json.Unmarshal(resData.Data, 
body))
+                       if err != nil {
+                               return nil, err
+                       }
+
+                       var results []interface{}
+                       for _, measure := range body.Measures {
+                               for _, entry := range measure.History {
+                                       if entry.Value == "" {
+                                               continue
+                                       }
+                                       analysisDate, parseErr := 
time.Parse("2006-01-02T15:04:05-0700", entry.Date)
+                                       if parseErr != nil {
+                                               return nil, 
errors.Default.Wrap(errors.Convert(parseErr), "failed to parse analysis date")
+                                       }
+                                       results = append(results, 
&models.SonarqubeProjectMetricsHistory{
+                                               ConnectionId: 
data.Options.ConnectionId,
+                                               ProjectKey:   
data.Options.ProjectKey,
+                                               AnalysisDate: analysisDate,
+                                               MetricKey:    measure.Metric,
+                                               MetricValue:  entry.Value,
+                                       })
+                               }
+                       }
+                       return results, nil
+               },
+       })
+       if err != nil {
+               return err
+       }
+
+       return extractor.Execute()
+}
+
+var ExtractProjectMetricsHistoryMeta = plugin.SubTaskMeta{
+       Name:             "ExtractProjectMetricsHistory",
+       EntryPoint:       ExtractProjectMetricsHistory,
+       EnabledByDefault: true,
+       Description:      "Extract raw project metrics history into tool layer 
table",
+       DomainTypes:      []string{plugin.DOMAIN_TYPE_CODE_QUALITY},
+}
diff --git a/grafana/dashboards/mysql/Sonarqube.json 
b/grafana/dashboards/mysql/Sonarqube.json
index d8708f36d..2dac28c9f 100644
--- a/grafana/dashboards/mysql/Sonarqube.json
+++ b/grafana/dashboards/mysql/Sonarqube.json
@@ -46,7 +46,7 @@
           "showLineNumbers": false,
           "showMiniMap": false
         },
-        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarQube.\n- Data Source Required: SonarQube v8.2+\n- This dashboard does 
not honor the time filter on the top-right side as SonarQube metrics are all 
from the latest scan.",
+        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarQube.\n- Data Source Required: SonarQube v8.2+\n- Snapshot panels 
(Reliability, Security, Test, etc.) show the latest scan and do not honor the 
time filter.\n- **Historical Trends** panels at the bottom respond to the time 
range selector and show metric history over time.",
         "mode": "markdown"
       },
       "pluginVersion": "11.2.0",
@@ -1023,6 +1023,263 @@
       ],
       "title": "Code Quality Metrics by Files (Top 20 order by Bugs)",
       "type": "table"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "datasource",
+        "uid": "grafana"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 30
+      },
+      "id": 20,
+      "panels": [
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 31
+          },
+          "id": 21,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  pmh.coverage 
AS 'Coverage %'\nFROM cq_project_metrics_history pmh\nWHERE\n  pmh.project_key 
in (${project_id})\n  AND $__timeFilter(pmh.analysis_date)\nORDER BY 
pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Coverage Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 31
+          },
+          "id": 22,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  pmh.bugs AS 
'Bugs',\n  pmh.vulnerabilities AS 'Vulnerabilities'\nFROM 
cq_project_metrics_history pmh\nWHERE\n  pmh.project_key in (${project_id})\n  
AND $__timeFilter(pmh.analysis_date)\nORDER BY pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Bugs & Vulnerabilities Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 39
+          },
+          "id": 23,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  
pmh.code_smells AS 'Code Smells'\nFROM cq_project_metrics_history pmh\nWHERE\n  
pmh.project_key in (${project_id})\n  AND 
$__timeFilter(pmh.analysis_date)\nORDER BY pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Code Smells Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 39
+          },
+          "id": 24,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  
pmh.duplicated_lines_density AS 'Duplication %'\nFROM 
cq_project_metrics_history pmh\nWHERE\n  pmh.project_key in (${project_id})\n  
AND $__timeFilter(pmh.analysis_date)\nORDER BY pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Duplication Trend",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "datasource",
+            "uid": "grafana"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Historical Trends",
+      "type": "row"
     }
   ],
   "refresh": "",
@@ -1086,7 +1343,7 @@
     ]
   },
   "time": {
-    "from": "now",
+    "from": "now-6M",
     "to": "now"
   },
   "timepicker": {},
diff --git a/grafana/dashboards/mysql/sonar-qube-cloud.json 
b/grafana/dashboards/mysql/sonar-qube-cloud.json
index 1a2907038..8cb1939a5 100644
--- a/grafana/dashboards/mysql/sonar-qube-cloud.json
+++ b/grafana/dashboards/mysql/sonar-qube-cloud.json
@@ -47,7 +47,7 @@
           "showLineNumbers": false,
           "showMiniMap": false
         },
-        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarCloud.\n- Data Source Required: SonarCloud\n- This dashboard does not 
honor the time filter on the top-right side as SonarQube metrics are all from 
the latest scan.",
+        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarCloud.\n- Data Source Required: SonarCloud\n- Snapshot panels 
(Software Quality, Security Review, Test, etc.) show the latest scan and do not 
honor the time filter.\n- **Historical Trends** panels at the bottom respond to 
the time range selector and show metric history over time.",
         "mode": "markdown"
       },
       "pluginVersion": "11.2.0",
@@ -1428,6 +1428,263 @@
       ],
       "title": "Code Quality Metrics by Files (Top 20 order by Bugs)",
       "type": "table"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "datasource",
+        "uid": "grafana"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 44
+      },
+      "id": 27,
+      "panels": [
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 45
+          },
+          "id": 28,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  pmh.coverage 
AS 'Coverage %'\nFROM cq_project_metrics_history pmh\nWHERE\n  pmh.project_key 
in (${project_id})\n  AND $__timeFilter(pmh.analysis_date)\nORDER BY 
pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Coverage Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 45
+          },
+          "id": 29,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  pmh.bugs AS 
'Bugs',\n  pmh.vulnerabilities AS 'Vulnerabilities'\nFROM 
cq_project_metrics_history pmh\nWHERE\n  pmh.project_key in (${project_id})\n  
AND $__timeFilter(pmh.analysis_date)\nORDER BY pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Bugs & Vulnerabilities Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 53
+          },
+          "id": 30,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  
pmh.code_smells AS 'Code Smells'\nFROM cq_project_metrics_history pmh\nWHERE\n  
pmh.project_key in (${project_id})\n  AND 
$__timeFilter(pmh.analysis_date)\nORDER BY pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Code Smells Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "mysql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 53
+          },
+          "id": 31,
+          "options": {
+            "legend": {
+              "calcs": ["lastNotNull"],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "mysql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT\n  pmh.analysis_date AS time,\n  
pmh.duplicated_lines_density AS 'Duplication %'\nFROM 
cq_project_metrics_history pmh\nWHERE\n  pmh.project_key in (${project_id})\n  
AND $__timeFilter(pmh.analysis_date)\nORDER BY pmh.analysis_date ASC",
+              "refId": "A"
+            }
+          ],
+          "title": "Duplication Trend",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "datasource",
+            "uid": "grafana"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Historical Trends",
+      "type": "row"
     }
   ],
   "refresh": "",
@@ -1491,7 +1748,7 @@
     ]
   },
   "time": {
-    "from": "now",
+    "from": "now-6M",
     "to": "now"
   },
   "timepicker": {},
diff --git a/grafana/dashboards/postgresql/Sonarqube.json 
b/grafana/dashboards/postgresql/Sonarqube.json
index 02d9ccbc2..1bae97b37 100644
--- a/grafana/dashboards/postgresql/Sonarqube.json
+++ b/grafana/dashboards/postgresql/Sonarqube.json
@@ -46,7 +46,7 @@
           "showLineNumbers": false,
           "showMiniMap": false
         },
-        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarQube.\n- Data Source Required: SonarQube v8.2+\n- This dashboard does 
not honor the time filter on the top-right side as SonarQube metrics are all 
from the latest scan.",
+        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarQube.\n- Data Source Required: SonarQube v8.2+\n- Snapshot panels 
(Reliability, Security, Test, etc.) show the latest scan and do not honor the 
time filter.\n- **Historical Trends** panels at the bottom respond to the time 
range selector and show metric history over time.",
         "mode": "markdown"
       },
       "pluginVersion": "11.2.0",
@@ -1023,6 +1023,271 @@
       ],
       "title": "Code Quality Metrics by Files (Top 20 order by Bugs)",
       "type": "table"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "datasource",
+        "uid": "grafana"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 30
+      },
+      "id": 20,
+      "panels": [
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 31
+          },
+          "id": 21,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, pmh.coverage AS 
\"Coverage %\" FROM cq_project_metrics_history AS pmh WHERE 
('${project_id:csv}' = '' OR pmh.project_key::text = 
ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Coverage Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 31
+          },
+          "id": 22,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, pmh.bugs AS 
\"Bugs\", pmh.vulnerabilities AS \"Vulnerabilities\" FROM 
cq_project_metrics_history AS pmh WHERE ('${project_id:csv}' = '' OR 
pmh.project_key::text = ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Bugs & Vulnerabilities Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 39
+          },
+          "id": 23,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, pmh.code_smells AS 
\"Code Smells\" FROM cq_project_metrics_history AS pmh WHERE 
('${project_id:csv}' = '' OR pmh.project_key::text = 
ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Code Smells Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 39
+          },
+          "id": 24,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, 
pmh.duplicated_lines_density AS \"Duplication %\" FROM 
cq_project_metrics_history AS pmh WHERE ('${project_id:csv}' = '' OR 
pmh.project_key::text = ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Duplication Trend",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "datasource",
+            "uid": "grafana"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Historical Trends",
+      "type": "row"
     }
   ],
   "refresh": "",
@@ -1086,7 +1351,7 @@
     ]
   },
   "time": {
-    "from": "now",
+    "from": "now-6M",
     "to": "now"
   },
   "timepicker": {},
diff --git a/grafana/dashboards/postgresql/sonar-qube-cloud.json 
b/grafana/dashboards/postgresql/sonar-qube-cloud.json
index 6719a3e2d..08cd7359e 100644
--- a/grafana/dashboards/postgresql/sonar-qube-cloud.json
+++ b/grafana/dashboards/postgresql/sonar-qube-cloud.json
@@ -47,7 +47,7 @@
           "showLineNumbers": false,
           "showMiniMap": false
         },
-        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarCloud.\n- Data Source Required: SonarCloud\n- This dashboard does not 
honor the time filter on the top-right side as SonarQube metrics are all from 
the latest scan.",
+        "content": "- Use Cases: This dashboard shows the code quality metrics 
from SonarCloud.\n- Data Source Required: SonarCloud\n- Snapshot panels 
(Software Quality, Security Review, Test, etc.) show the latest scan and do not 
honor the time filter.\n- **Historical Trends** panels at the bottom respond to 
the time range selector and show metric history over time.",
         "mode": "markdown"
       },
       "pluginVersion": "11.2.0",
@@ -1428,6 +1428,271 @@
       ],
       "title": "Code Quality Metrics by Files (Top 20 order by Bugs)",
       "type": "table"
+    },
+    {
+      "collapsed": true,
+      "datasource": {
+        "type": "datasource",
+        "uid": "grafana"
+      },
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 44
+      },
+      "id": 27,
+      "panels": [
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 45
+          },
+          "id": 28,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, pmh.coverage AS 
\"Coverage %\" FROM cq_project_metrics_history AS pmh WHERE 
('${project_id:csv}' = '' OR pmh.project_key::text = 
ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Coverage Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 45
+          },
+          "id": 29,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, pmh.bugs AS 
\"Bugs\", pmh.vulnerabilities AS \"Vulnerabilities\" FROM 
cq_project_metrics_history AS pmh WHERE ('${project_id:csv}' = '' OR 
pmh.project_key::text = ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Bugs & Vulnerabilities Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              }
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 53
+          },
+          "id": 30,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, pmh.code_smells AS 
\"Code Smells\" FROM cq_project_metrics_history AS pmh WHERE 
('${project_id:csv}' = '' OR pmh.project_key::text = 
ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Code Smells Trend",
+          "type": "timeseries"
+        },
+        {
+          "datasource": "postgresql",
+          "fieldConfig": {
+            "defaults": {
+              "color": {
+                "mode": "palette-classic"
+              },
+              "custom": {
+                "lineWidth": 2,
+                "fillOpacity": 10,
+                "spanNulls": true,
+                "axisBorderShow": false,
+                "axisCenteredZero": false,
+                "axisColorMode": "text",
+                "axisLabel": "",
+                "scaleDistribution": {
+                  "type": "linear"
+                },
+                "showPoints": "auto",
+                "pointSize": 5
+              },
+              "unit": "percent"
+            },
+            "overrides": []
+          },
+          "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 53
+          },
+          "id": 31,
+          "options": {
+            "legend": {
+              "calcs": [
+                "lastNotNull"
+              ],
+              "displayMode": "list",
+              "placement": "bottom",
+              "showLegend": true
+            },
+            "tooltip": {
+              "mode": "multi",
+              "sort": "none"
+            }
+          },
+          "pluginVersion": "11.2.0",
+          "targets": [
+            {
+              "datasource": "postgresql",
+              "editorMode": "code",
+              "format": "time_series",
+              "rawQuery": true,
+              "rawSql": "SELECT pmh.analysis_date AS time, 
pmh.duplicated_lines_density AS \"Duplication %\" FROM 
cq_project_metrics_history AS pmh WHERE ('${project_id:csv}' = '' OR 
pmh.project_key::text = ANY(ARRAY[${project_id:singlequote}]::text[])) AND 
$__timeFilter(pmh.analysis_date) ORDER BY pmh.analysis_date ASC NULLS FIRST",
+              "refId": "A"
+            }
+          ],
+          "title": "Duplication Trend",
+          "type": "timeseries"
+        }
+      ],
+      "targets": [
+        {
+          "datasource": {
+            "type": "datasource",
+            "uid": "grafana"
+          },
+          "refId": "A"
+        }
+      ],
+      "title": "Historical Trends",
+      "type": "row"
     }
   ],
   "refresh": "",
@@ -1491,7 +1756,7 @@
     ]
   },
   "time": {
-    "from": "now",
+    "from": "now-6M",
     "to": "now"
   },
   "timepicker": {},

Reply via email to