Compare commits

..

1 Commits

Author SHA1 Message Date
Piyush Singariya
17e51ba580 fix: v3 filter_suggestions 2026-03-19 14:15:54 +05:30
87 changed files with 700 additions and 2216 deletions

View File

@@ -49,7 +49,6 @@ jobs:
- ttl
- alerts
- ingestionkeys
- rootuser
sqlstore-provider:
- postgres
- sqlite

View File

@@ -33,14 +33,13 @@ import (
func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
var flags signoz.DeprecatedFlags
var configFiles []string
serverCmd := &cobra.Command{
Use: "server",
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, configFiles, flags)
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
if err != nil {
return err
}
@@ -49,7 +48,6 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
},
}
serverCmd.Flags().StringArrayVar(&configFiles, "config", nil, "path to a YAML configuration file (can be specified multiple times, later files override earlier ones)")
flags.RegisterFlags(serverCmd)
parentCmd.AddCommand(serverCmd)
}

View File

@@ -10,18 +10,12 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
)
func NewSigNozConfig(ctx context.Context, logger *slog.Logger, configFiles []string, flags signoz.DeprecatedFlags) (signoz.Config, error) {
uris := make([]string, 0, len(configFiles)+1)
for _, f := range configFiles {
uris = append(uris, "file:"+f)
}
uris = append(uris, "env:")
func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.DeprecatedFlags) (signoz.Config, error) {
config, err := signoz.NewConfig(
ctx,
logger,
config.ResolverConfig{
Uris: uris,
Uris: []string{"env:"},
ProviderFactories: []config.ProviderFactory{
envprovider.NewFactory(),
fileprovider.NewFactory(),

View File

@@ -1,87 +0,0 @@
package cmd
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSigNozConfig_NoConfigFiles(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, nil, signoz.DeprecatedFlags{})
require.NoError(t, err)
assert.NotZero(t, config)
}
func TestNewSigNozConfig_SingleConfigFile(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
err := os.WriteFile(configPath, []byte(`
cache:
provider: "redis"
`), 0644)
require.NoError(t, err)
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, []string{configPath}, signoz.DeprecatedFlags{})
require.NoError(t, err)
assert.Equal(t, "redis", config.Cache.Provider)
}
func TestNewSigNozConfig_MultipleConfigFiles_LaterOverridesEarlier(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, "base.yaml")
err := os.WriteFile(basePath, []byte(`
cache:
provider: "memory"
sqlstore:
provider: "sqlite"
`), 0644)
require.NoError(t, err)
overridePath := filepath.Join(dir, "override.yaml")
err = os.WriteFile(overridePath, []byte(`
cache:
provider: "redis"
`), 0644)
require.NoError(t, err)
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, []string{basePath, overridePath}, signoz.DeprecatedFlags{})
require.NoError(t, err)
// Later file overrides earlier
assert.Equal(t, "redis", config.Cache.Provider)
// Value from base file that wasn't overridden persists
assert.Equal(t, "sqlite", config.SQLStore.Provider)
}
func TestNewSigNozConfig_EnvOverridesConfigFile(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
err := os.WriteFile(configPath, []byte(`
cache:
provider: "fromfile"
`), 0644)
require.NoError(t, err)
t.Setenv("SIGNOZ_CACHE_PROVIDER", "fromenv")
logger := slog.New(slog.DiscardHandler)
config, err := NewSigNozConfig(context.Background(), logger, []string{configPath}, signoz.DeprecatedFlags{})
require.NoError(t, err)
// Env should override file
assert.Equal(t, "fromenv", config.Cache.Provider)
}
func TestNewSigNozConfig_NonexistentFile(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
_, err := NewSigNozConfig(context.Background(), logger, []string{"/nonexistent/config.yaml"}, signoz.DeprecatedFlags{})
assert.Error(t, err)
}

View File

@@ -43,14 +43,13 @@ import (
func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
var flags signoz.DeprecatedFlags
var configFiles []string
serverCmd := &cobra.Command{
Use: "server",
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, configFiles, flags)
config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags)
if err != nil {
return err
}
@@ -59,7 +58,6 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
},
}
serverCmd.Flags().StringArrayVar(&configFiles, "config", nil, "path to a YAML configuration file (can be specified multiple times, later files override earlier ones)")
flags.RegisterFlags(serverCmd)
parentCmd.AddCommand(serverCmd)
}

View File

@@ -328,18 +328,15 @@ user:
##################### IdentN #####################
identn:
tokenizer:
# toggle tokenizer identN
# toggle the identN resolver
enabled: true
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
# toggle the identN resolver
enabled: true
# headers to use for apikey identN resolver
headers:
- SIGNOZ-API-KEY
impersonation:
# toggle impersonation identN, when enabled, all requests will impersonate the root user
enabled: false

View File

@@ -598,39 +598,6 @@ components:
required:
- config
type: object
GlobaltypesAPIKeyConfig:
properties:
enabled:
type: boolean
type: object
GlobaltypesConfig:
properties:
external_url:
type: string
identN:
$ref: '#/components/schemas/GlobaltypesIdentNConfig'
ingestion_url:
type: string
type: object
GlobaltypesIdentNConfig:
properties:
apikey:
$ref: '#/components/schemas/GlobaltypesAPIKeyConfig'
impersonation:
$ref: '#/components/schemas/GlobaltypesImpersonationConfig'
tokenizer:
$ref: '#/components/schemas/GlobaltypesTokenizerConfig'
type: object
GlobaltypesImpersonationConfig:
properties:
enabled:
type: boolean
type: object
GlobaltypesTokenizerConfig:
properties:
enabled:
type: boolean
type: object
MetricsexplorertypesListMetric:
properties:
description:
@@ -2063,6 +2030,13 @@ components:
required:
- id
type: object
TypesGettableGlobalConfig:
properties:
external_url:
type: string
ingestion_url:
type: string
type: object
TypesIdentifiable:
properties:
id:
@@ -3281,7 +3255,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/GlobaltypesConfig'
$ref: '#/components/schemas/TypesGettableGlobalConfig'
status:
type: string
required:
@@ -3289,12 +3263,29 @@ paths:
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Get global config
tags:
- global
@@ -5823,9 +5814,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Get ingestion keys for workspace
tags:
- gateway
@@ -5873,9 +5864,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Create ingestion key for workspace
tags:
- gateway
@@ -5913,9 +5904,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Delete ingestion key for workspace
tags:
- gateway
@@ -5957,9 +5948,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Update ingestion key for workspace
tags:
- gateway
@@ -6014,9 +6005,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Create limit for the ingestion key
tags:
- gateway
@@ -6054,9 +6045,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Delete limit for the ingestion key
tags:
- gateway
@@ -6098,9 +6089,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Update limit for the ingestion key
tags:
- gateway
@@ -6158,9 +6149,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Search ingestion keys for workspace
tags:
- gateway

View File

@@ -193,16 +193,6 @@ module.exports = {
],
},
],
'no-restricted-syntax': [
'error',
{
selector:
// TODO: Make this generic on removal of redux
"CallExpression[callee.property.name='getState'][callee.object.name=/^use/]",
message:
'Avoid calling .getState() directly. Export a standalone action from the store instead.',
},
],
},
overrides: [
{
@@ -227,13 +217,5 @@ module.exports = {
'@typescript-eslint/no-unused-vars': 'warn',
},
},
{
// Store definition files are the only place .getState() is permitted —
// they are the canonical source for standalone action exports.
files: ['**/*Store.{ts,tsx}'],
rules: {
'no-restricted-syntax': 'off',
},
},
],
};

View File

