Compare commits

..

7 Commits

Author SHA1 Message Date
swapnil-signoz
20085fc03f chore: generating OpenAPI spec 2026-06-09 00:08:17 +05:30
swapnil-signoz
bc4a033799 refactor: updating metric names in integration config 2026-06-08 23:53:27 +05:30
swapnil-signoz
7ff6c43460 feat: adding support for Azure AKS 2026-06-08 23:45:46 +05:30
Nikhil Soni
c688170a13 feat(trace-detail): add flamegraph v3 with optimized memory consumption (part 1) (#11462)
* feat: add types for flamegraph v3 in module structure

* chore: remove limit from request payload

It's a new api so doesn't need to be backward compatible

* feat: add config for flamegraph

* feat: add method to enrich selected spans

* feat: add api and module for flamegraph v3

* feat: query full spans for smaller traces

* chore: move exported methods to the top

* chore: ignore nil assigment lint error

* chore: extract out flamegraph building logic for easier review

* chore: update openapi specs

* chore: move flamegraph after aggregation to reduce diff

* chore: remove un related changes to keep diff minimum

* chore: avoid passing request type to module

* feat: return selected fields only in flamegraph

And reduce the no. of fields scanned from db

* chore: make array fields required and non nullable

* chore: update openapi specs

* chore: mark all fields in response as required

* refactor: switch to using group by instead of distinct on

Since group by is faster is enough no. of threads are used

* chore: remove service name root level field from flamegraph span

* chore: update openapi specs

* fix: update alias in order by of the query

* chore: remove unnecessary nil check

* fix: set empty array for missing data

* chore: use single line for error creation

* chore: mark response fields as required

* chore: update openapi specs

* chore: add test to verify the flamegraph query sql

* chore: update openapi specs

* chore: use orderbyasc instead of order by
2026-06-08 17:29:30 +00:00
Vikrant Gupta
7844fc1fe1 fix(authn): include base path in SSO callback and error-redirect URLs (#11588)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(authn): include base path in SSO callback and error-redirect URLs

The SAML ACS URL and the OIDC/Google redirect URLs were built from the
site URL host plus a hardcoded path (e.g. /api/v1/complete/saml), dropping
the base path. When SigNoz is served under a sub-path (global.external_url
with a path, e.g. https://example.com/signoz), the API is served at
<prefix>/api/v1/complete/<provider>, so the identity provider was told to
call back to a path without the prefix and hit a 404.

Thread global.Config into the SAML/OIDC/Google callback providers and the
session handler, and prepend global.Config.ExternalPath() to the callback
paths and the SSO error redirect to /login. Root deployments are
unchanged since ExternalPath() returns "" without a configured sub-path.

* fix(authn): run callbackauthn suite with base path

* refactor(tests): self-contained base-path fixture for callbackauthn

Move the base-path setup out of the shared create_signoz factory and into
a package-scoped signoz fixture in the callbackauthn suite's own conftest
(same pattern as rootuser/conftest.py). When --base-path is set the fixture
appends SIGNOZ_GLOBAL_EXTERNAL__URL and the url-config prefix locally;
without it it behaves exactly like the global fixture. The shared factory
and docker config are left untouched.

* test(authn): add base-path SSO integration suite

Adds a dedicated `basepath` integration suite that serves SigNoz under a
hardcoded /signoz prefix (SIGNOZ_GLOBAL_EXTERNAL__URL) and exercises the SAML
and OIDC happy-path logins end-to-end. Every SigNoz API call is issued under
the prefix and the IdP callback (ACS / redirect URI) is registered with the
prefix, so the flow only passes when the backend builds prefixed callback URLs.

The shared TestContainerUrlConfig and create_signoz factory are left untouched.
The suite's conftest shadows the same-named auth fixtures (create_user_admin,
get_token, get_session_context, apply_license) with base-path-aware variants and
reuses the Keycloak/browser fixtures, which are not under the base path.

Google SSO is not covered: it requires the real accounts.google.com issuer and
a real Google login, so it cannot run against the local Keycloak IdP; it shares
the identical path.Join(ExternalPath, redirectPath) callback logic that SAML
and OIDC validate.

* revert: drop in-place base-path wiring from integration harness

Removes the --base-path flag, TestContainerUrlConfig.base_path, the idp.py and
02_saml.py .get() changes, and the callbackauthn base-path conftest fixture.
Base-path SSO is now covered by the dedicated `basepath` suite, so the shared
harness (TestContainerUrlConfig, create_signoz, callbackauthn) is back to its
original root-only form.

* refactor(test): remove apply_license fixture

* refactor(test): extract base-path-aware auth factories

Extract the session-context / token / token-pair / admin-registration logic
in fixtures/auth.py into reusable factory functions that take an optional
base_path (token_getter, session_context_getter, tokens_getter, register_admin),
with the fixtures delegating to them. Default base_path="" is byte-identical for
existing callers.

The basepath suite's conftest now reuses these factories with the /signoz prefix
as thin one-line fixture overrides instead of duplicating the request logic.

* refactor(test): give base-path admin registration a distinct cache key

register_admin takes an optional cache_key (default "create_user_admin"); the
basepath suite passes a distinct key so that under --reuse the admin marker
cached against the signoz-base-path container is not restored for (or from)
other suites' default signoz instance.
2026-06-08 14:15:04 +00:00
Vinicius Lourenço
e02da843f2 fix(infra-monitoring-charts): fixes for hosts/deployments/jobs/namespaces (#11599)
* fix(infra-monitoring-jobs): title of the chart misleading

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issuecomment-4619888389

* fix(infra-monitoring-namespaces): wrong limit & using wrong filter for statefulsets

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issuecomment-4619023361

* feat(infra-monitoring-hosts): add new chart based on operations time

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issue-4578950064

* feat(infra-monitoring-hosts): add group by on chart for system disk io

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issuecomment-4611797867

* fix(infra-monitoring-hosts): chart for disk operations using the wrong metric

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issue-4578950064

* fix(infra-monitoring-deployments): little typo in the chart name

* fix(volumes): ensure the name/type are standard based on the metric type
2026-06-08 11:45:27 +00:00
Ashwin Bhatkal
0948a983c3 feat(dashboards): V2 dashboard — settings, configure drawer & inline title (#11581)
* refactor(dashboard-v2): name props interfaces by component (Props → <Component>Props)

* feat(dashboard-v2): shared header chrome + confirm-delete dialog

* feat(dashboard-v2): dashboard settings drawer — general, variables/publish tabs

* feat(dashboard-v2): sections & panels — empty states, menus, theming, review fixes

* feat(dashboard-v2): dashboard header — inline-editable title, actions menu

* feat(dashboard-v2): container wiring + new-panel flow
2026-06-08 08:13:06 +00:00
97 changed files with 5827 additions and 832 deletions

View File

@@ -39,6 +39,7 @@ jobs:
matrix:
suite:
- alerts
- basepath
- callbackauthn
- cloudintegrations
- dashboard
@@ -83,7 +84,7 @@ jobs:
run: |
cd tests && uv sync
- name: webdriver
if: matrix.suite == 'callbackauthn'
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list

View File

@@ -91,7 +91,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)

View File

@@ -107,17 +107,17 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing, config.Global)
if err != nil {
return nil, err
}
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings, config.Global)
if err != nil {
return nil, err
}
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
if err != nil {
return nil, err
}

View File

@@ -440,6 +440,17 @@ traces:
max_depth_to_auto_expand: 5
# Threshold below which all spans are returned without windowing.
max_limit_to_select_all_spans: 10000
flamegraph:
# Maximum number of BFS depth levels included in a windowed response.
max_selected_levels: 50
# Maximum spans per level before sampling is applied.
max_spans_per_level: 100
# Number of highest-latency spans always included when sampling a level.
sampling_top_latency_count: 5
# Number of timestamp buckets used for uniform sampling within a level.
sampling_bucket_count: 50
# Threshold below which all spans are returned without windowing or sampling.
select_all_spans_limit: 100000
##################### Authz #################################
authz:

View File

@@ -1360,6 +1360,7 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- aks
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -6638,6 +6639,70 @@ components:
- attribute
- resource
type: string
SpantypesFlamegraphSpan:
properties:
attributes:
additionalProperties: {}
type: object
durationNano:
minimum: 0
type: integer
event:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
hasError:
type: boolean
level:
format: int64
type: integer
name:
type: string
parentSpanId:
type: string
resource:
additionalProperties:
type: string
type: object
spanId:
type: string
timestamp:
minimum: 0
type: integer
required:
- spanId
- parentSpanId
- timestamp
- durationNano
- hasError
- name
- level
- event
- attributes
- resource
type: object
SpantypesGettableFlamegraphTrace:
properties:
endTimestampMillis:
format: int64
type: integer
hasMore:
type: boolean
spans:
items:
items:
$ref: '#/components/schemas/SpantypesFlamegraphSpan'
type: array
type: array
startTimestampMillis:
format: int64
type: integer
required:
- spans
- startTimestampMillis
- endTimestampMillis
- hasMore
type: object
SpantypesGettableSpanMapperGroups:
properties:
items:
@@ -6703,6 +6768,15 @@ components:
traceId:
type: string
type: object
SpantypesPostableFlamegraph:
properties:
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
selectedSpanId:
type: string
type: object
SpantypesPostableSpanMapper:
properties:
config:
@@ -20535,6 +20609,75 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/traces/{traceID}/flamegraph:
post:
deprecated: false
description: Returns the flamegraph view of spans for a given trace ID.
operationId: GetFlamegraph
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableFlamegraph'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableFlamegraphTrace'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get flamegraph view for a trace
tags:
- tracedetail
/api/v3/traces/{traceID}/waterfall:
post:
deprecated: false

View File

@@ -5,10 +5,12 @@ import (
"fmt"
"log/slog"
"net/url"
"path"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -26,13 +28,14 @@ var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
globalConfig global.Config
}
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -41,10 +44,11 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
return &AuthN{
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
globalConfig: globalConfig,
}, nil
}
@@ -197,7 +201,7 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: redirectPath,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
}).String(),
}, nil
}

View File

@@ -6,10 +6,12 @@ import (
"encoding/base64"
"encoding/pem"
"net/url"
"path"
"strings"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -24,14 +26,16 @@ const (
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
licensing licensing.Licensing
store authtypes.AuthNStore
licensing licensing.Licensing
globalConfig global.Config
}
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (*AuthN, error) {
return &AuthN{
store: store,
licensing: licensing,
store: store,
licensing: licensing,
globalConfig: globalConfig,
}, nil
}
@@ -132,7 +136,7 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
return nil, err
}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: path.Join(a.globalConfig.ExternalPath(), redirectPath)}
// Note:
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.

View File

@@ -2651,6 +2651,7 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
aks = 'aks',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -7769,6 +7770,77 @@ export enum SpantypesFieldContextDTO {
attribute = 'attribute',
resource = 'resource',
}
export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
export interface SpantypesFlamegraphSpanDTO {
/**
* @type object
*/
attributes: SpantypesFlamegraphSpanDTOAttributes;
/**
* @type integer
* @minimum 0
*/
durationNano: number;
/**
* @type array
*/
event: SpantypesEventDTO[];
/**
* @type boolean
*/
hasError: boolean;
/**
* @type integer
* @format int64
*/
level: number;
/**
* @type string
*/
name: string;
/**
* @type string
*/
parentSpanId: string;
/**
* @type object
*/
resource: SpantypesFlamegraphSpanDTOResource;
/**
* @type string
*/
spanId: string;
/**
* @type integer
* @minimum 0
*/
timestamp: number;
}
export interface SpantypesGettableFlamegraphTraceDTO {
/**
* @type integer
* @format int64
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMore: boolean;
/**
* @type array
*/
spans: SpantypesFlamegraphSpanDTO[][];
/**
* @type integer
* @format int64
*/
startTimestampMillis: number;
}
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
/**
* @type array,null
@@ -8070,6 +8142,17 @@ export interface SpantypesGettableWaterfallTraceDTO {
uncollapsedSpans?: string[] | null;
}
export interface SpantypesPostableFlamegraphDTO {
/**
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @type string
*/
selectedSpanId?: string;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
@@ -10424,6 +10507,17 @@ export type GetHosts200 = {
status: string;
};
export type GetFlamegraphPathParameters = {
traceID: string;
};
export type GetFlamegraph200 = {
data: SpantypesGettableFlamegraphTraceDTO;
/**
* @type string
*/
status: string;
};
export type GetWaterfallPathParameters = {
traceID: string;
};

