Compare commits

...

3 Commits

Author SHA1 Message Date
Gaurav Tewari
59a161a45b fix: smooth Only/Toggle hover reveal in quick-filters checkbox
The Only and Toggle buttons were revealed by overlapping hover triggers
(`.value` for Toggle, `.checkbox-value-section` for Only), so hovering the
label matched both rules and showed the two buttons side by side, shifting
the row layout — the visible hover "jerk".

- Scope the Toggle trigger to the checkbox (`.check-box:hover ~ ...`) so it
  no longer overlaps the label region; Only and Toggle are now mutually
  exclusive by cursor position.
- Stack both buttons in a single grid cell (`.value-actions`) so revealing
  one swaps in place instead of shifting the row.
- Add a fade + slide entry/exit animation (opacity + translateX with
  `@starting-style` and `display` `allow-discrete`), disabled under
  prefers-reduced-motion.
2026-07-23 12:21:14 +05:30
Swapnil Nakade
8c6a0de8c3 refactor: gcp integration memorystore redis dashboard update (#12229)
Some checks failed
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
2026-07-22 11:42:12 +00:00
Naman Verma
c7abe0fe7b fix: add rank column to tag relations to keep note of tag ordering in dashboards (#12170)
* fix: always sort dashboard tags in alphabetical order

* fix: add rank column to tag relations to keep note of tag ordering in dashboards

* fix: dont disable FK just for adding a new column

* fix: add back tag relation unique index
2026-07-22 11:34:25 +00:00
11 changed files with 288 additions and 69 deletions

View File

@@ -92,52 +92,76 @@
color: var(--l3-foreground);
}
.only-btn {
cursor: not-allowed;
color: var(--l3-foreground);
}
.only-btn,
.toggle-btn {
cursor: not-allowed;
color: var(--l3-foreground);
}
}
.only-btn {
display: none;
// Stack "Only"/"All" and "Toggle" in a single cell so revealing
// one swaps in place instead of shifting the row layout.
.value-actions {
display: grid;
align-items: center;
justify-items: end;
> * {
grid-area: 1 / 1;
}
}
.only-btn,
.toggle-btn {
display: none;
}
.toggle-btn:hover {
background-color: unset;
}
.only-btn:hover {
background-color: unset;
}
}
.checkbox-value-section:hover {
.toggle-btn {
display: none;
}
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
opacity: 0;
transform: translateX(4px);
// `display` is animated as a discrete property so the button
// stays mounted through its fade-out on exit.
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
&:hover {
background-color: unset;
}
}
}
// Hovering the label/value area reveals the "Only"/"All" action.
.checkbox-value-section:hover .only-btn {
display: flex;
opacity: 1;
transform: translateX(0);
// Entry animation start state (fires on the display switch).
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
// Hovering the checkbox reveals the "Toggle" action.
.check-box:hover ~ .checkbox-value-section .toggle-btn {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
}
.value:hover {
@media (prefers-reduced-motion: reduce) {
.only-btn,
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
transition: none;
}
}
}

View File

@@ -53,12 +53,14 @@ function CheckboxValueRow({
</Typography.Text>
</TooltipSimple>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
<div className="value-actions">
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
</div>
);

View File

@@ -238,7 +238,7 @@
}
},
"tags": [],
"title": "Signoz GCP Memorystory Redis overview",
"title": "Signoz GCP Memorystore Redis overview",
"uploadedGrafana": false,
"uuid": "019f85e9-3846-7dc9-9bce-b626b8a3eaff",
"variables": {
@@ -262,7 +262,7 @@
"type": "DYNAMIC"
},
"dabcf41a-4121-4a03-8738-927576cc90bf": {
"allSelected": true,
"allSelected": false,
"customValue": "",
"defaultValue": "",
"description": "GCP Project ID",
@@ -390,7 +390,7 @@
"disabled": false,
"legend": "",
"name": "A",
"query": "rate({\"redis.googleapis.com/server/uptime\", project_id=\"$project_id\"}[5m])"
"query": ""
}
],
"queryType": "builder",