@@ -776,45 +776,6 @@ export interface GatewaytypesUpdatableIngestionKeyLimitDTO {
tags?: string[] | null;
}
export interface GlobaltypesAPIKeyConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesConfigDTO {
/**
* @type string
*/
external_url?: string;
identN?: GlobaltypesIdentNConfigDTO;
/**
* @type string
*/
ingestion_url?: string;
}
export interface GlobaltypesIdentNConfigDTO {
apikey?: GlobaltypesAPIKeyConfigDTO;
impersonation?: GlobaltypesImpersonationConfigDTO;
tokenizer?: GlobaltypesTokenizerConfigDTO;
}
export interface GlobaltypesImpersonationConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesTokenizerConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface MetricsexplorertypesListMetricDTO {
/**
* @type string
@@ -2441,6 +2402,17 @@ export interface TypesGettableAPIKeyDTO {
userId?: string;
}
export interface TypesGettableGlobalConfigDTO {
/**
* @type string
*/
external_url?: string;
/**
* @type string
*/
ingestion_url?: string;
}
export interface TypesIdentifiableDTO {
/**
* @type string
@@ -3054,7 +3026,7 @@ export type GetResetPasswordToken200 = {
};
export type GetGlobalConfig200 = {
data: GlobaltypesConfigDTO;
data: TypesGettableGlobalConfigDTO;
/**
* @type string
*/

View File

@@ -5,7 +5,7 @@ import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';

View File

@@ -123,7 +123,7 @@ export const prepareUPlotConfig = ({
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: widget.spanGaps ?? true,
spanGaps: true,
lineStyle: widget.lineStyle || LineStyle.Solid,
lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline,
showPoints:

View File

@@ -0,0 +1,21 @@
.fill-mode-selector {
.fill-mode-icon {
width: 24px;
height: 24px;
}
.fill-mode-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.fill-mode-selector {
.fill-mode-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,94 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { FillMode } from 'lib/uPlotV2/config/types';
import './FillModeSelector.styles.scss';
interface FillModeSelectorProps {
value: FillMode;
onChange: (value: FillMode) => void;
}
export function FillModeSelector({
value,
onChange,
}: FillModeSelectorProps): JSX.Element {
return (
<section className="fill-mode-selector control-container">
<Typography.Text className="section-heading">Fill mode</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as FillMode);
}
}}
>
<ToggleGroupItem value={FillMode.None} aria-label="None" title="None">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" stroke="#888" fill="none" />
</svg>
<Typography.Text className="section-heading-small">None</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem value={FillMode.Solid} aria-label="Solid" title="Solid">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" fill="#888" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={FillMode.Gradient}
aria-label="Gradient"
title="Gradient"
>
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<defs>
<linearGradient id="fill-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#888" stopOpacity="0.2" />
<stop offset="100%" stopColor="#888" stopOpacity="0.8" />
</linearGradient>
</defs>
<rect
x="8"
y="16"
width="32"
height="16"
fill="url(#fill-gradient)"
stroke="#888"
/>
</svg>
<Typography.Text className="section-heading-small">
Gradient
</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -0,0 +1,21 @@
.line-interpolation-selector {
.line-interpolation-icon {
width: 24px;
height: 24px;
}
.line-interpolation-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-interpolation-selector {
.line-interpolation-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,110 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineInterpolation } from 'lib/uPlotV2/config/types';
import './LineInterpolationSelector.styles.scss';
interface LineInterpolationSelectorProps {
value: LineInterpolation;
onChange: (value: LineInterpolation) => void;
}
export function LineInterpolationSelector({
value,
onChange,
}: LineInterpolationSelectorProps): JSX.Element {
return (
<section className="line-interpolation-selector control-container">
<Typography.Text className="section-heading">
Line interpolation
</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineInterpolation);
}
}}
>
<ToggleGroupItem
value={LineInterpolation.Linear}
aria-label="Linear"
title="Linear"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 L24 16 L40 32" stroke="#888" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem value={LineInterpolation.Spline} aria-label="Spline">
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 C16 8, 32 8, 40 32" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepAfter}
aria-label="Step After"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 V16 H24 V32 H40" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepBefore}
aria-label="Step Before"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 H24 V16 H40 V32" />
</svg>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -0,0 +1,21 @@
.line-style-selector {
.line-style-icon {
width: 24px;
height: 24px;
}
.line-style-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-style-selector {
.line-style-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,66 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineStyle } from 'lib/uPlotV2/config/types';
import './LineStyleSelector.styles.scss';
interface LineStyleSelectorProps {
value: LineStyle;
onChange: (value: LineStyle) => void;
}
export function LineStyleSelector({
value,
onChange,
}: LineStyleSelectorProps): JSX.Element {
return (
<section className="line-style-selector control-container">
<Typography.Text className="section-heading">Line style</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineStyle);
}
}}
>
<ToggleGroupItem value={LineStyle.Solid} aria-label="Solid" title="Solid">
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={LineStyle.Dashed}
aria-label="Dashed"
title="Dashed"
>
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="6 4"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Dashed</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -4,10 +4,6 @@
font-family: 'Space Mono';
padding-bottom: 48px;
.panel-type-select {
width: 100%;
}
.section-heading {
font-family: 'Space Mono';
color: var(--bg-vanilla-400);
@@ -30,6 +26,10 @@
letter-spacing: 0.48px;
}
.panel-type-select {
width: 100%;
}
.header {
display: flex;
padding: 14px 14px 14px 12px;
@@ -192,16 +192,6 @@
justify-content: space-between;
}
}
.context-links {
padding: 12px 12px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
}
.thresholds-section {
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
}
}
.select-option {
@@ -226,8 +216,7 @@
.lightMode {
.right-container {
background-color: var(--bg-vanilla-100);
.section-heading,
.section-heading-small {
.section-heading {
color: var(--bg-ink-400);
}
.header {

View File

@@ -7,10 +7,9 @@ import {
} from 'lib/uPlotV2/config/types';
import { Paintbrush } from 'lucide-react';
import DisconnectValuesSelector from '../../components/DisconnectValuesSelector/DisconnectValuesSelector';
import FillModeSelector from '../../components/FillModeSelector/FillModeSelector';
import LineInterpolationSelector from '../../components/LineInterpolationSelector/LineInterpolationSelector';
import LineStyleSelector from '../../components/LineStyleSelector/LineStyleSelector';
import { FillModeSelector } from '../../components/FillModeSelector/FillModeSelector';
import { LineInterpolationSelector } from '../../components/LineInterpolationSelector/LineInterpolationSelector';
import { LineStyleSelector } from '../../components/LineStyleSelector/LineStyleSelector';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
interface ChartAppearanceSectionProps {
@@ -22,14 +21,10 @@ interface ChartAppearanceSectionProps {
setLineInterpolation: Dispatch<SetStateAction<LineInterpolation>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
spanGaps: boolean | number;
setSpanGaps: Dispatch<SetStateAction<boolean | number>>;
allowFillMode: boolean;
allowLineStyle: boolean;
allowLineInterpolation: boolean;
allowShowPoints: boolean;
allowSpanGaps: boolean;
stepInterval: number;
}
export default function ChartAppearanceSection({
@@ -41,14 +36,10 @@ export default function ChartAppearanceSection({
setLineInterpolation,
showPoints,
setShowPoints,
spanGaps,
setSpanGaps,
allowFillMode,
allowLineStyle,
allowLineInterpolation,
allowShowPoints,
allowSpanGaps,
stepInterval,
}: ChartAppearanceSectionProps): JSX.Element {
return (
<SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
@@ -75,13 +66,6 @@ export default function ChartAppearanceSection({
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
</section>
)}
{allowSpanGaps && (
<DisconnectValuesSelector
value={spanGaps}
minValue={stepInterval}
onChange={setSpanGaps}
/>
)}
</SettingsSection>
);
}

View File

@@ -178,8 +178,6 @@ describe('RightContainer - Alerts Section', () => {
setLineStyle: jest.fn(),
showPoints: false,
setShowPoints: jest.fn(),
spanGaps: false,
setSpanGaps: jest.fn(),
};
beforeEach(() => {

View File

@@ -1,38 +0,0 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types';
interface DisconnectValuesModeToggleProps {
value: DisconnectedValuesMode;
onChange: (value: DisconnectedValuesMode) => void;
}
export default function DisconnectValuesModeToggle({
value,
onChange,
}: DisconnectValuesModeToggleProps): JSX.Element {
return (
<ToggleGroup
type="single"
value={value}
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as DisconnectedValuesMode);
}
}}
>
<ToggleGroupItem value={DisconnectedValuesMode.Never} aria-label="Never">
<Typography.Text className="section-heading-small">Never</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={DisconnectedValuesMode.Threshold}
aria-label="Threshold"
>
<Typography.Text className="section-heading-small">
Threshold
</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
);
}

View File

@@ -1,21 +0,0 @@
.disconnect-values-selector {
.disconnect-values-input-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
.disconnect-values-threshold-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
.disconnect-values-threshold-input {
max-width: 160px;
height: auto;
.disconnect-values-threshold-prefix {
padding: 0 8px;
font-size: 20px;
}
}
}
}
}

View File

@@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
import { Typography } from 'antd';
import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types';
import DisconnectValuesModeToggle from './DisconnectValuesModeToggle';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import './DisconnectValuesSelector.styles.scss';
const DEFAULT_THRESHOLD_SECONDS = 60;
interface DisconnectValuesSelectorProps {
value: boolean | number;
minValue: number;
onChange: (value: boolean | number) => void;
}
export default function DisconnectValuesSelector({
value,
minValue,
onChange,
}: DisconnectValuesSelectorProps): JSX.Element {
const [mode, setMode] = useState<DisconnectedValuesMode>(() => {
if (typeof value === 'number') {
return DisconnectedValuesMode.Threshold;
}
return DisconnectedValuesMode.Never;
});
const [thresholdSeconds, setThresholdSeconds] = useState<number>(
typeof value === 'number' ? value : minValue ?? DEFAULT_THRESHOLD_SECONDS,
);
useEffect(() => {
if (typeof value === 'boolean') {
setMode(DisconnectedValuesMode.Never);
} else if (typeof value === 'number') {
setMode(DisconnectedValuesMode.Threshold);
setThresholdSeconds(value);
}
}, [value]);
useEffect(() => {
if (minValue !== undefined) {
setThresholdSeconds(minValue);
if (mode === DisconnectedValuesMode.Threshold) {
onChange(minValue);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minValue]);
const handleModeChange = (newMode: DisconnectedValuesMode): void => {
setMode(newMode);
switch (newMode) {
case DisconnectedValuesMode.Never:
onChange(true);
break;
case DisconnectedValuesMode.Threshold:
onChange(thresholdSeconds);
break;
}
};
const handleThresholdChange = (seconds: number): void => {
setThresholdSeconds(seconds);
onChange(seconds);
};
return (
<section className="disconnect-values-selector control-container">
<Typography.Text className="section-heading">
Disconnect values
</Typography.Text>
<div className="disconnect-values-input-wrapper">
<DisconnectValuesModeToggle value={mode} onChange={handleModeChange} />
{mode === DisconnectedValuesMode.Threshold && (
<section className="control-container">
<Typography.Text className="section-heading">
Threshold Value
</Typography.Text>
<DisconnectValuesThresholdInput
value={thresholdSeconds}
minValue={minValue}
onChange={handleThresholdChange}
/>
</section>
)}
</div>
</section>
);
}

View File

@@ -1,92 +0,0 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Callout, Input } from '@signozhq/ui';
interface DisconnectValuesThresholdInputProps {
value: number;
onChange: (seconds: number) => void;
minValue: number;
}
export default function DisconnectValuesThresholdInput({
value,
onChange,
minValue,
}: DisconnectValuesThresholdInputProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
rangeUtil.secondsToHms(value),
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setInputValue(rangeUtil.secondsToHms(value));
setError(null);
}, [value]);
const updateValue = (txt: string): void => {
if (!txt) {
return;
}
try {
let seconds: number;
if (rangeUtil.isValidTimeSpan(txt)) {
seconds = rangeUtil.intervalToSeconds(txt);
} else {
const parsed = Number(txt);
if (Number.isNaN(parsed) || parsed <= 0) {
setError('Enter a valid duration (e.g. 1h, 10m, 1d)');
return;
}
seconds = parsed;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
setInputValue(txt);
onChange(seconds);
} catch {
setError('Invalid threshold value');
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
updateValue(e.currentTarget.value);
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>): void => {
updateValue(e.currentTarget.value);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.currentTarget.value);
if (error) {
setError(null);
}
};
return (
<div className="disconnect-values-threshold-wrapper">
<Input
name="disconnect-values-threshold"
type="text"
className="disconnect-values-threshold-input"
prefix={<span className="disconnect-values-threshold-prefix">&gt;</span>}
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
autoFocus={true}
aria-invalid={!!error}
aria-describedby={error ? 'threshold-error' : undefined}
/>
{error && (
<Callout type="error" size="small" showIcon>
{error}
</Callout>
)}
</div>
);
}

View File

@@ -9,7 +9,7 @@ interface FillModeSelectorProps {
onChange: (value: FillMode) => void;
}
export default function FillModeSelector({
export function FillModeSelector({
value,
onChange,
}: FillModeSelectorProps): JSX.Element {

View File

@@ -9,7 +9,7 @@ interface LineInterpolationSelectorProps {
onChange: (value: LineInterpolation) => void;
}
export default function LineInterpolationSelector({
export function LineInterpolationSelector({
value,
onChange,
}: LineInterpolationSelectorProps): JSX.Element {

View File

@@ -9,7 +9,7 @@ interface LineStyleSelectorProps {
onChange: (value: LineStyle) => void;
}
export default function LineStyleSelector({
export function LineStyleSelector({
value,
onChange,
}: LineStyleSelectorProps): JSX.Element {

View File

@@ -262,17 +262,3 @@ export const panelTypeVsShowPoints: {
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsSpanGaps: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -1,7 +1,6 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Typography } from 'antd';
import { ExecStats } from 'api/v5/v5';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
@@ -12,7 +11,6 @@ import {
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import get from 'lodash-es/get';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
@@ -38,7 +36,6 @@ import {
panelTypeVsPanelTimePreferences,
panelTypeVsShowPoints,
panelTypeVsSoftMinMax,
panelTypeVsSpanGaps,
panelTypeVsStackingChartPreferences,
panelTypeVsThreshold,
panelTypeVsYAxisUnit,
@@ -71,8 +68,6 @@ function RightContainer({
setLineStyle,
showPoints,
setShowPoints,
spanGaps,
setSpanGaps,
bucketCount,
bucketWidth,
stackedBarChart,
@@ -143,7 +138,6 @@ function RightContainer({
const allowLineStyle = panelTypeVsLineStyle[selectedGraph];
const allowFillMode = panelTypeVsFillMode[selectedGraph];
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
const allowSpanGaps = panelTypeVsSpanGaps[selectedGraph];
const decimapPrecisionOptions = useMemo(
() => [
@@ -182,26 +176,10 @@ function RightContainer({
(allowFillMode ||
allowLineStyle ||
allowLineInterpolation ||
allowShowPoints ||
allowSpanGaps),
[
allowFillMode,
allowLineStyle,
allowLineInterpolation,
allowShowPoints,
allowSpanGaps,
],
allowShowPoints),
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
);
const stepInterval = useMemo(() => {
const stepIntervals: ExecStats['stepIntervals'] = get(
queryResponse,
'data.payload.data.newResult.meta.stepIntervals',
{},
);
return Math.min(...Object.values(stepIntervals));
}, [queryResponse]);
return (
<div className="right-container">
<section className="header">
@@ -259,14 +237,10 @@ function RightContainer({
setLineInterpolation={setLineInterpolation}
showPoints={showPoints}
setShowPoints={setShowPoints}
spanGaps={spanGaps}
setSpanGaps={setSpanGaps}
allowFillMode={allowFillMode}
allowLineStyle={allowLineStyle}
allowLineInterpolation={allowLineInterpolation}
allowShowPoints={allowShowPoints}
allowSpanGaps={allowSpanGaps}
stepInterval={stepInterval}
/>
)}
@@ -390,8 +364,6 @@ export interface RightContainerProps {
setLineStyle: Dispatch<SetStateAction<LineStyle>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
spanGaps: boolean | number;
setSpanGaps: Dispatch<SetStateAction<boolean | number>>;
}
RightContainer.defaultProps = {

View File

@@ -14,6 +14,7 @@ import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import i18n from 'ReactI18';
import {
fireEvent,
getByText as getByTextUtil,
render,
userEvent,
@@ -341,8 +342,9 @@ describe('Stacking bar in new panel', () => {
const STACKING_STATE_ATTR = 'data-stacking-state';
describe('when switching to BAR panel type', () => {
jest.setTimeout(10000);
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
// Mock useSearchParams to return the expected values
@@ -352,15 +354,7 @@ describe('when switching to BAR panel type', () => {
]);
});
afterEach(() => {
jest.useRealTimers();
});
it('should preserve saved stacking value of true', async () => {
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime.bind(jest),
});
const { getByTestId, getByText, container } = render(
<DashboardProvider dashboardId="">
<NewWidget
@@ -376,7 +370,7 @@ describe('when switching to BAR panel type', () => {
'true',
);
await user.click(getByText('Bar')); // Panel Type Selected
await userEvent.click(getByText('Bar')); // Panel Type Selected
// find dropdown with - .ant-select-dropdown
const panelDropdown = document.querySelector(
@@ -386,7 +380,7 @@ describe('when switching to BAR panel type', () => {
// Select TimeSeries from dropdown
const option = within(panelDropdown).getByText('Time Series');
await user.click(option);
fireEvent.click(option);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
@@ -401,7 +395,7 @@ describe('when switching to BAR panel type', () => {
expect(panelTypeDropdown2).toBeInTheDocument();
expect(getByTextUtil(panelTypeDropdown2, 'Time Series')).toBeInTheDocument();
await user.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
fireEvent.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
// find dropdown with - .ant-select-dropdown
const panelDropdown2 = document.querySelector(
@@ -409,7 +403,7 @@ describe('when switching to BAR panel type', () => {
) as HTMLElement;
// // Select BAR from dropdown
const BarOption = within(panelDropdown2).getByText('Bar');
await user.click(BarOption);
fireEvent.click(BarOption);
// Stack series should be true
checkStackSeriesState(container, true);

View File

@@ -220,9 +220,6 @@ function NewWidget({
const [showPoints, setShowPoints] = useState<boolean>(
selectedWidget?.showPoints ?? false,
);
const [spanGaps, setSpanGaps] = useState<boolean | number>(
selectedWidget?.spanGaps ?? true,
);
const [customLegendColors, setCustomLegendColors] = useState<
Record<string, string>
>(selectedWidget?.customLegendColors || {});
@@ -292,7 +289,6 @@ function NewWidget({
fillMode,
lineStyle,
showPoints,
spanGaps,
columnUnits,
bucketCount,
stackedBarChart,
@@ -332,7 +328,6 @@ function NewWidget({
fillMode,
lineStyle,
showPoints,
spanGaps,
customLegendColors,
contextLinks,
selectedWidget.columnWidths,
@@ -546,7 +541,6 @@ function NewWidget({
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
spanGaps: selectedWidget?.spanGaps ?? true,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
@@ -578,7 +572,6 @@ function NewWidget({
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
spanGaps: selectedWidget?.spanGaps ?? true,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
@@ -896,8 +889,6 @@ function NewWidget({
setLineStyle={setLineStyle}
showPoints={showPoints}
setShowPoints={setShowPoints}
spanGaps={spanGaps}
setSpanGaps={setSpanGaps}
opacity={opacity}
yAxisUnit={yAxisUnit}
columnUnits={columnUnits}

View File

@@ -6,9 +6,8 @@ import { LineChart } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import uPlot, { AlignedData, Options } from 'uplot';
import { usePlotContext } from '../../context/PlotContext';
import { UPlotChartProps } from '../types';
import { prepareAlignedData } from './utils';
import { usePlotContext } from '../context/PlotContext';
import { UPlotChartProps } from './types';
/**
* Check if dimensions have changed
@@ -84,11 +83,8 @@ export default function UPlotChart({
...configOptions,
} as Options;
// prepare final AlignedData
const preparedData = prepareAlignedData({ data, config });
// Create new plot instance
const plot = new uPlot(plotConfig, preparedData, containerRef.current);
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
if (plotRef) {
plotRef(plot);
@@ -166,8 +162,7 @@ export default function UPlotChart({
}
// Update data if only data changed
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
const preparedData = prepareAlignedData({ data, config });
plotInstanceRef.current.setData(preparedData as AlignedData);
plotInstanceRef.current.setData(data as AlignedData);
}
prevPropsRef.current = currentProps;

View File

@@ -1,16 +0,0 @@
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
import { AlignedData } from 'uplot';
export function prepareAlignedData({
data,
config,
}: {
data: AlignedData;
config: UPlotConfigBuilder;
}): AlignedData {
const seriesSpanGaps = config.getSeriesSpanGapsOptions();
return seriesSpanGaps.length > 0
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
}

View File

@@ -4,7 +4,7 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { AlignedData } from 'uplot';
import { PlotContextProvider } from '../../context/PlotContext';
import UPlotChart from '../UPlotChart/UPlotChart';
import UPlotChart from '../UPlotChart';
// ---------------------------------------------------------------------------
// Mocks
@@ -86,7 +86,6 @@ const createMockConfig = (): UPlotConfigBuilder => {
}),
getId: jest.fn().mockReturnValue(undefined),
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
getSeriesSpanGapsOptions: jest.fn().mockReturnValue([]),
} as unknown) as UPlotConfigBuilder;
};
@@ -329,78 +328,6 @@ describe('UPlotChart', () => {
});
});
describe('spanGaps data transformation', () => {
it('inserts null break points before passing data to uPlot when a gap exceeds the numeric threshold', () => {
const config = createMockConfig();
// gap 0→100 = 100 > threshold 50 → null inserted at midpoint x=50
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
const data: AlignedData = [
[0, 100],
[1, 2],
];
render(<UPlotChart config={config} data={data} width={600} height={400} />, {
wrapper: Wrapper,
});
const [, receivedData] = mockUPlotConstructor.mock.calls[0];
expect(receivedData[0]).toEqual([0, 50, 100]);
expect(receivedData[1]).toEqual([1, null, 2]);
});
it('passes data through unchanged when no gap exceeds the numeric threshold', () => {
const config = createMockConfig();
// all gaps = 10, threshold = 50 → no insertions, same reference returned
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
const data: AlignedData = [
[0, 10, 20],
[1, 2, 3],
];
render(<UPlotChart config={config} data={data} width={600} height={400} />, {
wrapper: Wrapper,
});
const [, receivedData] = mockUPlotConstructor.mock.calls[0];
expect(receivedData).toBe(data);
});
it('transforms data passed to setData when data updates and a new gap exceeds the threshold', () => {
const config = createMockConfig();
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
// initial render: gap 10 < 50, no transformation
const initialData: AlignedData = [
[0, 10],
[1, 2],
];
// updated data: gap 100 > 50 → null inserted at midpoint x=50
const newData: AlignedData = [
[0, 100],
[3, 4],
];
const { rerender } = render(
<UPlotChart config={config} data={initialData} width={600} height={400} />,
{ wrapper: Wrapper },
);
rerender(
<UPlotChart config={config} data={newData} width={600} height={400} />,
);
const receivedData = instances[0].setData.mock.calls[0][0];
expect(receivedData[0]).toEqual([0, 50, 100]);
expect(receivedData[1]).toEqual([3, null, 4]);
});
});
describe('prop updates', () => {
it('calls setData without recreating the plot when only data changes', () => {
const config = createMockConfig();

View File

@@ -14,7 +14,6 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../constants';
import { calculateWidthBasedOnStepInterval } from '../utils';
import { SeriesSpanGapsOption } from '../utils/dataUtils';
import {
ConfigBuilder,
ConfigBuilderProps,
@@ -162,13 +161,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.series.push(new UPlotSeriesBuilder(props));
}
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
return this.series.map((s) => {
const { spanGaps } = s.props;
return { spanGaps };
});
}
/**
* Add a hook for extensibility
*/

View File

@@ -4,7 +4,6 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import { generateGradientFill } from '../utils/generateGradientFill';
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
import {
BarAlignment,
ConfigBuilder,
@@ -147,8 +146,20 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
}: {
resolvedLineColor: string;
}): Partial<Series.Points> {
const { lineWidth, pointSize, pointsFilter } = this.props;
const {
lineWidth,
pointSize,
pointsBuilder,
pointsFilter,
drawStyle,
showPoints,
} = this.props;
/**
* If pointSize is not provided, use the lineWidth * POINT_SIZE_FACTOR
* to determine the point size.
* POINT_SIZE_FACTOR is 2, so the point size will be 2x the line width.
*/
const resolvedPointSize =
pointSize ?? (lineWidth ?? DEFAULT_LINE_WIDTH) * POINT_SIZE_FACTOR;
@@ -157,39 +168,19 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
fill: resolvedLineColor,
size: resolvedPointSize,
filter: pointsFilter || undefined,
show: this.resolvePointsShow(),
};
// When spanGaps is in threshold (numeric) mode, points hidden by default
// become invisible when isolated by injected gap-nulls (no line connects
// to them). Use a gap-based filter to show only those isolated points as
// dots. Do NOT set show=true here — the filter is called with show=false
// and returns specific indices to render; setting show=true would cause
// uPlot to call filter with show=true which short-circuits the logic and
// renders all points.
if (this.shouldApplyIsolatedPointFilter(pointsConfig.show)) {
pointsConfig.filter = isolatedPointFilter;
if (pointsBuilder) {
pointsConfig.show = pointsBuilder;
} else if (drawStyle === DrawStyle.Points) {
pointsConfig.show = true;
} else {
pointsConfig.show = !!showPoints;
}
return pointsConfig;
}
private resolvePointsShow(): Series.Points['show'] {
const { pointsBuilder, drawStyle, showPoints } = this.props;
if (pointsBuilder) {
return pointsBuilder;
}
if (drawStyle === DrawStyle.Points) {
return true;
}
return !!showPoints;
}
private shouldApplyIsolatedPointFilter(show: Series.Points['show']): boolean {
const { drawStyle, pointsFilter } = this.props;
return drawStyle === DrawStyle.Line && !pointsFilter && !show;
}
private getLineColor(): string {
const { colorMapping, label, lineColor, isDarkMode } = this.props;
if (!label) {
@@ -221,12 +212,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
return {
scale: scaleKey,
label,
// When spanGaps is numeric, we always disable uPlot's internal
// spanGaps behavior and rely on data-prep to implement the
// threshold-based null handling. When spanGaps is boolean we
// map it directly. When spanGaps is undefined we fall back to
// the default of true.
spanGaps: typeof spanGaps === 'number' ? false : spanGaps ?? true,
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
value: (): string => '',
pxAlign: true,
show,

View File

@@ -1,7 +1,6 @@
import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
import type { SeriesProps } from '../types';
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
@@ -41,37 +40,6 @@ describe('UPlotSeriesBuilder', () => {
expect(typeof config.value).toBe('function');
});
it('maps boolean spanGaps directly to uPlot spanGaps', () => {
const trueBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: true,
}),
);
const falseBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: false,
}),
);
const trueConfig = trueBuilder.getConfig();
const falseConfig = falseBuilder.getConfig();
expect(trueConfig.spanGaps).toBe(true);
expect(falseConfig.spanGaps).toBe(false);
});
it('disables uPlot spanGaps when spanGaps is a number', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: 10000,
}),
);
const config = builder.getConfig();
expect(config.spanGaps).toBe(false);
});
it('uses explicit lineColor when provided, regardless of mapping', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
@@ -316,50 +284,4 @@ describe('UPlotSeriesBuilder', () => {
expect(config.points?.filter).toBe(pointsFilter);
});
it('assigns isolatedPointFilter and does not force show=true when spanGaps is numeric and no custom filter', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Line,
spanGaps: 10_000,
showPoints: false,
}),
);
const config = builder.getConfig();
expect(config.points?.filter).toBe(isolatedPointFilter);
expect(config.points?.show).toBe(false);
});
it('does not assign isolatedPointFilter when a custom pointsFilter is provided alongside numeric spanGaps', () => {
const customFilter: uPlot.Series.Points.Filter = jest.fn(() => null);
const builder = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Line,
spanGaps: 10_000,
pointsFilter: customFilter,
}),
);
const config = builder.getConfig();
expect(config.points?.filter).toBe(customFilter);
});
it('does not assign isolatedPointFilter when showPoints is true even with numeric spanGaps', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Line,
spanGaps: 10_000,
showPoints: true,
}),
);
const config = builder.getConfig();
expect(config.points?.filter).toBeUndefined();
expect(config.points?.show).toBe(true);
});
});

View File

@@ -99,11 +99,6 @@ export interface ScaleProps {
distribution?: DistributionType;
}
export enum DisconnectedValuesMode {
Never = 'never',
Threshold = 'threshold',
}
/**
* Props for configuring a series
*/
@@ -180,16 +175,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
pointsFilter?: Series.Points.Filter;
pointsBuilder?: Series.Points.Show;
show?: boolean;
/**
* Controls how nulls are treated for this series.
*
* - boolean: mapped directly to uPlot's spanGaps behavior
* - number: interpreted as an X-axis threshold (same unit as ref values),
* where gaps smaller than this threshold are spanned by
* converting short null runs to undefined during data prep
* while uPlot's internal spanGaps is kept disabled.
*/
spanGaps?: boolean | number;
spanGaps?: boolean;
fillColor?: string;
fillMode?: FillMode;
isDarkMode?: boolean;

View File

@@ -1,12 +1,4 @@
import uPlot from 'uplot';
import {
applySpanGapsToAlignedData,
insertLargeGapNullsIntoAlignedData,
isInvalidPlotValue,
normalizePlotValue,
SeriesSpanGapsOption,
} from '../dataUtils';
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
describe('dataUtils', () => {
describe('isInvalidPlotValue', () => {
@@ -67,217 +59,4 @@ describe('dataUtils', () => {
expect(normalizePlotValue(42.5)).toBe(42.5);
});
});
describe('insertLargeGapNullsIntoAlignedData', () => {
it('returns original data unchanged when no gap exceeds the threshold', () => {
// all gaps = 10, threshold = 25 → no insertions
const data: uPlot.AlignedData = [
[0, 10, 20, 30],
[1, 2, 3, 4],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('does not insert when the gap equals the threshold exactly', () => {
// gap = 50, threshold = 50 → condition is gap > threshold, not >=
const data: uPlot.AlignedData = [
[0, 50],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('inserts a null at the midpoint when a single gap exceeds the threshold', () => {
// gap 0→100 = 100 > 50 → insert null at x=50
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100]);
expect(result[1]).toEqual([1, null, 2]);
});
it('inserts nulls at every gap that exceeds the threshold', () => {
// gaps: 0→100=100, 100→110=10, 110→210=100; threshold=50
// → insert at 0→100 and 110→210
const data: uPlot.AlignedData = [
[0, 100, 110, 210],
[1, 2, 3, 4],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110, 160, 210]);
expect(result[1]).toEqual([1, null, 2, 3, null, 4]);
});
it('inserts null for all series at a gap triggered by any one series', () => {
// series 0: threshold=50, gap=100 → triggers insertion
// series 1: threshold=200, gap=100 → would not trigger alone
// result: both series get null at the inserted x because the x-axis is shared
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
[3, 4],
];
const options: SeriesSpanGapsOption[] = [
{ spanGaps: 50 },
{ spanGaps: 200 },
];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100]);
expect(result[1]).toEqual([1, null, 2]);
expect(result[2]).toEqual([3, null, 4]);
});
it('ignores boolean spanGaps options (only numeric values trigger insertion)', () => {
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('returns original data when series options array is empty', () => {
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const result = insertLargeGapNullsIntoAlignedData(data, []);
expect(result).toBe(data);
});
it('returns original data when there is only one x point', () => {
const data: uPlot.AlignedData = [[0], [1]];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 10 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('preserves existing null values in the series alongside inserted ones', () => {
// original series already has a null; gap 0→100 also triggers insertion
const data: uPlot.AlignedData = [
[0, 100, 110],
[1, null, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110]);
expect(result[1]).toEqual([1, null, null, 2]);
});
});
describe('applySpanGapsToAlignedData', () => {
const xs: uPlot.AlignedData[0] = [0, 10, 20, 30];
it('returns original data when there are no series', () => {
const data: uPlot.AlignedData = [xs];
const result = applySpanGapsToAlignedData(data, []);
expect(result).toBe(data);
});
it('leaves data unchanged when spanGaps is undefined', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{}];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('converts nulls to undefined when spanGaps is true', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual([1, undefined, 2, undefined]);
});
it('leaves data unchanged when spanGaps is false', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: false }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('inserts a null break point when a gap exceeds the numeric threshold', () => {
// gap 0→100 = 100 > 50 → null inserted at midpoint x=50
const data: uPlot.AlignedData = [
[0, 100, 110],
[1, 2, 3],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110]);
expect(result[1]).toEqual([1, null, 2, 3]);
});
it('returns original data when no gap exceeds the numeric threshold', () => {
// all gaps = 10, threshold = 25 → no insertions
const data: uPlot.AlignedData = [xs, [1, 2, 3, 4]];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
const result = applySpanGapsToAlignedData(data, options);
expect(result).toBe(data);
});
it('applies both numeric gap insertion and boolean null-to-undefined in one pass', () => {
// series 0: spanGaps: 50 → gap 0→100 triggers a null break at midpoint x=50
// series 1: spanGaps: true → the inserted null at x=50 becomes undefined,
// so the line spans over it rather than breaking
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
[3, 4],
];
const options: SeriesSpanGapsOption[] = [
{ spanGaps: 50 },
{ spanGaps: true },
];
const result = applySpanGapsToAlignedData(data, options);
// x-axis extended with the inserted midpoint
expect(result[0]).toEqual([0, 50, 100]);
// series 0: null at midpoint breaks the line
expect(result[1]).toEqual([1, null, 2]);
// series 1: null at midpoint converted to undefined → line spans over it
expect(result[2]).toEqual([3, undefined, 4]);
});
});
});

View File

@@ -1,251 +0,0 @@
import type uPlot from 'uplot';
import {
findNearestNonNull,
findSandwichedIndices,
isolatedPointFilter,
} from '../seriesPointsFilter';
// ---------------------------------------------------------------------------
// Minimal uPlot stub — only the surface used by seriesPointsFilter
// ---------------------------------------------------------------------------
function makeUPlot({
xData,
yData,
idxs,
valToPosFn,
posToIdxFn,
}: {
xData: number[];
yData: (number | null | undefined)[];
idxs?: [number, number];
valToPosFn?: (val: number) => number;
posToIdxFn?: (pos: number) => number;
}): uPlot {
return ({
data: [xData, yData],
series: [{}, { idxs: idxs ?? [0, yData.length - 1] }],
valToPos: jest.fn((val: number) => (valToPosFn ? valToPosFn(val) : val)),
posToIdx: jest.fn((pos: number) =>
posToIdxFn ? posToIdxFn(pos) : Math.round(pos),
),
} as unknown) as uPlot;
}
// ---------------------------------------------------------------------------
// findNearestNonNull
// ---------------------------------------------------------------------------
describe('findNearestNonNull', () => {
it('returns the right neighbor when left side is null', () => {
const yData = [null, null, 42, null];
expect(findNearestNonNull(yData, 1)).toBe(2);
});
it('returns the left neighbor when right side is null', () => {
const yData = [null, 42, null, null];
expect(findNearestNonNull(yData, 2)).toBe(1);
});
it('prefers the right neighbor over the left when both exist at the same distance', () => {
const yData = [10, null, 20];
// j=1: right (idx 3) is out of bounds (undefined == null), left (idx 1) is null
// Actually right (idx 2) exists at j=1
expect(findNearestNonNull(yData, 1)).toBe(2);
});
it('returns approxIdx unchanged when no non-null value is found within 100 steps', () => {
const yData: (number | null)[] = Array(5).fill(null);
expect(findNearestNonNull(yData, 2)).toBe(2);
});
it('handles undefined values the same as null', () => {
const yData: (number | null | undefined)[] = [undefined, undefined, 99];
expect(findNearestNonNull(yData, 0)).toBe(2);
});
});
// ---------------------------------------------------------------------------
// findSandwichedIndices
// ---------------------------------------------------------------------------
describe('findSandwichedIndices', () => {
it('returns empty array when no consecutive gaps share a pixel boundary', () => {
const gaps = [
[0, 10],
[20, 30],
];
const yData = [1, null, null, 2];
const u = makeUPlot({ xData: [0, 1, 2, 3], yData });
expect(findSandwichedIndices(gaps, yData, u)).toEqual([]);
});
it('returns the index between two gaps that share a pixel boundary', () => {
// gaps[0] ends at 10, gaps[1] starts at 10 → sandwiched point at pixel 10
const gaps = [
[0, 10],
[10, 20],
];
// posToIdx(10) → 2
const yData = [null, null, 5, null, null];
const u = makeUPlot({ xData: [0, 1, 2, 3, 4], yData, posToIdxFn: () => 2 });
expect(findSandwichedIndices(gaps, yData, u)).toEqual([2]);
});
it('scans to nearest non-null when posToIdx lands on a null', () => {
// posToIdx returns 2 which is null; nearest non-null is index 3
const gaps = [
[0, 10],
[10, 20],
];
const yData = [null, null, null, 7, null];
const u = makeUPlot({ xData: [0, 1, 2, 3, 4], yData, posToIdxFn: () => 2 });
expect(findSandwichedIndices(gaps, yData, u)).toEqual([3]);
});
it('returns multiple indices when several gap pairs share boundaries', () => {
// Three consecutive gaps: [0,10], [10,20], [20,30]
// → two sandwiched points: between gaps 0-1 at px 10, between gaps 1-2 at px 20
const gaps = [
[0, 10],
[10, 20],
[20, 30],
];
const yData = [null, 1, null, 2, null];
const u = makeUPlot({
xData: [0, 1, 2, 3, 4],
yData,
posToIdxFn: (pos) => (pos === 10 ? 1 : 3),
});
expect(findSandwichedIndices(gaps, yData, u)).toEqual([1, 3]);
});
});
// ---------------------------------------------------------------------------
// isolatedPointFilter
// ---------------------------------------------------------------------------
describe('isolatedPointFilter', () => {
it('returns null when show is true (normal point rendering active)', () => {
const u = makeUPlot({ xData: [0, 1], yData: [1, null] });
expect(isolatedPointFilter(u, 1, true, [[0, 10]])).toBeNull();
});
it('returns null when gaps is null', () => {
const u = makeUPlot({ xData: [0, 1], yData: [1, null] });
expect(isolatedPointFilter(u, 1, false, null)).toBeNull();
});
it('returns null when gaps is empty', () => {
const u = makeUPlot({ xData: [0, 1], yData: [1, null] });
expect(isolatedPointFilter(u, 1, false, [])).toBeNull();
});
it('returns null when series idxs is undefined', () => {
const u = ({
data: [
[0, 1],
[1, null],
],
series: [{}, { idxs: undefined }],
valToPos: jest.fn(() => 0),
posToIdx: jest.fn(() => 0),
} as unknown) as uPlot;
expect(isolatedPointFilter(u, 1, false, [[0, 10]])).toBeNull();
});
it('includes firstIdx when the first gap starts at the first data point pixel', () => {
// xData[firstIdx=0] → valToPos → 5; gaps[0][0] === 5 → isolated leading point
const xData = [0, 1, 2, 3, 4];
const yData = [10, null, null, null, 20];
const u = makeUPlot({
xData,
yData,
idxs: [0, 4],
valToPosFn: (val) => (val === 0 ? 5 : 40), // firstPos=5, lastPos=40
});
// gaps[0][0] === 5 (firstPos), gaps last end !== 40
const result = isolatedPointFilter(u, 1, false, [
[5, 15],
[20, 30],
]);
expect(result).toContain(0); // firstIdx
});
it('includes lastIdx when the last gap ends at the last data point pixel', () => {
const xData = [0, 1, 2, 3, 4];
const yData = [10, null, null, null, 20];
const u = makeUPlot({
xData,
yData,
idxs: [0, 4],
valToPosFn: (val) => (val === 0 ? 5 : 40), // firstPos=5, lastPos=40
});
// gaps last end === 40 (lastPos), gaps[0][0] !== 5
const result = isolatedPointFilter(u, 1, false, [
[10, 20],
[30, 40],
]);
expect(result).toContain(4); // lastIdx
});
it('includes sandwiched index between two gaps sharing a pixel boundary', () => {
const xData = [0, 1, 2, 3, 4];
const yData = [null, null, 5, null, null];
const u = makeUPlot({
xData,
yData,
idxs: [0, 4],
valToPosFn: () => 99, // firstPos/lastPos won't match gap boundaries
posToIdxFn: () => 2,
});
const result = isolatedPointFilter(u, 1, false, [
[0, 50],
[50, 100],
]);
expect(result).toContain(2);
});
it('returns null when no isolated points are found', () => {
const xData = [0, 1, 2];
const yData = [1, 2, 3];
const u = makeUPlot({
xData,
yData,
idxs: [0, 2],
// firstPos = 10, lastPos = 30 — neither matches any gap boundary
valToPosFn: (val) => (val === 0 ? 10 : 30),
});
// gaps don't share boundaries and don't touch firstPos/lastPos
const result = isolatedPointFilter(u, 1, false, [
[0, 5],
[15, 20],
]);
expect(result).toBeNull();
});
it('returns all three kinds of isolated points in one pass', () => {
// Leading (firstPos=0 === gaps[0][0]), sandwiched (gaps[1] and gaps[2] share 50),
// trailing (lastPos=100 === gaps last end)
const xData = [0, 1, 2, 3, 4];
const yData = [1, null, 2, null, 3];
const u = makeUPlot({
xData,
yData,
idxs: [0, 4],
valToPosFn: (val) => (val === 0 ? 0 : 100),
posToIdxFn: () => 2, // sandwiched point at idx 2
});
const gaps = [
[0, 20],
[40, 50],
[50, 80],
[90, 100],
];
const result = isolatedPointFilter(u, 1, false, gaps);
expect(result).toContain(0); // leading
expect(result).toContain(2); // sandwiched
expect(result).toContain(4); // trailing
});
});

View File

@@ -24,10 +24,10 @@ export function isInvalidPlotValue(value: unknown): boolean {
}
// Try to parse the string as a number
const parsedNumber = parseFloat(value);
const numValue = parseFloat(value);
// If parsing failed or resulted in a non-finite number, it's invalid
if (Number.isNaN(parsedNumber) || !Number.isFinite(parsedNumber)) {
if (Number.isNaN(numValue) || !Number.isFinite(numValue)) {
return true;
}
}
@@ -51,178 +51,3 @@ export function normalizePlotValue(
// Already a valid number
return value as number;
}
export interface SeriesSpanGapsOption {
spanGaps?: boolean | number;
}
// Internal type alias: a series value array that may contain nulls/undefineds.
// uPlot uses null to draw a visible gap and undefined to represent "no sample"
// (the line continues across undefined points but breaks at null ones).
type SeriesArray = Array<number | null | undefined>;
/**
* Returns true if the given gap size exceeds the numeric spanGaps threshold
* of at least one series. Used to decide whether to insert a null break point.
*/
function gapExceedsThreshold(
gapSize: number,
seriesOptions: SeriesSpanGapsOption[],
): boolean {
return seriesOptions.some(
({ spanGaps }) =>
typeof spanGaps === 'number' && spanGaps > 0 && gapSize > spanGaps,
);
}
/**
* For each series with a numeric spanGaps threshold, insert a null data point
* between consecutive x timestamps whose gap exceeds the threshold.
*
* Why: uPlot draws a continuous line between all non-null points. When the
* time gap between two consecutive samples is larger than the configured
* spanGaps value, we inject a synthetic null at the midpoint so uPlot renders
* a visible break instead of a misleading straight line across the gap.
*
* Because uPlot's AlignedData shares a single x-axis across all series, a null
* is inserted for every series at each position where any series needs a break.
*
* Two-pass approach for performance:
* Pass 1 — count how many nulls will be inserted (no allocations).
* Pass 2 — fill pre-allocated output arrays by index (no push/reallocation).
*/
export function insertLargeGapNullsIntoAlignedData(
data: uPlot.AlignedData,
seriesOptions: SeriesSpanGapsOption[],
): uPlot.AlignedData {
const [xValues, ...seriesValues] = data;
if (
!Array.isArray(xValues) ||
xValues.length < 2 ||
seriesValues.length === 0
) {
return data;
}
const timestamps = xValues as number[];
const totalPoints = timestamps.length;
// Pass 1: count insertions needed so we know the exact output length.
// This lets us pre-allocate arrays rather than growing them dynamically.
let insertionCount = 0;
for (let i = 0; i < totalPoints - 1; i += 1) {
if (gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions)) {
insertionCount += 1;
}
}
// No gaps exceed any threshold — return the original data unchanged.
if (insertionCount === 0) {
return data;
}
// Pass 2: build output arrays of exact size and fill them.
// `writeIndex` is the write cursor into the output arrays.
const outputLen = totalPoints + insertionCount;
const newX = new Array<number>(outputLen);
const newSeries: SeriesArray[] = seriesValues.map(
() => new Array<number | null | undefined>(outputLen),
);
let writeIndex = 0;
for (let i = 0; i < totalPoints; i += 1) {
// Copy the real data point at position i
newX[writeIndex] = timestamps[i];
for (
let seriesIndex = 0;
seriesIndex < seriesValues.length;
seriesIndex += 1
) {
newSeries[seriesIndex][writeIndex] = (seriesValues[
seriesIndex
] as SeriesArray)[i];
}
writeIndex += 1;
// If the gap to the next x timestamp exceeds the threshold, insert a
// synthetic null at the midpoint. The midpoint x is placed halfway
// between timestamps[i] and timestamps[i+1] (minimum 1 unit past timestamps[i] to stay unique).
if (
i < totalPoints - 1 &&
gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions)
) {
newX[writeIndex] =
timestamps[i] +
Math.max(1, Math.floor((timestamps[i + 1] - timestamps[i]) / 2));
for (
let seriesIndex = 0;
seriesIndex < seriesValues.length;
seriesIndex += 1
) {
newSeries[seriesIndex][writeIndex] = null; // null tells uPlot to break the line here
}
writeIndex += 1;
}
}
return [newX, ...newSeries] as uPlot.AlignedData;
}
/**
* Apply per-series spanGaps (boolean | number) handling to an aligned dataset.
*
* spanGaps controls how uPlot handles gaps in a series:
* - boolean true → convert null → undefined so uPlot spans over every gap
* (draws a continuous line, skipping missing samples)
* - boolean false → no change; nulls render as visible breaks (default)
* - number → insert a null break point between any two consecutive
* timestamps whose difference exceeds the threshold;
* gaps smaller than the threshold are left as-is
*
* The input data is expected to be of the form:
* [xValues, series1Values, series2Values, ...]
*/
export function applySpanGapsToAlignedData(
data: uPlot.AlignedData,
seriesOptions: SeriesSpanGapsOption[],
): uPlot.AlignedData {
const [xValues, ...seriesValues] = data;
if (!Array.isArray(xValues) || seriesValues.length === 0) {
return data;
}
// Numeric spanGaps: operates on the whole dataset at once because inserting
// null break points requires modifying the shared x-axis.
const hasNumericSpanGaps = seriesOptions.some(
({ spanGaps }) => typeof spanGaps === 'number',
);
const gapProcessed = hasNumericSpanGaps
? insertLargeGapNullsIntoAlignedData(data, seriesOptions)
: data;
// Boolean spanGaps === true: convert null → undefined per series so uPlot
// draws a continuous line across missing samples instead of breaking it.
// Skip this pass entirely if no series uses spanGaps: true.
const hasBooleanTrue = seriesOptions.some(({ spanGaps }) => spanGaps === true);
if (!hasBooleanTrue) {
return gapProcessed;
}
const [newX, ...newSeries] = gapProcessed;
const transformedSeries = newSeries.map((yValues, seriesIndex) => {
const { spanGaps } = seriesOptions[seriesIndex] ?? {};
if (spanGaps !== true) {
// This series doesn't use spanGaps: true — leave it unchanged.
return yValues;
}
// Replace null with undefined: uPlot skips undefined points without
// breaking the line, effectively spanning over the gap.
return (yValues as SeriesArray).map((pointValue) =>
pointValue === null ? undefined : pointValue,
) as uPlot.AlignedData[0];
});
return [newX, ...transformedSeries] as uPlot.AlignedData;
}

View File

@@ -1,93 +0,0 @@
import uPlot from 'uplot';
/**
* Scans outward from approxIdx to find the nearest non-null data index.
* posToIdx can land on a null when pixel density exceeds 1 point-per-pixel.
*/
export function findNearestNonNull(
yData: (number | null | undefined)[],
approxIdx: number,
): number {
for (let j = 1; j < 100; j++) {
if (yData[approxIdx + j] != null) {
return approxIdx + j;
}
if (yData[approxIdx - j] != null) {
return approxIdx - j;
}
}
return approxIdx;
}
/**
* Returns data indices of points sandwiched between two consecutive gaps that
* share a pixel boundary — meaning a point (or cluster) is isolated between them.
*/
export function findSandwichedIndices(
gaps: number[][],
yData: (number | null | undefined)[],
uPlotInstance: uPlot,
): number[] {
const indices: number[] = [];
for (let i = 0; i < gaps.length; i++) {
const nextGap = gaps[i + 1];
if (nextGap && gaps[i][1] === nextGap[0]) {
const approxIdx = uPlotInstance.posToIdx(gaps[i][1], true);
indices.push(
yData[approxIdx] == null ? findNearestNonNull(yData, approxIdx) : approxIdx,
);
}
}
return indices;
}
/**
* Points filter that shows data points isolated by gap-nulls (no connecting line).
* Used when spanGaps threshold mode injects nulls around gaps — without this,
* lone points become invisible because no line connects to them.
*
* Uses uPlot's gap pixel array rather than checking raw null neighbors in the
* data array. Returns an array of data indices (not a bitmask); null = no points.
*
*/
// eslint-disable-next-line max-params
export function isolatedPointFilter(
uPlotInstance: uPlot,
seriesIdx: number,
show: boolean,
gaps?: null | number[][],
): number[] | null {
if (show || !gaps || gaps.length === 0) {
return null;
}
const idxs = uPlotInstance.series[seriesIdx].idxs;
if (!idxs) {
return null;
}
const [firstIdx, lastIdx] = idxs;
const xData = uPlotInstance.data[0] as number[];
const yData = uPlotInstance.data[seriesIdx] as (number | null | undefined)[];
// valToPos with canvas=true matches the pixel space used by the gaps array.
const firstPos = Math.round(
uPlotInstance.valToPos(xData[firstIdx], 'x', true),
);
const lastPos = Math.round(uPlotInstance.valToPos(xData[lastIdx], 'x', true));
const filtered: number[] = [];
if (gaps[0][0] === firstPos) {
filtered.push(firstIdx);
}
filtered.push(...findSandwichedIndices(gaps, yData, uPlotInstance));
if (gaps[gaps.length - 1][1] === lastPos) {
filtered.push(lastIdx);
}
return filtered.length ? filtered : null;
}

View File

@@ -141,7 +141,6 @@ export interface IBaseWidget {
showPoints?: boolean;
lineStyle?: LineStyle;
fillMode?: FillMode;
spanGaps?: boolean | number;
}
export interface Widgets extends IBaseWidget {
query: Query;

View File

@@ -10,7 +10,7 @@ import (
)
func (provider *provider) addGatewayRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{
ID: "GetIngestionKeys",
Tags: []string{"gateway"},
Summary: "Get ingestion keys for workspace",
@@ -23,12 +23,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{
ID: "SearchIngestionKeys",
Tags: []string{"gateway"},
Summary: "Search ingestion keys for workspace",
@@ -41,12 +41,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{
ID: "CreateIngestionKey",
Tags: []string{"gateway"},
Summary: "Create ingestion key for workspace",
@@ -58,12 +58,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{
ID: "UpdateIngestionKey",
Tags: []string{"gateway"},
Summary: "Update ingestion key for workspace",
@@ -75,12 +75,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{
ID: "DeleteIngestionKey",
Tags: []string{"gateway"},
Summary: "Delete ingestion key for workspace",
@@ -92,12 +92,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{
ID: "CreateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Create limit for the ingestion key",
@@ -109,12 +109,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{
ID: "UpdateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Update limit for the ingestion key",
@@ -126,12 +126,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{
ID: "DeleteIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Delete limit for the ingestion key",
@@ -143,7 +143,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -4,24 +4,24 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
"github.com/SigNoz/signoz/pkg/types"
"github.com/gorilla/mux"
)
func (provider *provider) addGlobalRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.OpenAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{
if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.EditAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{
ID: "GetGlobalConfig",
Tags: []string{"global"},
Summary: "Get global config",
Description: "This endpoint returns global config",
Request: nil,
RequestContentType: "",
Response: new(globaltypes.Config),
Response: new(types.GettableGlobalConfig),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: nil,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -238,7 +238,7 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
return []handler.OpenAPISecurityScheme{
{Name: authtypes.IdentNProviderAPIKey.StringValue(), Scopes: []string{role.String()}},
{Name: authtypes.IdentNProviderAPIkey.StringValue(), Scopes: []string{role.String()}},
{Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: []string{role.String()}},
}
}

View File

@@ -1,14 +1,9 @@
package global
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
)
import "net/http"
type Global interface {
GetConfig(context.Context) *globaltypes.Config
GetConfig() Config
}
type Handler interface {

View File

@@ -1,12 +1,11 @@
package signozglobal
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types"
)
type handler struct {
@@ -18,10 +17,7 @@ func NewHandler(global global.Global) global.Handler {
}
func (handler *handler) GetConfig(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
cfg := handler.global.GetConfig()
cfg := handler.global.GetConfig(ctx)
render.Success(rw, http.StatusOK, cfg)
render.Success(rw, http.StatusOK, types.NewGettableGlobalConfig(cfg.ExternalURL, cfg.IngestionURL))
}

View File

@@ -5,38 +5,27 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
)
type provider struct {
config global.Config
identNConfig identn.Config
settings factory.ScopedProviderSettings
config global.Config
settings factory.ScopedProviderSettings
}
func NewFactory(identNConfig identn.Config) factory.ProviderFactory[global.Global, global.Config] {
func NewFactory() factory.ProviderFactory[global.Global, global.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) {
return newProvider(ctx, providerSettings, config, identNConfig)
return newProvider(ctx, providerSettings, config)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config, identNConfig identn.Config) (global.Global, error) {
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/global/signozglobal")
return &provider{
config: config,
identNConfig: identNConfig,
settings: settings,
config: config,
settings: settings,
}, nil
}
func (provider *provider) GetConfig(context.Context) *globaltypes.Config {
return globaltypes.NewConfig(
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String()),
globaltypes.NewIdentNConfig(
globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled},
globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled},
globaltypes.ImpersonationConfig{Enabled: provider.identNConfig.Impersonation.Enabled},
),
)
func (provider *provider) GetConfig() global.Config {
return provider.config
}

View File

@@ -40,7 +40,7 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -90,7 +90,7 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -139,7 +139,7 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)

View File

@@ -25,7 +25,7 @@ type provider struct {
}
func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIKey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIkey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(providerSettings, store, config)
})
}
@@ -40,7 +40,7 @@ func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, con
}
func (provider *provider) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderAPIKey
return authtypes.IdentNProviderAPIkey
}
func (provider *provider) Test(req *http.Request) bool {
@@ -52,6 +52,10 @@ func (provider *provider) Test(req *http.Request) bool {
return false
}
func (provider *provider) Enabled() bool {
return provider.config.APIKeyConfig.Enabled
}
func (provider *provider) Pre(req *http.Request) *http.Request {
token := provider.extractToken(req)
if token == "" {

View File

@@ -1,7 +1,6 @@
package identn
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
@@ -11,20 +10,11 @@ type Config struct {
// Config for apikey identN resolver
APIKeyConfig APIKeyConfig `mapstructure:"apikey"`
// Config for impersonation identN resolver
Impersonation ImpersonationConfig `mapstructure:"impersonation"`
}
type ImpersonationConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
}
type TokenizerConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
// Headers to extract from incoming requests
Headers []string `mapstructure:"headers"`
}
@@ -32,7 +22,6 @@ type TokenizerConfig struct {
type APIKeyConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
// Headers to extract from incoming requests
Headers []string `mapstructure:"headers"`
}
@@ -51,22 +40,9 @@ func newConfig() factory.Config {
Enabled: true,
Headers: []string{"SIGNOZ-API-KEY"},
},
Impersonation: ImpersonationConfig{
Enabled: false,
},
}
}
func (c Config) Validate() error {
if c.Impersonation.Enabled {
if c.Tokenizer.Enabled {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::tokenizer is enabled")
}
if c.APIKeyConfig.Enabled {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::apikey is enabled")
}
}
return nil
}

View File

@@ -23,6 +23,8 @@ type IdentN interface {
GetIdentity(r *http.Request) (*authtypes.Identity, error)
Name() authtypes.IdentNProvider
Enabled() bool
}
// IdentNWithPreHook is optionally implemented by resolvers that need to

View File

@@ -1,96 +0,0 @@
package impersonationidentn
import (
"context"
"net/http"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type provider struct {
config identn.Config
settings factory.ScopedProviderSettings
orgGetter organization.Getter
userGetter user.Getter
userConfig user.Config
mu sync.RWMutex
identity *authtypes.Identity
}
func NewFactory(orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderImpersonation.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(ctx, providerSettings, config, orgGetter, userGetter, userConfig)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) (identn.IdentN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/impersonationidentn")
settings.Logger().WarnContext(ctx, "impersonation identity provider is enabled, all requests will impersonate the root user")
if !userConfig.Root.Enabled {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "root user is not enabled, impersonation identity provider will not be able to resolve any identity")
}
return &provider{
config: config,
settings: settings,
orgGetter: orgGetter,
userGetter: userGetter,
userConfig: userConfig,
}, nil
}
func (provider *provider) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderImpersonation
}
func (provider *provider) Test(_ *http.Request) bool {
return true
}
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
provider.mu.RLock()
if provider.identity != nil {
provider.mu.RUnlock()
return provider.identity, nil
}
provider.mu.RUnlock()
provider.mu.Lock()
defer provider.mu.Unlock()
// Re-check after acquiring write lock; another goroutine may have resolved it.
if provider.identity != nil {
return provider.identity, nil
}
org, _, err := provider.orgGetter.GetByIDOrName(ctx, provider.userConfig.Root.Org.ID, provider.userConfig.Root.Org.Name)
if err != nil {
return nil, err
}
rootUser, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
if err != nil {
return nil, err
}
provider.identity = authtypes.NewIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
rootUser.Role,
authtypes.IdentNProviderImpersonation,
)
return provider.identity, nil
}

View File

@@ -1,11 +1,9 @@
package identn
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type identNResolver struct {
@@ -13,55 +11,19 @@ type identNResolver struct {
settings factory.ScopedProviderSettings
}
func NewIdentNResolver(ctx context.Context, providerSettings factory.ProviderSettings, identNConfig Config, identNFactories factory.NamedMap[factory.ProviderFactory[IdentN, Config]]) (IdentNResolver, error) {
identNs := []IdentN{}
func NewIdentNResolver(providerSettings factory.ProviderSettings, identNs ...IdentN) IdentNResolver {
enabledIdentNs := []IdentN{}
if identNConfig.Impersonation.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderImpersonation.StringValue())
if err != nil {
return nil, err
for _, identN := range identNs {
if identN.Enabled() {
enabledIdentNs = append(enabledIdentNs, identN)
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
if identNConfig.Tokenizer.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderTokenizer.StringValue())
if err != nil {
return nil, err
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
if identNConfig.APIKeyConfig.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderAPIKey.StringValue())
if err != nil {
return nil, err
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
return &identNResolver{
identNs: identNs,
identNs: enabledIdentNs,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn"),
}, nil
}
}
// GetIdentN returns the first IdentN whose Test() returns true.

View File

@@ -48,6 +48,10 @@ func (provider *provider) Test(req *http.Request) bool {
return false
}
func (provider *provider) Enabled() bool {
return provider.config.Tokenizer.Enabled
}
func (provider *provider) Pre(req *http.Request) *http.Request {
accessToken := provider.extractToken(req)
if accessToken == "" {

View File

@@ -3,7 +3,6 @@ package implorganization
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/types"
@@ -23,33 +22,6 @@ func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.Organizat
return module.store.Get(ctx, id)
}
func (module *getter) GetByIDOrName(ctx context.Context, id valuer.UUID, name string) (*types.Organization, bool, error) {
if id.IsZero() {
org, err := module.store.GetByName(ctx, name)
if err != nil {
return nil, false, err
}
return org, true, nil
}
org, err := module.store.Get(ctx, id)
if err == nil {
return org, false, nil
}
if !errors.Ast(err, errors.TypeNotFound) {
return nil, false, err
}
org, err = module.store.GetByName(ctx, name)
if err != nil {
return nil, false, err
}
return org, true, nil
}
func (module *getter) ListByOwnedKeyRange(ctx context.Context) ([]*types.Organization, error) {
start, end, err := module.sharder.GetMyOwnedKeyRange(ctx)
if err != nil {

View File

@@ -12,10 +12,6 @@ type Getter interface {
// Get gets the organization based on the given id
Get(context.Context, valuer.UUID) (*types.Organization, error)
// GetByIDOrName gets the organization by id, falling back to name on not found.
// The boolean is true when the name fallback path was used.
GetByIDOrName(context.Context, valuer.UUID, string) (*types.Organization, bool, error)
// ListByOwnedKeyRange gets all the organizations owned by the instance
ListByOwnedKeyRange(context.Context) ([]*types.Organization, error)

View File

@@ -77,26 +77,52 @@ func (s *service) Stop(ctx context.Context) error {
}
func (s *service) reconcile(ctx context.Context) error {
org, resolvedByName, err := s.orgGetter.GetByIDOrName(ctx, s.config.Org.ID, s.config.Org.Name)
if !s.config.Org.ID.IsZero() {
return s.reconcileWithOrgID(ctx)
}
return s.reconcileByName(ctx)
}
func (s *service) reconcileWithOrgID(ctx context.Context) error {
org, err := s.orgGetter.Get(ctx, s.config.Org.ID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err // something really went wrong
}
if s.config.Org.ID.IsZero() {
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
_, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
// org was not found using id check if we can find an org using name
existingOrgByName, nameErr := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if nameErr != nil && !errors.Ast(nameErr, errors.TypeNotFound) {
return nameErr // something really went wrong
}
// we found an org using name
if existingOrgByName != nil {
// the existing org has the same name as config but org id is different inform user with actionable message
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, existingOrgByName.ID.StringValue(), s.config.Org.ID.StringValue())
}
// default - we did not found any org using id and name both - create a new org
newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name)
_, err = s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
if !s.config.Org.ID.IsZero() && resolvedByName {
// the existing org has the same name as config but org id is different; inform user with actionable message
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, org.ID.StringValue(), s.config.Org.ID.StringValue())
return s.reconcileRootUser(ctx, org.ID)
}
func (s *service) reconcileByName(ctx context.Context) error {
org, err := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
_, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
return err
}
return s.reconcileRootUser(ctx, org.ID)

View File

@@ -3945,53 +3945,67 @@ func (r *ClickHouseReader) GetLogAttributeKeys(ctx context.Context, req *v3.Filt
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetLogAttributeKeys",
})
var query string
var err error
var rows driver.Rows
var response v3.FilterAttributeKeyResponse
tagTypeFilter := `tag_type != 'logfield'`
if req.TagType != "" {
tagTypeFilter = fmt.Sprintf(`tag_type != 'logfield' and tag_type = '%s'`, req.TagType)
}
attributeKeysTable := r.logsDB + "." + r.logsAttributeKeys
resourceAttrKeysTable := r.logsDB + "." + r.logsResourceKeys
if len(req.SearchText) != 0 {
query = fmt.Sprintf("select distinct tag_key, tag_type, tag_data_type from %s.%s where %s and tag_key ILIKE $1 limit $2", r.logsDB, r.logsTagAttributeTableV2, tagTypeFilter)
rows, err = r.db.Query(ctx, query, fmt.Sprintf("%%%s%%", req.SearchText), req.Limit)
} else {
query = fmt.Sprintf("select distinct tag_key, tag_type, tag_data_type from %s.%s where %s limit $1", r.logsDB, r.logsTagAttributeTableV2, tagTypeFilter)
rows, err = r.db.Query(ctx, query, req.Limit)
var tagTypes []string
var tables []string
switch req.TagType {
case v3.TagTypeTag:
tables, tagTypes = []string{attributeKeysTable}, []string{"tag"}
case v3.TagTypeResource:
tables, tagTypes = []string{resourceAttrKeysTable}, []string{"resource"}
case "":
tables, tagTypes = []string{attributeKeysTable, resourceAttrKeysTable}, []string{"tag", "resource"}
default:
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "unsupported tag type: %s", req.TagType)
}
if err != nil {
r.logger.Error("Error while executing query", "error", err)
return nil, fmt.Errorf("error while executing query: %s", err.Error())
}
defer rows.Close()
statements := []model.ShowCreateTableStatement{}
query = fmt.Sprintf("SHOW CREATE TABLE %s.%s", r.logsDB, r.logsLocalTableName)
err = r.db.Select(ctx, &statements, query)
if err != nil {
return nil, fmt.Errorf("error while fetching logs schema: %s", err.Error())
stmtQuery := fmt.Sprintf("SHOW CREATE TABLE %s.%s", r.logsDB, r.logsLocalTableName)
if err := r.db.Select(ctx, &statements, stmtQuery); err != nil {
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while fetching logs schema")
}
var attributeKey string
var attributeDataType string
var tagType string
for rows.Next() {
if err := rows.Scan(&attributeKey, &tagType, &attributeDataType); err != nil {
return nil, fmt.Errorf("error while scanning rows: %s", err.Error())
for i, table := range tables {
tagType := tagTypes[i]
var query string
if len(req.SearchText) != 0 {
query = fmt.Sprintf("select distinct name, lower(datatype) from %s where name ILIKE $1 limit $2", table)
} else {
query = fmt.Sprintf("select distinct name, lower(datatype) from %s limit $1", table)
}
key := v3.AttributeKey{
Key: attributeKey,
DataType: v3.AttributeKeyDataType(attributeDataType),
Type: v3.AttributeKeyType(tagType),
IsColumn: isColumn(statements[0].Statement, tagType, attributeKey, attributeDataType),
var rows driver.Rows
var err error
if len(req.SearchText) != 0 {
rows, err = r.db.Query(ctx, query, fmt.Sprintf("%%%s%%", req.SearchText), req.Limit)
} else {
rows, err = r.db.Query(ctx, query, req.Limit)
}
if err != nil {
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while executing query")
}
response.AttributeKeys = append(response.AttributeKeys, key)
for rows.Next() {
var keyName string
var datatype string
if err := rows.Scan(&keyName, &datatype); err != nil {
rows.Close()
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while scanning rows")
}
key := v3.AttributeKey{
Key: keyName,
DataType: v3.AttributeKeyDataType(datatype),
Type: v3.AttributeKeyType(tagType),
IsColumn: isColumn(statements[0].Statement, tagType, keyName, datatype),
}
response.AttributeKeys = append(response.AttributeKeys, key)
}
rows.Close()
}
// add other attributes only when the tagType is not specified

View File

@@ -125,6 +125,7 @@ type DeprecatedFlags struct {
MaxIdleConns int
MaxOpenConns int
DialTimeout time.Duration
Config string
FluxInterval string
FluxIntervalForTraceDetail string
PreferSpanMetrics bool
@@ -136,6 +137,7 @@ func (df *DeprecatedFlags) RegisterFlags(cmd *cobra.Command) {
cmd.Flags().IntVar(&df.MaxIdleConns, "max-idle-conns", 50, "max idle connections to the database")
cmd.Flags().IntVar(&df.MaxOpenConns, "max-open-conns", 100, "max open connections to the database")
cmd.Flags().DurationVar(&df.DialTimeout, "dial-timeout", 5*time.Second, "dial timeout for the database")
cmd.Flags().StringVar(&df.Config, "config", "./config/prometheus.yml", "(prometheus config to read metrics)")
cmd.Flags().StringVar(&df.FluxInterval, "flux-interval", "5m", "flux interval")
cmd.Flags().StringVar(&df.FluxIntervalForTraceDetail, "flux-interval-for-trace-detail", "2m", "flux interval for trace detail")
cmd.Flags().BoolVar(&df.PreferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)")
@@ -145,6 +147,7 @@ func (df *DeprecatedFlags) RegisterFlags(cmd *cobra.Command) {
_ = cmd.Flags().MarkDeprecated("max-idle-conns", "use SIGNOZ_TELEMETRYSTORE_MAX__IDLE__CONNS instead")
_ = cmd.Flags().MarkDeprecated("max-open-conns", "use SIGNOZ_TELEMETRYSTORE_MAX__OPEN__CONNS instead")
_ = cmd.Flags().MarkDeprecated("dial-timeout", "use SIGNOZ_TELEMETRYSTORE_DIAL__TIMEOUT instead")
_ = cmd.Flags().MarkDeprecated("config", "use SIGNOZ_PROMETHEUS_CONFIG instead")
_ = cmd.Flags().MarkDeprecated("flux-interval", "use SIGNOZ_QUERIER_FLUX__INTERVAL instead")
_ = cmd.Flags().MarkDeprecated("flux-interval-for-trace-detail", "use SIGNOZ_QUERIER_FLUX__INTERVAL instead")
_ = cmd.Flags().MarkDeprecated("cluster", "use SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER instead")
@@ -267,6 +270,10 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge
config.TelemetryStore.Connection.DialTimeout = deprecatedFlags.DialTimeout
}
if deprecatedFlags.Config != "" {
logger.WarnContext(ctx, "[Deprecated] flag --config is deprecated for passing prometheus config. The flag will be used for passing the entire SigNoz config. More details can be found at https://github.com/SigNoz/signoz/issues/6805.")
}
if os.Getenv("INVITE_EMAIL_TEMPLATE") != "" {
logger.WarnContext(ctx, "[Deprecated] env INVITE_EMAIL_TEMPLATE is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_TEMPLATES_DIRECTORY instead.")
config.Emailing.Templates.Directory = path.Dir(os.Getenv("INVITE_EMAIL_TEMPLATE"))

View File

@@ -82,7 +82,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
reflector.SpecSchema().SetTitle("SigNoz")
reflector.SpecSchema().SetDescription("OpenTelemetry-Native Logs, Metrics and Traces in a single pane")
reflector.SpecSchema().SetAPIKeySecurity(authtypes.IdentNProviderAPIKey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys")
reflector.SpecSchema().SetAPIKeySecurity(authtypes.IdentNProviderAPIkey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys")
reflector.SpecSchema().SetHTTPBearerTokenSecurity(authtypes.IdentNProviderTokenizer.StringValue(), "Tokenizer", "Tokens generated by the tokenizer")
collector := handler.NewOpenAPICollector(reflector)

View File

@@ -24,7 +24,6 @@ import (
"github.com/SigNoz/signoz/pkg/global/signozglobal"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/identn/apikeyidentn"
"github.com/SigNoz/signoz/pkg/identn/impersonationidentn"
"github.com/SigNoz/signoz/pkg/identn/tokenizeridentn"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -243,7 +242,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
)
}
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, global global.Global, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
@@ -253,7 +252,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
implsession.NewHandler(modules.Session),
implauthdomain.NewHandler(modules.AuthDomain),
implpreference.NewHandler(modules.Preference),
handlers.Global,
signozglobal.NewHandler(global),
implpromote.NewHandler(modules.Promote),
handlers.FlaggerHandler,
modules.Dashboard,
@@ -277,17 +276,16 @@ func NewTokenizerProviderFactories(cache cache.Cache, sqlstore sqlstore.SQLStore
)
}
func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
return factory.MustNewNamedMap(
impersonationidentn.NewFactory(orgGetter, userGetter, userConfig),
tokenizeridentn.NewFactory(tokenizer),
apikeyidentn.NewFactory(sqlstore),
)
}
func NewGlobalProviderFactories(identNConfig identn.Config) factory.NamedMap[factory.ProviderFactory[global.Global, global.Config]] {
func NewGlobalProviderFactories() factory.NamedMap[factory.ProviderFactory[global.Global, global.Config]] {
return factory.MustNewNamedMap(
signozglobal.NewFactory(identNConfig),
signozglobal.NewFactory(),
)
}

View File

@@ -92,6 +92,7 @@ func TestNewProviderFactories(t *testing.T) {
NewAPIServerProviderFactories(
implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil),
nil,
nil,
Modules{},
Handlers{},
)

View File

@@ -382,7 +382,7 @@ func New(
ctx,
providerSettings,
config.Global,
NewGlobalProviderFactories(config.IdentN),
NewGlobalProviderFactories(),
"signoz",
)
if err != nil {
@@ -393,11 +393,16 @@ func New(
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter)
// Initialize identN resolver
identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer, orgGetter, userGetter, config.User)
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
if err != nil {
return nil, err
identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer)
identNs := []identn.IdentN{}
for _, identNFactory := range identNFactories.GetInOrder() {
identN, err := identNFactory.New(ctx, providerSettings, config.IdentN)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
identNResolver := identn.NewIdentNResolver(providerSettings, identNs...)
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root)
@@ -412,7 +417,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers),
NewAPIServerProviderFactories(orgGetter, authz, global, modules, handlers),
"signoz",
)
if err != nil {

View File

@@ -3,11 +3,10 @@ package authtypes
import "github.com/SigNoz/signoz/pkg/valuer"
var (
IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")}
IdentNProviderAPIKey = IdentNProvider{valuer.NewString("api_key")}
IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")}
IdentNProviderInternal = IdentNProvider{valuer.NewString("internal")}
IdentNProviderImpersonation = IdentNProvider{valuer.NewString("impersonation")}
IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")}
IdentNProviderAPIkey = IdentNProvider{valuer.NewString("api_key")}
IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")}
IdentNProviderInternal = IdentNProvider{valuer.NewString("internal")}
)
type IdentNProvider struct{ valuer.String }

15
pkg/types/global.go Normal file
View File

@@ -0,0 +1,15 @@
package types
import "net/url"
type GettableGlobalConfig struct {
ExternalURL string `json:"external_url"`
IngestionURL string `json:"ingestion_url"`
}
func NewGettableGlobalConfig(externalURL, ingestionURL *url.URL) *GettableGlobalConfig {
return &GettableGlobalConfig{
ExternalURL: externalURL.String(),
IngestionURL: ingestionURL.String(),
}
}

View File

@@ -1,13 +0,0 @@
package globaltypes
type Config struct {
Endpoint
IdentN IdentNConfig `json:"identN"`
}
func NewConfig(endpoint Endpoint, identN IdentNConfig) *Config {
return &Config{
Endpoint: endpoint,
IdentN: identN,
}
}

View File

@@ -1,13 +0,0 @@
package globaltypes
type Endpoint struct {
ExternalURL string `json:"external_url"`
IngestionURL string `json:"ingestion_url"`
}
func NewEndpoint(externalURL, ingestionURL string) Endpoint {
return Endpoint{
ExternalURL: externalURL,
IngestionURL: ingestionURL,
}
}

View File

@@ -1,27 +0,0 @@
package globaltypes
type IdentNConfig struct {
Tokenizer TokenizerConfig `json:"tokenizer"`
APIKey APIKeyConfig `json:"apikey"`
Impersonation ImpersonationConfig `json:"impersonation"`
}
type TokenizerConfig struct {
Enabled bool `json:"enabled"`
}
type APIKeyConfig struct {
Enabled bool `json:"enabled"`
}
type ImpersonationConfig struct {
Enabled bool `json:"enabled"`
}
func NewIdentNConfig(tokenizer TokenizerConfig, apiKey APIKeyConfig, impersonation ImpersonationConfig) IdentNConfig {
return IdentNConfig{
Tokenizer: tokenizer,
APIKey: apiKey,
Impersonation: impersonation,
}
}

View File

@@ -172,7 +172,7 @@ def clickhouse(
(
'version="v0.0.1" && '
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
'node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && '
"cd /tmp && "
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
"tar -xzf histogram-quantile.tar.gz && "

View File

@@ -2,7 +2,6 @@ import platform
import time
from http import HTTPStatus
from os import path
from typing import Optional
import docker
import docker.errors
@@ -17,7 +16,8 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def create_signoz(
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
@@ -25,12 +25,9 @@ def create_signoz(
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "signoz",
env_overrides: Optional[dict] = None,
) -> types.SigNoz:
"""
Factory function for creating a SigNoz container.
Accepts optional env_overrides to customize the container environment.
Package-scoped fixture for setting up SigNoz.
"""
def create() -> types.SigNoz:
@@ -84,9 +81,6 @@ def create_signoz(
if with_web:
env["SIGNOZ_WEB_ENABLED"] = True
if env_overrides:
env = env | env_overrides
container = DockerContainer("signoz:integration")
for k, v in env.items():
container.with_env(k, v)
@@ -175,7 +169,7 @@ def create_signoz(
return dev.wrap(
request,
pytestconfig,
cache_key,
"signoz",
empty=lambda: types.SigNoz(
self=types.TestContainerDocker(
id="",
@@ -191,27 +185,3 @@ def create_signoz(
delete=delete,
restore=restore,
)
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped fixture for setting up SigNoz.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
)

View File

@@ -664,9 +664,7 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
# --- Step 3: SSO login should be blocked for deleted user ---
create_user_idp(email, "password", True, "SAML", "Lifecycle")
perform_saml_login(
signoz, driver, get_session_context, idp_login, email, "password"
)
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
# Verify user is NOT reactivated — check via DB since API may filter deleted users
with signoz.sqlstore.conn.connect() as conn:
@@ -685,11 +683,7 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
headers={"Authorization": f"Bearer {admin_token}"},
)
found_user = next(
(
user
for user in response.json()["data"]
if user["email"] == email and user["id"] != user_id
),
(user for user in response.json()["data"] if user["email"] == email and user["id"] != user_id),
None,
)
assert found_user is not None

View File

@@ -4,6 +4,7 @@ from urllib.parse import urlparse
import requests
from selenium import webdriver
from sqlalchemy import sql
from wiremock.resources.mappings import Mapping
from fixtures.auth import (

View File

@@ -6,6 +6,7 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.cloudintegrations import create_cloud_integration_account
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.logger import setup_logger
@@ -167,14 +168,14 @@ def test_duplicate_cloud_account_checkins(
assert account1_id != account2_id, "Two accounts should have different internal IDs"
# First check-in succeeds: account1 claims cloud_account_id
# First check-in succeeds: account1 claims cloud_account_id
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account1_id, same_cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
#
#
# Second check-in should fail: account2 tries to use the same cloud_account_id
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account2_id, same_cloud_account_id

View File

@@ -21,9 +21,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
GATEWAY_APIS_EDITOR_EMAIL = "gatewayapiseditor@integration.test"
GATEWAY_APIS_EDITOR_PASSWORD = "password123Z$"
def test_apply_license(
signoz: types.SigNoz,
@@ -35,31 +32,6 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
def test_create_editor_user(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Invite and register an editor user for gateway API tests."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": GATEWAY_APIS_EDITOR_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert invite_response.status_code == HTTPStatus.CREATED
reset_token = invite_response.json()["data"]["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": GATEWAY_APIS_EDITOR_PASSWORD, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# ---------------------------------------------------------------------------
# Ingestion key CRUD
# ---------------------------------------------------------------------------
@@ -72,7 +44,7 @@ def test_create_ingestion_key(
get_token: Callable[[str, str], str],
) -> None:
"""POST /api/v2/gateway/ingestion_keys creates a key via the gateway."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
make_http_mocks(
signoz.gateway,
@@ -105,7 +77,7 @@ def test_create_ingestion_key(
"tags": ["env:test", "team:platform"],
"expires_at": "2030-01-01T00:00:00Z",
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -131,7 +103,7 @@ def test_get_ingestion_keys(
get_token: Callable[[str, str], str],
) -> None:
"""GET /api/v2/gateway/ingestion_keys lists keys via the gateway."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Default page=1, per_page=10 → gateway gets ?page=1&per_page=10
make_http_mocks(
@@ -174,7 +146,7 @@ def test_get_ingestion_keys(
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/gateway/ingestion_keys"),
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -196,7 +168,7 @@ def test_get_ingestion_keys_custom_pagination(
get_token: Callable[[str, str], str],
) -> None:
"""GET /api/v2/gateway/ingestion_keys with custom pagination params."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
make_http_mocks(
signoz.gateway,
@@ -228,7 +200,7 @@ def test_get_ingestion_keys_custom_pagination(
signoz.self.host_configs["8080"].get(
"/api/v2/gateway/ingestion_keys?page=2&per_page=5"
),
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -249,7 +221,7 @@ def test_search_ingestion_keys(
get_token: Callable[[str, str], str],
) -> None:
"""GET /api/v2/gateway/ingestion_keys/search searches keys by name."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# name, page, per_page are sorted alphabetically by Go url.Values.Encode()
make_http_mocks(
@@ -294,7 +266,7 @@ def test_search_ingestion_keys(
signoz.self.host_configs["8080"].get(
"/api/v2/gateway/ingestion_keys/search?name=my-test"
),
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -314,7 +286,7 @@ def test_search_ingestion_keys_empty(
get_token: Callable[[str, str], str],
) -> None:
"""Search returns an empty list when no keys match."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
make_http_mocks(
signoz.gateway,
@@ -346,7 +318,7 @@ def test_search_ingestion_keys_empty(
signoz.self.host_configs["8080"].get(
"/api/v2/gateway/ingestion_keys/search?name=nonexistent"
),
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -366,7 +338,7 @@ def test_update_ingestion_key(
get_token: Callable[[str, str], str],
) -> None:
"""PATCH /api/v2/gateway/ingestion_keys/{keyId} updates a key via the gateway."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}"
@@ -394,7 +366,7 @@ def test_update_ingestion_key(
"tags": ["env:prod"],
"expires_at": "2031-06-15T00:00:00Z",
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -416,7 +388,7 @@ def test_delete_ingestion_key(
get_token: Callable[[str, str], str],
) -> None:
"""DELETE /api/v2/gateway/ingestion_keys/{keyId} deletes a key via the gateway."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}"
@@ -439,7 +411,7 @@ def test_delete_ingestion_key(
signoz.self.host_configs["8080"].get(
f"/api/v2/gateway/ingestion_keys/{TEST_KEY_ID}"
),
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -10,7 +10,7 @@ from wiremock.client import (
)
from fixtures import types
from fixtures.auth import add_license
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.gatewayutils import (
TEST_KEY_ID,
TEST_LIMIT_ID,
@@ -22,9 +22,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
GATEWAY_APIS_EDITOR_EMAIL = "gatewayapiseditor@integration.test"
GATEWAY_APIS_EDITOR_PASSWORD = "password123Z$"
def test_apply_license(
signoz: types.SigNoz,
@@ -48,7 +45,7 @@ def test_create_ingestion_key_limit_only_size(
get_token: Callable[[str, str], str],
) -> None:
"""Creating a limit with only size omits count from the gateway payload."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits"
@@ -82,7 +79,7 @@ def test_create_ingestion_key_limit_only_size(
"config": {"day": {"size": 1000}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -108,7 +105,7 @@ def test_create_ingestion_key_limit_only_count(
get_token: Callable[[str, str], str],
) -> None:
"""Creating a limit with only count omits size from the gateway payload."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits"
@@ -142,7 +139,7 @@ def test_create_ingestion_key_limit_only_count(
"config": {"day": {"count": 500}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -165,7 +162,7 @@ def test_create_ingestion_key_limit_both_size_and_count(
get_token: Callable[[str, str], str],
) -> None:
"""Creating a limit with both size and count includes both in the gateway payload."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits"
@@ -202,7 +199,7 @@ def test_create_ingestion_key_limit_both_size_and_count(
},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -232,7 +229,7 @@ def test_update_ingestion_key_limit_only_size(
get_token: Callable[[str, str], str],
) -> None:
"""Updating a limit with only size omits count from the gateway payload."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -259,7 +256,7 @@ def test_update_ingestion_key_limit_only_size(
"config": {"day": {"size": 2000}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -282,7 +279,7 @@ def test_update_ingestion_key_limit_only_count(
get_token: Callable[[str, str], str],
) -> None:
"""Updating a limit with only count omits size from the gateway payload."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -309,7 +306,7 @@ def test_update_ingestion_key_limit_only_count(
"config": {"day": {"count": 750}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -331,7 +328,7 @@ def test_update_ingestion_key_limit_both_size_and_count(
get_token: Callable[[str, str], str],
) -> None:
"""Updating a limit with both size and count includes both in the gateway payload."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -358,7 +355,7 @@ def test_update_ingestion_key_limit_both_size_and_count(
"config": {"day": {"size": 1000, "count": 500}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -385,7 +382,7 @@ def test_delete_ingestion_key_limit(
get_token: Callable[[str, str], str],
) -> None:
"""DELETE /api/v2/gateway/ingestion_keys/limits/{limitId} deletes a limit."""
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -408,7 +405,7 @@ def test_delete_ingestion_key_limit(
signoz.self.host_configs["8080"].get(
f"/api/v2/gateway/ingestion_keys/limits/{TEST_LIMIT_ID}"
),
headers={"Authorization": f"Bearer {editor_token}"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -110,7 +110,9 @@ def test_invite_and_register(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "editor@integration.test", "role": "EDITOR", "name": "editor"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
headers={
"Authorization": f"Bearer {admin_token}"
},
)
assert response.status_code == HTTPStatus.CREATED
@@ -137,7 +139,9 @@ def test_invite_and_register(
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {editor_token}"},
headers={
"Authorization": f"Bearer {editor_token}"
},
)
assert response.status_code == HTTPStatus.FORBIDDEN
@@ -190,6 +194,7 @@ def test_revoke_invite_and_register(
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try to use the reset token — should fail (user deleted)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
@@ -234,11 +239,7 @@ def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], s
# invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": "oldinviteflow@integration.test",
"role": "VIEWER",
"name": "old invite flow",
},
json={"email": "oldinviteflow@integration.test", "role": "VIEWER", "name": "old invite flow"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
@@ -248,7 +249,9 @@ def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], s
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
headers={
"Authorization": f"Bearer {admin_token}"
},
)
invite_response = response.json()["data"]
@@ -294,17 +297,15 @@ def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], s
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
headers={
"Authorization": f"Bearer {admin_token}"
},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "oldinviteflow@integration.test"
),
(user for user in user_response if user["email"] == "oldinviteflow@integration.test"),
None,
)

View File

@@ -63,9 +63,7 @@ def test_api_key(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
assert found_pat["role"] == "ADMIN"
def test_api_key_role(
signoz: types.SigNoz, get_token: Callable[[str, str], str]
) -> None:
def test_api_key_role(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
response = requests.post(

View File

@@ -6,6 +6,8 @@ import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import SigNoz
from sqlalchemy import sql
def test_reinvite_deleted_user(
signoz: SigNoz,
@@ -29,11 +31,7 @@ def test_reinvite_deleted_user(
# invite the user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": reinvite_user_email,
"role": reinvite_user_role,
"name": reinvite_user_name,
},
json={"email": reinvite_user_email, "role": reinvite_user_role, "name": reinvite_user_name},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -60,27 +58,21 @@ def test_reinvite_deleted_user(
# Re-invite the same email — should succeed
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": reinvite_user_email,
"role": "VIEWER",
"name": "reinvite user v2",
},
json={"email": reinvite_user_email, "role": "VIEWER", "name": "reinvite user v2"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
reinvited_user = response.json()["data"]
assert reinvited_user["role"] == "VIEWER"
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
assert reinvited_user["role"] == "VIEWER"
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
reinvited_user_reset_password_token = reinvited_user["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={
"password": "newPassword123Z$",
"token": reinvited_user_reset_password_token,
},
json={"password": "newPassword123Z$", "token": reinvited_user_reset_password_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -103,16 +95,8 @@ def test_bulk_invite(
signoz.self.host_configs["8080"].get("/api/v1/invite/bulk"),
json={
"invites": [
{
"email": "bulk1@integration.test",
"role": "EDITOR",
"name": "bulk user 1",
},
{
"email": "bulk2@integration.test",
"role": "VIEWER",
"name": "bulk user 2",
},
{"email": "bulk1@integration.test", "role": "EDITOR", "name": "bulk user 1"},
{"email": "bulk2@integration.test", "role": "VIEWER", "name": "bulk user 2"},
]
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -42,11 +42,7 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 1: invite and delete the first user
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"name": "unique index user v1",
},
json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v1"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -63,11 +59,7 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 2: re-invite and delete the same email (second deleted row)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"name": "unique index user v2",
},
json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v2"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -93,9 +85,9 @@ def test_unique_index_allows_multiple_deleted_rows(
)
deleted_rows = result.fetchall()
assert (
len(deleted_rows) == 2
), f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}"
assert len(deleted_rows) == 2, (
f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}"
)
deleted_ids = {row[0] for row in deleted_rows}
assert first_user_id in deleted_ids
assert second_user_id in deleted_ids

View File

@@ -585,14 +585,13 @@ def test_metrics_fill_formula_with_group_by(
context=f"metrics/{fill_mode}/F1/{group}",
)
def test_histogram_p90_returns_404_outside_data_window(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[List[Metrics]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
metric_name = "test_p90_last_seen_bucket"

View File

@@ -373,7 +373,6 @@ def test_histogram_count_no_param(
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert values[-1]["value"] == last_values[le]
@pytest.mark.parametrize(
"space_agg, zeroth_value, first_value, last_value",
[
@@ -424,7 +423,6 @@ def test_histogram_percentile_for_all_services(
assert result_values[1]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"space_agg, first_value, last_value",
[
@@ -474,7 +472,6 @@ def test_histogram_percentile_for_cumulative_service(
assert result_values[0]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"space_agg, zeroth_value, first_value, last_value",
[
@@ -524,4 +521,4 @@ def test_histogram_percentile_for_delta_service(
assert len(result_values) == 60
assert result_values[0]["value"] == zeroth_value
assert result_values[1]["value"] == first_value
assert result_values[-1]["value"] == last_value
assert result_values[-1]["value"] == last_value

View File

@@ -1,31 +0,0 @@
import time
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_root_user_created(signoz: types.SigNoz) -> None:
"""
The root user service reconciles asynchronously after startup.
Wait until the root user is available by polling /api/v1/version.
"""
for attempt in range(15):
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/version"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
if response.json().get("setupCompleted") is True:
return
logger.info(
"Attempt %s: setupCompleted is not yet true, retrying ...",
attempt + 1,
)
time.sleep(2)
raise AssertionError("root user was not created within the expected time")

View File

@@ -1,62 +0,0 @@
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_global_config_returns_impersonation_enabled(signoz: types.SigNoz) -> None:
"""
GET /api/v1/global/config without any auth header should return 200
and report impersonation as enabled.
"""
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/global/config"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["identN"]["impersonation"]["enabled"] is True
assert data["identN"]["tokenizer"]["enabled"] is False
assert data["identN"]["apikey"]["enabled"] is False
def test_unauthenticated_request_succeeds(signoz: types.SigNoz) -> None:
"""
With impersonation enabled, requests without any auth header
should succeed as the root user (admin).
"""
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
def test_impersonated_user_is_admin(signoz: types.SigNoz) -> None:
"""
The impersonated identity should have admin privileges.
Listing users is an admin-only endpoint.
"""
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
assert len(users) >= 1
root_user = next(
(u for u in users if u.get("isRoot") is True),
None,
)
assert root_user is not None
assert root_user["role"] == "ADMIN"

View File

@@ -1,41 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
ROOT_USER_EMAIL = "rootuser@integration.test"
ROOT_USER_PASSWORD = "password123Z$"
@pytest.fixture(name="signoz", scope="package")
def signoz_rootuser(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped fixture for SigNoz with root user and impersonation enabled.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-rootuser",
env_overrides={
"SIGNOZ_IDENTN_IMPERSONATION_ENABLED": True,
"SIGNOZ_IDENTN_TOKENIZER_ENABLED": False,
"SIGNOZ_IDENTN_APIKEY_ENABLED": False,
"SIGNOZ_USER_ROOT_ENABLED": True,
"SIGNOZ_USER_ROOT_EMAIL": ROOT_USER_EMAIL,
"SIGNOZ_USER_ROOT_PASSWORD": ROOT_USER_PASSWORD,
},
)