View File

@@ -12,6 +12,8 @@ import type {
} from 'react-query';
import type {
GetFlamegraph200,
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
@@ -19,6 +21,7 @@ import type {
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableFlamegraphDTO,
SpantypesPostableTraceAggregationsDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
@@ -126,6 +129,105 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace
*/
export const getFlamegraph = (
{ traceID }: GetFlamegraphPathParameters,
spantypesPostableFlamegraphDTO?: BodyType<SpantypesPostableFlamegraphDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFlamegraph200>({
url: `/api/v3/traces/${traceID}/flamegraph`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableFlamegraphDTO,
signal,
});
};
export const getGetFlamegraphMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
> => {
const mutationKey = ['getFlamegraph'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getFlamegraph>>,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getFlamegraph(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetFlamegraphMutationResult = NonNullable<
Awaited<ReturnType<typeof getFlamegraph>>
>;
export type GetFlamegraphMutationBody =
| BodyType<SpantypesPostableFlamegraphDTO>
| undefined;
export type GetFlamegraphMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get flamegraph view for a trace
*/
export const useGetFlamegraph = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
> => {
return useMutation(getGetFlamegraphMutationOptions(options));
};
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace

View File

@@ -72,7 +72,7 @@ export const deploymentWidgetInfo = [
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits)',
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
},
{

View File

@@ -69,7 +69,7 @@ export const jobWidgetInfo = [
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits',
title: 'Memory Usage',
yAxisUnit: 'bytes',
},
{

View File

@@ -703,7 +703,7 @@ export const getNamespaceMetricsQueryPayload = (
],
having: [],
legend: `{{${k8sPodNameKey}}}`,
limit: 20,
limit: 10,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
@@ -1014,8 +1014,8 @@ export const getNamespaceMetricsQueryPayload = (
id: '5f2a55c5',
key: {
dataType: DataTypes.String,
id: k8sStatefulsetNameKey,
key: k8sStatefulsetNameKey,
id: k8sNamespaceNameKey,
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',

View File

@@ -317,9 +317,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes_used--float64----true',
id: 'k8s_volume_inodes_used--float64--Gauge--true',
key: k8sVolumeInodesUsedKey,
type: '',
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
@@ -409,9 +409,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes--float64----true',
id: 'k8s_volume_inodes--float64--Gauge--true',
key: k8sVolumeInodesKey,
type: '',
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
@@ -501,9 +501,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes_free--float64----true',
id: 'k8s_volume_inodes_free--float64--Gauge--true',
key: k8sVolumeInodesFreeKey,
type: '',
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,

View File

@@ -1619,6 +1619,9 @@ export const getHostQueryPayload = (
const diskOpTimeKey = dotMetricsEnabled
? 'system.disk.operation_time'
: 'system_disk_operation_time';
const diskOpsKey = dotMetricsEnabled
? 'system.disk.operations'
: 'system_disk_operations';
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
@@ -2375,9 +2378,24 @@ export const getHostQueryPayload = (
op: 'AND',
},
functions: [],
groupBy: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'device--string--tag--false',
key: 'device',
type: 'tag',
},
],
having: [],
legend: 'system disk io',
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
queryName: 'A',
@@ -2409,9 +2427,9 @@ export const getHostQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_disk_operation_time--float64--Sum--true',
id: 'system_disk_operations--float64--Sum--true',
key: diskOpTimeKey,
key: diskOpsKey,
type: 'Sum',
},
aggregateOperator: 'rate',
@@ -2421,7 +2439,7 @@ export const getHostQueryPayload = (
filters: {
items: [
{
id: 'diskop_f1',
id: 'diskops_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
@@ -2454,7 +2472,7 @@ export const getHostQueryPayload = (
],
having: [
{
columnName: `SUM(${diskOpTimeKey})`,
columnName: `SUM(${diskOpsKey})`,
op: '>',
value: 0,
},
@@ -2557,6 +2575,88 @@ export const getHostQueryPayload = (
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_disk_operation_time--float64--Sum--true',
key: diskOpTimeKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'diskoptime_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'device--string--tag--false',
key: 'device',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${diskOpTimeKey})`,
op: '>',
value: 0,
},
],
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: 'a8b3d2e1-4f5c-4a6b-9c8d-7e2f1a0b3c4f',
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};
@@ -2631,5 +2731,5 @@ export const hostWidgetInfo = [
{ title: 'System disk io (bytes transferred)', yAxisUnit: 'bytes' },
{ title: 'System disk operations/s', yAxisUnit: 'short' },
{ title: 'Queue size', yAxisUnit: 'short' },
{ title: 'Disk operations time', yAxisUnit: 's' },
{ title: 'System disk operation time/s', yAxisUnit: 's' },
];

View File

@@ -1,29 +1,36 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
ClipboardCopy,
Configure,
Ellipsis,
FileJson,
Fullscreen,
LockKeyhole,
PenLine,
Plus,
Trash2,
} from '@signozhq/icons';
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import ROUTES from 'constants/routes';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import SettingsDrawer from '../SettingsDrawer';
import styles from '../DashboardDescription.module.scss';
interface Props {
interface DashboardActionsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
@@ -45,17 +52,19 @@ function DashboardActions({
onAddPanel,
onLockToggle,
onOpenRename,
}: Props): JSX.Element {
}: DashboardActionsProps): JSX.Element {
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id;
const id = dashboard.id ?? '';
const title = dashboard.spec?.display?.name ?? '';
const [isDashboardSettingsOpen, setIsDashboardSettingsOpen] =
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(id);
useEffect(() => {
if (state.error) {
@@ -66,9 +75,12 @@ function DashboardActions({
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string => JSON.stringify(dashboard, null, 2);
const dashboardDataJSON = useCallback(
(): string => JSON.stringify(dashboard, null, 2),
[dashboard],
);
const exportJSON = (): void => {
const exportJSON = useCallback((): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -78,119 +90,141 @@ function DashboardActions({
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
}, [dashboardDataJSON, title]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
setIsDeleteOpen(false);
history.replace(ROUTES.ALL_DASHBOARD);
},
});
}, [deleteDashboardMutation]);
const menuItems = useMemo<MenuItem[]>(() => {
const editGroup: MenuItem[] = [];
if (!isDashboardLocked && editDashboard) {
editGroup.push({
key: 'rename',
label: 'Rename',
icon: <PenLine size={14} />,
onClick: onOpenRename,
});
}
if (isAuthor || user.role === USER_ROLES.ADMIN) {
editGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
icon: <LockKeyhole size={14} />,
disabled: dashboard.createdBy === 'integration',
onClick: onLockToggle,
});
}
editGroup.push({
key: 'fullscreen',
label: 'Full screen',
icon: <Fullscreen size={14} />,
onClick: handle.enter,
});
const exportGroup: MenuItem[] = [
{
key: 'export',
label: 'Export JSON',
icon: <FileJson size={14} />,
onClick: exportJSON,
},
{
key: 'copy',
label: 'Copy as JSON',
icon: <ClipboardCopy size={14} />,
onClick: (): void => setCopy(dashboardDataJSON()),
},
];
const dangerGroup: MenuItem[] = [
{
key: 'delete',
label: 'Delete dashboard',
icon: <Trash2 size={14} />,
danger: true,
onClick: (): void => setIsDeleteOpen(true),
},
];
return [editGroup, exportGroup, dangerGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index > 0 ? [{ type: 'divider' } as MenuItem, ...group] : group,
);
}, [
isDashboardLocked,
editDashboard,
isAuthor,
user.role,
dashboard.createdBy,
onOpenRename,
onLockToggle,
handle.enter,
exportJSON,
setCopy,
dashboardDataJSON,
]);
return (
<div className={styles.rightSection}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashboardSettingsOpen(visible)}
rootClassName={styles.dashboardSettings}
content={
<div className={styles.menuContent}>
<section className={styles.section1}>
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<TooltipSimple
title={
dashboard.createdBy === 'integration'
? 'Dashboards created by integrations cannot be unlocked'
: ''
}
>
<Button
variant="ghost"
prefix={<LockKeyhole size={14} />}
disabled={dashboard.createdBy === 'integration'}
onClick={(): void => {
setIsDashboardSettingsOpen(false);
onLockToggle();
}}
testId="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</TooltipSimple>
)}
{!isDashboardLocked && editDashboard && (
<Button
variant="ghost"
prefix={<PenLine size={14} />}
onClick={(): void => {
onOpenRename();
setIsDashboardSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
variant="ghost"
prefix={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className={styles.section2}>
<Button
variant="ghost"
prefix={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashboardSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
variant="ghost"
prefix={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashboardSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className={styles.deleteDashboard}>
<DeleteButton
createdBy={dashboard.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</Popover>
</DropdownMenuSimple>
{!isDashboardLocked && editDashboard && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
className={styles.addPanelBtn}
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard "${title}"?`}
description="This action cannot be undone."
isLoading={deleteDashboardMutation.isLoading}
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}
/>
</div>
);
}

View File

@@ -20,6 +20,7 @@
align-items: center;
gap: 8px;
width: 45%;
height: 40px;
.dashboardImg {
height: 16px;
@@ -42,6 +43,35 @@
overflow: hidden;
}
.clickableTitle {
cursor: pointer;
}
.titleEdit {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
.titleInput {
flex: 1;
min-width: 0;
max-width: 70%;
}
.titleEditActionButton {
--button-height: auto;
--button-padding: 4px;
flex-shrink: 0;
}
.titleSaveActionButton {
--button-border-color: var(--text-forest-700);
--button-outlined-foreground: var(--text-forest-700);
}
.publicDashboardIcon {
margin-right: 4px;
}
@@ -54,6 +84,7 @@
flex-wrap: wrap;
align-items: center;
gap: 14px;
height: 40px;
.icons {
display: flex;
@@ -77,41 +108,6 @@
.icons:hover {
background-color: unset;
}
.configureButton {
display: flex;
align-items: center;
width: 93px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.addPanelBtn {
display: flex;
width: 119px;
height: 34px;
padding: 5.937px 11.875px;
justify-content: center;
align-items: center;
color: var(--primary-foreground);
background: var(--primary-background);
font-family: Inter;
font-size: 11.875px;
font-style: normal;
font-weight: 500;
line-height: 17.812px; /* 150% */
}
}
}
@@ -209,95 +205,6 @@
}
}
.renameDashboard {
:global(.ant-modal-content) {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
:global(.ant-modal-header) {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
:global(.ant-modal-title) {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
:global(.ant-modal-body) {
padding: 16px;
.dashboardContent {
display: flex;
flex-direction: column;
gap: 8px;
.nameText {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.dashboardNameInput {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
:global(.ant-modal-footer) {
padding: 16px;
margin-top: 0px;
.dashboardRename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancelBtn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
}
.renameBtn {
display: flex;
align-items: center;
width: 169px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
}
}
}
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}

View File

@@ -3,12 +3,12 @@ import { isEmpty } from 'lodash-es';
import styles from '../DashboardDescription.module.scss';
interface Props {
interface DashboardMetaProps {
tags: string[];
description: string;
}
function DashboardMeta({ tags, description }: Props): JSX.Element {
function DashboardMeta({ tags, description }: DashboardMetaProps): JSX.Element {
return (
<>
{tags.length > 0 && (

View File

@@ -1,14 +1,25 @@
import { Globe, LockKeyhole } from '@signozhq/icons';
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from '../DashboardDescription.module.scss';
interface Props {
interface DashboardTitleProps {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditable: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardTitle({
@@ -16,18 +27,76 @@ function DashboardTitle({
image,
isPublicDashboard,
isDashboardLocked,
}: Props): JSX.Element {
isEditable,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardTitleProps): JSX.Element {
const canEdit = isEditable && !isDashboardLocked;
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={styles.dashboardTitle}
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</TooltipSimple>
{isEditing ? (
<div className={styles.titleEdit}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.titleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
size="icon"
className={cx(styles.titleEditActionButton, styles.titleSaveActionButton)}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="destructive"
size="icon"
className={styles.titleEditActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.clickableTitle]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">

View File

@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
interface UseEditableTitleArgs {
value: string;
onSave: (next: string) => void;
}
interface UseEditableTitleResult {
isEditing: boolean;
draft: string;
setDraft: (next: string) => void;
startEdit: () => void;
cancel: () => void;
commit: () => void;
}
/**
* Drives an inline-editable title. The parent owns the canonical `value`; this
* hook tracks the in-flight `draft` and whether we're editing. `commit` saves
* only when the trimmed draft is non-empty and actually changed. A `cancelled`
* ref guards against a blur firing right after Escape from also committing.
*/
export function useEditableTitle({
value,
onSave,
}: UseEditableTitleArgs): UseEditableTitleResult {
const [isEditing, setIsEditing] = useState<boolean>(false);
const [draft, setDraft] = useState<string>(value);
const cancelled = useRef<boolean>(false);
// Keep the draft in sync with the canonical value while not editing (e.g.
// after a refetch updates the title).
useEffect(() => {
if (!isEditing) {
setDraft(value);
}
}, [value, isEditing]);
const startEdit = (): void => {
cancelled.current = false;
setDraft(value);
setIsEditing(true);
};
const cancel = (): void => {
cancelled.current = true;
setIsEditing(false);
};
const commit = (): void => {
if (cancelled.current) {
cancelled.current = false;
return;
}
const trimmed = draft.trim();
if (trimmed && trimmed !== value) {
onSave(trimmed);
}
setIsEditing(false);
};
return { isEditing, draft, setDraft, startEdit, cancel, commit };
}

View File

@@ -1,70 +0,0 @@
import { Input, Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
open: boolean;
value: string;
isLoading: boolean;
onChange: (value: string) => void;
onRename: () => void;
onClose: () => void;
}
function RenameDashboardModal({
open,
value,
isLoading,
onChange,
onRename,
onClose,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Rename Dashboard"
onOk={onRename}
onCancel={onClose}
rootClassName={styles.renameDashboard}
footer={
<div className={styles.dashboardRename}>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
className={styles.renameBtn}
onClick={onRename}
disabled={isLoading}
>
Rename Dashboard
</Button>
<Button
variant="ghost"
prefix={<X size={14} />}
className={styles.cancelBtn}
onClick={onClose}
>
Cancel
</Button>
</div>
}
>
<div className={styles.dashboardContent}>
<Typography.Text className={styles.nameText}>
Enter a new name
</Typography.Text>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
</Modal>
);
}
export default RenameDashboardModal;

View File

@@ -0,0 +1,43 @@
.settingsContainerRoot {
:global(.ant-drawer-wrapper-body) {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
:global(.ant-drawer-header) {
height: 48px;
border-bottom: 1px solid var(--l1-border);
padding: 14px 14px 14px 11px;
:global(.ant-drawer-header-title) {
gap: 16px;
:global(.ant-drawer-title) {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding-left: 16px;
border-left: 1px solid var(--l1-border);
}
:global(.ant-drawer-close) {
height: 16px;
width: 16px;
margin-inline-end: 0px !important;
}
}
}
:global(.ant-drawer-body) {
padding: 16px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
}

View File

@@ -0,0 +1,34 @@
import { memo, PropsWithChildren, ReactElement } from 'react';
import { Drawer } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import styles from './SettingsDrawer.module.scss';
type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
}>;
function SettingsDrawer({
children,
drawerTitle,
isOpen,
onClose,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
open={isOpen}
rootClassName={styles.settingsContainerRoot}
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
</Drawer>
);
}
export default memo(SettingsDrawer);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
@@ -15,6 +15,7 @@ import type {
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -22,7 +23,7 @@ import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import RenameDashboardModal from './RenameDashboardModal/RenameDashboardModal';
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
import styles from './DashboardDescription.module.scss';
@@ -52,6 +53,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -59,16 +63,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
setUpdatedTitle(title);
}, [title]);
const handleLockDashboardToggle = async (): Promise<void> => {
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
@@ -84,41 +79,43 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
} catch (error) {
showErrorModal(error as APIError);
}
};
}, [id, isDashboardLocked, refetch, showErrorModal]);
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
refetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onNameSave = useCallback(
async (next: string): Promise<void> => {
if (!id) {
return;
}
try {
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: next,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[id, refetch, showErrorModal],
);
const onEmptyWidgetHandler = (): void => {
const { isEditing, draft, setDraft, startEdit, cancel, commit } =
useEditableTitle({
value: title,
onSave: onNameSave,
});
const onEmptyWidgetHandler = useCallback((): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
setIsPanelTypeSelectionModalOpen(true);
}, [id, setIsPanelTypeSelectionModalOpen]);
return (
<Card className={styles.dashboardDescriptionContainer}>
@@ -129,6 +126,13 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
image={image}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
isEditable={editDashboard}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
<DashboardActions
dashboard={dashboard}
@@ -139,19 +143,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onLockToggle={handleLockDashboardToggle}
onOpenRename={(): void => setIsRenameDashboardOpen(true)}
onOpenRename={startEdit}
/>
</section>
<DashboardMeta tags={tags} description={description} />
<RenameDashboardModal
open={isRenameDashboardOpen}
value={updatedTitle}
isLoading={isRenameLoading}
onChange={setUpdatedTitle}
onRename={onNameChangeHandler}
onClose={(): void => setIsRenameDashboardOpen(false)}
/>
</Card>
);
}

View File

@@ -0,0 +1,11 @@
.placeholder {
padding: 24px;
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
padding-top: 4px;
}

View File

@@ -0,0 +1,114 @@
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Radio, Tooltip } from 'antd';
import { ExternalLink, SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { getAbsoluteUrl } from 'utils/basePath';
import cx from 'classnames';
import styles from '../GeneralSettings.module.scss';
interface CrossPanelSyncProps {
dashboardId: string;
}
function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
const [cursorSyncMode, setCursorSyncMode] =
useDashboardCursorSyncMode(dashboardId);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardId);
return (
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
);
}
export default CrossPanelSync;

View File

@@ -0,0 +1,85 @@
import { Dispatch, SetStateAction } from 'react';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Select/Input to @signozhq/ui
import { Col, Input, Select, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import styles from '../GeneralSettings.module.scss';
const { Option } = Select;
interface GeneralFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function GeneralForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: GeneralFormProps): JSX.Element {
return (
<Col className={styles.overviewSettings}>
<Space direction="vertical" className={styles.formSpace}>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={image}
onChange={onImageChange}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</Space>
</Col>
);
}
export default GeneralForm;

View File

@@ -0,0 +1,238 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px 16px;
}
.overviewSettings {
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.formSpace {
width: 100%;
display: flex;
flex-direction: column;
gap: 21px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
display: flex;
align-items: center;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
display: flex;
align-items: center;
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../GeneralSettings.module.scss';
interface UnsavedChangesFooterProps {
count: number;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function UnsavedChangesFooter({
count,
isSaving,
onDiscard,
onSave,
}: UnsavedChangesFooterProps): JSX.Element {
const { t } = useTranslation('common');
return (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{count} unsaved change
{count > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<Button
variant="ghost"
disabled={isSaving}
prefix={<X size={14} />}
onClick={onDiscard}
className={styles.discardBtn}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
disabled={isSaving}
loading={isSaving}
prefix={<Check size={14} />}
testId="save-dashboard-config"
onClick={onSave}
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
);
}
export default UnsavedChangesFooter;

View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { isEqual } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import GeneralForm from './GeneralForm/GeneralForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
import styles from './GeneralSettings.module.scss';
interface GeneralSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
const id = dashboard.id;
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard.tags ?? []),
[dashboard.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
const [updatedDescription, setUpdatedDescription] =
useState<string>(description);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
useState<number>(0);
const { showErrorModal } = useErrorModal();
// Sync state when dashboard refetches after a save
useEffect(() => {
setUpdatedTitle(title);
setUpdatedDescription(description);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const buildPatch = useCallback((): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
}
return ops;
}, [
updatedTitle,
title,
updatedDescription,
description,
updatedImage,
image,
updatedTags,
tagsAsStrings,
]);
const onSaveHandler = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const ops = buildPatch();
if (ops.length === 0) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [id, buildPatch, refetch, showErrorModal]);
useEffect(() => {
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
n += 1;
}
});
setNumberOfUnsavedChanges(n);
}, [
description,
image,
tagsAsStrings,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = useCallback((): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
setUpdatedDescription(description);
}, [title, image, tagsAsStrings, description]);
return (
<div className={styles.overviewContent}>
<GeneralForm
title={updatedTitle}
description={updatedDescription}
image={updatedImage}
tags={updatedTags}
onTitleChange={setUpdatedTitle}
onDescriptionChange={setUpdatedDescription}
onImageChange={setUpdatedImage}
onTagsChange={setUpdatedTags}
/>
<CrossPanelSync dashboardId={id} />
{numberOfUnsavedChanges > 0 && (
<UnsavedChangesFooter
count={numberOfUnsavedChanges}
isSaving={isSaving}
onDiscard={discardHandler}
onSave={onSaveHandler}
/>
)}
</div>
);
}
export default GeneralSettings;

View File

@@ -0,0 +1,24 @@
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
}
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
return tagStrings
.map((s) => {
const trimmed = s.trim();
const idx = trimmed.indexOf(':');
if (idx === -1) {
return { key: trimmed, value: trimmed };
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
return { key, value: value || key };
})
.filter((t) => t.key.length > 0);
}

View File

@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { Tabs } from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import styles from './DashboardSettings.module.scss';
interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
}
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const items = useMemo(
() => [
{
key: 'general',
label: tabLabel(<Table size={14} />, 'General'),
children: <GeneralSettings dashboard={dashboard} />,
},
{
key: 'variables',
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
},
{
key: 'public-dashboard',
label: tabLabel(<Globe size={14} />, 'Publish'),
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
},
],
[dashboard],
);
return <Tabs defaultValue="general" items={items} />;
}
export default DashboardSettings;

View File

@@ -0,0 +1,23 @@
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './DashboardSettings.module.scss';
/**
* TEMPORARY: stand-in for the not-yet-built Variables / Publish settings tabs.
* Will be cleaned up later once those tabs ship their real content.
*/
export function SettingsTabPlaceholder({
message,
}: {
message: string;
}): JSX.Element {
return (
<div className={styles.placeholder}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<Typography.Text>{message}</Typography.Text>}
/>
</div>
);
}

View File

@@ -0,0 +1,86 @@
.emptyState {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 48px 16px;
}
.content {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 480px;
}
.heading {
display: flex;
flex-direction: column;
gap: 6px;
.emoji {
height: 32px;
width: 32px;
}
.welcome {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: -0.08px;
}
.welcomeInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
}
.addPanel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px dashed var(--l1-border);
border-radius: 6px;
}
.addPanelText {
display: flex;
align-items: flex-start;
gap: 10px;
.icon {
height: 14px;
width: 14px;
margin-top: 2px;
}
}
.addPanelCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.addPanelTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.addPanelInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}

View File

@@ -0,0 +1,63 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
import landscapeUrl from '@/assets/Icons/landscape.svg';
import styles from './DashboardEmptyState.module.scss';
interface DashboardEmptyStateProps {
canAddPanel: boolean;
}
function DashboardEmptyState({
canAddPanel,
}: DashboardEmptyStateProps): JSX.Element {
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
return (
<section className={styles.emptyState}>
<div className={styles.content}>
<div className={styles.heading}>
<img src={dashboardEmojiUrl} alt="" className={styles.emoji} />
<Typography.Text className={styles.welcome}>
Welcome to your new dashboard
</Typography.Text>
<Typography.Text className={styles.welcomeInfo}>
Follow the steps to populate it with data and share with your teammates
</Typography.Text>
</div>
<div className={styles.addPanel}>
<div className={styles.addPanelText}>
<img src={landscapeUrl} alt="" className={styles.icon} />
<div className={styles.addPanelCopy}>
<Typography.Text className={styles.addPanelTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.addPanelInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
</div>
{canAddPanel && (
<Button
color="primary"
prefix={<Plus size="md" />}
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
testId="add-panel"
>
New Panel
</Button>
)}
</div>
</div>
</section>
);
}
export default DashboardEmptyState;

View File

@@ -4,7 +4,7 @@
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border: 1px solid var(--l1-border);
border-radius: 4px;
overflow: hidden;
}
@@ -14,7 +14,7 @@
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
border-bottom: 1px solid var(--l1-border);
cursor: grab;
}
@@ -42,7 +42,7 @@
align-items: center;
justify-content: center;
padding: 12px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
font-size: 12px;
text-align: center;
}

View File

@@ -12,7 +12,15 @@ import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface Props {
/** Panel action context — present together only in editable sectioned mode. */
export interface PanelActionsConfig {
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
@@ -21,22 +29,16 @@ interface Props {
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Section actions — present only in editable sectioned mode. */
currentLayoutIndex?: number;
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
function Panel({
panel,
panelId,
isVisible,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
panelActions,
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
@@ -65,13 +67,13 @@ function Panel({
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={currentLayoutIndex}
sections={sections ?? []}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />

View File

@@ -6,11 +6,11 @@
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
color: var(--l1-foreground);
background: var(--l2-background);
}
}

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
@@ -8,7 +9,7 @@ import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import styles from './PanelActionsMenu.module.scss';
interface Props {
interface PanelActionsMenuProps {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
@@ -22,7 +23,7 @@ function PanelActionsMenu({
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
}: PanelActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
@@ -75,8 +76,11 @@ function PanelActionsMenu({
return (
<DropdownMenuSimple menu={{ items }}>
<button
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
@@ -87,7 +91,7 @@ function PanelActionsMenu({
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</button>
</Button>
</DropdownMenuSimple>
);
}

View File

@@ -10,9 +10,9 @@
gap: 8px;
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border: 1px solid var(--l1-border);
border-radius: 4px;
color: var(--bg-vanilla-100, #fff);
color: var(--l1-foreground);
cursor: pointer;
text-align: left;

View File

@@ -1,48 +1,10 @@
import { Modal } from 'antd';
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { PANEL_TYPES } from './constants';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}
const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];
interface Props {
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
@@ -52,7 +14,7 @@ function PanelTypeSelectionModal({
open,
onClose,
onSelect,
}: Props): JSX.Element {
}: PanelTypeSelectionModalProps): JSX.Element {
return (
<Modal
open={open}
@@ -63,16 +25,17 @@ function PanelTypeSelectionModal({
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
<button
<Button
key={type.pluginKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
{type.icon}
{type.label}
</button>
</Button>
))}
</div>
</Modal>

View File

@@ -0,0 +1,36 @@
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import type { PanelType } from './types';
export const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];

View File

@@ -0,0 +1,5 @@
export interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}

View File

@@ -36,9 +36,6 @@ export function useAddPanelToSection({
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;

View File

@@ -5,13 +5,13 @@
margin-top: 8px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--bg-slate-400, #1d212d);
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: pointer;
&:hover {
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100, #fff);
color: var(--l1-foreground);
}
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../../utils';
@@ -10,7 +11,7 @@ import styles from './AddSectionControl.module.scss';
const DEFAULT_SECTION_TITLE = 'New section';
interface Props {
interface AddSectionControlProps {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
isSectioned: boolean;
@@ -20,7 +21,7 @@ function AddSectionControl({
sections,
layouts,
isSectioned,
}: Props): JSX.Element {
}: AddSectionControlProps): JSX.Element {
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
const { addSection } = useAddSection({ layouts });
const { migrate, isSaving } = useFirstSectionMigration({ sections });
@@ -30,30 +31,31 @@ function AddSectionControl({
const needsMigration =
!isSectioned && sections.some((s) => s.items.length > 0);
const handleClick = (): void => {
const handleClick = useCallback((): void => {
if (needsMigration) {
setIsMigrationOpen(true);
return;
}
void addSection(DEFAULT_SECTION_TITLE);
};
}, [needsMigration, addSection]);
const handleConfirmMigration = async (): Promise<void> => {
const handleConfirmMigration = useCallback(async (): Promise<void> => {
await migrate(DEFAULT_SECTION_TITLE);
setIsMigrationOpen(false);
};
}, [migrate]);
return (
<>
<button
<Button
type="button"
variant="ghost"
className={styles.addButton}
onClick={handleClick}
data-testid="add-section"
>
<Plus size={14} />
Add section
</button>
</Button>
<FirstSectionMigrationModal
open={isMigrationOpen}
isSaving={isSaving}

View File

@@ -1,7 +1,7 @@
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
interface Props {
interface FirstSectionMigrationModalProps {
open: boolean;
isSaving: boolean;
onClose: () => void;
@@ -18,7 +18,7 @@ function FirstSectionMigrationModal({
isSaving,
onClose,
onConfirm,
}: Props): JSX.Element {
}: FirstSectionMigrationModalProps): JSX.Element {
return (
<Modal
open={open}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface Props {
interface RenameSectionModalProps {
open: boolean;
initialValue: string;
isSaving: boolean;
@@ -16,7 +16,7 @@ function RenameSectionModal({
isSaving,
onClose,
onSubmit,
}: Props): JSX.Element {
}: RenameSectionModalProps): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.

View File

@@ -1,9 +1,20 @@
.section {
margin-bottom: 12px;
border: 1px solid var(--bg-slate-500);
border: 1px solid var(--l1-border);
border-radius: 4px;
}
.dragging {
opacity: 0.8;
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}
.emptySection {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 12px;
}

View File

@@ -1,8 +1,11 @@
import { useRef, useState } from 'react';
import { Modal } from 'antd';
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
@@ -19,7 +22,7 @@ import SectionHeader, {
} from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
interface SectionProps {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
@@ -38,8 +41,12 @@ function Section({
onMovePanel,
onDeletePanel,
dragHandle,
}: Props): JSX.Element {
}: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
@@ -54,30 +61,30 @@ function Section({
layoutIndex: section.layoutIndex,
});
const handleRenameSubmit = async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
};
const handleRenameSubmit = useCallback(
async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
},
[rename],
);
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = (pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
};
const handleSelectPanelType = useCallback(
(pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
},
[onAddPanel, section.layoutIndex],
);
const { deleteSection } = useDeleteSection({ section });
const confirmDeleteSection = (): void => {
Modal.confirm({
title: `Delete section "${section.title ?? ''}"?`,
content: 'Panels in this section will be removed.',
okText: 'Delete',
okButtonProps: { danger: true },
centered: true,
onOk: () => deleteSection(),
});
};
const handleDeleteSection = useCallback((): void => {
void deleteSection();
setIsDeleteOpen(false);
}, [deleteSection]);
const grid = (
<SectionGrid
@@ -118,13 +125,35 @@ function Section({
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
onAddPanel={
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
actions={
isEditable
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => setIsAddingPanel(true),
onDeleteSection: (): void => setIsDeleteOpen(true),
}
: undefined
}
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
/>
{open ? grid : null}
{open &&
(section.items.length > 0 ? (
grid
) : (
<div className={styles.emptySection}>
{isEditable && (
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
)}
</div>
))}
<RenameSectionModal
open={isRenaming}
initialValue={section.title}
@@ -137,6 +166,13 @@ function Section({
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete section "${section.title ?? ''}"?`}
description="Panels in this section will be removed."
onConfirm={handleDeleteSection}
onClose={(): void => setIsDeleteOpen(false)}
/>
</div>
);
}

View File

@@ -6,11 +6,11 @@
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
color: var(--l1-foreground);
background: var(--l2-background);
}
}

View File

@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import styles from './SectionActionsMenu.module.scss';
interface Props {
interface SectionActionsMenuProps {
sectionId: string;
onAddPanel?: () => void;
onRename?: () => void;
@@ -17,7 +18,7 @@ function SectionActionsMenu({
onAddPanel,
onRename,
onDeleteSection,
}: Props): JSX.Element {
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onAddPanel) {
@@ -53,14 +54,17 @@ function SectionActionsMenu({
return (
<DropdownMenuSimple menu={{ items }}>
<button
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Section actions"
data-testid={`dashboard-section-actions-${sectionId}`}
>
<EllipsisVertical size={14} />
</button>
</Button>
</DropdownMenuSimple>
);
}

View File

@@ -2,7 +2,7 @@ import type { DashboardSection } from '../../../utils';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './SectionDragPreview.module.scss';
interface Props {
interface SectionDragPreviewProps {
section: DashboardSection;
}
@@ -11,7 +11,7 @@ interface Props {
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
* cheap and never triggers RGL width re-measurement.
*/
function SectionDragPreview({ section }: Props): JSX.Element {
function SectionDragPreview({ section }: SectionDragPreviewProps): JSX.Element {
const panelCount = section.items.length;
const title = `${section.title ?? ''} · ${panelCount} ${
panelCount === 1 ? 'panel' : 'panels'

View File

@@ -11,7 +11,7 @@ import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
interface SectionGridProps {
items: DashboardSection['items'];
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
@@ -29,7 +29,7 @@ function SectionGrid({
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
@@ -66,10 +66,16 @@ function SectionGrid({
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
currentLayoutIndex={layoutIndex}
sections={isEditable ? sections : undefined}
onMovePanel={isEditable ? onMovePanel : undefined}
onDeletePanel={isEditable ? onDeletePanel : undefined}
panelActions={
isEditable && onMovePanel && onDeletePanel
? {
currentLayoutIndex: layoutIndex,
sections: sections ?? [],
onMovePanel,
onDeletePanel,
}
: undefined
}
/>
</div>
))}

View File

@@ -5,7 +5,7 @@
padding: 8px 12px;
&.headerOpen {
border-bottom: 1px solid var(--bg-slate-500);
border-bottom: 1px solid var(--l1-border);
}
}
@@ -16,7 +16,7 @@
padding: 0;
background: transparent;
border: none;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: grab;
&:active {
@@ -33,7 +33,8 @@
padding: 0;
background: transparent;
border: none;
color: inherit;
// Muted chevron; the title below carries the prominent heading color.
color: var(--l2-foreground);
text-align: left;
cursor: pointer;
min-width: 0;
@@ -41,6 +42,8 @@
.title {
margin-left: 4px;
color: var(--l1-foreground);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,6 +1,7 @@
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
@@ -13,7 +14,14 @@ export interface SectionDragHandle {
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
interface Props {
/** Editable-mode section actions — present together or not at all. */
export interface SectionHeaderActions {
onRename: () => void;
onAddPanel: () => void;
onDeleteSection: () => void;
}
interface SectionHeaderProps {
sectionId: string;
title: string;
open: boolean;
@@ -21,9 +29,8 @@ interface Props {
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
onRename?: () => void;
onAddPanel?: () => void;
onDeleteSection?: () => void;
/** Present only in editable mode; absent (read-only) when locked/no-permission. */
actions?: SectionHeaderActions;
}
function SectionHeader({
@@ -33,16 +40,16 @@ function SectionHeader({
onToggle,
repeatVariable,
dragHandle,
onRename,
onAddPanel,
onDeleteSection,
}: Props): JSX.Element {
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
actions,
}: SectionHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
{dragHandle ? (
<button
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.dragHandle}
ref={dragHandle.setActivatorNodeRef}
aria-label="Drag to reorder section"
@@ -51,10 +58,12 @@ function SectionHeader({
{...dragHandle.listeners}
>
<GripVertical size={14} />
</button>
</Button>
) : null}
<button
<Button
type="button"
variant="ghost"
color="secondary"
className={styles.toggle}
onClick={onToggle}
data-testid={`dashboard-section-toggle-${sectionId}`}
@@ -66,13 +75,13 @@ function SectionHeader({
(repeats per ${repeatVariable})
</Typography.Text>
) : null}
</button>
{hasActions ? (
</Button>
{actions ? (
<SectionActionsMenu
sectionId={sectionId}
onAddPanel={onAddPanel}
onRename={onRename}
onDeleteSection={onDeleteSection}
onAddPanel={actions.onAddPanel}
onRename={actions.onRename}
onDeleteSection={actions.onDeleteSection}
/>
) : null}
</div>

View File

@@ -20,12 +20,12 @@ import Section from './Section/Section';
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
import SortableSection from './SortableSection';
interface Props {
interface SectionListProps {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
function SectionList({ sections, layouts }: Props): JSX.Element {
function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const {

View File

@@ -7,7 +7,7 @@ import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
import Section from './Section/Section';
interface Props {
interface SortableSectionProps {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
@@ -21,7 +21,7 @@ function SortableSection({
onAddPanel,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
}: SortableSectionProps): JSX.Element {
const {
attributes,
listeners,

View File

@@ -1,7 +1,5 @@
import { ReactNode, useMemo } from 'react';
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
@@ -9,7 +7,7 @@ import type {
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import AddSectionControl from './Section/AddSectionControl/AddSectionControl';
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import Section from './Section/Section/Section';
import SectionList from './Section/SectionList';
import styles from './PanelsAndSectionsLayout.module.scss';
@@ -17,12 +15,15 @@ import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
interface Props {
interface PanelsAndSectionsLayoutProps {
layouts: DashboardtypesLayoutDTO[];
panels: Record<string, DashboardtypesPanelDTO | undefined>;
}
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
function PanelsAndSectionsLayout({
layouts,
panels,
}: PanelsAndSectionsLayoutProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const sections = useMemo(
@@ -40,16 +41,7 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const renderContent = (): ReactNode => {
if (isEmpty) {
return (
<div className={styles.emptyState}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>No panels in this dashboard yet</Typography.Text>
}
/>
</div>
);
return <DashboardEmptyState canAddPanel={isEditable} />;
}
if (isSectioned) {
@@ -61,18 +53,7 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
));
};
return (
<div className={styles.body}>
{renderContent()}
{isEditable ? (
<AddSectionControl
sections={sections}
layouts={layouts}
isSectioned={isSectioned}
/>
) : null}
</div>
);
return <div className={styles.body}>{renderContent()}</div>;
}
export default PanelsAndSectionsLayout;

View File

@@ -0,0 +1,12 @@
.body {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,69 @@
import { ReactNode } from 'react';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import styles from './ConfirmDeleteDialog.module.scss';
interface ConfirmDeleteDialogProps {
open: boolean;
title: string;
description: ReactNode;
confirmLabel?: string;
isLoading?: boolean;
onConfirm: () => void;
onClose: () => void;
}
/**
* Shared destructive-confirm dialog built on @signozhq/ui DialogWrapper (not
* antd Modal), so it inherits the design-system styling/theme. Used by the
* dashboard and section delete flows.
*/
function ConfirmDeleteDialog({
open,
title,
description,
confirmLabel = 'Delete',
isLoading = false,
onConfirm,
onClose,
}: ConfirmDeleteDialogProps): JSX.Element {
const footer = (
<div className={styles.footer}>
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isLoading}
onClick={onConfirm}
testId="confirm-delete"
>
<Trash2 size={12} />
{confirmLabel}
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={title}
width="narrow"
showCloseButton={false}
footer={footer}
>
<div className={styles.body}>{description}</div>
</DialogWrapper>
);
}
export default ConfirmDeleteDialog;

View File

@@ -5,26 +5,23 @@
gap: 6px;
align-items: center;
max-width: 80%;
padding-left: 8px;
.dashboardBtn {
display: flex;
align-items: center;
.linkToPreviousPage {
// Collapse the design-system Button's fixed-height/padding box so it hugs
// the label like inline text (the breadcrumb is text, not a chunky button).
--button-height: auto;
--button-padding: 0;
--button-gap: 4px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboardBtn:hover {
background-color: unset;
}
.idBtn {
.currentPage {
display: flex;
align-items: center;
gap: 4px;
@@ -46,12 +43,9 @@
overflow: hidden;
text-overflow: ellipsis;
}
:global(.ant-btn-icon) {
margin-inline-end: 4px;
}
}
.idBtn:hover {
.currentPage:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}

View File

@@ -1,19 +1,23 @@
import { useCallback } from 'react';
import { LayoutGrid } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from '@signozhq/icons';
import styles from './DashboardBreadcrumbs.module.scss';
interface Props {
interface DashboardBreadcrumbsProps {
title: string;
image: string;
}
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
function DashboardBreadcrumbs({
title,
image,
}: DashboardBreadcrumbsProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const goToListPage = useCallback(() => {
@@ -35,20 +39,23 @@ function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
<div className={styles.dashboardBreadcrumbs}>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutGrid size={14} />}
className={styles.dashboardBtn}
onClick={goToListPage}
className={styles.linkToPreviousPage}
testId="dashboard-breadcrumb-list"
>
Dashboard /
Dashboard
</Button>
<Button variant="ghost" className={styles.idBtn}>
<div>/</div>
<div className={styles.currentPage}>
<img
src={image}
alt="dashboard-icon"
className={styles.dashboardIconImage}
/>
{title}
</Button>
<Typography.Text>{title}</Typography.Text>
</div>
</div>
);
}

View File

@@ -5,12 +5,12 @@ import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import styles from './DashboardHeader.module.scss';
interface Props {
interface DashboardHeaderProps {
title: string;
image: string;
}
function DashboardHeader({ title, image }: Props): JSX.Element {
function DashboardHeader({ title, image }: DashboardHeaderProps): JSX.Element {
return (
<div className={styles.dashboardHeader}>
<DashboardBreadcrumbs title={title} image={image} />

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelectionModal';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
@@ -10,12 +11,15 @@ import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
interface Props {
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
}
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
function DashboardContainer({
dashboard,
refetch,
}: DashboardContainerProps): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
@@ -43,6 +47,9 @@ function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
/>
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
</div>
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
trigger; navigates to the widget editor route on selection. */}
<PanelTypeSelectionModal />
</FullScreen>
);
}

View File

@@ -67,5 +67,24 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v3/traces/{traceID}/flamegraph", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetFlamegraph),
handler.OpenAPIDef{
ID: "GetFlamegraph",
Tags: []string{"tracedetail"},
Summary: "Get flamegraph view for a trace",
Description: "Returns the flamegraph view of spans for a given trace ID.",
Request: new(spantypes.PostableFlamegraph),
RequestContentType: "application/json",
Response: new(spantypes.GettableFlamegraphTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"net/url"
"path"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@@ -14,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -29,12 +31,13 @@ var scopes []string = []string{"email", "profile"}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
globalConfig global.Config
}
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn")
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -43,9 +46,10 @@ func New(ctx context.Context, store authtypes.AuthNStore, providerSettings facto
}
return &AuthN{
store: store,
settings: settings,
httpClient: httpClient,
store: store,
settings: settings,
httpClient: httpClient,
globalConfig: globalConfig,
}, nil
}
@@ -178,7 +182,7 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: redirectPath,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
}).String(),
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
<svg id="uuid-c6c3f75e-5369-448e-b895-3f99fb11bebe" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M7.456.608c-.902-.411-1.909-.559-2.898-.417.053.041.086.107.082.179l-.082,1.405c.879-.183,1.827-.043,2.65.469.338.21.639.474.892.781,0,0,.024.027.061.069.091.104.26.299.334.402.006.031-.004.062-.026.084-.001.001-.002.002-.003.004l-.052.048-.765.681c-.039.035-.042.095-.007.134.017.019.04.03.065.031l1.107.065,1.402.082c.072.004.138-.029.179-.083.025-.033.041-.073.044-.117l.147-2.513c.003-.052-.037-.097-.089-.1-.025-.001-.049.007-.068.024l-.764.682v.003c-.106-.164-.22-.319-.34-.467-.516-.636-1.159-1.122-1.869-1.445Z" fill="#0078d4"/><path d="M4.441.147L1.932,0c-.052-.003-.097.037-.1.09-.001.025.007.049.024.068l.681.766h.003c-.159.104-.311.214-.455.331-.629.509-1.111,1.143-1.436,1.842-.424.913-.578,1.937-.434,2.942.041-.053.107-.086.179-.082l1.402.082c-.183-.881-.043-1.83.468-2.655.209-.338.473-.64.78-.893,0,0,.029-.026.072-.064.104-.092.297-.259.399-.332.031-.006.062.004.084.026.001.001.002.002.003.003l.048.052.679.766c.035.039.095.042.134.008.019-.017.03-.04.031-.065l.064-1.109.082-1.405c.004-.072-.029-.138-.082-.179-.033-.025-.073-.041-.117-.044Z" fill="#46a0de"/><path d="M10.411,5.611c.025-.363.013-.73-.039-1.095-.041.053-.107.086-.179.082l-1.402-.082c.038.186.062.374.071.564l1.55.53Z" fill="#155ea1"/><path d="M3.576,9.604l.271-.049,1.845-.343c-.095-.084-.155-.206-.155-.34v-.025c-.733.051-1.487-.119-2.159-.536-.338-.21-.639-.474-.892-.781,0,0-.024-.027-.061-.069-.091-.104-.26-.299-.334-.402-.006-.031.004-.062.026-.084.001-.001.002-.002.003-.004l.052-.048.765-.681c.039-.035.042-.095.007-.134-.017-.019-.04-.03-.065-.031l-1.107-.065-1.402-.082c-.072-.004-.138.029-.179.083-.025.033-.041.073-.044.117L0,8.645c-.003.052.037.097.089.1.025.001.049-.007.068-.024l.764-.682v-.003c.106.164.22.319.34.467.516.636,1.159,1.122,1.869,1.445.026.012.053.021.08.033.029-.188.173-.342.365-.376Z" fill="#8dc8e8"/><g><polygon points="8.241 5.343 5.968 5.765 5.968 8.87 8.241 9.355 10.522 8.44 10.522 6.123 8.241 5.343" fill="#8661c5"/><path d="M8.328,9.307l2.082-.844c.048-.019.084-.061.095-.111v-2.102c-.004-.064-.044-.119-.103-.143l-2.106-.716h-.095l-2.066.382c-.066.017-.114.075-.119.143v2.81c-.002.073.048.136.119.151l2.09.438c.035.004.07.002.103-.008Z" fill="none"/><path d="M5.968,5.765v3.105l2.297.486v-3.98l-2.297.39ZM6.938,8.631l-.644-.127v-2.388l.644-.103v2.619ZM7.939,8.814l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="13.16 5.383 10.887 5.805 10.887 8.909 13.16 9.395 15.433 8.471 15.433 6.163 13.16 5.383" fill="#8661c5"/><path d="M10.887,5.805v3.105l2.281.486v-3.98l-2.281.39ZM11.849,8.67l-.644-.127v-2.388l.644-.103v2.619ZM12.85,8.854l-.739-.119v-2.73l.739-.135v2.985Z" fill="#56407f"/><polygon points="5.912 9.626 3.639 10.048 3.639 13.152 5.912 13.638 8.193 12.722 8.193 10.406 5.912 9.626" fill="#8661c5"/><path d="M3.632,10.048v3.081l2.297.486v-3.98l-2.297.414ZM4.593,12.921l-.644-.135v-2.388l.644-.111v2.635ZM5.602,13.128l-.739-.119v-2.762l.739-.127v3.009Z" fill="#56407f"/><polygon points="10.816 9.594 8.543 10.016 8.543 13.12 10.816 13.614 13.089 12.69 13.089 10.374 10.816 9.594" fill="#8661c5"/><path d="M8.543,10.016v3.112l2.289.486v-3.98l-2.289.382ZM9.504,12.889l-.644-.135v-2.388l.644-.111v2.635ZM10.506,13.065l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="15.719 9.634 13.446 10.056 13.446 13.16 15.719 13.646 18 12.73 18 10.414 15.719 9.634" fill="#8661c5"/><path d="M13.446,10.056v3.073l2.297.486v-3.98l-2.297.422ZM14.416,12.929l-.644-.135v-2.388l.644-.111v2.635ZM15.417,13.104l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="8.185 13.956 5.912 14.37 5.912 17.475 8.185 17.968 10.466 17.045 10.466 14.736 8.185 13.956" fill="#8661c5"/><path d="M8.273,17.904l2.074-.796c.06-.021.099-.08.095-.143v-2.07c.012-.076-.031-.149-.103-.175l-2.098-.716c-.031-.012-.065-.012-.095,0l-2.066.374c-.074.012-.128.076-.127.151v2.818c-.002.073.048.136.119.151l2.09.406c.036.012.075.012.111,0Z" fill="none"/><path d="M5.912,14.37v3.105l2.297.494v-4.044l-2.297.446ZM6.882,17.244l-.644-.135v-2.388l.644-.111v2.635ZM7.883,17.427l-.739-.119v-2.738l.739-.127v2.985Z" fill="#56407f"/><polygon points="13.097 13.988 10.824 14.41 10.824 17.514 13.097 18 15.377 17.085 15.377 14.768 13.097 13.988" fill="#8661c5"/><path d="M10.824,14.41v3.105l2.297.486v-3.98l-2.297.39ZM11.793,17.284l-.644-.135v-2.388l.644-.111v2.635ZM12.795,17.459l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/></g></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,293 @@
{
"id": "aks",
"title": "Azure Kubernetes Service (AKS)",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_kube_pod_status_ready_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_ready_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_phase_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_phase_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_condition_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_condition_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_millicores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_millicores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_in_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_in_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_out_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_out_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_current_inflight_requests_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_current_inflight_requests_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_memory_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_memory_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_database_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_database_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_memory_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_memory_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_cpu_cores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_cpu_cores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_memory_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_memory_bytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.ContainerService",
"resourceType": "managedClusters",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure Kubernetes Service (AKS) Overview",
"description": "Overview of Azure Kubernetes Service (AKS) metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor Azure Kubernetes Service (AKS) with SigNoz
Collect key AKS metrics and view them with an out of the box dashboard.

View File

@@ -4,9 +4,11 @@ import (
"context"
"net/http"
"net/url"
"path"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/session"
@@ -15,11 +17,12 @@ import (
)
type handler struct {
module session.Module
module session.Module
globalConfig global.Config
}
func NewHandler(module session.Module) session.Handler {
return &handler{module: module}
func NewHandler(module session.Module, globalConfig global.Config) session.Handler {
return &handler{module: module, globalConfig: globalConfig}
}
func (handler *handler) GetSessionContext(rw http.ResponseWriter, req *http.Request) {
@@ -158,13 +161,13 @@ func (handler *handler) DeleteSession(rw http.ResponseWriter, req *http.Request)
render.Success(rw, http.StatusNoContent, nil)
}
func (*handler) getRedirectURLFromErr(err error) string {
func (handler *handler) getRedirectURLFromErr(err error) string {
values := errors.AsURLValues(err)
values.Add("callbackauthnerr", "true")
return (&url.URL{
// When UI is being served on a prefix, we need to redirect to the login page on the prefix.
Path: "/login",
Path: path.Join(handler.globalConfig.ExternalPath(), "/login"),
RawQuery: values.Encode(),
}).String()
}

View File

@@ -6,7 +6,16 @@ import (
)
type Config struct {
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Flamegraph FlamegraphConfig `mapstructure:"flamegraph"`
}
type FlamegraphConfig struct {
MaxSelectedLevels int `mapstructure:"max_selected_levels"`
MaxSpansPerLevel int `mapstructure:"max_spans_per_level"`
SamplingTopLatencySpansCount int `mapstructure:"sampling_top_latency_count"`
SamplingBucketCount int `mapstructure:"sampling_bucket_count"`
SelectAllSpansLimit uint `mapstructure:"select_all_spans_limit"`
}
type WaterfallConfig struct {
@@ -29,6 +38,13 @@ func newConfig() factory.Config {
MaxDepthToAutoExpand: 5,
MaxLimitToSelectAllSpans: 10_000,
},
Flamegraph: FlamegraphConfig{
MaxSelectedLevels: 50,
MaxSpansPerLevel: 100,
SamplingTopLatencySpansCount: 5,
SamplingBucketCount: 50,
SelectAllSpansLimit: 100_000,
},
}
}
@@ -42,5 +58,20 @@ func (c Config) Validate() error {
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive")
}
if c.Flamegraph.MaxSelectedLevels <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.level_limit must be positive, got %d", c.Flamegraph.MaxSelectedLevels)
}
if c.Flamegraph.MaxSpansPerLevel <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.spans_per_level must be positive, got %d", c.Flamegraph.MaxSpansPerLevel)
}
if c.Flamegraph.SamplingTopLatencySpansCount < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.top_latency_count cannot be negative, got %d", c.Flamegraph.SamplingTopLatencySpansCount)
}
if c.Flamegraph.SamplingBucketCount <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.bucket_count must be positive, got %d", c.Flamegraph.SamplingBucketCount)
}
if c.Flamegraph.SelectAllSpansLimit == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.max_limit_to_select_all_spans must be positive")
}
return nil
}

View File

@@ -80,3 +80,19 @@ func (h *handler) GetTraceAggregations(rw http.ResponseWriter, r *http.Request)
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableFlamegraph)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetFlamegraph(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.SelectFields)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"go.opentelemetry.io/otel/metric"
)
@@ -164,6 +165,17 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
return &spantypes.GettableTraceAggregations{Aggregations: results}, nil
}
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
}
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window
@@ -204,3 +216,47 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
), nil
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
if err != nil {
return nil, err
}
if len(fullSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
if err != nil {
return nil, err
}
if len(minimalSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
cfg := m.config.Flamegraph
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID, cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
if len(selectedSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
if err != nil {
return nil, err
}
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
summary.Start.UnixMilli(),
summary.End.UnixMilli(),
true,
), nil
}

View File

@@ -154,6 +154,47 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"span_id",
"any(parent_span_id) AS parent_span_id",
"any(timestamp) AS timestamp",
"any(duration_nano) AS duration_nano",
"any(has_error) AS has_error",
"any(name) AS name",
"any(events) AS events",
"any(attributes_string) AS attributes_string",
"any(attributes_number) AS attributes_number",
"any(attributes_bool) AS attributes_bool",
"any(resources_string) AS resources_string",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
conditions := []string{
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", start.Unix()-1800),
sb.LE("ts_bucket_start", end.Unix()),
}
if len(spanIDs) > 0 {
ids := make([]any, len(spanIDs))
for i, id := range spanIDs {
ids[i] = id
}
conditions = append(conditions, sb.In("span_id", ids...))
}
sb.Where(conditions...)
sb.GroupBy("span_id")
sb.OrderByAsc("timestamp")
sb.OrderByAsc("name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying flamegraph spans")
}
return spans, nil
}
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {

View File

@@ -91,6 +91,30 @@ func TestGetSpanCountByField(t *testing.T) {
}
}
func TestGetFlamegraphSpans(t *testing.T) {
baseSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC"
withSpanIDsSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC"
tests := []struct {
name string
spanIDs []string
sql string
}{
{name: "NoSpanIDs_GeneratesBaseSQL", spanIDs: nil, sql: baseSQL},
{name: "WithSpanIDs_GeneratesInClauseSQL", spanIDs: []string{"span-1", "span-2"}, sql: withSpanIDsSQL},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := newTestStore(sqlmock.QueryMatcherRegexp)
s.Mock().ExpectSelect(regexp.QuoteMeta(tc.sql)).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, tc.spanIDs)
assert.NoError(t, s.Mock().ExpectationsWereMet())
})
}
}
func TestGetSpanDurationByField(t *testing.T) {
expectedSQL := "WITH all_spans AS (SELECT DISTINCT ON (span_id) resource.`service.name`::String AS field_value, toUnixTimestamp64Nano(timestamp) AS start_ns, start_ns + duration_nano AS end_ns FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND notEmpty(field_value) ORDER BY timestamp ASC, name ASC), effective_start AS (SELECT field_value, end_ns, greatest(start_ns, ifNull(max(end_ns) OVER (PARTITION BY field_value ORDER BY start_ns ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), toUInt64(0))) AS effective_start_ns FROM all_spans) SELECT field_value, sum(toUInt64(greatest(end_ns - effective_start_ns, 0))) AS total_ns FROM effective_start GROUP BY field_value"

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// Handler exposes HTTP handlers for trace detail APIs.
@@ -12,6 +13,7 @@ type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
@@ -19,4 +21,5 @@ type Module interface {
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
}

View File

@@ -86,12 +86,11 @@ func New(
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
// Normalize Start/End to ms. UnmarshalJSON covers HTTP requests; callers
// that build the request programmatically skip it, so this is the catch-all
// (idempotent for the already-normalized path).
if err := req.Normalize(); err != nil {
return nil, err
}
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
tmplVars := req.Variables
if tmplVars == nil {
@@ -428,12 +427,10 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Catch-all normalization for programmatic callers (see QueryRange). End is
// 0 here for the open-ended stream, which Normalize leaves untouched.
if err := req.Normalize(); err != nil {
client.Error <- err
return
}
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
event := &qbtypes.QBEvent{
Version: "v5",

View File

@@ -33,6 +33,28 @@ func ToNanoSecs(epoch uint64) uint64 {
return temp * uint64(math.Pow(10, float64(19-count)))
}
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
// That would make graph look nice but "nice" but should be less important than the usefulness.
func RecommendedStepInterval(start, end uint64) uint64 {

View File

@@ -60,3 +60,51 @@ func TestToNanoSecs(t *testing.T) {
})
}
}
func TestToMilliSecs(t *testing.T) {
tests := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
epoch: 1672531200, // seconds
expected: 1672531200000, // * 10^3
},
{
name: "13-digit Unix timestamp (milliseconds) - already ms",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "16-digit Unix timestamp (microseconds)",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "19-digit Unix timestamp (nanoseconds)",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Unix epoch start - zero is unchanged",
epoch: 0,
expected: 0,
},
{
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
epoch: 1716638400,
expected: 1716638400000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToMilliSecs(tt.epoch)
if result != tt.expected {
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
}
})
}
}

View File

@@ -7,14 +7,15 @@ import (
"github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn"
"github.com/SigNoz/signoz/pkg/authn/passwordauthn/emailpasswordauthn"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (map[authtypes.AuthNProvider]authn.AuthN, error) {
emailPasswordAuthN := emailpasswordauthn.New(store)
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings)
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings, globalConfig)
if err != nil {
return nil, err
}

View File

@@ -275,14 +275,14 @@ 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, modules Modules, handlers Handlers, globalConfig global.Config) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
authz,
implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
implsession.NewHandler(modules.Session),
implsession.NewHandler(modules.Session, globalConfig),
implauthdomain.NewHandler(modules.AuthDomain),
implpreference.NewHandler(modules.Preference),
handlers.Global,

View File

@@ -95,6 +95,7 @@ func TestNewProviderFactories(t *testing.T) {
nil,
Modules{},
Handlers{},
global.Config{},
)
})
}

View File

@@ -542,7 +542,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global),
"signoz",
)
if err != nil {

View File

@@ -27,6 +27,7 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceAKS = ServiceID{valuer.NewString("aks")}
)
func (ServiceID) Enum() []any {
@@ -46,6 +47,7 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceAKS,
}
}
@@ -69,6 +71,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceAKS,
},
}

View File

@@ -12,13 +12,6 @@ import (
"github.com/swaggest/jsonschema-go"
)
const (
// minEpochMs and maxEpochMs bound a plausible ms timestamp to
// 1990-01-01 .. 2100-01-01, used to reject malformed Start/End values.
minEpochMs uint64 = 631_152_000_000
maxEpochMs uint64 = 4_102_444_800_000
)
type QueryEnvelope struct {
// Type is the type of the query.
Type QueryType `json:"type"` // "builder_query" | "builder_formula" | "builder_sub_query" | "builder_join" | "promql" | "clickhouse_sql"
@@ -556,23 +549,7 @@ func (r *QueryRangeRequest) SkipFillGaps(name string) bool {
return false
}
// Normalize coerces Start and End to epoch milliseconds, inferring the source
// resolution (s/ms/µs/ns) from each value's magnitude, and rejects non-zero
// values outside the plausible 1990-2100 range. Lets downstream consumers
// assume ms regardless of what the caller sent.
func (r *QueryRangeRequest) Normalize() error {
start, err := toMilliSecs(r.Start)
if err != nil {
return err
}
end, err := toMilliSecs(r.End)
if err != nil {
return err
}
r.Start, r.End = start, end
return nil
}
// UnmarshalJSON implements custom JSON unmarshaling to disallow unknown fields.
func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Define a type alias to avoid infinite recursion
type Alias QueryRangeRequest
@@ -632,11 +609,6 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Copy the decoded values back to the original struct
*r = QueryRangeRequest(temp)
// Coerce Start/End to ms (and validate) at decode time for HTTP requests.
if err := r.Normalize(); err != nil {
return err
}
return nil
}
@@ -690,24 +662,3 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
return canDefaultZero
}
// toMilliSecs scales an epoch to milliseconds based on its magnitude: seconds are
// scaled up, micro/nanoseconds down, milliseconds left as-is. Zero is returned
// unchanged. A non-zero result outside 1990-2100 is rejected as malformed.
func toMilliSecs(epoch uint64) (uint64, error) {
var ms uint64
switch {
case epoch < 1e12: // seconds
ms = epoch * 1_000
case epoch < 1e15: // milliseconds
ms = epoch
case epoch < 1e18: // microseconds
ms = epoch / 1_000
default: // nanoseconds
ms = epoch / 1_000_000
}
if epoch != 0 && (ms < minEpochMs || ms > maxEpochMs) {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "timestamp %d is outside the supported range (1990-2100)", epoch)
}
return ms, nil
}

View File

@@ -1903,70 +1903,3 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestQueryRangeRequest_Normalize(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantStart uint64
wantEnd uint64
wantErr bool
}{
{
name: "seconds are scaled up to ms",
start: 1672531200, // 2023-01-01 in seconds
end: 1716638400, // 2024-05-25 in seconds
wantStart: 1672531200000, // * 10^3
wantEnd: 1716638400000,
},
{
name: "milliseconds pass through unchanged",
start: 1672531200000,
end: 1716638400000,
wantStart: 1672531200000,
wantEnd: 1716638400000,
},
{
name: "microseconds are scaled down to ms",
start: 1672531200000000, // µs
end: 1716638400000000,
wantStart: 1672531200000, // / 10^3
wantEnd: 1716638400000,
},
{
name: "nanoseconds are scaled down to ms",
start: 1672531200000000000, // ns
end: 1716638400000000000,
wantStart: 1672531200000, // / 10^6
wantEnd: 1716638400000,
},
{
name: "zero end (open-ended stream) is left untouched",
start: 1672531200000,
end: 0,
wantStart: 1672531200000,
wantEnd: 0,
},
{
name: "out-of-range timestamp is rejected",
start: 5_000_000_000_000, // ~year 2128 in ms, beyond the 2100 bound
end: 5_000_000_000_000,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{Start: tt.start, End: tt.end}
err := r.Normalize()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantStart, r.Start)
assert.Equal(t, tt.wantEnd, r.End)
})
}
}

View File

@@ -0,0 +1,102 @@
package spantypes
import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type FlamegraphSpan struct {
SpanID string `json:"spanId" required:"true"`
ParentSpanID string `json:"parentSpanId" required:"true"`
Timestamp uint64 `json:"timestamp" required:"true"`
DurationNano uint64 `json:"durationNano" required:"true"`
HasError bool `json:"hasError" required:"true"`
Name string `json:"name" required:"true"`
Level int64 `json:"level" required:"true"`
Events []Event `json:"event" required:"true" nullable:"false"`
Attributes map[string]any `json:"attributes" required:"true" nullable:"false"`
Resource map[string]string `json:"resource" required:"true" nullable:"false"`
Children []*FlamegraphSpan `json:"-"` // internal tree use only
}
// FlamegraphLevel groups span IDs at a single level within the selected window.
type FlamegraphLevel struct {
Level int64
SpanIDs []string
}
type PostableFlamegraph struct {
SelectedSpanID string `json:"selectedSpanId"`
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
}
// GettableFlamegraphTrace is the response for the v3 flamegraph API.
type GettableFlamegraphTrace struct {
Spans [][]*FlamegraphSpan `json:"spans" required:"true" nullable:"false"`
StartTimestampMillis int64 `json:"startTimestampMillis" required:"true"`
EndTimestampMillis int64 `json:"endTimestampMillis" required:"true"`
HasMore bool `json:"hasMore" required:"true"`
}
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
return &GettableFlamegraphTrace{
Spans: spans,
StartTimestampMillis: startMs,
EndTimestampMillis: endMs,
HasMore: hasMore,
}
}
func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64, selectFields []telemetrytypes.TelemetryFieldKey) *FlamegraphSpan {
span := &FlamegraphSpan{
SpanID: s.SpanID,
ParentSpanID: s.ParentSpanID,
Timestamp: uint64(s.StartTime.UnixNano()),
DurationNano: s.DurationNano,
HasError: s.HasError,
Name: s.Name,
Level: level,
Events: s.UnmarshalledEvents(),
Attributes: make(map[string]any),
Resource: make(map[string]string),
}
if len(selectFields) == 0 {
return span
}
for _, field := range selectFields {
switch field.FieldContext {
case telemetrytypes.FieldContextResource:
if v, ok := s.ResourcesString[field.Name]; ok && v != "" {
span.Resource[field.Name] = v
}
case telemetrytypes.FieldContextAttribute:
if v := s.AttributeValue(field.Name); v != nil {
span.Attributes[field.Name] = v
}
}
}
return span
}
func NewMissingParentFlamegraphSpan(node *FlamegraphSpan) *FlamegraphSpan {
return &FlamegraphSpan{
SpanID: node.ParentSpanID,
Name: "Missing Span",
Timestamp: node.Timestamp,
DurationNano: node.DurationNano,
Events: []Event{},
Children: []*FlamegraphSpan{node},
}
}
// FlamegraphWindowSpanIDs collects all span IDs from a level window into a flat slice.
func FlamegraphWindowSpanIDs(window []FlamegraphLevel) []string {
total := 0
for _, lvl := range window {
total += len(lvl.SpanIDs)
}
ids := make([]string, 0, total)
for _, lvl := range window {
ids = append(ids, lvl.SpanIDs...)
}
return ids
}

View File

@@ -0,0 +1,111 @@
package spantypes
import (
"sort"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// FlamegraphTrace holds the level wise tree built from minimal spans.
type FlamegraphTrace struct {
roots []*FlamegraphSpan
nodeByID map[string]*FlamegraphSpan
startTime uint64
endTime uint64
}
func NewFlamegraphTraceFromMinimal(spans []MinimalSpan) *FlamegraphTrace {
t := &FlamegraphTrace{
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
}
for i := range spans {
node := spans[i].ToFlamegraphSpan()
t.updateTimeRange(node.Timestamp, node.DurationNano)
t.nodeByID[node.SpanID] = node
}
t.buildSpanTree()
return t
}
func NewFlamegraphTraceFromStorable(spans []StorableSpan, selectFields []telemetrytypes.TelemetryFieldKey) *FlamegraphTrace {
t := &FlamegraphTrace{
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
}
for i := range spans {
node := NewFlamegraphSpanFromStorable(&spans[i], 0, selectFields) // level is set later by BFS
t.updateTimeRange(node.Timestamp, node.DurationNano)
t.nodeByID[node.SpanID] = node
}
t.buildSpanTree()
return t
}
func (t *FlamegraphTrace) GetAllLevels() [][]*FlamegraphSpan {
return nil
}
// GetSelectedLevels returns the window of levels around selectedSpanID with sampling applied to dense levels.
func (t *FlamegraphTrace) GetSelectedLevels(selectedSpanID string, levelLimit, spansPerLevel, topLatencyCount, bucketCount int) []FlamegraphLevel {
return nil
}
func (t *FlamegraphTrace) EnrichSelectedSpans(selectedSpans []FlamegraphLevel, fullSpans []StorableSpan, selectFields []telemetrytypes.TelemetryFieldKey) [][]*FlamegraphSpan {
fullByID := make(map[string]*StorableSpan, len(fullSpans))
for i := range fullSpans {
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
}
result := make([][]*FlamegraphSpan, len(selectedSpans))
for i, lvl := range selectedSpans {
result[i] = make([]*FlamegraphSpan, 0, len(lvl.SpanIDs))
for _, spanID := range lvl.SpanIDs {
if full, ok := fullByID[spanID]; ok {
result[i] = append(result[i], NewFlamegraphSpanFromStorable(full, lvl.Level, selectFields))
} else if lean, ok := t.nodeByID[spanID]; ok {
result[i] = append(result[i], lean)
}
}
}
return result
}
func (t *FlamegraphTrace) updateTimeRange(timestamp, durationNano uint64) {
if t.startTime == 0 || timestamp < t.startTime {
t.startTime = timestamp
}
if end := timestamp + durationNano; end > t.endTime {
t.endTime = end
}
}
func (t *FlamegraphTrace) buildSpanTree() {
for _, node := range t.nodeByID {
if node.ParentSpanID != "" {
if parent, ok := t.nodeByID[node.ParentSpanID]; ok {
parent.Children = append(parent.Children, node)
} else {
missing := NewMissingParentFlamegraphSpan(node)
t.nodeByID[missing.SpanID] = missing
t.roots = append(t.roots, missing)
}
} else if flamegraphSpanIndex(t.roots, node.SpanID) == -1 {
t.roots = append(t.roots, node)
}
}
sort.Slice(t.roots, func(i, j int) bool {
if t.roots[i].Timestamp == t.roots[j].Timestamp {
return t.roots[i].SpanID < t.roots[j].SpanID
}
return t.roots[i].Timestamp < t.roots[j].Timestamp
})
}
func flamegraphSpanIndex(spans []*FlamegraphSpan, spanID string) int {
for i, s := range spans {
if s.SpanID == spanID {
return i
}
}
return -1
}

View File

@@ -30,6 +30,7 @@ type TraceStore interface {
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)

View File

@@ -164,6 +164,17 @@ func (item *MinimalSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
}
}
func (item *MinimalSpan) ToFlamegraphSpan() *FlamegraphSpan {
return &FlamegraphSpan{
SpanID: item.SpanID,
ParentSpanID: item.ParentSpanID,
Timestamp: uint64(item.StartTime.UnixNano()),
DurationNano: item.DurationNano,
HasError: item.HasError,
Children: make([]*FlamegraphSpan, 0),
}
}
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
return &WaterfallSpan{
@@ -267,6 +278,19 @@ func (ws *WaterfallSpan) getPathToSelectedSpanID(selectedSpanID string) ([]strin
return nil, false
}
func (item *StorableSpan) AttributeValue(name string) any {
if v, ok := item.AttributesString[name]; ok {
return v
}
if v, ok := item.AttributesNumber[name]; ok {
return v
}
if v, ok := item.AttributesBool[name]; ok {
return v
}
return nil
}
func (item *StorableSpan) Attributes() map[string]any {
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
for k, v := range item.AttributesString {
@@ -296,7 +320,7 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
func (item *StorableSpan) UnmarshalledRefs() []OtelSpanRef {
refs := []OtelSpanRef{}
if err := json.Unmarshal([]byte(item.References), &refs); err != nil {
return nil // skip malformed values
return []OtelSpanRef{} // skip malformed values
}
return refs
}

140
tests/fixtures/auth.py vendored
View File

@@ -56,11 +56,18 @@ def _login(signoz: types.SigNoz, email: str, password: str) -> str:
return login.json()["data"]["accessToken"]
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
def create() -> None:
def register_admin(
signoz: types.SigNoz,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "create_user_admin",
base_path: str = "",
) -> types.Operation:
"""Register the first admin (creates the org), under base_path. Reuse-wrapped."""
def create() -> types.Operation:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/register"),
signoz.self.host_configs["8080"].get(f"{base_path}/api/v1/register"),
json={
"name": USER_ADMIN_NAME,
"orgName": "",
@@ -83,7 +90,7 @@ def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pyte
return reuse.wrap(
request,
pytestconfig,
"create_user_admin",
cache_key,
lambda: types.Operation(name=""),
create,
delete,
@@ -91,86 +98,86 @@ def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pyte
)
@pytest.fixture(name="get_session_context", scope="function")
def get_session_context(signoz: types.SigNoz) -> Callable[[str, str], str]:
def _get_session_context(email: str) -> str:
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
return register_admin(signoz, request, pytestconfig)
def session_context_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str], dict]:
"""Build a callable that fetches the session context for an email (under base_path)."""
def fetch_session_context(email: str) -> dict:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
return response.json()["data"]
return _get_session_context
return fetch_session_context
@pytest.fixture(name="get_session_context", scope="function")
def get_session_context(signoz: types.SigNoz) -> Callable[[str], dict]:
return session_context_getter(signoz)
def token_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str, str], str]:
"""Build a callable that logs in (email/password) and returns the access token (under base_path)."""
def fetch_token(email: str, password: str) -> str:
context = requests.get(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert context.status_code == HTTPStatus.OK
org_id = context.json()["data"]["orgs"][0]["id"]
login = requests.post(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/email_password"),
json={"email": email, "password": password, "orgId": org_id},
timeout=5,
)
assert login.status_code == HTTPStatus.OK
return login.json()["data"]["accessToken"]
return fetch_token
@pytest.fixture(name="get_token", scope="function")
def get_token(signoz: types.SigNoz) -> Callable[[str, str], str]:
def _get_token(email: str, password: str) -> str:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
return token_getter(signoz)
def tokens_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str, str], tuple[str, str]]:
"""Build a callable that logs in and returns the (access, refresh) token pair (under base_path)."""
def fetch_tokens(email: str, password: str) -> tuple[str, str]:
context = requests.get(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert context.status_code == HTTPStatus.OK
org_id = context.json()["data"]["orgs"][0]["id"]
assert response.status_code == HTTPStatus.OK
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={
"email": email,
"password": password,
"orgId": org_id,
},
login = requests.post(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/email_password"),
json={"email": email, "password": password, "orgId": org_id},
timeout=5,
)
assert login.status_code == HTTPStatus.OK
data = login.json()["data"]
return data["accessToken"], data["refreshToken"]
assert response.status_code == HTTPStatus.OK
return response.json()["data"]["accessToken"]
return _get_token
return fetch_tokens
@pytest.fixture(name="get_tokens", scope="function")
def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
def _get_tokens(email: str, password: str) -> str:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={
"email": email,
"password": password,
"orgId": org_id,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
access_token = response.json()["data"]["accessToken"]
refresh_token = response.json()["data"]["refreshToken"]
return access_token, refresh_token
return _get_tokens
return tokens_getter(signoz)
@pytest.fixture(name="apply_license", scope="package")
@@ -270,6 +277,7 @@ def add_license(
signoz: types.SigNoz,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str], # pylint: disable=redefined-outer-name
base_path: str = "",
) -> None:
make_http_mocks(
signoz.zeus,
@@ -308,7 +316,7 @@ def add_license(
access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v3/licenses"),
url=signoz.self.host_configs["8080"].get(f"{base_path}/api/v3/licenses"),
json={"key": "secret-key"},
headers={"Authorization": "Bearer " + access_token},
timeout=5,

View File

@@ -0,0 +1,126 @@
from collections.abc import Callable
from http import HTTPStatus
from urllib.parse import urlparse
import requests
from selenium import webdriver
from wiremock.resources.mappings import Mapping
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, assert_user_has_role
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
# SigNoz is served under /signoz, so the OIDC callback registered with the IdP
# must include the prefix to match the backend-generated redirect URI.
BASE_PATH = "/signoz"
OIDC_CALLBACK_PATH = f"{BASE_PATH}/api/v1/complete/oidc"
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
) -> None:
"""
Applies a license to the signoz instance. add_license is a plain function
called from the test (function scope), so the function-scoped make_http_mocks
fixture is safe to use; base_path prefixes the licensing API call.
"""
add_license(signoz, make_http_mocks, get_token, base_path=BASE_PATH)
def test_create_auth_domain(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
create_oidc_client: Callable[[str, str], None],
get_oidc_settings: Callable[[str], dict],
get_token: Callable[[str, str], str],
) -> None:
"""
Creates an OIDC auth domain in SigNoz served under a base path. The callback
registered with the IdP carries the /signoz prefix.
"""
client_id = f"oidc.basepath.test.{signoz.self.host_configs['8080'].address}:{signoz.self.host_configs['8080'].port}"
# Create an oidc client in the idp with the prefixed callback.
create_oidc_client(client_id, OIDC_CALLBACK_PATH)
# Get the oidc settings from keycloak.
settings = get_oidc_settings(client_id)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
json={
"name": "oidc.basepath.test",
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
# Change the hostname of the issuer to the internal resolvable hostname of the idp
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
"issuerAlias": settings["issuer"],
"getUserInfo": True,
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
def test_oidc_authn(
signoz: SigNoz,
idp: TestContainerIDP,
driver: webdriver.Chrome,
create_user_idp: Callable[[str, str, bool, str, str], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
"""
Tests the OIDC authn flow when SigNoz is served under a base path. The login
URL the backend produces (and thus the IdP callback) carries the /signoz
prefix; the e2e browser login must complete and create the user.
"""
# Create a user in the idp.
create_user_idp("viewer@oidc.basepath.test", "password123", True)
# Get the session context from signoz which will give the OIDC login URL.
session_context = get_session_context("viewer@oidc.basepath.test")
assert len(session_context["orgs"]) == 1
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
# change the url to the external resolvable hostname of the idp
parsed_url = urlparse(url)
actual_url = f"{idp.container.host_configs['6060'].get(parsed_url.path)}?{parsed_url.query}"
driver.get(actual_url)
idp_login("viewer@oidc.basepath.test", "password123")
# Assert that the user was created in signoz (lookup under the base path).
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
users = requests.get(
signoz.self.host_configs["8080"].get("/signoz/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert users.status_code == HTTPStatus.OK, users.text
user = next((u for u in users.json()["data"] if u["email"] == "viewer@oidc.basepath.test"), None)
assert user is not None, "User with email 'viewer@oidc.basepath.test' not found"
user_with_roles = requests.get(
signoz.self.host_configs["8080"].get(f"/signoz/api/v2/users/{user['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert user_with_roles.status_code == HTTPStatus.OK, user_with_roles.text
assert_user_has_role(user_with_roles.json()["data"], "signoz-viewer")

View File

@@ -0,0 +1,117 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from selenium import webdriver
from wiremock.resources.mappings import Mapping
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, assert_user_has_role
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
# SigNoz is served under /signoz, so the SAML ACS registered with the IdP must
# include the prefix to match the backend-generated AssertionConsumerServiceURL.
BASE_PATH = "/signoz"
SAML_CALLBACK_PATH = f"{BASE_PATH}/api/v1/complete/saml"
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
) -> None:
"""
Applies a license to the signoz instance. add_license is a plain function
called from the test (function scope), so the function-scoped make_http_mocks
fixture is safe to use; base_path prefixes the licensing API call.
"""
add_license(signoz, make_http_mocks, get_token, base_path=BASE_PATH)
def test_create_auth_domain(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
create_saml_client: Callable[[str, str], None],
get_saml_settings: Callable[[], dict],
get_token: Callable[[str, str], str],
) -> None:
"""
Creates a SAML auth domain in SigNoz served under a base path. The ACS
registered with the IdP carries the /signoz prefix.
"""
# Create a saml client in the idp with the prefixed ACS.
create_saml_client("saml.basepath.test", SAML_CALLBACK_PATH)
# Get the saml settings from keycloak.
settings = get_saml_settings()
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
json={
"name": "saml.basepath.test",
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
def test_saml_authn(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
driver: webdriver.Chrome,
create_user_idp: Callable[[str, str, bool, str, str], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
"""
Tests the SAML authn flow when SigNoz is served under a base path. The
AssertionConsumerServiceURL in the AuthnRequest carries the /signoz prefix;
the e2e browser login must complete and create the user.
"""
# Create a user in the idp.
create_user_idp("viewer@saml.basepath.test", "password", True)
# Get the session context from signoz which will give the SAML login URL.
session_context = get_session_context("viewer@saml.basepath.test")
assert len(session_context["orgs"]) == 1
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
driver.get(url)
idp_login("viewer@saml.basepath.test", "password")
# Assert that the user was created in signoz (lookup under the base path).
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
users = requests.get(
signoz.self.host_configs["8080"].get("/signoz/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert users.status_code == HTTPStatus.OK, users.text
user = next((u for u in users.json()["data"] if u["email"] == "viewer@saml.basepath.test"), None)
assert user is not None, "User with email 'viewer@saml.basepath.test' not found"
user_with_roles = requests.get(
signoz.self.host_configs["8080"].get(f"/signoz/api/v2/users/{user['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert user_with_roles.status_code == HTTPStatus.OK, user_with_roles.text
assert_user_has_role(user_with_roles.json()["data"], "signoz-viewer")

View File

@@ -0,0 +1,57 @@
from collections.abc import Callable
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.auth import register_admin, session_context_getter, token_getter
from fixtures.signoz import create_signoz
# SigNoz is served under this URL path prefix for the base-path suite. The auth
# helpers from fixtures/auth.py are reused via their factories with this prefix,
# so these fixtures shadow the same-named root ones without duplicating logic.
# Only the path component is read by global.ExternalPath(), which derives the
# http.StripPrefix route prefix.
BASE_PATH = "/signoz"
@pytest.fixture(name="signoz", scope="package")
def signoz_base_path( # 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 SigNoz served under BASE_PATH. Sets SIGNOZ_GLOBAL_EXTERNAL__URL
with the prefix so the backend derives the http.StripPrefix route prefix.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_base_path",
env_overrides={"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
)
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
@pytest.fixture(name="get_token", scope="function")
def get_token(signoz: types.SigNoz) -> Callable[[str, str], str]:
return token_getter(signoz, BASE_PATH)
@pytest.fixture(name="get_session_context", scope="function")
def get_session_context(signoz: types.SigNoz) -> Callable[[str], dict]:
return session_context_getter(signoz, BASE_PATH)

View File

@@ -483,7 +483,7 @@ def test_enable_metrics_provisions_dashboards(
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
provisioned_ids = set()
for dash in dashboards_in_service:
assert "integrationDashboard" in dash, f"Integration dashboard entry missing"
assert "integrationDashboard" in dash, "Integration dashboard entry missing"
try:
uuid.UUID(dash["integrationDashboard"]["id"])
except ValueError as err: