mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-12 20:00:28 +01:00
Compare commits
11 Commits
sentry-fix
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa454f6b2a | ||
|
|
e38ed9649f | ||
|
|
386358214c | ||
|
|
38b28ba4c5 | ||
|
|
9237e25ef8 | ||
|
|
867d626b27 | ||
|
|
0769ac0e7d | ||
|
|
6f79d6b18d | ||
|
|
e57a9556e3 | ||
|
|
bf35748db5 | ||
|
|
2781f73057 |
@@ -12790,6 +12790,53 @@ paths:
|
||||
summary: Update a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/stats:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the collected stats for the organization
|
||||
operationId: GetStats
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get stats
|
||||
tags:
|
||||
- stats
|
||||
/api/v1/testChannel:
|
||||
post:
|
||||
deprecated: true
|
||||
|
||||
@@ -109,6 +109,20 @@ func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
```
|
||||
|
||||
When you need an ID from `claims` as a `valuer.UUID` (for example to pass it to a module), derive it with the `Must*` constructor instead of `NewUUID` plus an error check. Claims are validated by the auth middleware, so the conversion cannot fail and the error branch would be dead code:
|
||||
|
||||
```go
|
||||
// Good — claims are pre-validated, the conversion cannot fail.
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
// Avoid — the error path is unreachable.
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register the handler in `signozapiserver`
|
||||
|
||||
In `pkg/apiserver/signozapiserver`, add a route in the appropriate `add*Routes` function (`addUserRoutes`, `addSessionRoutes`, `addOrgRoutes`, etc.). The pattern is:
|
||||
@@ -387,3 +401,4 @@ Note the discriminator property lives in the variants, not on the parent — the
|
||||
- **Add `nullable:"true"`** on fields that can be `null`. Pay special attention to slices and maps -- in Go these default to `nil` which serializes to `null`. If the field should always be an array, initialize it and do not mark it nullable.
|
||||
- **Implement `Enum()`** on every type that has a fixed set of acceptable values so the JSON schema generates proper `enum` constraints.
|
||||
- **Add request examples** via `RequestExamples` in `OpenAPIDef` for any non-trivial endpoint. See `pkg/apiserver/signozapiserver/querier.go` for reference.
|
||||
- **Derive IDs from `claims` with `valuer.MustNewUUID`** (e.g. `claims.OrgID`, `claims.UserID`). Claims are pre-validated by the auth middleware, so use the `Must*` constructor — don't write `NewUUID` followed by an `if err != nil { render.Error(...); return }` block.
|
||||
|
||||
@@ -9746,6 +9746,19 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
data: GetStats200Data;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceAggregationsPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
96
frontend/src/api/generated/services/stats/index.ts
Normal file
96
frontend/src/api/generated/services/stats/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type { GetStats200, RenderErrorResponseDTO } from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint returns the collected stats for the organization
|
||||
* @summary Get stats
|
||||
*/
|
||||
export const getStats = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetStats200>({
|
||||
url: `/api/v1/stats`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetStatsQueryKey = () => {
|
||||
return [`/api/v1/stats`] as const;
|
||||
};
|
||||
|
||||
export const getGetStatsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetStatsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getStats>>> = ({
|
||||
signal,
|
||||
}) => getStats(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getStats>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetStatsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getStats>>
|
||||
>;
|
||||
export type GetStatsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get stats
|
||||
*/
|
||||
|
||||
export function useGetStats<
|
||||
TData = Awaited<ReturnType<typeof getStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetStatsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get stats
|
||||
*/
|
||||
export const invalidateGetStats = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetStatsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -36,6 +36,7 @@ export const REACT_QUERY_KEY = {
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',
|
||||
|
||||
42
frontend/src/hooks/trace/useGetTraceFlamegraphV3.tsx
Normal file
42
frontend/src/hooks/trace/useGetTraceFlamegraphV3.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getFlamegraph } from 'api/generated/services/tracedetail';
|
||||
import {
|
||||
SpantypesGettableFlamegraphTraceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface GetTraceFlamegraphV3Props {
|
||||
traceId: string;
|
||||
selectedSpanId?: string;
|
||||
selectFields?: TelemetryFieldKey[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const useGetTraceFlamegraphV3 = (
|
||||
props: GetTraceFlamegraphV3Props,
|
||||
): UseQueryResult<SpantypesGettableFlamegraphTraceDTO, unknown> =>
|
||||
useQuery({
|
||||
queryFn: () =>
|
||||
getFlamegraph(
|
||||
{ traceID: props.traceId },
|
||||
{
|
||||
selectedSpanId: props.selectedSpanId,
|
||||
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
|
||||
// the literal-union vs enum nominal types differ
|
||||
selectFields: props.selectFields as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
},
|
||||
).then((res) => res.data),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V3_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.selectFields,
|
||||
],
|
||||
enabled: props.enabled,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export default useGetTraceFlamegraphV3;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
|
||||
import { Select } from 'antd';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface DynamicVariableFieldsProps {
|
||||
attribute: string;
|
||||
signal: TelemetrySignal;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: TelemetrySignal;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
|
||||
function DynamicVariableFields({
|
||||
attribute,
|
||||
signal,
|
||||
onChange,
|
||||
onPreview,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const { data: keyData, isLoading } = useGetFieldKeys({
|
||||
signal,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
// `keys` is a Record keyed BY field name; the field names are the map keys.
|
||||
// When the API reports the list is `complete`, search filters locally.
|
||||
const isComplete = keyData?.data?.complete === true;
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
[keyData],
|
||||
);
|
||||
|
||||
const { data: valueData } = useGetFieldValues({
|
||||
signal,
|
||||
name: attribute,
|
||||
enabled: !!attribute,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const payload = valueData?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
onPreview(values);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={signal}
|
||||
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as TelemetrySignal })
|
||||
}
|
||||
testId="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
filterOption={isComplete}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
options={options}
|
||||
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableFields;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
sort: VariableSort;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}
|
||||
|
||||
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
|
||||
function QueryVariableFields({
|
||||
queryValue,
|
||||
sort,
|
||||
onChange,
|
||||
onPreview,
|
||||
onError,
|
||||
}: QueryVariableFieldsProps): JSX.Element {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const runTest = async (): Promise<void> => {
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({
|
||||
query: queryValue,
|
||||
variables: {},
|
||||
});
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message || 'Failed to run query');
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.queryContainer}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Query</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.editorWrap}>
|
||||
<Editor
|
||||
language="sql"
|
||||
value={queryValue}
|
||||
onChange={(value): void => onChange(value)}
|
||||
height="240px"
|
||||
options={{
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.testRow}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isRunning}
|
||||
disabled={!queryValue}
|
||||
onClick={runTest}
|
||||
testId="variable-test-run"
|
||||
>
|
||||
Test Run Query
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryVariableFields;
|
||||
@@ -0,0 +1,310 @@
|
||||
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
|
||||
built on @signozhq components where possible. antd is retained only for the
|
||||
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.allVariables {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.allVariablesBtn {
|
||||
--button-height: 24px;
|
||||
--button-padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 12px 16px 20px;
|
||||
}
|
||||
|
||||
/* VariableItemRow */
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* LabelContainer */
|
||||
.labelContainer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.defaultInput {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.defaultInput {
|
||||
width: 342px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Variable type segmented group */
|
||||
.typeSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.typeLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.typeBtnGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typeBtn {
|
||||
--button-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 114px;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.typeBtnSelected {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
.queryContainer {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editorWrap {
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.testRow {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Custom — antd Collapse */
|
||||
.customSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.customSection :global(.custom-collapse) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 12px;
|
||||
background: var(--l3-background);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 1px 2px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.comma-input) {
|
||||
height: 109px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Textbox */
|
||||
.textboxSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Preview strip */
|
||||
.previewSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 88px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 3px 0 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
.previewValues {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.previewValues [data-slot='badge'] {
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.previewError {
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Sort / multi / all / default rows */
|
||||
.sortSection,
|
||||
.multiSection,
|
||||
.allOptionSection,
|
||||
.dynamicSection {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sortSection {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
width: 339px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.defaultValueSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.defaultValueSection .label {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.defaultValueDesc {
|
||||
display: block;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.searchSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, Check, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput, Select } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import {
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
type VariableType,
|
||||
} from '../variableModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
import QueryVariableFields from './QueryVariableFields';
|
||||
import VariableTypeSelector from './VariableTypeSelector';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
const SORT_LABEL: Record<VariableSort, string> = {
|
||||
DISABLED: 'Disabled',
|
||||
ASC: 'Ascending',
|
||||
DESC: 'Descending',
|
||||
};
|
||||
|
||||
function getNameError(name: string, existingNames: string[]): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** Names of the other variables, for uniqueness validation. */
|
||||
existingNames: string[];
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
|
||||
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
|
||||
* and searchable selects). Master→detail: renders in place of the list.
|
||||
*/
|
||||
function VariableForm({
|
||||
initial,
|
||||
existingNames,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
setDefaultValue(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setPreviewValues(
|
||||
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
|
||||
);
|
||||
};
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const handleSave = (): void => {
|
||||
onSave({
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.allVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.allVariablesBtn}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* Name */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Name</Typography.Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={model.name}
|
||||
placeholder="Unique name of the variable"
|
||||
onChange={(e): void => set({ name: e.target.value })}
|
||||
testId="variable-name-input"
|
||||
/>
|
||||
{nameError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{nameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={styles.textarea}
|
||||
value={model.description}
|
||||
placeholder="Enter a description for the variable"
|
||||
rows={3}
|
||||
onChange={(e): void => set({ description: e.target.value })}
|
||||
data-testid="variable-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Variable Type */}
|
||||
<VariableTypeSelector value={model.type} onChange={selectType} />
|
||||
|
||||
{/* Type-specific body */}
|
||||
{model.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={(patch): void => set(patch)}
|
||||
onPreview={setPreviewValues}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'QUERY' ? (
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
sort={model.sort}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setPreviewValues}
|
||||
onError={setPreviewError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'CUSTOM' ? (
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{model.type === 'TEXT' ? (
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Shared rows for list-type variables */}
|
||||
{isListType ? (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={model.sort}
|
||||
items={VARIABLE_SORTS.map((sort) => ({
|
||||
label: SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => set({ sort: value as VariableSort })}
|
||||
testId="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void => {
|
||||
set({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
});
|
||||
}}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => set({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => setDefaultValue(value ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableForm;
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import type { VariableType } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface VariableTypeSelectorProps {
|
||||
value: VariableType;
|
||||
onChange: (type: VariableType) => void;
|
||||
}
|
||||
|
||||
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
|
||||
function VariableTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: VariableTypeSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.row, styles.typeSection)}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.typeBtnGroup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<Pyramid size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'DYNAMIC',
|
||||
})}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<ClipboardType size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'TEXT',
|
||||
})}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutList size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'CUSTOM',
|
||||
})}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<DatabaseZap size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'QUERY',
|
||||
})}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeSelector;
|
||||
@@ -0,0 +1,101 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.rowMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.varName {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.varDesc {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeTag {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
background: var(--l2-background);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.confirmText {
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PenLine,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariablesListProps {
|
||||
variables: VariableFormModel[];
|
||||
canEdit: boolean;
|
||||
/** Index whose delete is awaiting inline confirmation, if any. */
|
||||
confirmingIndex: number | null;
|
||||
onEdit: (index: number) => void;
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
}
|
||||
|
||||
function VariablesList({
|
||||
variables,
|
||||
canEdit,
|
||||
confirmingIndex,
|
||||
onEdit,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<div
|
||||
className={styles.row}
|
||||
key={variable.name || `variable-${index}`}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && confirmingIndex === index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && confirmingIndex !== index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={(): void => onMove(index, index - 1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === variables.length - 1}
|
||||
onClick={(): void => onMove(index, index + 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesList;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from './variableModel';
|
||||
import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
type EditingState = { index: number | null } | null;
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
|
||||
const initialModels = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
|
||||
|
||||
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
|
||||
useEffect(() => {
|
||||
setVariables(initialModels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [editing, setEditing] = useState<EditingState>(null);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const editingModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!editing) {
|
||||
return null;
|
||||
}
|
||||
return editing.index === null
|
||||
? emptyVariableFormModel()
|
||||
: variables[editing.index];
|
||||
}, [editing, variables]);
|
||||
|
||||
const existingNames = useMemo(() => {
|
||||
const self = editing?.index ?? null;
|
||||
return variables.filter((_, i) => i !== self).map((v) => v.name);
|
||||
}, [variables, editing]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (model: VariableFormModel): void => {
|
||||
const next = [...variables];
|
||||
if (editing?.index == null) {
|
||||
next.push(model);
|
||||
} else {
|
||||
next[editing.index] = model;
|
||||
}
|
||||
setEditing(null);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
const handleMove = (from: number, to: number): void => {
|
||||
if (to < 0 || to >= variables.length) {
|
||||
return;
|
||||
}
|
||||
const next = [...variables];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = (index: number): void => {
|
||||
persist(variables.filter((_, i) => i !== index));
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
// Detail view — edit/new form replaces the list in place (no modal).
|
||||
if (editingModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
initial={editingModel}
|
||||
existingNames={existingNames}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Master view — the variables list.
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<Typography.Text className={styles.title}>Variables</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Define variables to parameterize panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setEditing({ index: null })}
|
||||
testId="add-variable"
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variables.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text>No variables defined yet.</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setEditing({ index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesSettings;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import { buildVariablesPatch } from './variablePatchOps';
|
||||
|
||||
interface UseSaveVariables {
|
||||
save: (variables: VariableFormModel[]) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the dashboard's variable list via a single `/spec/variables` patch,
|
||||
* then refetches. Mirrors the General-settings save flow (patch → toast →
|
||||
* refetch → surface errors).
|
||||
*/
|
||||
export function useSaveVariables(): UseSaveVariables {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const save = useCallback(
|
||||
async (variables: VariableFormModel[]): Promise<boolean> => {
|
||||
if (!dashboardId) {
|
||||
return false;
|
||||
}
|
||||
const dtos = variables.map(formModelToDto);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
|
||||
toast.success('Variables updated');
|
||||
refetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, refetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { save, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
PLUGIN_KIND,
|
||||
type TelemetrySignal,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
export function dtoToFormModel(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel {
|
||||
const base = emptyVariableFormModel();
|
||||
const display = dto.spec?.display;
|
||||
const common: VariableFormModel = {
|
||||
...base,
|
||||
name: dto.spec?.name ?? display?.name ?? '',
|
||||
description: display?.description ?? '',
|
||||
};
|
||||
|
||||
// Text variable — a distinct envelope (no list plugin).
|
||||
if (dto.kind === TextEnvelopeKind.TextVariable) {
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...common,
|
||||
type: 'TEXT',
|
||||
textValue: spec.value ?? '',
|
||||
textConstant: spec.constant ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const listCommon: VariableFormModel = {
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: (spec.sort as VariableSort) ?? 'DISABLED',
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
|
||||
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'CUSTOM',
|
||||
customValue: plugin.spec.customValue ?? '',
|
||||
};
|
||||
}
|
||||
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'QUERY',
|
||||
queryValue:
|
||||
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
|
||||
? (plugin.spec.queryValue ?? '')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlugin(
|
||||
model: VariableFormModel,
|
||||
): DashboardtypesVariablePluginDTO {
|
||||
switch (model.type) {
|
||||
case 'CUSTOM':
|
||||
return {
|
||||
kind: CustomPluginKind['signoz/CustomVariable'],
|
||||
spec: { customValue: model.customValue },
|
||||
};
|
||||
case 'DYNAMIC':
|
||||
return {
|
||||
kind: DynamicPluginKind['signoz/DynamicVariable'],
|
||||
spec: {
|
||||
name: model.dynamicAttribute,
|
||||
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
|
||||
},
|
||||
};
|
||||
case 'QUERY':
|
||||
default:
|
||||
return {
|
||||
kind: QueryPluginKind['signoz/QueryVariable'],
|
||||
spec: { queryValue: model.queryValue },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat form model → DTO envelope (for persistence). */
|
||||
export function formModelToDto(
|
||||
model: VariableFormModel,
|
||||
): DashboardtypesVariableDTO {
|
||||
const display = {
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
hidden: model.hidden,
|
||||
};
|
||||
|
||||
if (model.type === 'TEXT') {
|
||||
return {
|
||||
kind: TextEnvelopeKind.TextVariable,
|
||||
spec: {
|
||||
name: model.name,
|
||||
display,
|
||||
value: model.textValue,
|
||||
constant: model.textConstant,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: ListEnvelopeKind.ListVariable,
|
||||
spec: {
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
allowAllValue: model.showAllOption,
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
plugin: buildPlugin(model),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
|
||||
export function variableTypeOf(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel['type'] {
|
||||
return dtoToFormModel(dto).type;
|
||||
}
|
||||
|
||||
export { PLUGIN_KIND };
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
|
||||
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
|
||||
* to bind a form to; `variableAdapters` converts between this model and the DTO.
|
||||
*/
|
||||
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
|
||||
|
||||
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
|
||||
|
||||
/** Wire `kind` discriminators (string values of the generated enums). */
|
||||
export const ENVELOPE_KIND = {
|
||||
LIST: 'ListVariable',
|
||||
TEXT: 'TextVariable',
|
||||
} as const;
|
||||
|
||||
export const PLUGIN_KIND = {
|
||||
QUERY: 'signoz/QueryVariable',
|
||||
CUSTOM: 'signoz/CustomVariable',
|
||||
DYNAMIC: 'signoz/DynamicVariable',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
|
||||
|
||||
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
|
||||
'traces',
|
||||
'logs',
|
||||
'metrics',
|
||||
];
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
hidden: false,
|
||||
type: 'QUERY',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: 'DISABLED',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
|
||||
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
|
||||
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
|
||||
* member sets-or-replaces, so it works whether or not `variables` already exists.
|
||||
*/
|
||||
export function buildVariablesPatch(
|
||||
variables: DashboardtypesVariableDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
{
|
||||
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/variables',
|
||||
value: variables,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,48 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { TabItemProps, 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';
|
||||
import VariablesSettings from './Variables';
|
||||
|
||||
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(
|
||||
const items: TabItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'general',
|
||||
label: tabLabel(<Table size={14} />, 'General'),
|
||||
label: 'General',
|
||||
children: <GeneralSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Table size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'variables',
|
||||
label: tabLabel(<Braces size={14} />, 'Variables'),
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
|
||||
),
|
||||
label: 'Variables',
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'public-dashboard',
|
||||
label: tabLabel(<Globe size={14} />, 'Publish'),
|
||||
label: 'Publish',
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
prefixIcon: <Globe size={14} />,
|
||||
},
|
||||
],
|
||||
[dashboard],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
@@ -53,6 +53,9 @@ function TraceFlamegraph({
|
||||
);
|
||||
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
// Gate the fetch until prefs load, else selectFields (in the query key)
|
||||
// repopulates and triggers a second fetch.
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
|
||||
// color-by entries first so their canonical metadata wins on collision.
|
||||
@@ -70,17 +73,14 @@ function TraceFlamegraph({
|
||||
data,
|
||||
isFetching,
|
||||
error: fetchError,
|
||||
} = useGetTraceFlamegraph({
|
||||
} = useGetTraceFlamegraphV3({
|
||||
traceId,
|
||||
selectedSpanId: selectedSpanIdForFetch,
|
||||
limit: FLAMEGRAPH_SPAN_LIMIT,
|
||||
selectFields: flamegraphSelectFields,
|
||||
enabled: !!traceId && userPrefsReady,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
() => data?.payload?.spans || [],
|
||||
[data?.payload?.spans],
|
||||
);
|
||||
const spans = useMemo(() => data?.spans || [], [data?.spans]);
|
||||
|
||||
const {
|
||||
layout,
|
||||
@@ -99,8 +99,8 @@ function TraceFlamegraph({
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
startTime: data?.startTimestampMillis || 0,
|
||||
endTime: data?.endTimestampMillis || 0,
|
||||
}}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
@@ -124,7 +124,7 @@ function TraceFlamegraph({
|
||||
if (fetchError || workerError) {
|
||||
return <Error error={(fetchError || workerError) as any} />;
|
||||
}
|
||||
if (data?.payload?.spans && data.payload.spans.length === 0) {
|
||||
if (data?.spans && data.spans.length === 0) {
|
||||
return <div>No data found for trace {traceId}</div>;
|
||||
}
|
||||
return (
|
||||
@@ -134,17 +134,17 @@ function TraceFlamegraph({
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
startTime: data?.startTimestampMillis || 0,
|
||||
endTime: data?.endTimestampMillis || 0,
|
||||
}}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
data?.payload?.spans,
|
||||
data?.endTimestampMillis,
|
||||
data?.startTimestampMillis,
|
||||
data?.spans,
|
||||
fetchError,
|
||||
filteredSpanIds,
|
||||
firstSpanAtFetchLevel,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph';
|
||||
|
||||
jest.mock('hooks/trace/useGetTraceFlamegraph');
|
||||
jest.mock('hooks/trace/useGetTraceFlamegraphV3');
|
||||
|
||||
// Short-circuit the worker so the test doesn't depend on layout computation.
|
||||
jest.mock('../hooks/useVisualLayoutWorker', () => ({
|
||||
@@ -17,9 +17,8 @@ jest.mock('../hooks/useVisualLayoutWorker', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseGetTraceFlamegraph = useGetTraceFlamegraph as jest.MockedFunction<
|
||||
typeof useGetTraceFlamegraph
|
||||
>;
|
||||
const mockUseGetTraceFlamegraph =
|
||||
useGetTraceFlamegraphV3 as jest.MockedFunction<typeof useGetTraceFlamegraphV3>;
|
||||
|
||||
function renderFlamegraph(props: {
|
||||
selectedSpan: SpanV3 | undefined;
|
||||
@@ -45,7 +44,7 @@ describe('TraceFlamegraph - selectedSpanId pass-through', () => {
|
||||
beforeEach(() => {
|
||||
mockUseGetTraceFlamegraph.mockReset();
|
||||
mockUseGetTraceFlamegraph.mockReturnValue({
|
||||
data: { payload: { spans: [] } },
|
||||
data: { spans: [] },
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as never);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
computeVisualLayout,
|
||||
@@ -14,12 +14,12 @@ function makeSpan(
|
||||
): FlamegraphSpan {
|
||||
return {
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'svc',
|
||||
name: 'op',
|
||||
level: 0,
|
||||
event: [],
|
||||
resource: {},
|
||||
attributes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Minimal FlamegraphSpan for unit tests */
|
||||
export const MOCK_SPAN: FlamegraphSpan = {
|
||||
@@ -6,12 +6,12 @@ export const MOCK_SPAN: FlamegraphSpan = {
|
||||
durationNano: 50_000_000, // 50ms
|
||||
spanId: 'span-1',
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'test-service',
|
||||
name: 'test-span',
|
||||
level: 0,
|
||||
event: [],
|
||||
resource: {},
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
/** Nested spans structure for findSpanById tests */
|
||||
|
||||
@@ -65,37 +65,25 @@ describe('Presentation / Styling Utils', () => {
|
||||
describe('getFlamegraphSpanGroupValue', () => {
|
||||
it('returns resource[field.name] when present', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{
|
||||
serviceName: 'legacy',
|
||||
resource: { 'service.name': 'svc-from-resource' },
|
||||
},
|
||||
{ resource: { 'service.name': 'svc-from-resource' } },
|
||||
SERVICE_FIELD,
|
||||
);
|
||||
expect(value).toBe('svc-from-resource');
|
||||
});
|
||||
|
||||
it('falls back to top-level serviceName for service.name when resource is empty', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: 'svc-legacy', resource: {} },
|
||||
SERVICE_FIELD,
|
||||
);
|
||||
expect(value).toBe('svc-legacy');
|
||||
it('returns "unknown" for service.name when resource is empty', () => {
|
||||
const value = getFlamegraphSpanGroupValue({ resource: {} }, SERVICE_FIELD);
|
||||
expect(value).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns "unknown" for non-service fields when resource is missing', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: 'svc', resource: {} },
|
||||
HOST_FIELD,
|
||||
);
|
||||
const value = getFlamegraphSpanGroupValue({ resource: {} }, HOST_FIELD);
|
||||
expect(value).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reads host.name from resource when present', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{
|
||||
serviceName: 'svc',
|
||||
resource: { 'host.name': 'host-1' },
|
||||
},
|
||||
{ resource: { 'host.name': 'host-1' } },
|
||||
HOST_FIELD,
|
||||
);
|
||||
expect(value).toBe('host-1');
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface ConnectorLine {
|
||||
parentRow: number;
|
||||
childRow: number;
|
||||
timestampMs: number;
|
||||
serviceName: string;
|
||||
// Snapshot of the child span's resource so draw-time can resolve the
|
||||
// `colorByField` group value without crossing the worker boundary.
|
||||
resource?: Record<string, string>;
|
||||
@@ -159,24 +158,8 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract parentSpanId — the field may be missing at runtime when the API
|
||||
// returns `references` instead. Fall back to the first CHILD_OF reference.
|
||||
function getParentId(span: FlamegraphSpan): string {
|
||||
if (span.parentSpanId) {
|
||||
return span.parentSpanId;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const refs = (span as any).references as
|
||||
| Array<{ spanId?: string; refType?: string }>
|
||||
| undefined;
|
||||
if (refs) {
|
||||
for (const ref of refs) {
|
||||
if (ref.refType === 'CHILD_OF' && ref.spanId) {
|
||||
return ref.spanId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
return span.parentSpanId || '';
|
||||
}
|
||||
|
||||
// Build children map and identify roots
|
||||
@@ -480,7 +463,6 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
parentRow,
|
||||
childRow,
|
||||
timestampMs: child.timestamp,
|
||||
serviceName: child.serviceName,
|
||||
resource: child.resource,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
|
||||
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { ConnectorLine } from '../computeVisualLayout';
|
||||
@@ -200,7 +200,7 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
}
|
||||
|
||||
const groupValue = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: conn.serviceName, resource: conn.resource },
|
||||
{ resource: conn.resource },
|
||||
colorByField,
|
||||
);
|
||||
const pair = generateColorPair(groupValue);
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { EventRect, SpanRect } from '../types';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { EventRect, ITraceMetadata, SpanRect } from '../types';
|
||||
import {
|
||||
getFlamegraphServiceName,
|
||||
getFlamegraphSpanGroupValue,
|
||||
@@ -200,7 +199,7 @@ export function useFlamegraphHover(
|
||||
|
||||
if (eventRect) {
|
||||
const { event, span } = eventRect;
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
|
||||
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
|
||||
setHoveredSpanId(span.spanId);
|
||||
setTooltipContent({
|
||||
@@ -220,10 +219,10 @@ export function useFlamegraphHover(
|
||||
return isDarkMode ? pair.color : pair.colorDark;
|
||||
})(),
|
||||
event: {
|
||||
name: event.name,
|
||||
name: event.name ?? '',
|
||||
timeOffsetMs: eventTimeMs - span.timestamp,
|
||||
isError: event.isError,
|
||||
attributeMap: event.attributeMap || {},
|
||||
isError: event.isError ?? false,
|
||||
attributeMap: (event.attributeMap as Record<string, string>) ?? {},
|
||||
},
|
||||
});
|
||||
updateCursor(canvas, eventRect.span);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { MIN_VISIBLE_SPAN_MS } from '../constants';
|
||||
import { ITraceMetadata } from '../types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { computeVisualLayout, VisualLayout } from '../computeVisualLayout';
|
||||
import { LayoutWorkerResponse } from '../visualLayoutWorkerTypes';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
SpantypesEventDTO as FlamegraphEvent,
|
||||
SpantypesFlamegraphSpanDTO as FlamegraphSpan,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Event, FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { VisualLayout } from './computeVisualLayout';
|
||||
|
||||
@@ -28,7 +31,7 @@ export interface SpanRect {
|
||||
}
|
||||
|
||||
export interface EventRect {
|
||||
event: Event;
|
||||
event: FlamegraphEvent;
|
||||
span: FlamegraphSpan;
|
||||
cx: number;
|
||||
cy: number;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
generateColorPair,
|
||||
RESERVED_ERROR,
|
||||
} from 'pages/TraceDetailsV3/utils/generateColorPair';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
@@ -74,34 +74,25 @@ export function getFlamegraphRowMetrics(
|
||||
|
||||
/**
|
||||
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
|
||||
* (service identity, independent of the active colour-by field). Prefers
|
||||
* `resource['service.name']` with legacy top-level `serviceName` fallback.
|
||||
* (service identity, independent of the active colour-by field). Reads
|
||||
* `resource['service.name']`.
|
||||
*/
|
||||
export function getFlamegraphServiceName(
|
||||
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
|
||||
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
|
||||
): string {
|
||||
return getSpanAttribute(span, 'service.name') || span.serviceName || '';
|
||||
return getSpanAttribute(span, 'service.name') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the value used to bucket a flamegraph span by colour for the given
|
||||
* field. Prefers `resource[field.name]` (new contract from `selectFields`).
|
||||
* For `service.name`, falls back to the legacy top-level `serviceName` when
|
||||
* resource is empty (backward-compat with backends that haven't shipped
|
||||
* `selectFields` yet). For other fields, falls back to `'unknown'`.
|
||||
* field. Prefers `resource[field.name]` (contract from `selectFields`), falling
|
||||
* back to `'unknown'`.
|
||||
*/
|
||||
export function getFlamegraphSpanGroupValue(
|
||||
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
|
||||
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
|
||||
field: TelemetryFieldKey,
|
||||
): string {
|
||||
const fromAttribute = getSpanAttribute(span, field.name);
|
||||
if (fromAttribute) {
|
||||
return fromAttribute;
|
||||
}
|
||||
if (field.name === 'service.name') {
|
||||
return span.serviceName || 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
return getSpanAttribute(span, field.name) || 'unknown';
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
@@ -296,7 +287,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
|
||||
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
|
||||
@@ -306,7 +297,11 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
// Event dots derive from the effective bar color so they track the
|
||||
// light/dark variant the bar is rendered with.
|
||||
const parentBarColor = isDarkMode ? color : colorDark;
|
||||
const dotColor = getEventDotColor(parentBarColor, event.isError, isDarkMode);
|
||||
const dotColor = getEventDotColor(
|
||||
parentBarColor,
|
||||
event.isError ?? false,
|
||||
isDarkMode,
|
||||
);
|
||||
const eventKey = `${span.spanId}-${event.name}-${event.timeUnixNano}`;
|
||||
const isEventHovered = hoveredEventKey === eventKey;
|
||||
const dotSize = isEventHovered
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { VisualLayout } from './computeVisualLayout';
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -70,6 +71,7 @@ type provider struct {
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -102,6 +104,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -137,6 +140,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -174,6 +178,7 @@ func newProvider(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -210,6 +215,7 @@ func newProvider(
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -334,6 +340,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addStatsReporterRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
33
pkg/apiserver/signozapiserver/statsreporter.go
Normal file
33
pkg/apiserver/signozapiserver/statsreporter.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/stats", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.statsHandler.Get),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetStats",
|
||||
Tags: []string{"stats"},
|
||||
Summary: "Get stats",
|
||||
Description: "This endpoint returns the collected stats for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: map[string]any{},
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -35,11 +35,7 @@ func (handler *handler) GetFeatures(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -48,6 +50,8 @@ func NewModule(
|
||||
}
|
||||
|
||||
func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListHosts")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,6 +165,8 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
}
|
||||
|
||||
func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListPods")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -244,6 +250,8 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
}
|
||||
|
||||
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListNodes")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -332,6 +340,8 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
}
|
||||
|
||||
func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (*inframonitoringtypes.Namespaces, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListNamespaces")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -414,6 +424,8 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
}
|
||||
|
||||
func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListClusters")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -503,6 +515,8 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
}
|
||||
|
||||
func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableVolumes) (*inframonitoringtypes.Volumes, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListVolumes")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -586,6 +600,8 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
|
||||
}
|
||||
|
||||
func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (*inframonitoringtypes.Deployments, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListDeployments")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -674,6 +690,8 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
}
|
||||
|
||||
func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (*inframonitoringtypes.StatefulSets, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListStatefulSets")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -764,6 +782,8 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
}
|
||||
|
||||
func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (*inframonitoringtypes.Jobs, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListJobs")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -854,6 +874,8 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
}
|
||||
|
||||
func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (*inframonitoringtypes.DaemonSets, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListDaemonSets")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -942,3 +964,12 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *module) withInfraMonitoringContext(ctx context.Context, functionName string) context.Context {
|
||||
comments := map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "infra-monitoring",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
}
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, comments)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
|
||||
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
|
||||
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start, summary.End, false), nil
|
||||
}
|
||||
|
||||
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
|
||||
@@ -209,10 +209,6 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spantypes.NewGettableFlamegraphTrace(
|
||||
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
|
||||
summary.Start.UnixMilli(),
|
||||
summary.End.UnixMilli(),
|
||||
true,
|
||||
), nil
|
||||
enrichedSpans := flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields)
|
||||
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, summary.Start, summary.End, true), nil
|
||||
}
|
||||
|
||||
74
pkg/querier/collect.go
Normal file
74
pkg/querier/collect.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
tracesTable := fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.SpanIndexV3TableName)
|
||||
logsTable := fmt.Sprintf("%s.%s", telemetrylogs.DBName, telemetrylogs.LogsV2TableName)
|
||||
metricsTable := fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4TableName)
|
||||
|
||||
var traces uint64
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", tracesTable)).Scan(&traces); err == nil {
|
||||
stats["telemetry.traces.count"] = traces
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect traces count", errors.Attr(err))
|
||||
}
|
||||
|
||||
var logs uint64
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", logsTable)).Scan(&logs); err == nil {
|
||||
stats["telemetry.logs.count"] = logs
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect logs count", errors.Attr(err))
|
||||
}
|
||||
|
||||
var metrics uint64
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", metricsTable)).Scan(&metrics); err == nil {
|
||||
stats["telemetry.metrics.count"] = metrics
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect metrics count", errors.Attr(err))
|
||||
}
|
||||
|
||||
var tracesLastSeenAt time.Time
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT max(timestamp) FROM %s", tracesTable)).Scan(&tracesLastSeenAt); err == nil {
|
||||
if tracesLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
|
||||
stats["telemetry.traces.last_observed.time_unix"] = tracesLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect traces last observed", errors.Attr(err))
|
||||
}
|
||||
|
||||
var logsLastSeenAt time.Time
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT fromUnixTimestamp64Nano(max(timestamp)) FROM %s", logsTable)).Scan(&logsLastSeenAt); err == nil {
|
||||
if logsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
|
||||
stats["telemetry.logs.last_observed.time_unix"] = logsLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect logs last observed", errors.Attr(err))
|
||||
}
|
||||
|
||||
var metricsLastSeenAt time.Time
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT toDateTime(max(unix_milli) / 1000) FROM %s", metricsTable)).Scan(&metricsLastSeenAt); err == nil {
|
||||
if metricsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
|
||||
stats["telemetry.metrics.last_observed.time_unix"] = metricsLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect metrics last observed", errors.Attr(err))
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
type Querier interface {
|
||||
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
|
||||
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
|
||||
// BucketCache is the interface for bucket-based caching.
|
||||
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
@@ -80,6 +81,7 @@ type Handlers struct {
|
||||
TraceDetail tracedetail.Handler
|
||||
RulerHandler ruler.Handler
|
||||
LLMPricingRuleHandler llmpricingrule.Handler
|
||||
StatsHandler statsreporter.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(
|
||||
@@ -97,6 +99,7 @@ func NewHandlers(
|
||||
registryHandler factory.Handler,
|
||||
alertmanagerService alertmanager.Alertmanager,
|
||||
rulerService ruler.Ruler,
|
||||
statsAggregator statsreporter.Aggregator,
|
||||
) Handlers {
|
||||
return Handlers{
|
||||
SavedView: implsavedview.NewHandler(modules.SavedView),
|
||||
@@ -125,5 +128,6 @@ func NewHandlers(
|
||||
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
|
||||
StatsHandler: statsreporter.NewHandler(statsAggregator),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil)
|
||||
reflectVal := reflect.ValueOf(handlers)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
f := reflectVal.Field(i)
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
@@ -83,6 +84,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ llmpricingrule.Handler }{},
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -264,9 +264,9 @@ func NewSharderProviderFactories() factory.NamedMap[factory.ProviderFactory[shar
|
||||
)
|
||||
}
|
||||
|
||||
func NewStatsReporterProviderFactories(telemetryStore telemetrystore.TelemetryStore, collectors []statsreporter.StatsCollector, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.NamedMap[factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config]] {
|
||||
func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.NamedMap[factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
analyticsstatsreporter.NewFactory(telemetryStore, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig),
|
||||
analyticsstatsreporter.NewFactory(aggregator, orgGetter, userGetter, tokenizer, build, analyticsConfig),
|
||||
noopstatsreporter.NewFactory(),
|
||||
)
|
||||
}
|
||||
@@ -309,6 +309,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), instrumentationtest.New().ToProviderSettings()), userRoleStore, flagger)
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherEqual)
|
||||
NewStatsReporterProviderFactories(telemetryStore, []statsreporter.StatsCollector{}, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
|
||||
statsAggregator := statsreporter.NewAggregator(providerSettings, []statsreporter.StatsCollector{})
|
||||
NewStatsReporterProviderFactories(statsAggregator, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
|
||||
@@ -499,14 +499,18 @@ func New(
|
||||
serviceAccount,
|
||||
cloudIntegrationModule,
|
||||
modules.LogsPipeline,
|
||||
querier,
|
||||
}
|
||||
|
||||
// Initialize the stats aggregator (always-on, independent of whether reporting is enabled)
|
||||
statsAggregator := statsreporter.NewAggregator(providerSettings, statsCollectors)
|
||||
|
||||
// Initialize stats reporter from the available stats reporter provider factories
|
||||
statsReporter, err := factory.NewProviderFromNamedMap(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.StatsReporter,
|
||||
NewStatsReporterProviderFactories(telemetrystore, statsCollectors, orgGetter, userGetter, tokenizer, version.Info, config.Analytics),
|
||||
NewStatsReporterProviderFactories(statsAggregator, orgGetter, userGetter, tokenizer, version.Info, config.Analytics),
|
||||
config.StatsReporter.Provider(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -535,7 +539,7 @@ func New(
|
||||
|
||||
// Initialize all handlers for the modules
|
||||
registryHandler := factory.NewHandler(registry)
|
||||
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance)
|
||||
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance, statsAggregator)
|
||||
|
||||
// Initialize the API server (after registry so it can access service health)
|
||||
apiserverInstance, err := factory.NewProviderFromNamedMap(
|
||||
|
||||
67
pkg/statsreporter/aggregator.go
Normal file
67
pkg/statsreporter/aggregator.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package statsreporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Aggregator aggregates stats from every registered StatsCollector for a single organization.
|
||||
type Aggregator interface {
|
||||
Aggregate(ctx context.Context, orgID valuer.UUID) (map[string]any, error)
|
||||
}
|
||||
|
||||
type aggregator struct {
|
||||
// settings
|
||||
settings factory.ScopedProviderSettings
|
||||
|
||||
// a list of collectors, used to collect stats from across the codebase
|
||||
collectors []StatsCollector
|
||||
}
|
||||
|
||||
func NewAggregator(providerSettings factory.ProviderSettings, collectors []StatsCollector) Aggregator {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/statsreporter")
|
||||
|
||||
return &aggregator{
|
||||
settings: settings,
|
||||
collectors: collectors,
|
||||
}
|
||||
}
|
||||
|
||||
func (aggregator *aggregator) Aggregate(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "statsreporter",
|
||||
instrumentationtypes.CodeFunctionName: "Aggregate",
|
||||
})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(aggregator.collectors))
|
||||
|
||||
stats := make(map[string]any, 0)
|
||||
mtx := sync.Mutex{}
|
||||
|
||||
for _, collector := range aggregator.collectors {
|
||||
go func(collector StatsCollector) {
|
||||
defer wg.Done()
|
||||
|
||||
collectorStats, err := collector.Collect(ctx, orgID)
|
||||
if err != nil {
|
||||
aggregator.settings.Logger().ErrorContext(ctx, "failed to collect stats", errors.Attr(err))
|
||||
return
|
||||
}
|
||||
|
||||
mtx.Lock()
|
||||
for k, v := range collectorStats {
|
||||
stats[k] = v
|
||||
}
|
||||
mtx.Unlock()
|
||||
}(collector)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package analyticsstatsreporter
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -16,11 +15,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/tokenizer"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
)
|
||||
@@ -32,11 +28,8 @@ type provider struct {
|
||||
// config
|
||||
config statsreporter.Config
|
||||
|
||||
// used to get telemetry details. srikanthcvv to move this to the querier layer
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
|
||||
// a list of collectors, used to collect stats from across the codebase
|
||||
collectors []statsreporter.StatsCollector
|
||||
// used to aggregate stats for an organization
|
||||
aggregator statsreporter.Aggregator
|
||||
|
||||
// used to get organizations
|
||||
orgGetter organization.Getter
|
||||
@@ -60,9 +53,9 @@ type provider struct {
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory(telemetryStore telemetrystore.TelemetryStore, collectors []statsreporter.StatsCollector, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config] {
|
||||
func NewFactory(aggregator statsreporter.Aggregator, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("analytics"), func(ctx context.Context, settings factory.ProviderSettings, config statsreporter.Config) (statsreporter.StatsReporter, error) {
|
||||
return New(ctx, settings, config, telemetryStore, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig)
|
||||
return New(ctx, settings, config, aggregator, orgGetter, userGetter, tokenizer, build, analyticsConfig)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,8 +63,7 @@ func New(
|
||||
ctx context.Context,
|
||||
providerSettings factory.ProviderSettings,
|
||||
config statsreporter.Config,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
collectors []statsreporter.StatsCollector,
|
||||
aggregator statsreporter.Aggregator,
|
||||
orgGetter organization.Getter,
|
||||
userGetter user.Getter,
|
||||
tokenizer tokenizer.Tokenizer,
|
||||
@@ -86,17 +78,16 @@ func New(
|
||||
}
|
||||
|
||||
return &provider{
|
||||
settings: settings,
|
||||
config: config,
|
||||
telemetryStore: telemetryStore,
|
||||
collectors: collectors,
|
||||
orgGetter: orgGetter,
|
||||
userGetter: userGetter,
|
||||
analytics: analytics,
|
||||
tokenizer: tokenizer,
|
||||
build: build,
|
||||
deployment: deployment,
|
||||
stopC: make(chan struct{}),
|
||||
settings: settings,
|
||||
config: config,
|
||||
aggregator: aggregator,
|
||||
orgGetter: orgGetter,
|
||||
userGetter: userGetter,
|
||||
analytics: analytics,
|
||||
tokenizer: tokenizer,
|
||||
build: build,
|
||||
deployment: deployment,
|
||||
stopC: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -134,7 +125,12 @@ func (provider *provider) Report(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
stats := provider.collectOrg(ctx, org.ID)
|
||||
stats, err := provider.aggregator.Aggregate(ctx, org.ID)
|
||||
if err != nil {
|
||||
provider.settings.Logger().WarnContext(ctx, "failed to aggregate stats", errors.Attr(err), slog.Any("org_id", org.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
provider.settings.Logger().WarnContext(ctx, "no stats collected", slog.Any("org_id", org.ID))
|
||||
continue
|
||||
@@ -204,75 +200,3 @@ func (provider *provider) Stop(ctx context.Context) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) collectOrg(ctx context.Context, orgID valuer.UUID) map[string]any {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "statsreporter",
|
||||
instrumentationtypes.CodeFunctionName: "collectOrg",
|
||||
})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(provider.collectors))
|
||||
|
||||
stats := make(map[string]any, 0)
|
||||
mtx := sync.Mutex{}
|
||||
|
||||
for _, collector := range provider.collectors {
|
||||
go func(collector statsreporter.StatsCollector) {
|
||||
defer wg.Done()
|
||||
|
||||
collectorStats, err := collector.Collect(ctx, orgID)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to collect stats", errors.Attr(err))
|
||||
return
|
||||
}
|
||||
|
||||
mtx.Lock()
|
||||
for k, v := range collectorStats {
|
||||
stats[k] = v
|
||||
}
|
||||
mtx.Unlock()
|
||||
}(collector)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var traces uint64
|
||||
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT COUNT(*) FROM signoz_traces.distributed_signoz_index_v3").Scan(&traces); err == nil {
|
||||
stats["telemetry.traces.count"] = traces
|
||||
}
|
||||
|
||||
var logs uint64
|
||||
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT COUNT(*) FROM signoz_logs.distributed_logs_v2").Scan(&logs); err == nil {
|
||||
stats["telemetry.logs.count"] = logs
|
||||
}
|
||||
|
||||
var metrics uint64
|
||||
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT COUNT(*) FROM signoz_metrics.distributed_samples_v4").Scan(&metrics); err == nil {
|
||||
stats["telemetry.metrics.count"] = metrics
|
||||
}
|
||||
|
||||
var tracesLastSeenAt time.Time
|
||||
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT max(timestamp) FROM signoz_traces.distributed_signoz_index_v3").Scan(&tracesLastSeenAt); err == nil {
|
||||
if tracesLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
|
||||
stats["telemetry.traces.last_observed.time_unix"] = tracesLastSeenAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
var logsLastSeenAt time.Time
|
||||
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT fromUnixTimestamp64Nano(max(timestamp)) FROM signoz_logs.distributed_logs_v2").Scan(&logsLastSeenAt); err == nil {
|
||||
if logsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
|
||||
stats["telemetry.logs.last_observed.time_unix"] = logsLastSeenAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
var metricsLastSeenAt time.Time
|
||||
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT toDateTime(max(unix_milli) / 1000) FROM signoz_metrics.distributed_samples_v4").Scan(&metricsLastSeenAt); err == nil {
|
||||
if metricsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
|
||||
stats["telemetry.metrics.last_observed.time_unix"] = metricsLastSeenAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
46
pkg/statsreporter/handler.go
Normal file
46
pkg/statsreporter/handler.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package statsreporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
aggregator Aggregator
|
||||
}
|
||||
|
||||
func NewHandler(aggregator Aggregator) Handler {
|
||||
return &handler{
|
||||
aggregator: aggregator,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
stats, err := handler.aggregator.Aggregate(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, stats)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -37,11 +39,17 @@ type GettableFlamegraphTrace struct {
|
||||
HasMore bool `json:"hasMore" required:"true"`
|
||||
}
|
||||
|
||||
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
|
||||
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, start, end time.Time, hasMore bool) *GettableFlamegraphTrace {
|
||||
// convert timestamp to millisecond since client expect that
|
||||
for _, level := range spans {
|
||||
for _, span := range level {
|
||||
span.Timestamp /= 1_000_000
|
||||
}
|
||||
}
|
||||
return &GettableFlamegraphTrace{
|
||||
Spans: spans,
|
||||
StartTimestampMillis: startMs,
|
||||
EndTimestampMillis: endMs,
|
||||
StartTimestampMillis: start.UnixMilli(),
|
||||
EndTimestampMillis: end.UnixMilli(),
|
||||
HasMore: hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,28 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
BASE_URL = "/api/v2/dashboards"
|
||||
# v1 list returns every dashboard regardless of schema. v2 list converts each row
|
||||
# to the perses schema and 501s if any stored dashboard isn't perses-schema, so
|
||||
# listing for cleanup against a shared DB must go through v1.
|
||||
V1_BASE_URL = "/api/v1/dashboards"
|
||||
|
||||
|
||||
def _wipe_all_dashboards(signoz: SigNoz, token: str) -> None:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(V1_BASE_URL),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
for dashboard in response.json()["data"]:
|
||||
metadata = (dashboard.get("data") or {}).get("metadata") or {}
|
||||
base = BASE_URL if metadata.get("schemaVersion") == "v6" else V1_BASE_URL
|
||||
del_res = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{base}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert del_res.status_code == HTTPStatus.NO_CONTENT, del_res.text
|
||||
|
||||
|
||||
# ─── failure cases (create no dashboards) ────────────────────────────────────
|
||||
@@ -258,18 +280,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
# runs, so start from a clean slate: delete every dashboard (which also clears
|
||||
# pins via the delete cascade). This test then owns the whole dashboard space
|
||||
# and asserts on global counts.
|
||||
existing = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).json()["data"]["dashboards"]
|
||||
for dashboard in existing:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
_wipe_all_dashboards(signoz, token)
|
||||
|
||||
dashboard_requests = [
|
||||
(
|
||||
@@ -687,18 +698,7 @@ def test_dashboard_v2_pin_limit(
|
||||
|
||||
# Wipe the dashboard space (see lifecycle) so the per-user pin cap this test
|
||||
# asserts against starts empty — deleting dashboards clears their pins.
|
||||
existing = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).json()["data"]["dashboards"]
|
||||
for dashboard in existing:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
_wipe_all_dashboards(signoz, token)
|
||||
|
||||
ids: list[str] = []
|
||||
for i in range(max_pinned + 1):
|
||||
@@ -785,18 +785,7 @@ def test_dashboard_v2_like_escaping(
|
||||
|
||||
# Wipe the dashboard space (see lifecycle) so the filter assertions run
|
||||
# against only the dashboards this test creates.
|
||||
existing = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).json()["data"]["dashboards"]
|
||||
for dashboard in existing:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
_wipe_all_dashboards(signoz, token)
|
||||
|
||||
dashboard_requests = [
|
||||
("esc-pct", "Cost 50% Report"),
|
||||
|
||||
Reference in New Issue
Block a user