mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-27 22:20:29 +01:00
Compare commits
1 Commits
nv/tags-fo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c9864f52 |
5
frontend/knip.json
Normal file
5
frontend/knip.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignore": ["src/api/generated/**/*.ts"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import GeneralSettings from 'container/GeneralSettings';
|
||||
import { t } from 'i18next';
|
||||
|
||||
export const alertsRoutesConfig = [
|
||||
{
|
||||
Component: GeneralSettings,
|
||||
name: t('routes.general'),
|
||||
route: ROUTES.SETTINGS,
|
||||
key: ROUTES.SETTINGS,
|
||||
},
|
||||
{
|
||||
Component: (): JSX.Element => (
|
||||
<CreateAlertChannels preType={ChannelType.Slack} />
|
||||
),
|
||||
name: t('routes.alert_channels'),
|
||||
route: ROUTES.CHANNELS_NEW,
|
||||
key: ROUTES.CHANNELS_NEW,
|
||||
},
|
||||
];
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import history from 'lib/history';
|
||||
|
||||
import { alertsRoutesConfig } from './config';
|
||||
|
||||
function SettingsPage(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={alertsRoutesConfig}
|
||||
activeKey={pathname}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CircleCheck, Siren } from 'lucide-react';
|
||||
import { getDurationFromNow } from 'utils/timeUtils';
|
||||
|
||||
import { AlertStatusProps, StatusConfig } from './types';
|
||||
|
||||
import './AlertStatus.styles.scss';
|
||||
|
||||
export default function AlertStatus({
|
||||
status,
|
||||
timestamp,
|
||||
}: AlertStatusProps): JSX.Element {
|
||||
const statusConfig: StatusConfig = useMemo(
|
||||
() => ({
|
||||
firing: {
|
||||
icon: <Siren size={14} color={Color.TEXT_VANILLA_400} />,
|
||||
text: 'Firing since',
|
||||
extraInfo: timestamp ? (
|
||||
<>
|
||||
<div>⎯</div>
|
||||
<div className="time">{getDurationFromNow(timestamp)}</div>
|
||||
</>
|
||||
) : null,
|
||||
className: 'alert-status-info--firing',
|
||||
},
|
||||
resolved: {
|
||||
icon: (
|
||||
<CircleCheck
|
||||
size={14}
|
||||
fill={Color.BG_VANILLA_400}
|
||||
color={Color.BG_INK_400}
|
||||
/>
|
||||
),
|
||||
text: 'Resolved',
|
||||
extraInfo: null,
|
||||
className: 'alert-status-info--resolved',
|
||||
},
|
||||
}),
|
||||
[timestamp],
|
||||
);
|
||||
|
||||
const currentStatus = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div className={`alert-status-info ${currentStatus.className}`}>
|
||||
<div className="alert-status-info__icon">{currentStatus.icon}</div>
|
||||
<div className="alert-status-info__details">
|
||||
<div className="text">{currentStatus.text}</div>
|
||||
{currentStatus.extraInfo}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export type AlertStatusProps =
|
||||
| { status: 'firing'; timestamp: number }
|
||||
| { status: 'resolved'; timestamp?: number };
|
||||
|
||||
export type StatusConfig = {
|
||||
firing: {
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
extraInfo: JSX.Element | null;
|
||||
className: string;
|
||||
};
|
||||
resolved: {
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
extraInfo: JSX.Element | null;
|
||||
className: string;
|
||||
};
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
|
||||
export default AlertHistory;
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export const ExceptionsQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
dataSource: DataSource.TRACES,
|
||||
attributeKey: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Service Name',
|
||||
dataSource: DataSource.TRACES,
|
||||
attributeKey: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Hostname',
|
||||
dataSource: DataSource.TRACES,
|
||||
attributeKey: {
|
||||
key: 'host.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'K8s Cluster Name',
|
||||
dataSource: DataSource.TRACES,
|
||||
attributeKey: {
|
||||
key: 'k8s.cluster.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'K8s Deployment Name',
|
||||
dataSource: DataSource.TRACES,
|
||||
attributeKey: {
|
||||
key: 'k8s.deployment.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'K8s Namespace Name',
|
||||
dataSource: DataSource.TRACES,
|
||||
attributeKey: {
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'K8s Pod Name',
|
||||
dataSource: DataSource.TRACES,
|
||||
attributeKey: {
|
||||
key: 'k8s.pod.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: false,
|
||||
},
|
||||
];
|
||||
@@ -1,13 +0,0 @@
|
||||
import BillingContainer from 'container/BillingContainer/BillingContainer';
|
||||
|
||||
import './BillingPage.styles.scss';
|
||||
|
||||
function BillingPage(): JSX.Element {
|
||||
return (
|
||||
<div className="billingPageContainer">
|
||||
<BillingContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillingPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import BillingPage from './BillingPage';
|
||||
|
||||
export default BillingPage;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Title = styled(Typography)`
|
||||
&&& {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ButtonContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`;
|
||||
@@ -1,3 +0,0 @@
|
||||
import HomePage from './HomePage';
|
||||
|
||||
export default HomePage;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Col } from 'antd';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const WrapperStyled = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
color: ${themeColors.lightWhite};
|
||||
`;
|
||||
|
||||
export const ButtonWrapperStyled = styled(Col)`
|
||||
margin-left: auto;
|
||||
`;
|
||||
@@ -1,7 +0,0 @@
|
||||
export const removeSourcePageFromPath = (path: string): string => {
|
||||
const lastSlashIndex = path.lastIndexOf('/');
|
||||
if (lastSlashIndex !== -1) {
|
||||
return path.substring(0, lastSlashIndex);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import MySettingsContainer from 'container/MySettings';
|
||||
|
||||
function MySettings(): JSX.Element {
|
||||
return <MySettingsContainer />;
|
||||
}
|
||||
export default MySettings;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import SomethingWentWrongAsset from 'assets/SomethingWentWrong';
|
||||
import { Container } from 'components/NotFound/styles';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
|
||||
function SomethingWentWrong(): JSX.Element {
|
||||
return (
|
||||
<Container>
|
||||
<SomethingWentWrongAsset />
|
||||
<Typography.Title level={3}>Oops! Something went wrong</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
history.push(ROUTES.HOME);
|
||||
}}
|
||||
className="periscope-btn primary"
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SomethingWentWrong;
|
||||
@@ -1,10 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
margin: 1rem 0;
|
||||
`;
|
||||
|
||||
export const ActionsWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
@@ -1,42 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store tagtypes.Store
|
||||
}
|
||||
|
||||
func NewModule(store tagtypes.Store) tag.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (m *module) CreateMany(ctx context.Context, orgID valuer.UUID, postable []tagtypes.PostableTag, createdBy string) ([]*tagtypes.Tag, error) {
|
||||
if len(postable) == 0 {
|
||||
return []*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
toCreate, matched, err := tagtypes.Resolve(ctx, m.store, orgID, postable, createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := m.store.Create(ctx, toCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(matched, created...), nil
|
||||
}
|
||||
|
||||
func (m *module) LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType valuer.String, entityID valuer.UUID, tagIDs []valuer.UUID) error {
|
||||
if len(tagIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m.store.CreateRelations(ctx, tagtypes.NewTagRelations(orgID, entityType, entityID, tagIDs))
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) tagtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) List(ctx context.Context, orgID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
tags := make([]*tagtypes.Tag, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tags).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) Create(ctx context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
|
||||
if len(tags) == 0 {
|
||||
return tags, nil
|
||||
}
|
||||
// DO UPDATE on a self-set is a deliberate no-op write whose only purpose
|
||||
// is to make RETURNING fire on conflicting rows. Without it, RETURNING is
|
||||
// silent on the conflict path and we'd have to refetch by internal name to
|
||||
// learn the existing rows' IDs after a concurrent-insert race.
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&tags).
|
||||
On("CONFLICT (org_id, internal_name) DO UPDATE").
|
||||
Set("internal_name = EXCLUDED.internal_name").
|
||||
Returning("*").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRelation) error {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&relations).
|
||||
On("CONFLICT (entity_id, tag_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: dbPath,
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*tagtypes.Tag)(nil)).
|
||||
IfNotExists().
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_org_id_internal_name ON tag (org_id, internal_name)`)
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func tagsByInternalName(t *testing.T, db *bun.DB) map[string]*tagtypes.Tag {
|
||||
t.Helper()
|
||||
all := make([]*tagtypes.Tag, 0)
|
||||
require.NoError(t, db.NewSelect().Model(&all).Scan(context.Background()))
|
||||
out := map[string]*tagtypes.Tag{}
|
||||
for _, tag := range all {
|
||||
out[tag.InternalName] = tag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
tagA := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
|
||||
tagB := tagtypes.NewTag(orgID, "team/BLR", "team::blr", "u@signoz.io")
|
||||
preIDA := tagA.ID
|
||||
preIDB := tagB.ID
|
||||
|
||||
got, err := s.Create(ctx, []*tagtypes.Tag{tagA, tagB})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
// No race → pre-generated IDs stand. The slice is what we passed in,
|
||||
// confirming Scan didn't reallocate.
|
||||
assert.Equal(t, preIDA, got[0].ID)
|
||||
assert.Equal(t, preIDB, got[1].ID)
|
||||
|
||||
// And the rows are in the DB.
|
||||
stored := tagsByInternalName(t, sqlstore.BunDB())
|
||||
require.Contains(t, stored, "database")
|
||||
require.Contains(t, stored, "team::blr")
|
||||
assert.Equal(t, preIDA, stored["database"].ID)
|
||||
assert.Equal(t, preIDB, stored["team::blr"].ID)
|
||||
}
|
||||
|
||||
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
// Simulate a concurrent insert: someone else has already inserted "database".
|
||||
winner := tagtypes.NewTag(orgID, "Database", "database", "concurrent")
|
||||
_, err := s.Create(ctx, []*tagtypes.Tag{winner})
|
||||
require.NoError(t, err)
|
||||
winnerID := winner.ID
|
||||
|
||||
// Now our request runs with a different pre-generated ID for the same
|
||||
// internal name. RETURNING should overwrite our stale ID with winner's ID.
|
||||
loser := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
|
||||
loserPreID := loser.ID
|
||||
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
|
||||
|
||||
got, err := s.Create(ctx, []*tagtypes.Tag{loser})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
|
||||
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
|
||||
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
|
||||
|
||||
// And the DB still has exactly one row for that internal name — winner's.
|
||||
stored := tagsByInternalName(t, sqlstore.BunDB())
|
||||
require.Len(t, stored, 1)
|
||||
assert.Equal(t, winnerID, stored["database"].ID)
|
||||
}
|
||||
|
||||
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
pre := tagtypes.NewTag(orgID, "Database", "database", "concurrent")
|
||||
_, err := s.Create(ctx, []*tagtypes.Tag{pre})
|
||||
require.NoError(t, err)
|
||||
preExistingID := pre.ID
|
||||
|
||||
conflict := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
|
||||
fresh := tagtypes.NewTag(orgID, "team/BLR", "team::blr", "u@signoz.io")
|
||||
freshPreID := fresh.ID
|
||||
|
||||
got, err := s.Create(ctx, []*tagtypes.Tag{conflict, fresh})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
|
||||
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// CreateMany resolves user-supplied tag names against the existing tags for the
|
||||
// org — reusing the casing of any existing parent tag so that
|
||||
// "teams/blr/platform" inherits the "BLR" casing from a pre-existing
|
||||
// "teams/BLR" tag — and inserts any tags that don't yet exist.
|
||||
//
|
||||
// Does not link the resolved tags to any entity — call LinkToEntity for that.
|
||||
CreateMany(ctx context.Context, orgID valuer.UUID, postable []tagtypes.PostableTag, createdBy string) ([]*tagtypes.Tag, error)
|
||||
|
||||
// LinkToEntity inserts (entity, tag) rows in tag_relations. Existing rows
|
||||
// are left untouched. Uses the caller's transaction context if any so that
|
||||
// it can be made atomic with the entity row insert.
|
||||
LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType valuer.String, entityID valuer.UUID, tagIDs []valuer.UUID) error
|
||||
}
|
||||
@@ -39,8 +39,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
@@ -81,7 +79,6 @@ type Modules struct {
|
||||
CloudIntegration cloudintegration.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
Tag tag.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -134,6 +131,5 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
Tag: impltag.NewModule(impltag.NewStore(sqlstore)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
|
||||
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
|
||||
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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 addTags struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddTagsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_tags"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addTags{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addTags) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addTags) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
tagTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "tag",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "internal_name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: true},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: true},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tagTableSQLs...)
|
||||
|
||||
tagUniqueIndexSQL := migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "tag",
|
||||
ColumnNames: []sqlschema.ColumnName{"org_id", "internal_name"},
|
||||
})
|
||||
sqls = append(sqls, tagUniqueIndexSQL...)
|
||||
|
||||
tagRelationsTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "tag_relations",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "entity_type", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "entity_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "tag_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"entity_id", "tag_id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tagRelationsTableSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addTags) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID) ([]*Tag, error)
|
||||
|
||||
// Create upserts the given tags and returns them with authoritative IDs.
|
||||
// On conflict on (org_id, internal_name) — which happens only when a
|
||||
// concurrent insert raced ours — the returned entry carries the existing
|
||||
// row's ID rather than the pre-generated one in the input.
|
||||
Create(ctx context.Context, tags []*Tag) ([]*Tag, error)
|
||||
|
||||
// CreateRelations inserts tag-entity relations. Conflicts on the composite primary key are ignored.
|
||||
CreateRelations(ctx context.Context, relations []*TagRelation) error
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const (
|
||||
// separator users type in the display name (e.g. "team/blr").
|
||||
HierarchySeparator = "/"
|
||||
|
||||
// separator used in internal_name. Different from HierarchySeparator
|
||||
// because "/" is reserved by the access control layer.
|
||||
InternalSeparator = "::"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeTagInvalidName = errors.MustNewCode("tag_invalid_name")
|
||||
ErrCodeTagNotFound = errors.MustNewCode("tag_not_found")
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
bun.BaseModel `bun:"table:tag,alias:tag"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
Name string `json:"name" required:"true" bun:"name,type:text,notnull"`
|
||||
InternalName string `json:"internalName" required:"true" bun:"internal_name,type:text,notnull,unique:org_id_internal_name"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" bun:"org_id,type:text,notnull,unique:org_id_internal_name"`
|
||||
}
|
||||
|
||||
type PostableTag struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
}
|
||||
|
||||
func NewTag(orgID valuer.UUID, name string, internalName string, createdBy string) *Tag {
|
||||
now := time.Now()
|
||||
return &Tag{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: createdBy,
|
||||
UpdatedBy: createdBy,
|
||||
},
|
||||
Name: name,
|
||||
InternalName: internalName,
|
||||
OrgID: orgID,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve canonicalizes a batch of user-supplied tag names against the existing
|
||||
// tags for an org. Existing parent tags' casing is reused so that
|
||||
// "teams/blr/platform" inherits the "BLR" casing from a pre-existing
|
||||
// "teams/BLR". Inputs are deduped by internal name. Returns:
|
||||
// - toCreate: new Tag rows the caller should insert (with pre-generated IDs)
|
||||
// - matched: existing rows that the caller's input already pointed to. They
|
||||
// already carry authoritative IDs from the store.
|
||||
func Resolve(ctx context.Context, store Store, orgID valuer.UUID, postable []PostableTag, createdBy string) ([]*Tag, []*Tag, error) {
|
||||
if len(postable) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
existing, err := store.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
internalNameToExistingTag := make(map[string]*Tag, len(existing))
|
||||
for _, t := range existing {
|
||||
internalNameToExistingTag[t.InternalName] = t
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(postable))
|
||||
toCreate := make([]*Tag, 0)
|
||||
matched := make([]*Tag, 0)
|
||||
|
||||
for _, p := range postable {
|
||||
cleanedName, err := cleanupName(p.Name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
matchedName, matchedInternalName := matchCasingWithExistingTags(cleanedName, existing)
|
||||
if _, dup := seen[matchedInternalName]; dup {
|
||||
continue
|
||||
}
|
||||
seen[matchedInternalName] = struct{}{}
|
||||
|
||||
if existingTag, ok := internalNameToExistingTag[matchedInternalName]; ok {
|
||||
matched = append(matched, existingTag)
|
||||
continue
|
||||
}
|
||||
toCreate = append(toCreate, NewTag(orgID, matchedName, matchedInternalName, createdBy))
|
||||
}
|
||||
|
||||
return toCreate, matched, nil
|
||||
}
|
||||
|
||||
func cleanupName(name string) (string, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
raw := strings.Split(trimmed, HierarchySeparator)
|
||||
segments := make([]string, 0, len(raw))
|
||||
for _, seg := range raw {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidName, "tag name cannot be empty")
|
||||
}
|
||||
|
||||
for _, seg := range segments {
|
||||
if strings.Contains(seg, InternalSeparator) {
|
||||
return "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidName, "tag name segment %q cannot contain %q", seg, InternalSeparator)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(segments, HierarchySeparator), nil
|
||||
}
|
||||
|
||||
func buildInternalName(cleanedName string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(cleanedName, HierarchySeparator, InternalSeparator))
|
||||
}
|
||||
|
||||
// matchCasingWithExistingTags returns the display name and internal name to use for
|
||||
// a user-supplied tag, given the existing tags in the org. If an existing tag
|
||||
// has the same internal name, its display name (casing) is reused. If an
|
||||
// existing tag is a strict segment-prefix of the input, that prefix's casing
|
||||
// is reused for those segments and the remaining input segments are kept as
|
||||
// the user supplied them. Otherwise the input name is returned as-is.
|
||||
func matchCasingWithExistingTags(inputCleaned string, existing []*Tag) (canonicalName string, canonicalInternalName string) {
|
||||
inputInternal := buildInternalName(inputCleaned)
|
||||
|
||||
var bestPrefix *Tag
|
||||
bestPrefixLen := 0
|
||||
for _, tag := range existing {
|
||||
if tag.InternalName == inputInternal {
|
||||
return tag.Name, tag.InternalName
|
||||
}
|
||||
if strings.HasPrefix(inputInternal, tag.InternalName+InternalSeparator) {
|
||||
if len(tag.InternalName) > bestPrefixLen {
|
||||
bestPrefix = tag
|
||||
bestPrefixLen = len(tag.InternalName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestPrefix == nil {
|
||||
return inputCleaned, inputInternal
|
||||
}
|
||||
|
||||
prefixSegments := strings.Split(bestPrefix.Name, HierarchySeparator)
|
||||
inputSegments := strings.Split(inputCleaned, HierarchySeparator)
|
||||
canonicalSegments := append(prefixSegments, inputSegments[len(prefixSegments):]...)
|
||||
canonical := strings.Join(canonicalSegments, HierarchySeparator)
|
||||
return canonical, buildInternalName(canonical)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
EntityTypeDashboard = valuer.NewString("dashboard")
|
||||
)
|
||||
|
||||
type TagRelation struct {
|
||||
bun.BaseModel `bun:"table:tag_relations,alias:tag_relations"`
|
||||
|
||||
EntityType string `json:"entityType" required:"true" bun:"entity_type,type:text,notnull"`
|
||||
EntityID valuer.UUID `json:"entityId" required:"true" bun:"entity_id,pk,type:text,notnull"`
|
||||
TagID valuer.UUID `json:"tagId" required:"true" bun:"tag_id,pk,type:text,notnull"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" bun:"org_id,type:text,notnull"`
|
||||
}
|
||||
|
||||
func NewTagRelation(orgID valuer.UUID, entityType valuer.String, entityID valuer.UUID, tagID valuer.UUID) *TagRelation {
|
||||
return &TagRelation{
|
||||
EntityType: entityType.StringValue(),
|
||||
EntityID: entityID,
|
||||
TagID: tagID,
|
||||
OrgID: orgID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewTagRelations(orgID valuer.UUID, entityType valuer.String, entityID valuer.UUID, tagIDs []valuer.UUID) []*TagRelation {
|
||||
relations := make([]*TagRelation, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
relations = append(relations, NewTagRelation(orgID, entityType, entityID, tagID))
|
||||
}
|
||||
return relations
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCleanupName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "single segment", input: "prod", want: "prod"},
|
||||
{name: "two segments", input: "team/blr", want: "team/blr"},
|
||||
{name: "three segments", input: "team/BLR/platform", want: "team/BLR/platform"},
|
||||
{name: "leading whitespace", input: " prod", want: "prod"},
|
||||
{name: "trailing whitespace", input: "prod ", want: "prod"},
|
||||
{name: "leading separator", input: "/prod", want: "prod"},
|
||||
{name: "trailing separator", input: "prod/", want: "prod"},
|
||||
{name: "consecutive separators collapsed", input: "team//blr", want: "team/blr"},
|
||||
{name: "many separators collapsed", input: "team///blr////platform", want: "team/blr/platform"},
|
||||
{name: "whitespace within segments", input: "team/ blr ", want: "team/blr"},
|
||||
{name: "empty rejected", input: "", wantError: true},
|
||||
{name: "only whitespace rejected", input: " ", wantError: true},
|
||||
{name: "only separators rejected", input: "///", wantError: true},
|
||||
{name: "internal separator rejected", input: "team/foo::bar", wantError: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := cleanupName(tc.input)
|
||||
if tc.wantError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternalName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "prod", want: "prod"},
|
||||
{input: "Prod", want: "prod"},
|
||||
{input: "team/BLR/platform", want: "team::blr::platform"},
|
||||
{input: "TEAM/BLR", want: "team::blr"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, buildInternalName(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCasingWithExistingTags(t *testing.T) {
|
||||
existing := []*Tag{
|
||||
{Name: "team/BLR", InternalName: "team::blr"},
|
||||
{Name: "team/BLR/Pulse", InternalName: "team::blr::pulse"},
|
||||
{Name: "Database", InternalName: "database"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantInternalName string
|
||||
}{
|
||||
{
|
||||
name: "exact match reuses casing",
|
||||
input: "team/blr",
|
||||
wantName: "team/BLR",
|
||||
wantInternalName: "team::blr",
|
||||
},
|
||||
{
|
||||
name: "exact match reuses casing for deeper tag",
|
||||
input: "TEAM/blr/pulse",
|
||||
wantName: "team/BLR/Pulse",
|
||||
wantInternalName: "team::blr::pulse",
|
||||
},
|
||||
{
|
||||
name: "prefix match reuses prefix casing and keeps remainder",
|
||||
input: "team/blr/platform",
|
||||
wantName: "team/BLR/platform",
|
||||
wantInternalName: "team::blr::platform",
|
||||
},
|
||||
{
|
||||
name: "longest prefix wins",
|
||||
input: "team/blr/pulse/sub",
|
||||
wantName: "team/BLR/Pulse/sub",
|
||||
wantInternalName: "team::blr::pulse::sub",
|
||||
},
|
||||
{
|
||||
name: "no match returns input as-is",
|
||||
input: "Brand-New/Tag",
|
||||
wantName: "Brand-New/Tag",
|
||||
wantInternalName: "brand-new::tag",
|
||||
},
|
||||
{
|
||||
name: "single segment exact match",
|
||||
input: "DATABASE",
|
||||
wantName: "Database",
|
||||
wantInternalName: "database",
|
||||
},
|
||||
{
|
||||
name: "input that shares text but not segment boundary is not a prefix match",
|
||||
input: "teams/blr",
|
||||
wantName: "teams/blr",
|
||||
wantInternalName: "teams::blr",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cleaned, err := cleanupName(tc.input)
|
||||
require.NoError(t, err)
|
||||
gotName, gotInternal := matchCasingWithExistingTags(cleaned, existing)
|
||||
assert.Equal(t, tc.wantName, gotName)
|
||||
assert.Equal(t, tc.wantInternalName, gotInternal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCasingWithExistingTags_NoTagsExist(t *testing.T) {
|
||||
cleaned, err := cleanupName("Foo/Bar")
|
||||
require.NoError(t, err)
|
||||
name, internal := matchCasingWithExistingTags(cleaned, nil)
|
||||
assert.Equal(t, "Foo/Bar", name)
|
||||
assert.Equal(t, "foo::bar", internal)
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
tags []*Tag
|
||||
listCallCount int
|
||||
}
|
||||
|
||||
func (f *fakeStore) List(_ context.Context, _ valuer.UUID) ([]*Tag, error) {
|
||||
f.listCallCount++
|
||||
out := make([]*Tag, len(f.tags))
|
||||
copy(out, f.tags)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Create(_ context.Context, tags []*Tag) ([]*Tag, error) {
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateRelations(_ context.Context, _ []*TagRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
t.Run("empty input does not hit store", func(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
toCreate, matched, err := Resolve(context.Background(), store, valuer.GenerateUUID(), nil, "u@signoz.io")
|
||||
require.NoError(t, err)
|
||||
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 tags and reuses existing", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
dbTag := NewTag(orgID, "team/BLR", "team::blr", "seed")
|
||||
dbTag2 := NewTag(orgID, "Database", "database", "seed")
|
||||
store := &fakeStore{tags: []*Tag{dbTag, dbTag2}}
|
||||
|
||||
toCreate, matched, err := Resolve(context.Background(), store, orgID, []PostableTag{
|
||||
{Name: "team/blr/platform"},
|
||||
{Name: "DATABASE"},
|
||||
{Name: "Brand-New"},
|
||||
}, "u@signoz.io")
|
||||
require.NoError(t, err)
|
||||
|
||||
createdInternalNames := []string{}
|
||||
createdNames := map[string]string{}
|
||||
for _, tg := range toCreate {
|
||||
createdInternalNames = append(createdInternalNames, tg.InternalName)
|
||||
createdNames[tg.InternalName] = tg.Name
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"team::blr::platform", "brand-new"}, createdInternalNames,
|
||||
"only the two missing tags should be returned for insertion")
|
||||
assert.Equal(t, "team/BLR/platform", createdNames["team::blr::platform"], "should inherit casing from existing parent")
|
||||
assert.Equal(t, "Brand-New", createdNames["brand-new"], "should keep input casing when no existing match")
|
||||
|
||||
require.Len(t, matched, 1, "DATABASE should hit the existing 'Database' tag")
|
||||
assert.Same(t, dbTag2, matched[0], "matched should return the existing pointer with its authoritative ID")
|
||||
})
|
||||
|
||||
t.Run("dedupes inputs that map to the same internal name", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
store := &fakeStore{}
|
||||
|
||||
toCreate, matched, err := Resolve(context.Background(), store, orgID, []PostableTag{
|
||||
{Name: "Foo/Bar"},
|
||||
{Name: "foo/bar"},
|
||||
{Name: "FOO/BAR"},
|
||||
}, "u@signoz.io")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, matched)
|
||||
require.Len(t, toCreate, 1, "duplicate inputs must collapse into a single insert")
|
||||
assert.Equal(t, "Foo/Bar", toCreate[0].Name)
|
||||
assert.Equal(t, "foo::bar", toCreate[0].InternalName)
|
||||
})
|
||||
|
||||
t.Run("propagates validation error from any input", func(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
_, _, err := Resolve(context.Background(), store, valuer.GenerateUUID(), []PostableTag{
|
||||
{Name: "valid"},
|
||||
{Name: ""},
|
||||
}, "u@signoz.io")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user