View File

@@ -45,17 +45,17 @@ func (m *module) createMany(ctx context.Context, orgID valuer.UUID, kind coretyp
return []*tagtypes.Tag{}, nil
}
toCreate, matched, err := m.resolve(ctx, orgID, kind, postable)
ordered, toCreate, err := m.resolve(ctx, orgID, kind, postable)
if err != nil {
return nil, err
}
created, err := m.store.CreateOrGet(ctx, toCreate)
if err != nil {
// CreateOrGet fills new rows' IDs in place; ordered shares those pointers.
if _, err := m.store.CreateOrGet(ctx, toCreate); err != nil {
return nil, err
}
return append(matched, created...), nil
return ordered, nil
}
func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) error {

View File

@@ -13,10 +13,9 @@ import (
// the existing tags for an org. Lookup is case-insensitive on both key and
// value (matching the storage uniqueness rule); when an existing row matches,
// its display casing is reused. Inputs are deduped on (LOWER(key), LOWER(value));
// the first input's casing wins on collisions. Returns:
// - toCreate: new Tag rows the caller should insert (with pre-generated IDs)
// - matched: existing rows the caller's input already pointed to. They
// already carry authoritative IDs from the store.
// the first input's casing wins on collisions. Returns the resolved tags in
// request order (deduped) plus the new subset to insert; the new tags share
// pointers with the ordered slice, so their IDs populate in place on insert.
func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, []*tagtypes.Tag, error) {
if len(postable) == 0 {
return nil, nil, nil
@@ -34,8 +33,8 @@ func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.
}
seenInRequestAlready := make(map[string]struct{}, len(postable)) // postable can have the same tag multiple times
ordered := make([]*tagtypes.Tag, 0, len(postable))
toCreate := make([]*tagtypes.Tag, 0)
matched := make([]*tagtypes.Tag, 0)
for _, p := range postable {
key, value, err := tagtypes.ValidatePostableTag(p)
@@ -49,11 +48,13 @@ func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.
seenInRequestAlready[lookup] = struct{}{}
if existingTag, ok := lowercaseTagsMap[lookup]; ok {
matched = append(matched, existingTag)
ordered = append(ordered, existingTag)
continue
}
toCreate = append(toCreate, tagtypes.NewTag(orgID, kind, key, value))
newTag := tagtypes.NewTag(orgID, kind, key, value)
ordered = append(ordered, newTag)
toCreate = append(toCreate, newTag)
}
return toCreate, matched, nil
return ordered, toCreate, nil
}

View File

@@ -20,14 +20,14 @@ func TestModule_Resolve(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
ordered, toCreate, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
require.NoError(t, err)
assert.Empty(t, ordered)
assert.Empty(t, toCreate)
assert.Empty(t, matched)
assert.Zero(t, store.ListCallCount, "should not hit store when input is empty")
})
t.Run("creates missing pairs and reuses existing", func(t *testing.T) {
t.Run("creates missing pairs and reuses existing, in request order", func(t *testing.T) {
orgID := valuer.GenerateUUID()
dbTag := tagtypes.NewTag(orgID, testKind, "team", "Pulse")
dbTag2 := tagtypes.NewTag(orgID, testKind, "Database", "redis")
@@ -35,22 +35,28 @@ func TestModule_Resolve(t *testing.T) {
store.Tags = []*tagtypes.Tag{dbTag, dbTag2}
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "events"}, // new
{Key: "DATABASE", Value: "REDIS"}, // case-only conflict
{Key: "Brand", Value: "New"}, // new
})
require.NoError(t, err)
// ordered mirrors the request: new, existing (reused pointer), new.
require.Len(t, ordered, 3)
assert.Equal(t, "team", ordered[0].Key)
assert.Equal(t, "events", ordered[0].Value)
assert.Same(t, dbTag2, ordered[1], "case-only conflict reuses the existing pointer with its authoritative ID")
assert.Equal(t, "Brand", ordered[2].Key)
assert.Equal(t, "New", ordered[2].Value)
createdLowerKVs := []string{}
for _, tg := range toCreate {
createdLowerKVs = append(createdLowerKVs, strings.ToLower(tg.Key)+"\x00"+strings.ToLower(tg.Value))
}
assert.ElementsMatch(t, []string{"team\x00events", "brand\x00new"}, createdLowerKVs,
"only the two missing pairs should be returned for insertion")
require.Len(t, matched, 1, "DATABASE:REDIS should hit the existing 'Database:redis' tag")
assert.Same(t, dbTag2, matched[0], "matched should return the existing pointer with its authoritative ID")
assert.Same(t, ordered[0], toCreate[0], "toCreate shares pointers with ordered so inserted IDs propagate")
})
t.Run("dedupes inputs that map to the same lower(key)+lower(value)", func(t *testing.T) {
@@ -58,14 +64,14 @@ func TestModule_Resolve(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "Foo", Value: "Bar"},
{Key: "foo", Value: "bar"},
{Key: "FOO", Value: "BAR"},
})
require.NoError(t, err)
require.Empty(t, matched)
require.Len(t, ordered, 1, "duplicate inputs must collapse into a single tag")
require.Len(t, toCreate, 1, "duplicate inputs must collapse into a single insert")
assert.Equal(t, "Foo", toCreate[0].Key, "first input's casing wins")
assert.Equal(t, "Bar", toCreate[0].Value, "first input's casing wins")
@@ -78,15 +84,15 @@ func TestModule_Resolve(t *testing.T) {
store.Tags = []*tagtypes.Tag{dbTag}
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "PULSE"},
})
require.NoError(t, err)
assert.Empty(t, toCreate)
require.Len(t, matched, 1)
assert.Equal(t, "Team", matched[0].Key)
assert.Equal(t, "Pulse", matched[0].Value)
require.Len(t, ordered, 1)
assert.Equal(t, "Team", ordered[0].Key)
assert.Equal(t, "Pulse", ordered[0].Value)
})
t.Run("propagates validation error from any input", func(t *testing.T) {

View File

@@ -44,6 +44,7 @@ func (s *store) ListByResource(ctx context.Context, orgID valuer.UUID, kind core
Where("tr.kind = ?", kind).
Where("tr.resource_id = ?", resourceID).
Where("tag.org_id = ?", orgID).
OrderExpr("tr.rank ASC").
Scan(ctx)
if err != nil {
return nil, err
@@ -71,6 +72,7 @@ func (s *store) ListByResources(ctx context.Context, orgID valuer.UUID, kind cor
Where("tr.kind = ?", kind).
Where("tr.resource_id IN (?)", bun.In(resourceIDs)).
Where("tag.org_id = ?", orgID).
OrderExpr("tr.rank ASC").
Scan(ctx)
if err != nil {
return nil, err
@@ -112,11 +114,14 @@ func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRe
if len(relations) == 0 {
return nil
}
// On re-link (same tag, same resource) overwrite rank with the incoming
// position, so a reordered tag set persists its new order.
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&relations).
On("CONFLICT (kind, resource_id, tag_id) DO NOTHING").
On("CONFLICT (kind, resource_id, tag_id) DO UPDATE").
Set("rank = EXCLUDED.rank").
Exec(ctx)
return err
}

View File

@@ -221,6 +221,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,72 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTagRelationRank struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagRelationRankFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tag_relation_rank"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTagRelationRank{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTagRelationRank) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTagRelationRank) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("tag_relation"))
if err != nil {
return err
}
rankColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("rank"),
DataType: sqlschema.DataTypeInteger,
Nullable: false,
}
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, rankColumn, 0)
// AddColumn recreates the table for a NOT NULL column on SQLite, which drops
// standalone indices. GetTable only reports inline table constraints, so
// restore the standalone unique index from migration 082.
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: sqlschema.TableName("tag_relation"),
ColumnNames: []sqlschema.ColumnName{"kind", "resource_id", "tag_id"},
})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTagRelationRank) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -16,23 +16,27 @@ type TagRelation struct {
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
ResourceID valuer.UUID `json:"resourceId" required:"true" bun:"resource_id,type:text,notnull"`
TagID valuer.UUID `json:"tagId" required:"true" bun:"tag_id,type:text,notnull"`
CreatedAt time.Time `json:"createdAt" bun:"created_at,notnull"`
// Rank is the tag's position within its resource; reads order by it.
Rank int `json:"rank" bun:"rank,notnull"`
CreatedAt time.Time `json:"createdAt" bun:"created_at,notnull"`
}
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID) *TagRelation {
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID, rank int) *TagRelation {
return &TagRelation{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Kind: kind,
ResourceID: resourceID,
TagID: tagID,
Rank: rank,
CreatedAt: time.Now(),
}
}
// NewTagRelations ranks each tag by its position so reads preserve order.
func NewTagRelations(kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) []*TagRelation {
relations := make([]*TagRelation, 0, len(tagIDs))
for _, tagID := range tagIDs {
relations = append(relations, NewTagRelation(kind, resourceID, tagID))
for rank, tagID := range tagIDs {
relations = append(relations, NewTagRelation(kind, resourceID, tagID, rank))
}
return relations
}

View File

@@ -570,7 +570,11 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert alpha["schemaVersion"] == "v6"
assert alpha["source"] == "user"
assert alpha["locked"] is False
assert {"key": "team", "value": "pulse"} in alpha["tags"]
# tags round-trip in the order sent — no reordering, no drift
assert alpha["tags"] == [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
]
# ── stage 3: list everything ─────────────────────────────────────────────
response = requests.get(
@@ -590,6 +594,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Epsilon Metrics",
"Zeta Overview",
}
# per-dashboard tags also round-trip in the order sent
delta = next(d for d in body["data"]["dashboards"] if d["spec"]["display"]["name"] == "Delta Storage")
assert delta["tags"] == [
{"key": "team", "value": "storage"},
{"key": "env", "value": "dev"},
{"key": "tier", "value": "critical"},
]
# top-level tags = org-wide distinct tag set, sorted case-insensitively
# by (key, value). Asserting the exact list (not a set) locks in the sort.
assert body["data"]["tags"] == [
@@ -1013,6 +1024,99 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert response.json()["data"]["id"] == clone["id"]
def test_dashboard_v2_tag_order_round_trips(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Tags must round-trip in the order the client sent them across every write
path — create, update, patch — so GitOps/Terraform state never drifts."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def tags_of(response: requests.Response) -> list[dict]:
return response.json()["data"]["tags"]
# ── create with tags in a deliberate order; "env" repeats with two values ─
created_order = [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
{"key": "env", "value": "staging"},
{"key": "tier", "value": "critical"},
]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": created_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
dashboard_id = response.json()["data"]["id"]
assert tags_of(response) == created_order
# ── get echoes the same order ────────────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == created_order
# ── update reordered; the two "env" values land at non-adjacent positions ─
reordered = [
{"key": "tier", "value": "critical"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": reordered},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == reordered
# ── patch appends a new tag (a third "env" value); it lands at the end ────
response = requests.patch(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json=[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "dev"}}],
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == reordered + [{"key": "env", "value": "dev"}]
# ── update reorders everything and adds another new tag → all in the new order ─
# "env" now carries three interleaved values
new_order = [
{"key": "env", "value": "prod"},
{"key": "team", "value": "pulse"},
{"key": "env", "value": "dev"},
{"key": "tier", "value": "critical"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "storage"},
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": new_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == new_order
# ── and the final order persists on a fresh read ─────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == new_order
def test_dashboard_v2_pin_limit(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument