Repository: perses/perses

Issue:
Prometheus: Datasource form: header disappears if same header key is typed
When configuring a PrometheusDatasource, if you already provided a header, start adding another one & type the same header key, the former item with same key will get removed.
This is happening because we are updating the state at each input, and headers are stored in a map.

Should we use an intermediary array instead? And implement a logic to decide which item to keep in case of conflict when saving. More details about the proposed technical solution here https://github.com/perses/perses/pull/1282#discussion_r1265437000.

Discussion:

Solution patch:
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 99df40a3..f4accee5 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -43,6 +43,7 @@ jobs:
         run: npx playwright install --with-deps chromium
       - name: Run Playwright tests
         working-directory: ./ui
+        timeout-minutes: 10
         run: npm run e2e:ci
         env:
           # Outputs information from webservers started in the background by
diff --git a/cue/common/format.cue b/cue/common/format.cue
index d869eb17..f9bec811 100644
--- a/cue/common/format.cue
+++ b/cue/common/format.cue
@@ -13,44 +13,59 @@
 
 package common
 
-#format: #timeFormat | #percentFormat | #decimalFormat | #bitsFormat | #bytesFormat | #throughputFormat | #currencyFormat | #temperatureFormat | #dateFormat
+#format: #simpleFormat | #floatFormat | #shortenableFormat
+
+#simpleFormat: {
+    unit?: #dateFormat.unit
+}
+
+#floatFormat: {
+    unit?: #timeFormat.unit | #percentFormat.unit | #currencyFormat.unit | #temperatureFormat.unit
+    decimalPlaces?: number
+}
+
+#shortenableFormat: {
+    unit?: #decimalFormat.unit | #bitsFormat.unit | #bytesFormat.unit | #throughputFormat.unit
+	decimalPlaces?: number
+	shortValues?:   bool
+}
 
 #timeFormat: {
-	unit?:          "nanoseconds" | "microseconds" | "milliseconds" | "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years"
+	unit:          "nanoseconds" | "microseconds" | "milliseconds" | "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years"
 	decimalPlaces?: number
 }
 
 #percentFormat: {
-	unit?:          "percent" | "percent-decimal"
+	unit:          "percent" | "percent-decimal"
 	decimalPlaces?: number
 }
 
 #decimalFormat: {
-	unit?:          "decimal"
+	unit:          "decimal"
 	decimalPlaces?: number
 	shortValues?:   bool
 }
 
 #bitsFormat: {
-	unit?:          "bits" | "decbits"
+	unit:          "bits" | "decbits"
 	decimalPlaces?: number
 	shortValues?:   bool
 }
 
 #bytesFormat: {
-	unit?:          "bytes" | "decbytes"
+	unit:          "bytes" | "decbytes"
 	decimalPlaces?: number
 	shortValues?:   bool
 }
 
 #throughputFormat: {
-	unit?:          "bits/sec" | "decbits/sec" | "bytes/sec" | "decbytes/sec" | "counts/sec" | "events/sec" | "messages/sec" | "ops/sec" | "packets/sec" | "reads/sec" | "records/sec" | "requests/sec" | "rows/sec" | "writes/sec"
+	unit:          "bits/sec" | "decbits/sec" | "bytes/sec" | "decbytes/sec" | "counts/sec" | "events/sec" | "messages/sec" | "ops/sec" | "packets/sec" | "reads/sec" | "records/sec" | "requests/sec" | "rows/sec" | "writes/sec"
 	decimalPlaces?: number
 	shortValues?:   bool
 }
 
 #currencyFormat: {
-	unit?:          "aud" | "cad" | "chf" | "cny" | "eur" | "gbp" | "hkd" | "inr" | "jpy" | "krw" | "nok" | "nzd" | "sek" | "sgd" | "usd"
+	unit:          "aud" | "cad" | "chf" | "cny" | "eur" | "gbp" | "hkd" | "inr" | "jpy" | "krw" | "nok" | "nzd" | "sek" | "sgd" | "usd"
 	decimalPlaces?: number
 }
 
@@ -60,5 +75,5 @@ package common
 }
 
 #dateFormat: {
-	unit?: "datetime-iso" | "datetime-us" | "datetime-local" | "date-iso" | "date-us" | "date-local" | "time-local" | "time-iso" | "time-us" | "relative-time" | "unix-timestamp" | "unix-timestamp-ms"
+	unit: "datetime-iso" | "datetime-us" | "datetime-local" | "date-iso" | "date-us" | "date-local" | "time-local" | "time-iso" | "time-us" | "relative-time" | "unix-timestamp" | "unix-timestamp-ms"
 }
\ No newline at end of file
diff --git a/cue/model/api/v1/dashboard/variable_patch.cue b/cue/model/api/v1/dashboard/variable_patch.cue
index 85a223cf..3f8b7a9e 100644
--- a/cue/model/api/v1/dashboard/variable_patch.cue
+++ b/cue/model/api/v1/dashboard/variable_patch.cue
@@ -34,6 +34,9 @@ import "github.com/perses/perses/cue/model/api/v1/variable"
 }
 
 #Variable: {
-	kind: variable.#Kind                        @go(Kind)
-	spec: #TextVariableSpec | #ListVariableSpec @go(Spec)
+	kind: variable.#KindText @go(Kind)
+	spec: #TextVariableSpec  @go(Spec)
+} | {
+	kind: variable.#KindList @go(Kind)
+	spec: #ListVariableSpec  @go(Spec)
 }
diff --git a/cue/model/api/v1/variable_patch.cue b/cue/model/api/v1/variable_patch.cue
index f3c22421..2266e958 100644
--- a/cue/model/api/v1/variable_patch.cue
+++ b/cue/model/api/v1/variable_patch.cue
@@ -23,7 +23,14 @@ package v1
 
 import "github.com/perses/perses/cue/model/api/v1/variable"
 
+#Variable: kind: #KindVariable @go(Kind)
+
+#GlobalVariable: kind: #KindGlobalVariable @go(Kind)
+
 #VariableSpec: {
-	kind: variable.#Kind @go(Kind)
-	spec: _              @go(Spec)
+	kind: variable.#KindText @go(Kind)
+	spec: variable.#TextSpec  @go(Spec)
+} | {
+	kind: variable.#KindList @go(Kind)
+	spec: variable.#ListSpec  @go(Spec)
 }
diff --git a/docs/concepts/authorization.md b/docs/concepts/authorization.md
index e9f80120..f4353886 100644
--- a/docs/concepts/authorization.md
+++ b/docs/concepts/authorization.md
@@ -25,7 +25,7 @@ metadata:
   project: MySuperProject
 spec:
   permissions:
-    - actions: [ "edit" ]
+    - actions: [ "update" ]
       scopes: [ "Dashboard" ]
 ```
 
@@ -42,7 +42,7 @@ metadata:
   name: variable-editor
 spec:
   permissions:
-    - actions: [ "edit" ]
+    - actions: [ "update" ]
       scopes: [ "Variable" ]
 ```
 
@@ -113,7 +113,7 @@ metadata:
   name: admin-editor
 spec:
   permissions:
-    - actions: [ "edit" ]
+    - actions: [ "update" ]
       scopes: [ "*" ]
 ```
 
diff --git a/internal/api/impl/proxy/proxy.go b/internal/api/impl/proxy/proxy.go
index 57985411..9f85d45e 100644
--- a/internal/api/impl/proxy/proxy.go
+++ b/internal/api/impl/proxy/proxy.go
@@ -203,9 +203,10 @@ func newProxy(datasourceName, projectName string, spec v1.DatasourceSpec, path s
 			}
 		}
 		return &httpProxy{
-			config: httpConfig,
-			path:   path,
-			secret: scrt,
+			config:         httpConfig,
+			datasourceName: datasourceName,
+			path:           path,
+			secret:         scrt,
 		}, nil
 	case datasourceSQL.ProxyKindName:
 		sqlConfig := cfg.(*datasourceSQL.Config)
@@ -232,9 +233,10 @@ func newProxy(datasourceName, projectName string, spec v1.DatasourceSpec, path s
 }
 
 type httpProxy struct {
-	config *datasourceHTTP.Config
-	secret *v1.SecretSpec
-	path   string
+	config         *datasourceHTTP.Config
+	secret         *v1.SecretSpec
+	datasourceName string
+	path           string
 }
 
 func (h *httpProxy) serve(c echo.Context) error {
@@ -308,8 +310,18 @@ func (h *httpProxy) prepareRequest(c echo.Context) error {
 	}
 	// set header according to the configuration
 	if len(h.config.Headers) > 0 {
-		// TODO list the headers that cannot be overridden.
 		for k, v := range h.config.Headers {
+			if k == echo.HeaderAuthorization {
+				// Authorization header cannot be overwritten by the public configuration.
+				// It must be set using the Secret configuration.
+				// It will avoid leaking credentials and user to be able to set them directly in the datasource configuration.
+				//
+				// The verification is not done during the validation of the datasource configuration because this is up to the Observability vendor to decide how it wants to manage its datasource configuration.
+				// For Perses, we don't want to allow users to set the Authorization header directly in the datasource configuration because we have created a dedicated object to handle sensitive information: the Secret.
+				// But other vendors might have different policies about it and might want to allow it.
+				logrus.Infof("Datasource %s has Authorization header in its configuration. This is not allowed and will be ignored. Use Secret object to set Authorization header.", h.datasourceName)
+				continue
+			}
 			req.Header.Set(k, v)
 		}
 	}
@@ -334,7 +346,7 @@ func (h *httpProxy) setupAuthentication(req *http.Request) error {
 		if err != nil {
 			return err
 		}
-		req.Header.Set("Authorization", fmt.Sprintf("%s %s", auth.Type, credential))
+		req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("%s %s", auth.Type, credential))
 	}
 	oauth := h.secret.OAuth
 	if oauth != nil {
@@ -342,7 +354,7 @@ func (h *httpProxy) setupAuthentication(req *http.Request) error {
 		if err != nil {
 			return err
 		}
-		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
+		req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", token.AccessToken))
 	}
 
 	return nil
diff --git a/internal/api/plugin/dev.go b/internal/api/plugin/dev.go
index 3c57d384..6332ce77 100644
--- a/internal/api/plugin/dev.go
+++ b/internal/api/plugin/dev.go
@@ -54,15 +54,13 @@ func (p *pluginFile) LoadDevPlugin(plugins []v1.PluginInDevelopment) error {
 			},
 			Module: pluginModule,
 		}
-		if !IsSchemaRequired(pluginModule.Spec) || plg.DisableSchema {
-			logrus.Debugf("schema is disabled or not required for plugin %q", pluginModule.Metadata.Name)
-			return nil
-		}
-		if pluginSchemaLoadErr := p.sch.LoadDevPlugin(plg.AbsolutePath, pluginModule); pluginSchemaLoadErr != nil {
-			return apiinterface.HandleBadRequestError(fmt.Sprintf("failed to load plugin schema: %s", pluginSchemaLoadErr))
-		}
-		if pluginMigrateLoadErr := p.mig.LoadDevPlugin(plg.AbsolutePath, pluginModule); pluginMigrateLoadErr != nil {
-			return apiinterface.HandleBadRequestError(fmt.Sprintf("failed to load plugin migration: %s", pluginMigrateLoadErr))
+		if IsSchemaRequired(pluginModule.Spec) && !plg.DisableSchema {
+			if pluginSchemaLoadErr := p.sch.LoadDevPlugin(plg.AbsolutePath, pluginModule); pluginSchemaLoadErr != nil {
+				return apiinterface.HandleBadRequestError(fmt.Sprintf("failed to load plugin schema: %s", pluginSchemaLoadErr))
+			}
+			if pluginMigrateLoadErr := p.mig.LoadDevPlugin(plg.AbsolutePath, pluginModule); pluginMigrateLoadErr != nil {
+				return apiinterface.HandleBadRequestError(fmt.Sprintf("failed to load plugin migration: %s", pluginMigrateLoadErr))
+			}
 		}
 		p.mutex.Lock()
 		p.devLoaded[manifest.Name] = pluginLoaded
diff --git a/scripts/generate-changelog/generate-changelog.go b/scripts/generate-changelog/generate-changelog.go
index 845fff4a..52e915e8 100644
--- a/scripts/generate-changelog/generate-changelog.go
+++ b/scripts/generate-changelog/generate-changelog.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The Perses Authors
+// Copyright 2025 The Perses Authors
 // Licensed 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
@@ -15,15 +15,15 @@ package main
 
 import (
 	"flag"
-	"os/exec"
 	"strings"
 
-	"github.com/perses/perses/scripts/changelog"
+	"github.com/perses/perses/scripts/pkg/changelog"
+	"github.com/perses/perses/scripts/pkg/command"
 	"github.com/sirupsen/logrus"
 )
 
 func getPreviousTag() string {
-	previousVersion, err := exec.Command("git", "describe", "--abbrev=0").Output()
+	previousVersion, err := command.Create("git", "describe", "--abbrev=0").Output()
 	if err != nil {
 		logrus.WithError(err).Fatal("unable to get the latest tag")
 	}
diff --git a/scripts/generate-goreleaser/generate-goreleaser.go b/scripts/generate-goreleaser/generate-goreleaser.go
index 9ebb3ef6..6c86b013 100644
--- a/scripts/generate-goreleaser/generate-goreleaser.go
+++ b/scripts/generate-goreleaser/generate-goreleaser.go
@@ -16,7 +16,7 @@ package main
 import (
 	_ "embed"
 
-	goreleasergenerate "github.com/perses/perses/scripts/generate-docker-goreleaser-config"
+	goreleasergenerate "github.com/perses/perses/scripts/pkg/generate-docker-goreleaser-config"
 )
 
 //go:embed .goreleaser.base.yaml
diff --git a/scripts/pkg/README.md b/scripts/pkg/README.md
new file mode 100644
index 00000000..d80fea80
--- /dev/null
+++ b/scripts/pkg/README.md
@@ -0,0 +1,4 @@
+This package is a dedicated package shared across multiple repositories to hold common utilities to write scripts and have a
+consistent workflow across different projects.
+
+These utilities are **NOT** intended to be used in production code.
diff --git a/scripts/changelog/changelog.go b/scripts/pkg/changelog/changelog.go
similarity index 100%
rename from scripts/changelog/changelog.go
rename to scripts/pkg/changelog/changelog.go
diff --git a/scripts/pkg/command/command.go b/scripts/pkg/command/command.go
new file mode 100644
index 00000000..4ce2968d
--- /dev/null
+++ b/scripts/pkg/command/command.go
@@ -0,0 +1,49 @@
+// Copyright 2025 The Perses Authors
+// Licensed 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 command
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"os/exec"
+)
+
+func Run(name string, args ...string) error {
+	cmd := Create(name, args...)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed to run %s %v: %w\nstderr: %s", name, args, err, stderr.String())
+	}
+	return nil
+}
+
+func RunInDirectory(directory string, name string, args ...string) error {
+	cmd := Create(name, args...)
+	cmd.Dir = directory
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed to run %s %v: %w\nstderr: %s", name, args, err, stderr.String())
+	}
+	return nil
+}
+
+func Create(name string, args ...string) *exec.Cmd {
+	cmd := exec.Command(name, args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd
+}
diff --git a/scripts/generate-docker-goreleaser-config/generate-docker-goreleaser-config.go b/scripts/pkg/generate-docker-goreleaser-config/generate-docker-goreleaser-config.go
similarity index 100%
rename from scripts/generate-docker-goreleaser-config/generate-docker-goreleaser-config.go
rename to scripts/pkg/generate-docker-goreleaser-config/generate-docker-goreleaser-config.go
diff --git a/scripts/pkg/npm/npm.go b/scripts/pkg/npm/npm.go
new file mode 100644
index 00000000..ef876f73
--- /dev/null
+++ b/scripts/pkg/npm/npm.go
@@ -0,0 +1,88 @@
+// Copyright 2025 The Perses Authors
+// Licensed 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 npm
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+
+	"github.com/sirupsen/logrus"
+)
+
+type Package struct {
+	Name       string   `json:"name"`
+	Version    string   `json:"version"`
+	Workspaces []string `json:"workspaces"`
+}
+
+func readPackageFile(dirPath string) ([]byte, error) {
+	return os.ReadFile(filepath.Join(dirPath, "package.json")) //nolint: gosec
+}
+
+func GetPackage(dirPath string) (Package, error) {
+	data, err := readPackageFile(dirPath)
+	if err != nil {
+		return Package{}, err
+	}
+	pkg := Package{}
+	if unmarshalErr := json.Unmarshal(data, &pkg); unmarshalErr != nil {
+		return Package{}, unmarshalErr
+	}
+	return pkg, nil
+}
+
+func MustGetPackage(dirPath string) Package {
+	pkg, err := GetPackage(dirPath)
+	if err != nil {
+		logrus.WithError(err).Fatal("unable to load package.json")
+	}
+	return pkg
+}
+
+func GetWorkspaces(dirPath string) ([]string, error) {
+	data, err := readPackageFile(dirPath)
+	if err != nil {
+		return nil, err
+	}
+	pkg := Package{}
+	if unmarshalErr := json.Unmarshal(data, &pkg); unmarshalErr != nil {
+		return nil, unmarshalErr
+	}
+	return pkg.Workspaces, nil
+}
+
+func MustGetWorkspaces(dirPath string) []string {
+	workspaces, err := GetWorkspaces(dirPath)
+	if err != nil {
+		logrus.WithError(err).Fatal("unable to read workspaces from package.json")
+	}
+	return workspaces
+}
+
+func GetVersion(dirPath string) (string, error) {
+	pkg, err := GetPackage(dirPath)
+	if err != nil {
+		return "", err
+	}
+	return pkg.Version, nil
+}
+
+func MustGetVersion(dirPath string) string {
+	version, err := GetVersion(dirPath)
+	if err != nil {
+		logrus.WithError(err).Fatal("unable to read version from package.json")
+	}
+	return version
+}
diff --git a/scripts/pkg/npm/package.json b/scripts/pkg/npm/package.json
new file mode 100644
index 00000000..ea9560ce
--- /dev/null
+++ b/scripts/pkg/npm/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "perses-test",
+  "description": "Package.json for testing purposes",
+  "version": "0.1.0",
+  "workspaces": [
+    "foo",
+    "bar",
+    "octopus"
+  ],
+  "peerDependencies": {
+    "@types/react-dom": "^18.3.0",
+    "react": "^17.0.2 || ^18.0.0",
+    "react-dom": "^17.0.2 || ^18.0.0"
+  },
+  "packageManager": "npm@10.9.2"
+}
diff --git a/scripts/validate-cue/validate-cue.go b/scripts/validate-cue/validate-cue.go
index 4845f9b6..fada6c37 100644
--- a/scripts/validate-cue/validate-cue.go
+++ b/scripts/validate-cue/validate-cue.go
@@ -65,7 +65,7 @@ func fileExists(path string) bool {
 func runCueVet(schemaFile, testFile string) error {
 	logrus.Debugf("Validating %s against %s", schemaFile, testFile)
 
-	cmd := exec.Command("cue", "vet", "-c=false", schemaFile, testFile)
+	cmd := exec.Command("cue", "vet", "-c", schemaFile, testFile)
 	cmd.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
 
@@ -86,6 +86,7 @@ func validateCueFiles() error {
 
 	validatedCount := 0
 	skippedCount := 0
+	errCount := 0
 
 	for _, subDir := range dirsInScope {
 		logrus.Debugf("Processing directory: %s", subDir)
@@ -106,12 +107,16 @@ func validateCueFiles() error {
 			}
 
 			if err := runCueVet(schemaFile, testFile); err != nil {
-				return err
+				logrus.Errorf("Validation failed for %s: %v", schemaFile, err)
+				errCount++
 			}
 
 			validatedCount++
 		}
 	}
+	if errCount > 0 {
+		return fmt.Errorf("validation failed for %d file(s)", errCount)
+	}
 
 	logrus.Infof("CUE files validation completed: %d validated, %d skipped", validatedCount, skippedCount)
 	return nil
diff --git a/ui/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts b/ui/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts
index 5a60bd5b..45933846 100644
--- a/ui/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts
+++ b/ui/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts
@@ -13,7 +13,7 @@
 
 import { StateCreator } from 'zustand';
 import { PanelGroupItemId } from '@perses-dev/core';
-import { insertPanelInLayout, UnpositionedPanelGroupItemLayout } from '../../utils/panelUtils';
+import { generatePanelKey, insertPanelInLayout, UnpositionedPanelGroupItemLayout } from '../../utils/panelUtils';
 import { generateId, Middleware } from './common';
 import { PanelGroupSlice } from './panel-group-slice';
 import { PanelSlice } from './panel-slice';
@@ -69,7 +69,7 @@ export function createDuplicatePanelSlice(): StateCreator<
           throw new Error(`Cannot find layout for Panel with key '${panelKey}'`);
         }
 
-        const dupePanelKey = crypto.randomUUID().replaceAll('-', '');
+        const dupePanelKey = generatePanelKey();
 
         state.panels[dupePanelKey] = panelToDupe;
 
diff --git a/ui/dashboards/src/context/DashboardProvider/panel-editor-slice.ts b/ui/dashboards/src/context/DashboardProvider/panel-editor-slice.ts
index dca3a85a..0e71da48 100644
--- a/ui/dashboards/src/context/DashboardProvider/panel-editor-slice.ts
+++ b/ui/dashboards/src/context/DashboardProvider/panel-editor-slice.ts
@@ -20,7 +20,7 @@ import {
   PanelGroupItemLayout,
 } from '@perses-dev/core';
 import { StateCreator } from 'zustand';
-import { getYForNewRow } from '../../utils';
+import { generatePanelKey, getYForNewRow } from '../../utils';
 import { generateId, Middleware, createPanelDefinition } from './common';
 import { PanelGroupSlice, addPanelGroup, createEmptyPanelGroup } from './panel-group-slice';
 import { PanelSlice } from './panel-slice';
@@ -186,7 +186,7 @@ export function createPanelEditorSlice(): StateCreator<
           panelDefinition: get().initialValues?.panelDefinition ?? createPanelDefinition(),
         },
         applyChanges: (next) => {
-          const panelKey = crypto.randomUUID().replaceAll('-', '');
+          const panelKey = generatePanelKey();
           set((state) => {
             // Add a panel
             state.panels[panelKey] = next.panelDefinition;
diff --git a/ui/dashboards/src/utils/panelUtils.test.ts b/ui/dashboards/src/utils/panelUtils.test.ts
index 0dd90ea0..e29c261a 100644
--- a/ui/dashboards/src/utils/panelUtils.test.ts
+++ b/ui/dashboards/src/utils/panelUtils.test.ts
@@ -11,8 +11,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { PanelDefinition, PanelGroupItemLayout } from '@perses-dev/core';
-import { getValidPanelKey, insertPanelInLayout, UnpositionedPanelGroupItemLayout } from './panelUtils';
+import { PanelGroupItemLayout } from '@perses-dev/core';
+import { insertPanelInLayout, UnpositionedPanelGroupItemLayout } from './panelUtils';
 
 describe('insertPanelInLayout', () => {
   describe('inserts the panel to the right when space is available', () => {
@@ -120,59 +120,3 @@ describe('insertPanelInLayout', () => {
     });
   });
 });
-
-describe('getValidPanelKey', () => {
-  test('removes whitespace', () => {
-    expect(getValidPanelKey('my panel name', {})).toBe('mypanelname');
-  });
-
-  test('does not include a counter if the key is not in use', () => {
-    const mockPanelDefs = {
-      alreadyInUse: {
-        kind: 'Panel',
-        spec: {},
-      } as PanelDefinition,
-    };
-    expect(getValidPanelKey('newPanelName', mockPanelDefs)).toBe('newPanelName');
-  });
-
-  test('includes a counter if the key is already in use', () => {
-    const mockPanelDefs = {
-      alreadyInUse: {
-        kind: 'Panel',
-        spec: {},
-      } as PanelDefinition,
-    };
-    expect(getValidPanelKey('alreadyInUse', mockPanelDefs)).toBe('alreadyInUse-1');
-  });
-
-  test('includes an incremented counter if the key is already in use multiple times', () => {
-    const mockPanelDefs = {
-      popularKey: {
-        kind: 'Panel',
-        spec: {},
-      } as PanelDefinition,
-      'popularKey-1': {
-        kind: 'Panel',
-        spec: {},
-      } as PanelDefinition,
-    };
-    expect(getValidPanelKey('popularKey', mockPanelDefs)).toBe('popularKey-2');
-    expect(getValidPanelKey('popularKey-1', mockPanelDefs)).toBe('popularKey-2');
-  });
-
-  test('does not duplicate counters when they are out of order', () => {
-    const mockPanelDefs = {
-      outoforder: {
-        kind: 'Panel',
-        spec: {},
-      } as PanelDefinition,
-      'outoforder-2': {
-        kind: 'Panel',
-        spec: {},
-      } as PanelDefinition,
-    };
-    expect(getValidPanelKey('outoforder', mockPanelDefs)).toBe('outoforder-3');
-    expect(getValidPanelKey('outoforder-2', mockPanelDefs)).toBe('outoforder-3');
-  });
-});
diff --git a/ui/dashboards/src/utils/panelUtils.ts b/ui/dashboards/src/utils/panelUtils.ts
index e75e526c..ef892e39 100644
--- a/ui/dashboards/src/utils/panelUtils.ts
+++ b/ui/dashboards/src/utils/panelUtils.ts
@@ -11,7 +11,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { PanelGroupDefinition, PanelGroupItemLayout, PanelDefinition } from '@perses-dev/core';
+import { PanelGroupDefinition, PanelGroupItemLayout } from '@perses-dev/core';
 import { GRID_LAYOUT_SMALL_BREAKPOINT, GRID_LAYOUT_COLS } from '../constants';
 
 // Given a PanelGroup, will find the Y coordinate for adding a new row to the grid, taking into account the items present
@@ -151,67 +151,14 @@ export function insertPanelInLayout(
 }
 
 /**
- * @deprecated
- *
- * Get a valid panel key, where a valid key:
- * - does not include invalid characters
- * - is unique
- * TODO: It is not clear why too much complexity is needed for generating a panel key
+ * This function generates a unique panel key based on UUID or timestamp and random suffix.
  */
-
-export function getValidPanelKey(panelKey: string, panels: Record<string, PanelDefinition>): string {
-  const uniquePanelKeys = getUniquePanelKeys(panels);
-  let normalizedPanelKey = getPanelKeyParts(removeWhiteSpaces(panelKey)).name;
-
-  const matchingKey = uniquePanelKeys[normalizedPanelKey];
-  if (typeof matchingKey === 'number') {
-    normalizedPanelKey += `-${matchingKey + 1}`;
+export const generatePanelKey = (): string => {
+  /* crypto.randomUUID() is only available in secure contexts (HTTPS), */
+  if (window.isSecureContext) {
+    return crypto.randomUUID().replaceAll('-', '');
   }
-  return normalizedPanelKey;
-}
-
-type PanelKeyParts = {
-  name: string;
-  number?: number;
+  const timestamp = String(Date.now());
+  const randomSuffix = Math.random().toString(36).substring(2);
+  return `${timestamp}${randomSuffix}`;
 };
-
-const removeWhiteSpaces = (str: string): string => {
-  return str.replace(/\s+/g, '');
-};
-
-/**
- * Breaks the specified panel key into the name and the optional `-number` used
- * for deduping panels with the same name.
- */
-function getPanelKeyParts(panelKey: string): PanelKeyParts {
-  const parts = panelKey.match(/(.+)-([0-9]+)/);
-  if (parts && parts[1] && parts[2]) {
-    return {
-      name: parts[1],
-      number: parseInt(parts[2], 10),
-    };
-  }
-
-  return {
-    name: panelKey,
-  };
-}
-
-// Find all the unique panel keys and the largest number used for each.
-// ex: cpu, cpu-1, cpu-2 count as the same panel key since these panels have the same name
-function getUniquePanelKeys(panels: Record<string, PanelDefinition>): Record<string, number> {
-  const uniquePanelKeys: Record<string, number> = {};
-  Object.keys(panels).forEach((panelKey) => {
-    const { name, number } = getPanelKeyParts(panelKey);
-    if (uniquePanelKeys[name] === undefined) {
-      uniquePanelKeys[name] = 0;
-    }
-    const currentValue = uniquePanelKeys[name];
-    if (typeof currentValue === 'number' && number) {
-      // Check for the maximum value because we cannot rely on a sequential
-      // set of numbers when panels are modified.
-      uniquePanelKeys[name] = Math.max(currentValue, number);
-    }
-  });
-  return uniquePanelKeys;
-}
diff --git a/ui/package-lock.json b/ui/package-lock.json
index cc903b83..91743de7 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -13460,9 +13460,9 @@
       }
     },
     "node_modules/node-forge": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
-      "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
+      "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
       "dev": true,
       "license": "(BSD-3-Clause OR GPL-2.0)",
       "engines": {
diff --git a/ui/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx b/ui/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx
new file mode 100644
index 00000000..e46a6110
--- /dev/null
+++ b/ui/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.test.tsx
@@ -0,0 +1,490 @@
+// Copyright 2024 The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { HTTPDatasourceSpec } from '@perses-dev/core';
+import { FormProvider, useForm } from 'react-hook-form';
+import { ReactElement } from 'react';
+import { HTTPSettingsEditor } from './HTTPSettingsEditor';
+
+describe('HTTPSettingsEditor - Request Headers', () => {
+  const initialSpecDirect: HTTPDatasourceSpec = {
+    directUrl: '',
+  };
+
+  const initialSpecProxy: HTTPDatasourceSpec = {
+    proxy: {
+      kind: 'HTTPProxy',
+      spec: {
+        url: '',
+      },
+    },
+  };
+
+  const renderComponent = (value: HTTPDatasourceSpec, onChange = jest.fn()): ReturnType<typeof render> => {
+    const Wrapper = (): ReactElement => {
+      const methods = useForm();
+      return (
+        <FormProvider {...methods}>
+          <HTTPSettingsEditor
+            value={value}
+            onChange={onChange}
+            initialSpecDirect={initialSpecDirect}
+            initialSpecProxy={initialSpecProxy}
+          />
+        </FormProvider>
+      );
+    };
+    return render(<Wrapper />);
+  };
+
+  describe('Adding headers', () => {
+    it('should add a new empty header when clicking the add button', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      const addButtons = screen.getAllByRole('button').filter((btn) => btn.querySelector('svg'));
+      const addButton = addButtons[addButtons.length - 1];
+      expect(addButton).toBeInTheDocument();
+
+      await userEvent.click(addButton!);
+
+      // Should show header name and value fields
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Header name/i)).toBeInTheDocument();
+        expect(screen.getByLabelText(/Header value/i)).toBeInTheDocument();
+      });
+    });
+
+    it('should allow adding multiple headers', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      const addButtons = screen.getAllByRole('button').filter((btn) => btn.querySelector('svg'));
+      const addButton = addButtons[addButtons.length - 1];
+      expect(addButton).toBeDefined();
+
+      // Add first header
+      await userEvent.click(addButton!);
+      await waitFor(() => {
+        expect(screen.getAllByLabelText(/Header name/i)).toHaveLength(1);
+      });
+
+      // Add second header
+      await userEvent.click(addButton!);
+      await waitFor(() => {
+        expect(screen.getAllByLabelText(/Header name/i)).toHaveLength(2);
+      });
+    });
+  });
+
+  describe('Editing headers', () => {
+    it('should update header name', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      const nameInput = screen.getByLabelText(/Header name/i);
+      await userEvent.clear(nameInput);
+      await userEvent.type(nameInput, 'Authorization');
+
+      await waitFor(() => {
+        expect(onChange).toHaveBeenCalled();
+        const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
+        expect(lastCall.proxy?.spec.headers).toHaveProperty('Authorization');
+      });
+    });
+
+    it('should update header value', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      const valueInput = screen.getByLabelText(/Header value/i);
+      await userEvent.clear(valueInput);
+      await userEvent.type(valueInput, 'Bearer token123');
+
+      await waitFor(() => {
+        expect(onChange).toHaveBeenCalled();
+        const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
+        expect(lastCall.proxy?.spec.headers?.['X-Custom']).toBe('Bearer token123');
+      });
+    });
+
+    it('should initialize with existing headers', () => {
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom-1': 'value1',
+              'X-Custom-2': 'value2',
+            },
+          },
+        },
+      };
+
+      renderComponent(value);
+
+      const nameInputs = screen.getAllByLabelText(/Header name/i);
+      const valueInputs = screen.getAllByLabelText(/Header value/i);
+
+      expect(nameInputs).toHaveLength(2);
+      expect(valueInputs).toHaveLength(2);
+    });
+  });
+
+  describe('Removing headers', () => {
+    it('should remove a header when clicking the remove button', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      // Wait for the component to render and find the remove button by its aria-label
+      const removeButton = await screen.findByLabelText('Remove header X-Custom');
+      expect(removeButton).toBeInTheDocument();
+
+      await userEvent.click(removeButton);
+
+      // Give React time to process the state update and trigger onChange
+      await waitFor(
+        () => {
+          expect(onChange).toHaveBeenCalled();
+        },
+        { timeout: 2000 }
+      );
+
+      const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
+      expect(lastCall.proxy?.spec.headers).toBeUndefined();
+    });
+
+    it('should sync removed headers to parent immediately', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              Authorization: 'Bearer token',
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      // Find and click the remove button for X-Custom header
+      const removeButton = await screen.findByLabelText('Remove header X-Custom');
+      await userEvent.click(removeButton);
+
+      // onChange should be called with only Authorization header remaining
+      await waitFor(
+        () => {
+          expect(onChange).toHaveBeenCalled();
+          const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
+          expect(lastCall.proxy?.spec.headers).toEqual({ Authorization: 'Bearer token' });
+          expect(lastCall.proxy?.spec.headers).not.toHaveProperty('X-Custom');
+        },
+        { timeout: 2000 }
+      );
+    });
+  });
+
+  describe('Duplicate header detection', () => {
+    it('should show warning when duplicate header names exist', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      // Add a second header
+      const addButtons = screen.getAllByRole('button').filter((btn) => btn.querySelector('svg'));
+      const addButton = addButtons[addButtons.length - 1]!;
+      await userEvent.click(addButton);
+
+      // Type the same header name
+      const nameInputs = screen.getAllByLabelText(/Header name/i);
+      await userEvent.type(nameInputs[1]!, 'X-Custom');
+
+      // Should show duplicate warning
+      await waitFor(() => {
+        expect(screen.getByText(/Duplicate header names detected/i)).toBeInTheDocument();
+      });
+    });
+
+    it('should highlight duplicate header names with error state', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      // Add a second header
+      const addButtons = screen.getAllByRole('button').filter((btn) => btn.querySelector('svg'));
+      const addButton = addButtons[addButtons.length - 1];
+      expect(addButton).toBeDefined();
+      await userEvent.click(addButton!);
+
+      // Type the same header name
+      const nameInputs = screen.getAllByLabelText(/Header name/i);
+      expect(nameInputs[1]).toBeDefined();
+      await userEvent.type(nameInputs[1]!, 'X-Custom');
+
+      // Both inputs with duplicate names should have error state
+      await waitFor(() => {
+        const nameInputsAfter = screen.getAllByLabelText(/Header name/i);
+        // Check if any of the inputs has error styling
+        const hasError = nameInputsAfter.some((input) => {
+          const container = input.closest('.MuiFormControl-root');
+          return (
+            container?.classList.contains('Mui-error') ||
+            input.getAttribute('aria-invalid') === 'true' ||
+            container?.querySelector('.Mui-error') !== null
+          );
+        });
+        expect(hasError).toBe(true);
+      });
+    });
+
+    it('should preserve order when header names start with numbers', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'Z-Header': 'last',
+            },
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      // Add a header starting with a number
+      const addButtons = screen.getAllByRole('button').filter((btn) => btn.querySelector('svg'));
+      const addButton = addButtons[addButtons.length - 1];
+      expect(addButton).toBeDefined();
+      await userEvent.click(addButton!);
+
+      const nameInputs = screen.getAllByLabelText(/Header name/i);
+      expect(nameInputs[1]).toBeDefined();
+      await userEvent.type(nameInputs[1]!, '1-First-Header');
+
+      // Headers should maintain insertion order, not be reordered
+      await waitFor(() => {
+        const currentNameInputs = screen.getAllByLabelText(/Header name/i);
+        expect((currentNameInputs[0] as HTMLInputElement).value).toBe('Z-Header');
+        expect((currentNameInputs[1] as HTMLInputElement).value).toBe('1-First-Header');
+      });
+    });
+  });
+
+  describe('Empty headers', () => {
+    it('should show "None" when no headers exist', () => {
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+          },
+        },
+      };
+
+      renderComponent(value);
+
+      // "None" appears in both Allowed endpoints and Request Headers sections
+      const noneElements = screen.getAllByText(/None/i);
+      expect(noneElements.length).toBeGreaterThan(0);
+      expect(noneElements[noneElements.length - 1]).toBeInTheDocument();
+    });
+
+    it('should not sync empty headers to parent', async () => {
+      const onChange = jest.fn();
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+          },
+        },
+      };
+
+      renderComponent(value, onChange);
+
+      // Add an empty header
+      const addButtons = screen.getAllByRole('button').filter((btn) => btn.querySelector('svg'));
+      const addButton = addButtons[addButtons.length - 1];
+      expect(addButton).toBeDefined();
+      await userEvent.click(addButton!);
+
+      // Adding empty header should not trigger onChange
+      expect(onChange).not.toHaveBeenCalled();
+
+      // Verify the header inputs are present
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Header name/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('Readonly mode', () => {
+    it('should disable inputs in readonly mode', () => {
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      const Wrapper = (): ReactElement => {
+        const methods = useForm();
+        return (
+          <FormProvider {...methods}>
+            <HTTPSettingsEditor
+              value={value}
+              onChange={jest.fn()}
+              isReadonly={true}
+              initialSpecDirect={initialSpecDirect}
+              initialSpecProxy={initialSpecProxy}
+            />
+          </FormProvider>
+        );
+      };
+      render(<Wrapper />);
+
+      const nameInput = screen.getByLabelText(/Header name/i);
+      const valueInput = screen.getByLabelText(/Header value/i);
+
+      expect(nameInput).toHaveAttribute('readonly');
+      expect(valueInput).toHaveAttribute('readonly');
+    });
+
+    it('should disable add/remove buttons in readonly mode', () => {
+      const value: HTTPDatasourceSpec = {
+        proxy: {
+          kind: 'HTTPProxy',
+          spec: {
+            url: 'http://localhost:9090',
+            headers: {
+              'X-Custom': 'value1',
+            },
+          },
+        },
+      };
+
+      const Wrapper = (): ReactElement => {
+        const methods = useForm();
+        return (
+          <FormProvider {...methods}>
+            <HTTPSettingsEditor
+              value={value}
+              onChange={jest.fn()}
+              isReadonly={true}
+              initialSpecDirect={initialSpecDirect}
+              initialSpecProxy={initialSpecProxy}
+            />
+          </FormProvider>
+        );
+      };
+      render(<Wrapper />);
+
+      const buttons = screen.getAllByRole('button');
+      const iconButtons = buttons.filter((btn) => btn.querySelector('svg'));
+
+      iconButtons.forEach((btn) => {
+        expect(btn).toBeDisabled();
+      });
+    });
+  });
+});
diff --git a/ui/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx b/ui/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx
index e92cdd27..e92e760e 100644
--- a/ui/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx
+++ b/ui/plugin-system/src/components/HTTPSettingsEditor/HTTPSettingsEditor.tsx
@@ -15,11 +15,20 @@ import { RequestHeaders, HTTPDatasourceSpec } from '@perses-dev/core';
 import { Grid, IconButton, MenuItem, TextField, Typography } from '@mui/material';
 import React, { Fragment, ReactElement, useState } from 'react';
 import { produce } from 'immer';
-import { Controller } from 'react-hook-form';
+import { Controller, useForm, useFieldArray } from 'react-hook-form';
 import MinusIcon from 'mdi-material-ui/Minus';
 import PlusIcon from 'mdi-material-ui/Plus';
 import { OptionsEditorRadios } from '../OptionsEditorRadios';
 
+type HeaderEntry = {
+  name: string;
+  value: string;
+};
+
+type HeaderFormValues = {
+  headers: HeaderEntry[];
+};
+
 export interface HTTPSettingsEditor {
   value: HTTPDatasourceSpec;
   onChange: (next: HTTPDatasourceSpec) => void;
@@ -38,25 +47,57 @@ export function HTTPSettingsEditor(props: HTTPSettingsEditor): ReactElement {
     Object.assign(value, initialSpecProxy);
   }
 
-  // utilitary function used for headers when renaming a property
-  // -> TODO it would be cleaner to manipulate headers as an intermediary list instead, to avoid doing this.
-  const buildNewHeaders = (
-    oldHeaders: RequestHeaders | undefined,
-    oldName: string,
-    newName: string
-  ): RequestHeaders | undefined => {
-    if (oldHeaders === undefined) return oldHeaders;
-    const keys = Object.keys(oldHeaders);
-    const newHeaders = keys.reduce<Record<string, string>>((acc, val) => {
-      if (val === oldName) {
-        acc[newName] = oldHeaders[oldName] || '';
-      } else {
-        acc[val] = oldHeaders[val] || '';
+  // Use local state to maintain an array of header entries during editing, instead of
+  // manipulating a map directly which causes weird UX.
+  const headersForm = useForm<HeaderFormValues>({
+    defaultValues: {
+      headers: Object.entries(value.proxy?.spec.headers ?? {}).map(([name, headerValue]) => ({
+        name,
+        value: headerValue as string,
+      })),
+    },
+  });
+
+  const { fields, append, remove } = useFieldArray({
+    control: headersForm.control,
+    name: 'headers',
+  });
+
+  // Watch the headers array for changes to detect duplicates
+  const watchedHeaders = headersForm.watch('headers');
+
+  // Check for duplicate header names
+  // TODO: duplication detection logic to be replaced by proper zod schema validation in the future
+  // ref https://github.com/perses/perses/issues/3014
+  const nameMap = new Map<string, number>();
+  const duplicateNames = new Set<string>();
+  watchedHeaders.forEach(({ name }) => {
+    if (name !== '') {
+      const count = (nameMap.get(name) || 0) + 1;
+      nameMap.set(name, count);
+      if (count > 1) {
+        duplicateNames.add(name);
       }
-      return acc;
-    }, {});
+    }
+  });
+  const hasDuplicates = duplicateNames.size > 0;
 
-    return { ...newHeaders };
+  // Sync headers to parent
+  const syncHeadersToParent = (headers: HeaderEntry[]): void => {
+    const headersObject: RequestHeaders = {};
+    headers.forEach(({ name, value: headerValue }) => {
+      if (name !== '') {
+        headersObject[name] = headerValue;
+      }
+    });
+
+    onChange(
+      produce(value, (draft) => {
+        if (draft.proxy !== undefined) {
+          draft.proxy.spec.headers = Object.keys(headersObject).length > 0 ? headersObject : undefined;
+        }
+      })
+    );
   };
 
   const tabs = [
@@ -248,118 +289,88 @@ export function HTTPSettingsEditor(props: HTTPSettingsEditor): ReactElement {
             Request Headers
           </Typography>
           <Grid container spacing={2} mb={2}>
-            {value.proxy?.spec.headers &&
-              Object.keys(value.proxy.spec.headers).map((headerName, i) => {
-                return (
-                  <Fragment key={i}>
-                    <Grid item xs={4}>
-                      <Controller
-                        name={`Header name ${i}`}
-                        render={({ field, fieldState }) => (
-                          <TextField
-                            {...field}
-                            fullWidth
-                            label="Header name"
-                            value={headerName}
-                            error={!!fieldState.error}
-                            helperText={fieldState.error?.message}
-                            InputProps={{
-                              readOnly: isReadonly,
-                            }}
-                            InputLabelProps={{ shrink: isReadonly ? true : undefined }}
-                            onChange={(e) => {
-                              field.onChange(e);
-                              onChange(
-                                produce(value, (draft) => {
-                                  if (draft.proxy !== undefined) {
-                                    draft.proxy.spec.headers = buildNewHeaders(
-                                      draft.proxy.spec.headers,
-                                      headerName,
-                                      e.target.value
-                                    );
-                                  }
-                                })
-                              );
-                            }}
-                          />
-                        )}
-                      />
-                    </Grid>
-                    <Grid item xs={7}>
-                      <Controller
-                        name={`Header value ${i}`}
-                        render={({ field, fieldState }) => (
-                          <TextField
-                            {...field}
-                            fullWidth
-                            label="Header value"
-                            value={value.proxy?.spec.headers?.[headerName]}
-                            error={!!fieldState.error}
-                            helperText={fieldState.error?.message}
-                            InputProps={{
-                              readOnly: isReadonly,
-                            }}
-                            InputLabelProps={{ shrink: isReadonly ? true : undefined }}
-                            onChange={(e) => {
-                              field.onChange(e);
-                              onChange(
-                                produce(value, (draft) => {
-                                  if (draft.proxy !== undefined) {
-                                    draft.proxy.spec.headers = {
-                                      ...draft.proxy.spec.headers,
-                                      [headerName]: e.target.value,
-                                    };
-                                  }
-                                })
-                              );
-                            }}
-                          />
-                        )}
-                      />
-                    </Grid>
-                    <Grid item xs={1}>
-                      <Controller
-                        name={`Remove Header ${i}`}
-                        render={({ field }) => (
-                          <IconButton
-                            {...field}
-                            disabled={isReadonly}
-                            // Remove the given header from the list
-                            onClick={(e) => {
-                              field.onChange(e);
-                              const newHeaders = { ...value.proxy?.spec.headers };
-                              delete newHeaders[headerName];
-                              onChange(
-                                produce(value, (draft) => {
-                                  if (draft.proxy !== undefined) {
-                                    draft.proxy.spec.headers = newHeaders;
-                                  }
-                                })
-                              );
-                            }}
-                          >
-                            <MinusIcon />
-                          </IconButton>
-                        )}
-                      />
-                    </Grid>
-                  </Fragment>
-                );
-              })}
+            {fields.length > 0 ? (
+              fields.map((field, index) => (
+                <Fragment key={field.id}>
+                  <Grid item xs={4}>
+                    <Controller
+                      control={headersForm.control}
+                      name={`headers.${index}.name`}
+                      render={({ field: controllerField, fieldState }) => (
+                        <TextField
+                          {...controllerField}
+                          fullWidth
+                          label="Header name"
+                          error={!!fieldState.error || duplicateNames.has(controllerField.value)}
+                          helperText={fieldState.error?.message}
+                          InputProps={{
+                            readOnly: isReadonly,
+                          }}
+                          InputLabelProps={{ shrink: isReadonly ? true : undefined }}
+                          onChange={(e) => {
+                            controllerField.onChange(e);
+                            const updatedHeaders = [...watchedHeaders];
+                            updatedHeaders[index] = { name: e.target.value, value: updatedHeaders[index]?.value ?? '' };
+                            syncHeadersToParent(updatedHeaders);
+                          }}
+                        />
+                      )}
+                    />
+                  </Grid>
+                  <Grid item xs={7}>
+                    <Controller
+                      control={headersForm.control}
+                      name={`headers.${index}.value`}
+                      render={({ field: controllerField, fieldState }) => (
+                        <TextField
+                          {...controllerField}
+                          fullWidth
+                          label="Header value"
+                          error={!!fieldState.error}
+                          helperText={fieldState.error?.message}
+                          InputProps={{
+                            readOnly: isReadonly,
+                          }}
+                          InputLabelProps={{ shrink: isReadonly ? true : undefined }}
+                          onChange={(e) => {
+                            controllerField.onChange(e);
+                            const updatedHeaders = [...watchedHeaders];
+                            updatedHeaders[index] = { name: updatedHeaders[index]?.name ?? '', value: e.target.value };
+                            syncHeadersToParent(updatedHeaders);
+                          }}
+                        />
+                      )}
+                    />
+                  </Grid>
+                  <Grid item xs={1}>
+                    <IconButton
+                      disabled={isReadonly}
+                      aria-label={`Remove header ${watchedHeaders[index]?.name || index}`}
+                      onClick={() => {
+                        remove(index);
+                        const updatedHeaders = watchedHeaders.filter((_, i) => i !== index);
+                        syncHeadersToParent(updatedHeaders);
+                      }}
+                    >
+                      <MinusIcon />
+                    </IconButton>
+                  </Grid>
+                </Fragment>
+              ))
+            ) : (
+              <Grid item xs={4}>
+                <Typography sx={{ fontStyle: 'italic' }}>None</Typography>
+              </Grid>
+            )}
+            {hasDuplicates && (
+              <Grid item xs={12}>
+                <Typography variant="body2" color="error">
+                  Duplicate header names detected. Each header name must be unique.
+                </Typography>
+              </Grid>
+            )}
             <Grid item xs={12} sx={{ paddingTop: '0px !important', paddingLeft: '5px !important' }}>
-              <IconButton
-                disabled={isReadonly}
-                // Add a new (empty) header to the list
-                onClick={() =>
-                  onChange(
-                    produce(value, (draft) => {
-                      if (draft.proxy !== undefined) {
-                        draft.proxy.spec.headers = { ...draft.proxy.spec.headers, '': '' };
-                      }
-                    })
-                  )
-                }
-              >
+              <IconButton disabled={isReadonly} onClick={() => append({ name: '', value: '' })}>
                 <PlusIcon />
               </IconButton>
             </Grid>



Test patch:
diff --git a/cue-test/common/format.cue b/cue-test/common/format.cue
new file mode 100644
index 00000000..2bde374f
--- /dev/null
+++ b/cue-test/common/format.cue
@@ -0,0 +1,19 @@
+// Copyright 2025 The Perses Authors
+// Licensed 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 common
+
+myFormat: #format & {
+    decimalPlaces: 0
+    shortValues:   false
+}
\ No newline at end of file
diff --git a/cue-test/common/transform.cue b/cue-test/common/transform.cue
index 3205ee6f..28e7c7a0 100644
--- a/cue-test/common/transform.cue
+++ b/cue-test/common/transform.cue
@@ -13,14 +13,16 @@
 
 package common
 
-#joinByColumnValueTransform: spec: columns: ["job", "instance"]
+myJoinByColumnValueTransform: #joinByColumnValueTransform & { spec: columns: ["job", "instance"] }
 
-#mergeColumnsTransform: spec: {
-	columns: ["job", "instance"]
-	name:     "job-instance"
-	disabled: true
+myMergeColumnsTransform: #mergeColumnsTransform & {
+	spec: {
+		columns: ["job", "instance"]
+		name:     "job-instance"
+		disabled: true
+	}
 }
 
-#mergeIndexedColumnsTransform: spec: column: "instance"
+myMergeIndexedColumnsTransform: #mergeIndexedColumnsTransform & { spec: column: "instance" }
 
-#mergeSeries: spec: disabled: false
\ No newline at end of file
+mergeSeries: #mergeSeries & { spec: disabled: false }
\ No newline at end of file
diff --git a/cue-test/common/url.cue b/cue-test/common/url.cue
index 0b93ffb5..a63c9d1c 100644
--- a/cue-test/common/url.cue
+++ b/cue-test/common/url.cue
@@ -13,5 +13,5 @@
 
 package common
 
-test1: #url && "http://localhost:9090"
-test2: #url && "https://localhost:9090"
+test1: #url & "http://localhost:9090"
+test2: #url & "https://localhost:9090"
diff --git a/scripts/changelog/changelog_test.go b/scripts/pkg/changelog/changelog_test.go
similarity index 100%
rename from scripts/changelog/changelog_test.go
rename to scripts/pkg/changelog/changelog_test.go
diff --git a/scripts/pkg/npm/npm_test.go b/scripts/pkg/npm/npm_test.go
new file mode 100644
index 00000000..befd0250
--- /dev/null
+++ b/scripts/pkg/npm/npm_test.go
@@ -0,0 +1,48 @@
+// Copyright 2025 The Perses Authors
+// Licensed 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 npm
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetPackage(t *testing.T) {
+	expectedPkg := Package{
+		Name:    "perses-test",
+		Version: "0.1.0",
+		Workspaces: []string{
+			"foo",
+			"bar",
+			"octopus",
+		},
+	}
+	pkg, err := GetPackage(".")
+	assert.NoError(t, err)
+	assert.Equal(t, expectedPkg, pkg)
+}
+
+func TestGetWorkspaces(t *testing.T) {
+	workspaces, err := GetWorkspaces(".")
+	assert.NoError(t, err)
+	expectedWorkspaces := []string{"foo", "bar", "octopus"}
+	assert.Equal(t, expectedWorkspaces, workspaces)
+}
+
+func TestGetVersion(t *testing.T) {
+	version, err := GetVersion(".")
+	assert.NoError(t, err)
+	assert.Equal(t, "0.1.0", version)
+}