You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by kl...@apache.org on 2026/07/15 08:53:55 UTC

(devlake) branch main updated: feat(sonarqube): collect historical project metrics and Grafana trends (#8983)

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 <jb...@gmail.com>
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 <jb...@gmail.com>
    
    * 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 <jb...@gmail.com>
    
    * 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 <jb...@gmail.com>
---
 .../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": {},