Compare commits

...

7 Commits

Author SHA1 Message Date
Srikanth Chekuri
c6bb7569af Merge branch 'main' into issue-5535 2026-07-06 11:51:09 +05:30
srikanthccv
5d431f9f6f chore: add to ci 2026-07-06 11:50:46 +05:30
srikanthccv
1f0113645e chore: add integration tests for metrics under reduction - query part 2026-07-06 11:19:45 +05:30
Ashwin Bhatkal
d490126162 fix(dashboard-v2): list-page bug bash fixes (#11939)
* fix(dashboard-v2): refresh the list after deleting a dashboard

Delete invalidated invalidateListDashboardsV2, but the list renders from
useListDashboardsForUserV2 (as does pin/unpin), so the deleted row lingered until
a manual reload. Invalidate the for-user list instead.

* feat(dashboard-v2): show a lock icon on locked dashboards in the list

The list rows already carry `locked`; surface it with a LockKeyhole icon (with a
tooltip) next to the row actions, mirroring the detail-page header.

* fix(dashboard-v2): show pinned icon (not unpin) until the pin is hovered

A pinned row rendered the PinOff ("unpin") icon at rest, so it read as an action
rather than a state. Show the filled Pin by default and reveal PinOff only on
hover of the pin button.

* fix(dashboard-v2): break long unbroken dashboard names in the list

The list title clamps to 3 lines but a long unbroken string could still overflow
the row horizontally; add overflow-wrap so it wraps within the clamp. (The detail
header already single-line truncates with a tooltip.)

* fix(dashboard-v2): use sienna tags in the dashboard header

The create modal and settings tag inputs already render sienna chips (matching the
list rows); the detail-page header still showed amber/warning badges. Switch them
to sienna for consistency across create, configure and display.

* fix(dashboard-v2): use a columns icon for the list columns control

The columns/metadata popover trigger used the HdmiPort icon, which doesn't read as
'columns'. Swap it for the Columns3 icon.

* fix(dashboard-v2): close the new-dashboard modal after creating

Blank/Import/Template create flows navigated to the new dashboard but left the
modal mounted, so it lingered over the detail page. Call onClose() on success
(Import/Template now take the onClose prop the modal already passes to Blank).

* feat(dashboard-v2): open the public dashboard from the header globe

The header globe now reflects the real public state (via usePublicDashboardMeta,
a deduped read) and is clickable — it opens the public dashboard page in a new
tab, with the tooltip updated to say so alongside the existing text.

* fix(dashboard-v2): embed the V1 template gallery in the new-dashboard modal

The V2 templates tab rendered a mock gallery. Until the templates BE API lands,
show the existing V1 gallery instead: extract it into DashboardTemplatesContent
(shared by the V1 modal and the V2 tab, no modal-in-modal) and embed it inline in
the V2 'From a template' tab. The V1 templates are placeholders, so the action
creates a blank dashboard and closes the modal. Drops the now-unused mock
templatesData.

* feat(dashboard-v2): click-to-unlock header lock icon + keep header icons on long names

The header lock icon is now a click-to-unlock control for author/admin (routes to
the existing lock toggle; static for others). Give it flex-shrink:0 (like the
public globe and description icons) so the description/public/lock icons and the
tag overflow stay visible when the title is long and truncates.

* feat(dashboard-v2): add Rename and Lock/Unlock to the list row actions

Rename opens a small dialog that patches /spec/display/name and refreshes the
list (shown for unlocked dashboards). Lock/Unlock toggles via lock/unlockDashboardV2
and refreshes the list, gated to author/admin on non-integration dashboards
(mirroring the detail-page gate).

* fix(dashboard-v2): list row menu — lock casing, disabled Rename tooltip, stop nav on menu click

- Capitalize 'Lock/Unlock Dashboard'.
- Show Rename disabled (not hidden) on locked dashboards with a tooltip explaining
  why (matches Delete).
- Stop clicks inside the actions menu from bubbling to the row's navigate handler
  (disabled items were opening the dashboard).

* fix(dashboard-v2): single-line row title + bottom-anchored tooltip

Truncate the list row title to a single line with an ellipsis (was a 3-line clamp),
and show the full name in a tooltip anchored to the bottom (auto-flips to top, with
an arrow) instead of the left.

* feat(dashboard-v2): session-local lock toggle in the header

The header lock control now reflects a session-local state: it appears once the
dashboard is locked (on load or during the session) and stays as a lock/unlock
toggle for the rest of the page. A dashboard that loads unlocked shows no icon
until it's locked. Clicking flips state optimistically so it no longer depends on
the refetch to update.

* fix(dashboard-v2): add top margin above the public-dashboard variables hint

* feat(dashboard-v2): run search with a Run query button + Cmd/Ctrl+Enter

Show a 'Run query' affordance with the OS-aware ⌘/Ctrl ⏎ hint in the search bar
(matching the query builder) and run the search on Cmd/Ctrl+Enter.

* fix(dashboard-v2): make the filter Clear button prominent

Use the primary (outlined) style for the Clear-filters button so it stands out
once a filter is applied.

* revert(dashboard-v2): restore multi-line clamp for list row title

Single-line truncation broke the row's responsiveness; revert to the 3-line clamp.
Keeps the bottom-anchored tooltip for long names.

* fix(dashboard-v2): show the lock tooltip on disabled Rename/Delete items

antd tooltips don't fire on disabled buttons; wrap them in a span the tooltip can
attach to so the 'dashboard is locked' reason shows on hover.

* fix(dashboard-v2): header tooltips update when moving between icons

Set disableHoverableContent on the title/description/public/lock tooltips so
leaving a trigger closes its tooltip immediately, letting the next icon's tooltip
open in both directions (was sticking when moving public → lock).

* fix(dashboard-v2): tooltip on the pin button showing the click action

Replace the native title with an antd tooltip that reads 'Pin dashboard' /
'Unpin dashboard' based on the current state.

* fix(dashboard-v2): delete dashboards via the v2 endpoint from the list

The list-page delete action was calling the v1 delete endpoint; route it through
deleteDashboardV2 so v2-shape dashboards (incl. pinned) delete correctly.

* refactor(dashboard-v2): use signoz Button/Tooltip in list row & dashboard header

Address review feedback on #11939:
- DashboardRow: swap the antd Tooltip for @signozhq TooltipSimple and the native
  pin <button> for the @signozhq Button. Only wrap long titles in a tooltip so a
  short name no longer renders an empty hanging tooltip. Rename pinBtn -> pinButton.
- DashboardInfo: swap the native public-link and lock <button>s for @signozhq Button.
- Drop the now-orphaned .titleTooltipOverlay antd override.

* fix(dashboard-v2): show a loader and block double-click when deleting a dashboard

The list-row delete confirm fired the mutation on click and closed on settle,
so a quick second click could fire delete twice. Use a promise-based onOk so the
Delete button shows a loading spinner and rejects further clicks until the request
settles, then closes.
2026-07-06 05:42:31 +00:00
Shivam Gupta
526a7d1c46 fix: update stale docs links in frontend (#11319)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: update 47 stale docs links in frontend to current URLs

Update documentation links across 19 frontend files to match
current signoz.io docs structure after product module restructures.

Key changes:
- Instrumentation links updated to new OpenTelemetry-prefixed paths
- product-features/* links replaced with current locations
- Query builder links point to new querying module pages
- Alert notification channel links point to setup-alerts-notification
- SSO, infra monitoring, and version upgrade links corrected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update 278 more stale docs links and broken anchors in frontend

- Onboarding md-docs: update instrumentation URLs to canonical paths
  (django/flask/fastapi/falcon → opentelemetry-python, express/nestjs →
  javascript/opentelemetry-nodejs, springboot → java/opentelemetry-java,
  tomcat → java/opentelemetry-tomcat, jboss → java/opentelemetry-jboss,
  golang → opentelemetry-golang, elixir → opentelemetry-elixir,
  reactjs → frontend-monitoring/sending-traces-with-opentelemetry)
- Onboarding md-docs: update tutorial/* → opentelemetry-collection-agents/*,
  userguide/hostmetrics → infrastructure-monitoring/hostmetrics,
  userguide/logs# → userguide/logs_query_builder#
- Query builder UI: fix broken anchors after query-builder-v5 page
  restructure (Having → result-manipulation, Order By → result-manipulation,
  Limit → result-manipulation, Legend → aggregation-grouping, Group By →
  aggregation-grouping, Formula → multi-query-analysis, Trace Matching →
  multi-query-analysis, Reduce → result-manipulation, Aggregation functions
  → aggregation-grouping, Time aggregation → temporal-aggregation)
- Fix Apdex link → alerts-management/apdex-alerts
- Fix missing spans link → traces-management/troubleshooting/faqs
- Fix cost meter, ClickHouse traces, k8s pod logs anchors
- Drop broken anchors where sections were removed from docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: format CreateAlertRule files after anchor removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert OnboardingContainer changes (deprecated module)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
Co-authored-by: Ubuntu <ubuntu@ip-172-31-24-8.eu-central-1.compute.internal>
2026-07-06 04:47:31 +00:00
Abhi kumar
9f72338c87 fix(dashboards-v2): list panel pagination, spanGaps clamp, discard dialog & create-alert fixes (#11974)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(dashboards-v2): show the list panel pager

A new/edited List panel never showed its server pager. The V1→V5 mapper
folds a list query's internal `pageSize` into the V5 `limit`
(`createBaseSpec`), so a seeded panel (pageSize 100) always looked like it
had a user limit — and `usePanelQuery` suppresses the pager when a limit is
present.

Strip the V1 explorer's `pageSize`/`offset` in `toPerses` before conversion
so `limit` reflects only a real user limit; List panels then page by default
while an explicit user limit still renders as a static, unpaged list.

* fix(dashboards-v2): give logs list requests a deterministic order

Offset paging over a non-total order can duplicate or drop rows sharing a
value (e.g. same-millisecond logs) across page boundaries. Enforce a total
order on every logs-list request (`withListOrderTiebreaker`): default the
primary to `timestamp desc` and always append `id` (logs-explorer parity).
Request-only, so it never lands in the saved panel spec; traces keep theirs.

* fix(dashboards-v2): list pagination runtime behaviour

- Drive `canNext` from the backend's `nextCursor` (set iff the page filled)
  instead of a client-side row-count guess.
- Keep the table + pager mounted on a page change (`keepPreviousData`); show
  the full-panel loader only on the first fetch, not on every refetch.
- Ignore a stale cross-type response kept across a panel-kind switch (match
  the response type to the request) so the list doesn't flash "No data".
- While the next page loads, swap the stale rows for horizontal skeleton rows
  (dashboard grid and editor preview).

* fix(dashboards-v2): seed timestamp order when switching to a list panel

V1's shared `handleQueryChange` clears orderBy when switching to a list, so
the builder's Order By opened empty after e.g. Time series → List. Re-seed
the fresh-list default (timestamp desc) in `usePanelTypeSwitch` so the query
matches a newly created list panel.

* fix(dashboards-v2): clamp time-series spanGaps to the step interval

A numeric spanGaps is a max-gap threshold (seconds). Floor it at the smallest
step interval so a sub-step value doesn't break the line at every normal
point. Boolean "span all" passes through unchanged.

* fix(dashboards-v2): flip discard-dialog buttons

"Keep editing" now sits on the right and is outlined; "Discard" stays left
(destructive). The ConfirmDialog preset can't reorder/restyle its footer, so
swap to DialogWrapper with a custom footer (same chrome, async confirm state
preserved via the button's loading flag).

* fix(dashboards-v2): floor the create-alert time window to integer ms

buildQueryRangeRequest's start/end are int64 ms, so passing a float from the
nanosecond division could break the call. Floor the ns→ms conversion and use
the named NANO_SECOND_MULTIPLIER constant instead of a magic 1e6.

* fix(dashboards-v2): clone patched values so optimistic apply can't corrupt outgoing ops

Creating the first panel on an empty dashboard failed with
"spec.layouts[0].spec.items[0] and items[1] overlap". createPanelOps
emits three ops (add empty section, add panel, add item into that
section), but useOptimisticPatch.onMutate runs applyJsonPatch before the
request is sent, and applyJsonPatch inserted each op.value by reference.
The cache's layouts[0].spec.items then aliased the empty items array held
inside the section-add op, so applying the item op mutated that op's
value too. react-query sent the mutated ops (section already holding the
item, plus the separate item add) → two overlapping items.

Clone the inserted value on add/replace so the applied document never
shares references with the input ops, restoring the purity the docstring
already promises. New-dashboard-only because that is the only batch that
adds a layout and an item into that same layout together.
2026-07-05 06:00:51 +00:00
Abhi kumar
e7ef4c6bdb fix(dashboard-v2): correct formatting carry on panel-kind switch + unify spec seeding (#11956)
* refactor(dashboard-v2): make ThresholdVariant a string enum

Replace the ThresholdVariant string-union with a string enum so panel
section configs reference named members (ThresholdVariant.LABEL/COMPARISON/
TABLE) instead of bare string literals, and update every call site.

* fix(dashboard-v2): correct formatting carry on panel-kind switch

Switching a panel's visualization kind carried formatting fields the target
kind's schema does not accept (e.g. `unit` into a Table, which only supports
`columnUnits` + `decimalPrecision`), so the save API rejected the spec.

Unify new-panel and kind-switch spec seeding into one per-section registry
(`buildPluginSpec`, SECTION_SEEDS): each section derives its plugin-spec slice
from the target kind's declared `controls` and optional context, so a carried
field is emitted only when the target kind actually supports it. Adding a
section now means one registry entry rather than editing two seeders.

Delete the redundant `buildDefaultPluginSpec` wrapper (it only forwarded to
`buildPluginSpec`); `getSwitchedPluginSpec` becomes a thin delegating wrapper.
2026-07-05 05:51:44 +00:00
106 changed files with 3687 additions and 1286 deletions

View File

@@ -56,6 +56,8 @@ jobs:
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
- clickhousecluster
- metricreduction
sqlstore-provider:
- postgres
- sqlite

View File

@@ -1,7 +1,7 @@
import { QueryParams } from 'constants/query';
export const ExploreHeaderToolTip = {
url: 'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
};

View File

@@ -131,7 +131,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
@@ -254,7 +254,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -52,7 +52,7 @@ const ADD_ONS = [
key: ADD_ONS_KEYS.GROUP_BY,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
},
{
icon: <ScrollText size={14} />,
@@ -61,7 +61,7 @@ const ADD_ONS = [
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
},
{
icon: <ScrollText size={14} />,
@@ -70,7 +70,7 @@ const ADD_ONS = [
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
},
{
icon: <ScrollText size={14} />,
@@ -79,7 +79,7 @@ const ADD_ONS = [
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
},
{
icon: <ScrollText size={14} />,
@@ -88,7 +88,7 @@ const ADD_ONS = [
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
},
];
@@ -99,7 +99,7 @@ const REDUCE_TO = {
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
const hasValue = (value: unknown): boolean =>
@@ -349,7 +349,7 @@ function QueryAddOns({
<TooltipContent
label="Group By"
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
/>
}
placement="top"
@@ -385,7 +385,7 @@ function QueryAddOns({
<TooltipContent
label="Having"
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
/>
}
placement="top"
@@ -434,7 +434,7 @@ function QueryAddOns({
<TooltipContent
label="Order By"
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
/>
}
placement="top"
@@ -473,7 +473,7 @@ function QueryAddOns({
<TooltipContent
label="Reduce to"
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
/>
}
placement="top"

View File

@@ -65,7 +65,7 @@ function QueryAggregationOptions({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -676,7 +676,7 @@ function QueryAggregationSelect({
</span>
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
href="https://signoz.io/docs/querying/aggregation-grouping/#core-aggregation-functions-logs--traces"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -44,7 +44,7 @@ function TraceOperatorSection({
<div style={{ textAlign: 'center' }}>
Add Trace Matching
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
href="https://signoz.io/docs/querying/multi-query-analysis/#trace-matching"
target="_blank"
style={{ textDecoration: 'underline' }}
>
@@ -106,7 +106,7 @@ export default function QueryFooter({
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>

View File

@@ -1,5 +1,5 @@
export const apDexToolTipText =
"Apdex is a way to measure your users' satisfaction with the response time of your web service. It's represented as a score from 0-1.";
export const apDexToolTipUrl =
'https://signoz.io/docs/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
'https://signoz.io/docs/alerts-management/apdex-alerts/?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
export const apDexToolTipUrlText = 'Learn more about Apdex.';

View File

@@ -68,7 +68,7 @@ function AlertChannels(): JSX.Element {
<RightActionContainer>
<TextToolTip
text={t('tooltip_notification_channels')}
url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel"
url="https://signoz.io/docs/setup-alerts-notification/"
/>
<Tooltip

View File

@@ -29,8 +29,7 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
let url = '';
switch (option) {
case AlertTypes.ANOMALY_BASED_ALERT:
url =
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples';
url = 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/';
break;
case AlertTypes.METRICS_BASED_ALERT:
url =

View File

@@ -31,8 +31,7 @@ export const ALERT_TYPE_URL_MAP: Record<
'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
},
[AlertTypes.ANOMALY_BASED_ALERT]: {
selection:
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples',
selection: 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/',
creation:
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
},

View File

@@ -717,13 +717,13 @@ function ExplorerOptions({
const infoIconLink = useMemo(() => {
if (isLogsExplorer) {
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
return 'https://signoz.io/docs/userguide/logs_query_builder/?utm_source=product&utm_medium=logs-explorer-toolbar';
}
// TODO: Add metrics explorer info icon link
if (isMetricsExplorer) {
return '';
}
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
return 'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=trace-explorer-toolbar';
}, [isLogsExplorer, isMetricsExplorer]);
const getQueryName = (query: Query): string => {

View File

@@ -201,7 +201,7 @@ export default function SavedViews({
});
window.open(
'https://signoz.io/docs/product-features/saved-view/',
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
'_blank',
'noopener noreferrer',
);

View File

@@ -29,12 +29,12 @@ export const checkListStepToPreferenceKeyMap = {
export const DOCS_LINKS = {
ADD_DATA_SOURCE: 'https://signoz.io/docs/instrumentation/overview/',
SEND_LOGS: 'https://signoz.io/docs/userguide/logs/',
SEND_LOGS: 'https://signoz.io/docs/userguide/logs_query_builder/',
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
SETUP_ALERTS: 'https://signoz.io/docs/alerts/',
SETUP_SAVED_VIEWS:
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
SETUP_DASHBOARDS: 'https://signoz.io/docs/userguide/manage-dashboards/',
};

View File

@@ -82,7 +82,7 @@ export function K8sEmptyState({
<span className={styles.message}>
Please refer to{' '}
<a
href="https://signoz.io/docs/userguide/hostmetrics/"
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
target="_blank"
rel="noreferrer"
>

View File

@@ -611,7 +611,7 @@ describe('K8sBaseList', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
'https://signoz.io/docs/userguide/hostmetrics/',
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
);
});
});

View File

@@ -0,0 +1,228 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesContentProps {
onCreateNewDashboard: () => void;
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
onCancel?: () => void;
}
// The template gallery (search + list + preview + create), extracted from the
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
export default function DashboardTemplatesContent({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesContentProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<div className="new-dashboard-templates-content-container">
{onCancel && (
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
)}
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,129 +1,9 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import { Modal } from 'antd';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import DashboardTemplatesContent from './DashboardTemplatesContent';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesModalProps {
showNewDashboardTemplatesModal: boolean;
onCreateNewDashboard: () => void;
@@ -135,20 +15,6 @@ export default function DashboardTemplatesModal({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesModalProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<Modal
wrapClassName="new-dashboard-templates-modal"
@@ -159,75 +25,10 @@ export default function DashboardTemplatesModal({
destroyOnClose
width="60vw"
>
<div className="new-dashboard-templates-content-container">
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
<DashboardTemplatesContent
onCreateNewDashboard={onCreateNewDashboard}
onCancel={onCancel}
/>
</Modal>
);
}

View File

@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
Meter metrics data is aggregated over 1 hour period. Please select time
range accordingly.&nbsp;
<a
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
href="https://signoz.io/docs/cost-meter/overview/#get-started"
rel="noopener noreferrer"
target="_blank"
style={{ textDecoration: 'underline' }}

View File

@@ -197,7 +197,7 @@ function TopOperationsTable({
const entryPointSpanInfo = {
text: 'Shows the spans where requests enter new services for the first time',
url: 'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
urlText: 'Learn more about Entrypoint Spans.',
};

View File

@@ -64,7 +64,7 @@ function ConfigureGoogleAuthAuthnProvider({
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
target="_blank"
rel="noreferrer"
>

View File

@@ -38,7 +38,7 @@ function ConfigureOIDCAuthnProvider({
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
target="_blank"
rel="noreferrer"
>

View File

@@ -37,7 +37,7 @@ function ConfigureSAMLAuthnProvider({
<p className="authn-provider__description">
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
target="_blank"
rel="noreferrer"
>

View File

@@ -216,7 +216,7 @@ export default function QueryFunctions({
Add new function
<Typography.Link
style={{ textDecoration: 'underline' }}
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#functions-for-extended-data-analysis"
href="https://signoz.io/docs/querying/functions-extended-analysis/?utm_source=product&utm_medium=query-builder"
target="_blank"
>
{' '}

View File

@@ -100,7 +100,7 @@ function Version(): JSX.Element {
{!isError && !isLatestVersion && (
<div className="version-page-upgrade-container">
<Button
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
href="https://signoz.io/docs/opentelemetry-collection-agents/docker/overview/"
target="_blank"
type="primary"
className="periscope-btn primary"

View File

@@ -32,6 +32,34 @@
cursor: help;
}
.publicLink {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton:disabled {
cursor: default;
}
.divider {
flex-shrink: 0;
width: 1px;

View File

@@ -1,5 +1,12 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
import {
Check,
Globe,
LockKeyhole,
LockKeyholeOpen,
SolidInfoCircle,
X,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -7,6 +14,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
@@ -18,7 +26,13 @@ interface DashboardInfoProps {
tags: string[];
description: string;
isPublicDashboard: boolean;
/** Absolute URL of the public dashboard page; opened when the globe is clicked. */
publicUrl: string;
isDashboardLocked: boolean;
/** Whether to render the lock toggle at all (hidden for never-locked dashboards). */
showLockToggle: boolean;
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
onToggleLock?: () => void;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
@@ -33,7 +47,10 @@ function DashboardInfo({
tags,
description,
isPublicDashboard,
publicUrl,
isDashboardLocked,
showLockToggle,
onToggleLock,
isEditing,
draft,
onDraftChange,
@@ -51,6 +68,17 @@ function DashboardInfo({
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'
: 'This dashboard is unlocked';
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -101,7 +129,7 @@ function DashboardInfo({
</Button>
</div>
) : (
<TooltipSimple title={title}>
<TooltipSimple title={title} disableHoverableContent>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
@@ -115,7 +143,7 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple title={description}>
<TooltipSimple title={description} disableHoverableContent>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}
@@ -125,14 +153,44 @@ function DashboardInfo({
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
<TooltipSimple
title="This dashboard is publicly accessible. Click to open the public page."
disableHoverableContent
>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.publicLink}
aria-label="Open public dashboard"
testId="dashboard-public-link"
onClick={(): void => openInNewTab(publicUrl)}
>
<Globe size={14} />
</Button>
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
{showLockToggle && (
<TooltipSimple title={lockTooltip} disableHoverableContent>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.lockButton}
aria-label={isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard'}
testId="dashboard-lock"
disabled={!onToggleLock}
onClick={onToggleLock}
>
{isDashboardLocked ? (
<LockKeyhole size={14} />
) : (
<LockKeyholeOpen size={14} />
)}
</Button>
</TooltipSimple>
)}
@@ -145,14 +203,14 @@ function DashboardInfo({
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
<Badge key={tag} color="sienna" variant="outline">
{tag}
</Badge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="warning"
color="sienna"
variant="outline"
data-testid="dashboard-tags-overflow"
>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
@@ -15,9 +15,12 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
@@ -36,7 +39,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
const [showLockToggle, setShowLockToggle] = useState(!!dashboard.locked);
useEffect(() => {
setIsDashboardLocked(!!dashboard.locked);
setShowLockToggle(!!dashboard.locked);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.id]);
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -58,20 +69,36 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) &&
dashboard.createdBy !== 'integration';
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const next = !isDashboardLocked;
setIsDashboardLocked(next);
if (next) {
setShowLockToggle(true);
}
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
if (next) {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
} else {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
}
refetch();
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, refetch, showErrorModal]);
@@ -119,8 +146,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={false}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}

View File

@@ -2,6 +2,7 @@
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding-top: 2px;
color: var(--l3-foreground);
}

View File

@@ -8,7 +8,7 @@ import {
DashboardtypesThresholdFormatDTO,
type DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
import {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
@@ -77,7 +77,7 @@ function ThresholdsSection({
yAxisUnit,
tableColumns = [],
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? 'label';
const variant = controls?.variant ?? ThresholdVariant.LABEL;
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);

View File

@@ -4,7 +4,10 @@ import {
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
ThresholdVariant,
type AnyThreshold,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import UnifiedThresholdsSection from '../ThresholdsSection';
@@ -21,7 +24,7 @@ function ComparisonThresholdsSection(props: {
value={props.value}
onChange={props.onChange as (next: AnyThreshold[]) => void}
yAxisUnit={props.yAxisUnit}
controls={{ variant: 'comparison' }}
controls={{ variant: ThresholdVariant.COMPARISON }}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
@@ -61,24 +61,42 @@ function Header({
</Button>
</div>
<ConfirmDialog
<DialogWrapper
open={discard.open}
onOpenChange={(next): void => {
onOpenChange={(next: boolean): void => {
if (!next) {
discard.cancel();
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={discard.confirm}
onCancel={discard.cancel}
data-testid="panel-editor-v2-discard-modal"
testId="panel-editor-v2-discard-modal"
footer={
<>
<Button
type="button"
variant="solid"
color="destructive"
data-testid="panel-editor-v2-discard-confirm"
loading={discard.isPending}
onClick={discard.confirm}
>
Discard
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
data-testid="panel-editor-v2-discard-cancel"
onClick={discard.cancel}
>
Keep editing
</Button>
</>
}
>
<Typography>Your unsaved edits to this panel will be lost.</Typography>
</ConfirmDialog>
</DialogWrapper>
</div>
);
}

View File

@@ -23,6 +23,8 @@ interface PreviewPaneProps {
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
/** Showing a prior page's data while the next loads; forwarded so the list shows skeleton rows. */
isPreviousData?: boolean;
error: Error | null;
/** Re-run the query (drives PanelBody's error-state retry). */
refetch: () => void;
@@ -43,6 +45,7 @@ function PreviewPane({
panelDefinition,
data,
isFetching,
isPreviousData,
error,
refetch,
onDragSelect,
@@ -87,6 +90,7 @@ function PreviewPane({
panelId={panelId}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -1,7 +1,5 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -28,20 +26,21 @@ function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
} as unknown as DashboardtypesPanelSpecDTO;
}
// Thin wrapper — only prove delegation; seeding rules are covered in buildPluginSpec.test.ts.
describe('getSwitchedPluginSpec', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
it("resolves the target kind's sections and carries the old spec through them", () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
});
const old = specWith({
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
axes: { logScale: true },
sections: [
{ kind: 'legend', controls: { position: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
],
});
const old = specWith({ formatting: { unit: 'ms', decimalPrecision: 2 } });
const result = getSwitchedPluginSpec(
old,
@@ -49,25 +48,12 @@ describe('getSwitchedPluginSpec', () => {
TelemetrytypesSignalDTO.logs,
);
expect(mockGetPanelDefinition).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
expect(result.legend?.position).toBe('bottom');
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
// Type-specific config from the old kind is dropped.
expect((result as { axes?: unknown }).axes).toBeUndefined();
});
it('does not carry formatting when the new kind has no formatting section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({ formatting: { unit: 'ms' } });
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toBeUndefined();
});
it('seeds List columns from the signal when switching into a List', () => {
it('forwards the signal to seed List columns', () => {
const columns = [{ name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
@@ -83,155 +69,4 @@ describe('getSwitchedPluginSpec', () => {
);
expect(result.selectFields).toBe(columns);
});
it('includes the kind section defaults (e.g. legend position)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'legend', controls: { position: true } }],
});
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/PieChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.legend?.position).toBe('bottom');
});
describe('thresholds', () => {
it('does not carry thresholds when the new kind has no thresholds section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toBeUndefined();
});
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/BarChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/NumberPanel',
TelemetrytypesSignalDTO.logs,
);
// The label is dropped; operator/format are seeded so the threshold can match.
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TablePanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops the table-only columnName when remapping into the label variant', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
});
it('defaults the variant to label when the thresholds section omits controls', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: {} }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', label: 'warn' },
]);
});
});
});

View File

@@ -1,149 +1,27 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
type TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
SectionKind,
type ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
type DefaultPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
buildPluginSpec,
type SeededPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildPluginSpec';
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
export type SwitchedPluginSpec = SeededPluginSpec;
/**
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
* at the boundary.
*/
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
thresholds?: AnyThreshold[];
}
/** Every field any threshold variant can hold; switching reads across shapes to remap. */
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** The threshold variant a kind edits, or `undefined` when it has no Thresholds section. */
function getThresholdVariant(
sections: SectionConfig[],
): ThresholdVariant | undefined {
const section = sections.find(
(s): s is Extract<SectionConfig, { kind: SectionKind.Thresholds }> =>
s.kind === SectionKind.Thresholds,
);
return section ? (section.controls.variant ?? 'label') : undefined;
}
/**
* Remaps a threshold to the target kind's variant: keeps the shared core (color, value,
* unit) plus any cross-variant fields, and seeds the rest with the variant's defaults so
* the carried threshold stays functional (a comparison/table threshold needs an operator
* to match, a table threshold a column).
*/
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === 'comparison') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === 'table') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the cross-kind config worth keeping — unit + decimal precision, and thresholds when the
* new kind supports them (remapped to its variant). Switching into a List seeds the
* current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
* Plugin spec for a first-visit switch to `newKind`: the kind's defaults plus the cross-kind
* config each section carries from `oldSpec`. Revisiting a kind restores its stash instead.
*/
export function getSwitchedPluginSpec(
oldSpec: DashboardtypesPanelSpecDTO,
newKind: PanelKind,
signal: TelemetrytypesSignalDTO,
): SwitchedPluginSpec {
const sections = getPanelDefinition(newKind).sections;
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
if (sections.some((section) => section.kind === SectionKind.Formatting)) {
const oldFormatting = (
oldSpec.plugin.spec as {
formatting?: PanelFormattingSlice;
}
).formatting;
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
...(oldFormatting?.decimalPrecision !== undefined && {
decimalPrecision: oldFormatting.decimalPrecision,
}),
};
if (Object.keys(carried).length > 0) {
result.formatting = carried;
}
}
if (sections.some((section) => section.kind === SectionKind.Columns)) {
const columns = defaultColumnsForSignal(signal);
if (columns.length > 0) {
result.selectFields = columns;
}
}
const thresholdVariant = getThresholdVariant(sections);
if (thresholdVariant) {
const oldThresholds = (
oldSpec.plugin.spec as {
thresholds?: AnyThreshold[] | null;
}
).thresholds;
if (oldThresholds && oldThresholds.length > 0) {
result.thresholds = oldThresholds.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, thresholdVariant),
);
}
}
return result;
return buildPluginSpec(getPanelDefinition(newKind).sections, {
oldSpec,
signal,
});
}

View File

@@ -49,6 +49,7 @@ const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
const TRANSFORMED = {
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query;
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
@@ -131,7 +132,39 @@ describe('usePanelTypeSwitch', () => {
expect(next.plugin.kind).toBe('signoz/ListPanel');
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
expect(next.queries).toBe(CONVERTED);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }, { orderBy: undefined }] },
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
const [persisted] = mockToPerses.mock.calls[0] as [Query];
persisted.builder.queryData.forEach((qd) => {
expect(qd.orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {

View File

@@ -11,7 +11,10 @@ import {
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type {
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import {
@@ -25,6 +28,25 @@ import {
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
function withDefaultListOrder(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) =>
qd.orderBy && qd.orderBy.length > 0
? qd
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
),
},
};
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
@@ -116,16 +138,21 @@ export function usePanelTypeSwitch({
{ ...query, queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(transformed, newPanelType),
toPerses(nextQuery, newPanelType),
),
);
redirectWithQueryBuilderData(transformed);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData],
);

View File

@@ -102,12 +102,19 @@ function PanelEditorContainer({
// One shared query result for the whole editor; the preview renders it.
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
const { data, isFetching, error, cancelQuery, refetch, pagination } =
usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
const defaultSignal = panelDefinition.supportedSignals[0];
@@ -235,6 +242,7 @@ function PanelEditorContainer({
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -1,4 +1,8 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
@@ -10,6 +14,9 @@ export const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef } from 'react';
import { Select, Table } from 'antd';
import { Select, Skeleton, Table } from 'antd';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
@@ -36,6 +36,7 @@ function ListPanelRenderer({
refetch,
searchTerm = '',
pagination,
isPreviousData = false,
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
// Pin the header while the body scrolls (shared with the Table kind).
const containerRef = useRef<HTMLDivElement>(null);
@@ -115,6 +116,25 @@ function ListPanelRenderer({
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
const showPager = !!pagination;
// While the next page loads, swap the stale rows (held by keepPreviousData) for skeleton bars,
// keeping the header + pager. Row count mirrors the page being left.
const skeletonRowCount = dataSource.length || pagination?.pageSize || 10;
const skeletonColumns = useMemo(
() =>
resizableColumns.map((col) => ({
...col,
render: (): JSX.Element => <Skeleton.Input active block size="small" />,
})),
[resizableColumns],
);
const skeletonRows = useMemo(
() =>
Array.from({ length: skeletonRowCount }, (_, index) => ({
key: `skeleton-${index}`,
})) as unknown as typeof filteredDataSource,
[skeletonRowCount],
);
return (
<div
ref={containerRef}
@@ -134,13 +154,13 @@ function ListPanelRenderer({
<Table
size="small"
tableLayout="fixed"
columns={resizableColumns}
columns={isPreviousData ? skeletonColumns : resizableColumns}
components={components}
dataSource={filteredDataSource}
dataSource={isPreviousData ? skeletonRows : filteredDataSource}
pagination={false}
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
scroll={{ y: scrollY }}
onRow={onRow}
onRow={isPreviousData ? undefined : onRow}
/>
</div>
{showPager && pagination && (

View File

@@ -162,4 +162,18 @@ describe('ListPanelRenderer', () => {
expect(queryByTestId('list-panel-pager')).not.toBeInTheDocument();
});
it('swaps rows for skeletons while the next page loads (isPreviousData), keeping header + pager', () => {
const { getByText, queryByText, getByTestId, container } = renderPanel({
data: dataWith([{ data: { body: 'stale row' } }]),
pagination: makePagination({ canNext: true }),
isPreviousData: true,
});
expect(getByText('body')).toBeInTheDocument();
expect(getByTestId('list-panel-pager')).toBeInTheDocument();
// Stale page content is replaced by skeleton bars.
expect(queryByText('stale row')).not.toBeInTheDocument();
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,8 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
export const sections: SectionConfig[] = [
{
@@ -6,6 +10,9 @@ export const sections: SectionConfig[] = [
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'comparison' } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.COMPARISON },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,4 +1,8 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
// A table panel renders one scalar result (the V5 backend joins every query into a
// single column set). It exposes the per-panel time scope, formatting (decimals +
@@ -12,6 +16,9 @@ export const sections: SectionConfig[] = [
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
{ kind: SectionKind.Thresholds, controls: { variant: 'table' } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.TABLE },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,4 +1,8 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
export const sections: SectionConfig[] = [
{
@@ -18,6 +22,9 @@ export const sections: SectionConfig[] = [
spanGaps: true,
},
},
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -0,0 +1,76 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { buildTimeSeriesConfig } from '../buildConfig';
const series: PanelSeries[] = [
{
queryName: 'A',
legend: 'A',
labels: {},
values: [
{ timestamp: 1000, value: 1 },
{ timestamp: 61000, value: 2 },
],
kind: 'series',
aggregation: { index: 0, alias: 'A' },
},
];
/** Resolved per-series `spanGaps` values from a built TimeSeries config. */
function spanGapsFor(
spanGaps: unknown,
stepIntervals?: Record<string, number>,
): (boolean | number | undefined)[] {
const spec = {
chartAppearance: spanGaps ? { spanGaps } : {},
} as unknown as DashboardtypesTimeSeriesPanelSpecDTO;
return buildTimeSeriesConfig({
panelId: 'p1',
spec,
builderQueries: [],
series,
stepIntervals,
isDarkMode: false,
timezone: UTC_TIMEZONE,
panelMode: PanelMode.DASHBOARD_VIEW,
})
.getSeriesSpanGapsOptions()
.map((o) => o.spanGaps);
}
describe('buildTimeSeriesConfig spanGaps', () => {
it('floors a numeric threshold below the step interval at the step interval', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }, { A: 60 }),
).toStrictEqual([60]);
});
it('keeps a numeric threshold larger than the step interval', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '300s' }, { A: 60 }),
).toStrictEqual([300]);
});
it('uses the smallest step interval across queries as the floor', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }, { A: 60, B: 30 }),
).toStrictEqual([30]);
});
it('leaves boolean span-all (true) untouched', () => {
expect(spanGapsFor(undefined, { A: 60 })).toStrictEqual([true]);
// fillOnlyBelow false → resolveSpanGaps returns true → not clamped.
expect(
spanGapsFor({ fillOnlyBelow: false, fillLessThan: '10s' }, { A: 60 }),
).toStrictEqual([true]);
});
it('passes a numeric threshold through when no step intervals are known', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }),
).toStrictEqual([10]);
});
});

View File

@@ -2,7 +2,10 @@ import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/service
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
buildBaseConfig,
minStepInterval,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
LINE_INTERPOLATION_MAP,
@@ -80,7 +83,14 @@ export function buildTimeSeriesConfig({
onClick,
});
addSeries({ builder, spec, builderQueries, series, isDarkMode });
addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
});
return builder;
}
@@ -90,6 +100,8 @@ interface AddSeriesArgs {
spec: DashboardtypesTimeSeriesPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
/** Per-query step intervals (seconds); floor for a numeric spanGaps threshold. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
}
@@ -102,15 +114,23 @@ function addSeries({
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
}: AddSeriesArgs): void {
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance?.spanGaps)
const resolvedSpanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance.spanGaps)
: true;
// A numeric spanGaps is a max-gap threshold (seconds); floor it at the step interval so a
// sub-step value doesn't break the line at every normal point. Boolean `true` passes through.
const minStep = stepIntervals ? minStepInterval(stepIntervals) : undefined;
const spanGaps =
typeof resolvedSpanGaps === 'number' && minStep !== undefined
? Math.max(minStep, resolvedSpanGaps)
: resolvedSpanGaps;
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]

View File

@@ -34,6 +34,8 @@ export interface BaseRendererProps {
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
isFetching: boolean;
/** Showing a prior page's data while the next loads; list renderers swap in skeleton rows. */
isPreviousData?: boolean;
error: Error | null;
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
refetch?: () => void;

View File

@@ -58,7 +58,11 @@ export enum SectionKind {
* - `comparison` — value crosses an operator → recolor (Number)
* - `table` — per-column comparison (Table)
*/
export type ThresholdVariant = 'label' | 'comparison' | 'table';
export enum ThresholdVariant {
LABEL = 'label',
COMPARISON = 'comparison',
TABLE = 'table',
}
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
export type AnyThreshold =

View File

@@ -1,67 +0,0 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
import { sections as listSections } from '../../kinds/ListPanel/sections';
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
describe('buildDefaultPluginSpec', () => {
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.solid,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
fillMode: DashboardtypesFillModeDTO.none,
},
});
});
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('seeds only the legend for Histogram (no visualization section)', () => {
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('returns an empty spec for a kind with no seeded controls (List)', () => {
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
});
it('does not seed controls that already show a clear default', () => {
// `axes` and `formatting` stay unset — their empty state is the chart default.
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{ kind: SectionKind.ContextLinks },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
it('only seeds the legend position when the kind exposes that control', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Legend, controls: { colors: true } },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
});

View File

@@ -13,6 +13,14 @@ describe('buildDefaultQueries', () => {
expect(serialized.toLowerCase()).toContain('logs');
});
it('seeds a List panel without a limit so it pages server-side by default', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
// A limit would make usePanelQuery treat the panel as a static, unpaged list.
const spec = queries[0].spec.plugin.spec as { limit?: number };
expect(spec.limit).toBeUndefined();
});
it('seeds no query for non-List kinds (they seed from the builder)', () => {
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);

View File

@@ -0,0 +1,328 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTimePreferenceDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultColumnsForSignal } from '../../../PanelEditor/ListColumnsEditor/selectFields';
import { sections as listSections } from '../../kinds/ListPanel/sections';
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
import { buildPluginSpec } from '../buildPluginSpec';
jest.mock('../../../PanelEditor/ListColumnsEditor/selectFields', () => ({
defaultColumnsForSignal: jest.fn(),
}));
const mockDefaultColumnsForSignal =
defaultColumnsForSignal as unknown as jest.Mock;
/** A panel spec carrying the plugin.spec a seed reads; the rest of the shape is irrelevant. */
function oldSpecWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: pluginSpec },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
describe('buildPluginSpec', () => {
describe('folding mechanism', () => {
it('returns an empty spec for no sections', () => {
expect(buildPluginSpec([])).toStrictEqual({});
});
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
{ kind: SectionKind.ContextLinks },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
const result = buildPluginSpec([
{ kind: SectionKind.Legend, controls: { colors: true } },
]);
expect(result).toStrictEqual({});
expect(result).not.toHaveProperty('legend');
});
it('composes defaults and carried config from several sections in one pass', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
];
const oldSpec = oldSpecWith({
formatting: { unit: 'ms', decimalPrecision: 2 },
});
expect(buildPluginSpec(sections, { oldSpec })).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
formatting: { unit: 'ms', decimalPrecision: 2 },
});
});
});
describe('visualization / legend seeds', () => {
it('seeds visualization global_time and legend bottom when those controls are on', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Legend, controls: { position: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('seeds neither when their defaulting controls are absent', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
{ kind: SectionKind.Legend, controls: { colors: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
});
describe('chartAppearance seed', () => {
it('seeds only the declared defaulting controls', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { lineStyle: true, fillMode: true },
},
];
expect(buildPluginSpec(sections).chartAppearance).toStrictEqual({
lineStyle: DashboardtypesLineStyleDTO.solid,
fillMode: DashboardtypesFillModeDTO.none,
});
});
it('seeds nothing when only non-defaulting controls are declared (showPoints/spanGaps)', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { showPoints: true, spanGaps: true },
},
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
});
describe('formatting seed (carry, gated by controls)', () => {
it('carries unit + decimalPrecision when the kind declares both', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
];
const oldSpec = oldSpecWith({
formatting: { unit: 'ms', decimalPrecision: 3 },
});
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
unit: 'ms',
decimalPrecision: 3,
});
});
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
const sections: SectionConfig[] = [
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
];
const oldSpec = oldSpecWith({
formatting: { unit: 'ms', decimalPrecision: 2 },
});
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 2,
});
});
it('carries a decimalPrecision of 0 (falsy but defined) and omits missing fields', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
];
const oldSpec = oldSpecWith({ formatting: { decimalPrecision: 0 } });
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 0,
});
});
it('seeds no formatting on a new panel or when nothing supported is present', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
];
expect(buildPluginSpec(sections)).toStrictEqual({});
expect(
buildPluginSpec(sections, {
oldSpec: oldSpecWith({ formatting: { unit: 'ms' } }),
}),
).toStrictEqual({});
});
});
describe('columns seed', () => {
it('seeds the signal default columns when a Columns section is present', () => {
const columns = [{ name: 'timestamp' }, { name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
const result = buildPluginSpec([{ kind: SectionKind.Columns }], {
signal: TelemetrytypesSignalDTO.traces,
});
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
TelemetrytypesSignalDTO.traces,
);
expect(result.selectFields).toBe(columns);
});
it('seeds nothing (and skips the lookup) when no signal is in context', () => {
const result = buildPluginSpec([{ kind: SectionKind.Columns }]);
expect(mockDefaultColumnsForSignal).not.toHaveBeenCalled();
expect(result).toStrictEqual({});
});
});
describe('thresholds seed (variant remap)', () => {
function switchThresholds(
variant: ThresholdVariant | undefined,
thresholds: unknown[],
): unknown {
const sections: SectionConfig[] = [
{ kind: SectionKind.Thresholds, controls: { variant } },
];
return buildPluginSpec(sections, { oldSpec: oldSpecWith({ thresholds }) })
.thresholds;
}
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
expect(
switchThresholds(undefined, [
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]),
).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label → comparison, seeding operator + format and dropping label', () => {
expect(
switchThresholds(ThresholdVariant.COMPARISON, [
{ value: 80, color: '#F1575F', label: 'warn' },
]),
).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('preserves existing operator/format when remapping comparison → table', () => {
expect(
switchThresholds(ThresholdVariant.TABLE, [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
]),
).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops table-only operator/format/columnName when remapping table → label', () => {
expect(
switchThresholds(ThresholdVariant.LABEL, [
{
value: 0,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
]),
).toStrictEqual([{ value: 0, color: '#F1575F' }]);
});
it('seeds nothing for an empty or absent threshold list', () => {
expect(switchThresholds(ThresholdVariant.LABEL, [])).toBeUndefined();
const sections: SectionConfig[] = [
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
});
// Integration against real kind configs — guards against a section-config regression.
describe('per-kind defaults (real sections, no context)', () => {
it('seeds the full TimeSeries default set', () => {
expect(buildPluginSpec(timeSeriesSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.solid,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
fillMode: DashboardtypesFillModeDTO.none,
},
});
});
it('returns an empty spec for List (only switchPanelKind, nothing to seed)', () => {
expect(buildPluginSpec(listSections)).toStrictEqual({});
});
});
});

View File

@@ -178,11 +178,11 @@ export function mapThresholds(
}
/**
* Smallest per-query step interval, fed to uPlot so tick density matches the
* Smallest per-query step interval (seconds), fed to uPlot so tick density matches the
* densest query. Falls back to `undefined` (uPlot "auto") on an empty map, since
* `Math.min` returns `Infinity` there and would corrupt scale math.
*/
function minStepInterval(
export function minStepInterval(
stepIntervals: Record<string, number>,
): number | undefined {
const values = Object.values(stepIntervals);

View File

@@ -1,73 +0,0 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
type SectionConfig,
type SectionSpecMap,
} from '../types/sections';
/**
* Seeded plugin-spec slices, typed as canonical section slices so each value is
* checked against its DTO. A partial cross-section, not any single kind's spec,
* so the union cast stays localized to `createDefaultPanel`.
*/
export interface DefaultPluginSpec {
visualization?: SectionSpecMap[SectionKind.Visualization];
legend?: SectionSpecMap[SectionKind.Legend];
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
}
/**
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
* config pane opens populated. Values equal the renderer fallbacks (display only).
* Controls whose empty state already IS the default are left unset.
*/
export function buildDefaultPluginSpec(
sections: SectionConfig[],
): DefaultPluginSpec {
const spec: DefaultPluginSpec = {};
sections.forEach((section) => {
switch (section.kind) {
case SectionKind.Visualization:
if (section.controls.timePreference) {
spec.visualization = {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
};
}
break;
case SectionKind.Legend:
if (section.controls.position) {
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
}
break;
case SectionKind.ChartAppearance: {
const chartAppearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
if (section.controls.lineStyle) {
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (section.controls.lineInterpolation) {
chartAppearance.lineInterpolation =
DashboardtypesLineInterpolationDTO.spline;
}
if (section.controls.fillMode) {
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
}
if (Object.keys(chartAppearance).length > 0) {
spec.chartAppearance = chartAppearance;
}
break;
}
default:
break;
}
});
return spec;
}

View File

@@ -0,0 +1,201 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTimePreferenceDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
import {
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
type SectionControls,
SectionKind,
type SectionSpecMap,
ThresholdVariant,
} from '../types/sections';
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
export interface SeededPluginSpec {
visualization?: SectionSpecMap[SectionKind.Visualization];
legend?: SectionSpecMap[SectionKind.Legend];
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: SectionSpecMap[SectionKind.Columns];
thresholds?: AnyThreshold[];
}
export interface SeedContext {
/** Present only on a kind switch — the spec being switched away from, to carry config across. */
oldSpec?: DashboardtypesPanelSpecDTO;
signal?: TelemetrytypesSignalDTO;
}
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** Remaps a threshold to the target variant, seeding the fields that variant needs to stay functional. */
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === ThresholdVariant.COMPARISON) {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === ThresholdVariant.TABLE) {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* How one section derives its plugin-spec slice on create/switch — the single place a section
* declares this. Sections absent from `SECTION_SEEDS` seed nothing.
*/
interface SectionSeed {
specKey: keyof SeededPluginSpec;
seed: (controls: unknown, ctx: SeedContext) => unknown;
}
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
[SectionKind.Visualization]: {
specKey: 'visualization',
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
const c = controls as SectionControls[SectionKind.Visualization];
return c.timePreference
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
: undefined;
},
},
[SectionKind.Legend]: {
specKey: 'legend',
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
const c = controls as SectionControls[SectionKind.Legend];
return c.position
? { position: DashboardtypesLegendPositionDTO.bottom }
: undefined;
},
},
[SectionKind.ChartAppearance]: {
specKey: 'chartAppearance',
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
const c = controls as SectionControls[SectionKind.ChartAppearance];
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
if (c.lineStyle) {
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (c.lineInterpolation) {
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
}
if (c.fillMode) {
appearance.fillMode = DashboardtypesFillModeDTO.none;
}
return Object.keys(appearance).length > 0 ? appearance : undefined;
},
},
[SectionKind.Formatting]: {
specKey: 'formatting',
seed: (
controls,
{ oldSpec },
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
const c = controls as SectionControls[SectionKind.Formatting];
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
?.formatting;
// Carry a field only when the target kind declares it (e.g. Table has no `unit`),
// else the save API rejects the spec.
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
...(c.decimals &&
old?.decimalPrecision !== undefined && {
decimalPrecision: old.decimalPrecision,
}),
};
return Object.keys(carried).length > 0 ? carried : undefined;
},
},
[SectionKind.Columns]: {
specKey: 'selectFields',
seed: (
_controls,
{ signal },
): SectionSpecMap[SectionKind.Columns] | undefined => {
if (!signal) {
return undefined;
}
const columns = defaultColumnsForSignal(signal);
return columns.length > 0 ? columns : undefined;
},
},
[SectionKind.Thresholds]: {
specKey: 'thresholds',
seed: (controls, { oldSpec }): AnyThreshold[] | undefined => {
const c = controls as SectionControls[SectionKind.Thresholds];
const variant = c.variant ?? ThresholdVariant.LABEL;
const old = (oldSpec?.plugin.spec as { thresholds?: AnyThreshold[] | null })
?.thresholds;
if (!old || old.length === 0) {
return undefined;
}
return old.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, variant),
);
},
},
};
/**
* Builds a kind's plugin spec from its declared `sections`: no context → per-kind defaults
* (new panel); `{ oldSpec, signal }` → defaults plus the config each target section carries.
*/
export function buildPluginSpec(
sections: SectionConfig[],
ctx: SeedContext = {},
): SeededPluginSpec {
const spec: SeededPluginSpec = {};
sections.forEach((section) => {
const entry = SECTION_SEEDS[section.kind];
if (!entry) {
return;
}
const controls = 'controls' in section ? section.controls : undefined;
const value = entry.seed(controls, ctx);
if (value !== undefined) {
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
(spec as Record<string, unknown>)[entry.specKey] = value;
}
});
return spec;
}

View File

@@ -59,12 +59,13 @@ function Panel({
const searchable = !!panelDefinition?.actions.search;
const [searchTerm, setSearchTerm] = useState('');
const { data, isFetching, error, refetch, pagination } = usePanelQuery({
panel,
panelId,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
});
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -92,6 +93,7 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -6,6 +6,7 @@ import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/compon
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { getResponseType } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import type {
PanelPagination,
PanelQueryData,
@@ -21,6 +22,8 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
@@ -44,6 +47,7 @@ function PanelBody({
panelId,
data,
isFetching,
isPreviousData,
error,
refetch,
onDragSelect,
@@ -52,9 +56,11 @@ function PanelBody({
searchTerm,
pagination,
}: PanelBodyProps): JSX.Element {
// react-query keeps the previous response during refetches, so its presence is
// the "have something to show" signal — only fail hard when there's nothing.
const hasData = !!data.response;
// A retained response (keepPreviousData) counts as data only if its type matches the current
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
const hasData =
!!data.response &&
getResponseType(data.response) === data.requestPayload?.requestType;
const queries = panel.spec.queries;
// Not-configured panel: no runnable query, so nothing to error/load on.
@@ -89,7 +95,9 @@ function PanelBody({
);
}
if (isFetching) {
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change) with the header carrying the in-flight indicator.
if (isFetching && !hasData) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
@@ -104,6 +112,7 @@ function PanelBody({
panel={panel}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -42,8 +42,8 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('renders the kind renderer once a runnable query is present', () => {
const panel = panelWith([
const runnablePanel = (): DashboardtypesPanelDTO =>
panelWith([
{
spec: {
plugin: {
@@ -58,9 +58,62 @@ describe('PanelBody', () => {
},
]);
render(<PanelBody {...baseProps} panel={panel} />);
it('renders the kind renderer once a runnable query is present', () => {
render(<PanelBody {...baseProps} panel={runnablePanel()} />);
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-query')).not.toBeInTheDocument();
});
it('shows the full-panel loader only on the first fetch (no data yet)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={
{
response: { data: { type: 'raw' } },
requestPayload: { requestType: 'raw' },
} as unknown as PanelQueryData
}
isFetching
/>,
);
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('panel-loading')).not.toBeInTheDocument();
});
it('shows the loader (not a NoData flash) while a stale cross-type response is replaced on a kind switch', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={
{
response: { data: { type: 'time_series' } },
requestPayload: { requestType: 'raw' },
} as unknown as PanelQueryData
}
isFetching
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
});

View File

@@ -21,6 +21,7 @@ import {
buildCreateAlertUrl,
readPanelUnit,
} from '../utils/buildCreateAlertUrl';
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
/**
* Callback that seeds the alert builder from a panel's query in a new tab (V1 parity
@@ -61,8 +62,8 @@ export function useCreateAlertFromPanel(): (
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
panelType,
startMs: minTime / 1e6,
endMs: maxTime / 1e6,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
variables,
});

View File

@@ -328,6 +328,12 @@ describe('usePanelQuery', () => {
expect(result.current.pagination).toBeUndefined();
});
it('keeps previous data while paging so the table/pager stay mounted on page change', () => {
renderHook(() => usePanelQuery({ panel: listPanel({}), panelId: 'p1' }));
const [{ keepPreviousData }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(keepPreviousData).toBe(true);
});
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
@@ -377,26 +383,20 @@ describe('usePanelQuery', () => {
expect(result.current.pagination?.canNext).toBe(false);
});
it('flags canNext on a full page and clears it on a partial page', () => {
it('drives canNext from the response cursor, not the row count', () => {
// Full page but no cursor → backend says these are the last rows.
withResponse(rawResponse(25));
const full = renderHook(() =>
const noCursor = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(full.result.current.pagination?.canNext).toBe(true);
expect(noCursor.result.current.pagination?.canNext).toBe(false);
withResponse(rawResponse(10));
const partial = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(partial.result.current.pagination?.canNext).toBe(false);
});
it('flags canNext from a nextCursor even on a partial page', () => {
// Cursor present (even on a partial page) → more rows.
withResponse(rawResponse(3, 'cursor-1'));
const { result } = renderHook(() =>
const withCursor = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination?.canNext).toBe(true);
expect(withCursor.result.current.pagination?.canNext).toBe(true);
});
it('advances pageIndex and enables canPrev after goNext', () => {

View File

@@ -11,6 +11,8 @@ export interface UseGetQueryRangeV5Args {
requestPayload: Querybuildertypesv5QueryRangeRequestDTO;
queryKey: unknown[];
enabled: boolean;
/** Retain prior data across a key change (list paging) so the table + pager stay mounted. */
keepPreviousData?: boolean;
}
/**
@@ -40,11 +42,13 @@ export function useGetQueryRangeV5({
requestPayload,
queryKey,
enabled,
keepPreviousData,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
return useQuery<QueryRangeV5200, Error>({
queryKey,
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
enabled,
retry: retryUnlessClientError,
keepPreviousData,
});
}

View File

@@ -55,6 +55,8 @@ export interface UsePanelQueryResult {
isLoading: boolean;
/** Any request in flight, including a background refetch over stale data — drives a "refreshing" affordance, never a blank panel. */
isFetching: boolean;
/** Showing a prior page's data (keepPreviousData) while the next page loads — list renderers swap in skeleton rows. */
isPreviousData: boolean;
error: Error | null;
/** Re-run the query (e.g. a retry button on the error state). */
refetch: () => void;
@@ -211,6 +213,8 @@ export function usePanelQuery({
requestPayload,
queryKey,
enabled: enabled && runnable,
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
keepPreviousData: isPaginated,
});
const queryClient = useQueryClient();
@@ -232,8 +236,8 @@ export function usePanelQuery({
[pageSize],
);
// Paging handles for raw/list panels. `canNext` is a heuristic (no total count on the wire):
// a full page or a response `nextCursor` implies more rows.
// Paging handles for raw/list panels. The backend sets `nextCursor` iff the page filled,
// so it's the authoritative has-more signal (there's no total count on the wire).
const pagination = useMemo<PanelPagination | undefined>(() => {
if (!isPaginated) {
return undefined;
@@ -246,11 +250,10 @@ export function usePanelQuery({
// `getRawResults` returns [] for a missing/non-raw response, so this stays
// defined and zero-rowed rather than throwing while data is absent.
const result = getRawResults(response.data)[0];
const rowCount = result?.rows?.length ?? 0;
return {
pageIndex: Math.floor(safeOffset / safePageSize),
canPrev: safeOffset > 0,
canNext: !!result?.nextCursor || rowCount >= safePageSize,
canNext: !!result?.nextCursor,
goPrev,
goNext,
pageSize: safePageSize,
@@ -271,6 +274,7 @@ export function usePanelQuery({
data,
isLoading: response.isLoading,
isFetching: response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,
cancelQuery,

View File

@@ -37,6 +37,24 @@ describe('applyJsonPatch', () => {
expect(JSON.stringify(doc)).toBe(snapshot);
});
it('does not mutate the input ops when a later op targets a just-added node', () => {
// New-panel-on-empty-dashboard batch: add an empty section, then add an
// item into it. The item must not leak back into the section-add op's value
// (which is still queued for the network request) via a shared reference.
const ops = [
op(add, '/spec/layouts/-', { spec: { items: [] } }),
op(add, '/spec/layouts/0/spec/items/-', { x: 0, y: 0 }),
];
const empty = { spec: { layouts: [] } };
const next = applyJsonPatch(empty, ops);
// The section-add op's value stays empty — only the applied document grows.
expect((ops[0].value as any).spec.items).toStrictEqual([]);
expect((next.spec as any).layouts[0].spec.items).toStrictEqual([
{ x: 0, y: 0 },
]);
});
it('replaces a leaf string', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/layouts/0/spec/display/title', 'S1-renamed'),

View File

@@ -136,10 +136,14 @@ function applyOperation(
const key = tokens[tokens.length - 1];
// move / copy / test are never emitted by our builders → no-op (reconciled by refetch).
// Clone the inserted value: a later op in the same batch can target a node we
// just added (e.g. add an empty section, then add an item into it), and writing
// the value by reference would mutate the caller's `op.value` — corrupting the
// ops still queued for the network request.
if (op.op === DashboardtypesPatchOpDTO.add) {
addAt(parent, key, op.value);
addAt(parent, key, cloneDeep(op.value));
} else if (op.op === DashboardtypesPatchOpDTO.replace) {
replaceAt(parent, key, op.value);
replaceAt(parent, key, cloneDeep(op.value));
} else if (op.op === DashboardtypesPatchOpDTO.remove) {
removeAt(parent, key);
}

View File

@@ -12,7 +12,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from './Panels/types/panelKind';
import type { DefaultPluginSpec } from './Panels/utils/buildDefaultPluginSpec';
import type { SeededPluginSpec } from './Panels/utils/buildPluginSpec';
import type { GridItem } from './utils';
/**
@@ -36,7 +36,7 @@ export function panelRef(panelId: string): string {
*/
export function createDefaultPanel(
pluginKind: PanelKind,
pluginSpec: DefaultPluginSpec = {},
pluginSpec: SeededPluginSpec = {},
queries: DashboardtypesQueryDTO[] = [],
): DashboardtypesPanelDTO {
return {

View File

@@ -188,9 +188,80 @@ describe('buildQueryRangeRequest', () => {
signal: 'logs',
offset: 100,
limit: 50,
order: [
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
],
});
});
it('defaults a logs list with no order to timestamp desc + id tiebreaker', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: { key?: { name?: string }; direction?: string }[];
};
expect(spec.order).toStrictEqual([
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
]);
});
it('appends an id tiebreaker to a logs list order (mirroring the primary direction)', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({
name: 'A',
signal: 'logs',
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
}),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: { key?: { name?: string }; direction?: string }[];
};
expect(spec.order).toStrictEqual([
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
]);
});
it('does not duplicate an id tiebreaker already present in the order', () => {
const order = [
{ key: { name: 'timestamp' }, direction: 'asc' },
{ key: { name: 'id' }, direction: 'asc' },
];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: unknown[];
};
expect(spec.order).toStrictEqual(order);
});
it('does not add an id tiebreaker for a traces list order (explorer parity)', () => {
const order = [{ key: { name: 'timestamp' }, direction: 'desc' }];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: unknown[];
};
expect(spec.order).toStrictEqual(order);
});
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),

View File

@@ -111,6 +111,45 @@ describe('persesQueryAdapters', () => {
expect(result[0].kind).toBe('raw');
expect(result[0].spec.plugin.kind).toBe('signoz/BuilderQuery');
});
it('drops the pageSize-promoted limit for a List query with no user limit (so it pages server-side)', () => {
// pageSize with no user limit would otherwise be folded into the V5 limit.
const withPageSize: Query = {
...initialQueriesMap[DataSource.LOGS],
builder: {
...initialQueriesMap[DataSource.LOGS].builder,
queryData: [
{
...initialQueriesMap[DataSource.LOGS].builder.queryData[0],
limit: null,
pageSize: 100,
},
],
},
};
const result = toPerses(withPageSize, PANEL_TYPES.LIST);
const spec = result[0].spec.plugin.spec as { limit?: number };
expect(spec.limit).toBeUndefined();
});
it('keeps an explicit user limit on a List query (V1 parity: static, unpaged cap)', () => {
const withLimit: Query = {
...initialQueriesMap[DataSource.LOGS],
builder: {
...initialQueriesMap[DataSource.LOGS].builder,
queryData: [
{ ...initialQueriesMap[DataSource.LOGS].builder.queryData[0], limit: 50 },
],
},
};
const result = toPerses(withLimit, PANEL_TYPES.LIST);
const spec = result[0].spec.plugin.spec as { limit?: number };
expect(spec.limit).toBe(50);
});
});
describe('round-trip', () => {

View File

@@ -3,12 +3,14 @@ import type {
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5ClickHouseQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5QueryRangeRequestDTOVariables,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5OrderDirectionDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
@@ -24,6 +26,7 @@ interface QuerySpecView {
signal?: string;
stepInterval?: number | string;
aggregations?: { metricName?: string }[];
order?: Querybuildertypesv5OrderByDTO[];
}
/**
@@ -166,6 +169,48 @@ function withBarStepInterval(
});
}
/**
* Enforces a total order on logs-list requests so offset paging can't duplicate/drop
* same-millisecond rows: default the primary to `timestamp desc`, then always append `id`
* (logs-explorer parity). Request-only; traces keep their order.
*/
function withListOrderTiebreaker(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
const order = spec.order ?? [];
if (spec.signal !== 'logs' || order.some((o) => o.key?.name === 'id')) {
return envelope;
}
const primary =
order.length > 0
? order
: [
{
key: { name: 'timestamp' },
direction: Querybuildertypesv5OrderDirectionDTO.desc,
},
];
return {
...envelope,
spec: {
...envelope.spec,
order: [
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
});
}
/**
* Stamps offset/limit onto builder-query envelopes (server-side paging for raw/list); other
* kinds pass through.
@@ -224,6 +269,9 @@ export function buildQueryRangeRequest({
if (panelType === PANEL_TYPES.BAR) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
}
if (panelType === PANEL_TYPES.LIST) {
envelopes = withListOrderTiebreaker(envelopes);
}
if (pagination) {
envelopes = withPagination(envelopes, pagination);
}

View File

@@ -51,6 +51,23 @@ const isBuilderQueryEnvelope = (
): boolean =>
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
/**
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
* `pageSize` into the V5 `limit`, which usePanelQuery would read as a user cap and hide the
* pager. Dropped here, `limit` reflects only a real user limit and List panels page by default.
*/
const withoutExplorerPaging = (query: Query): Query => ({
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((data) => ({
...data,
pageSize: undefined,
offset: undefined,
})),
},
});
export function deriveQueryType(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): EQueryType {
@@ -120,7 +137,11 @@ export function toPerses(
query: Query,
panelType: PANEL_TYPES,
): DashboardtypesQueryDTO[] {
const composite = mapCompositeQueryFromQuery(query, panelType);
// List panels page server-side via usePanelQuery, so drop the V1 explorer's paging
// fields before conversion — otherwise the shared mapper folds them into `limit`.
const source =
panelType === PANEL_TYPES.LIST ? withoutExplorerPaging(query) : query;
const composite = mapCompositeQueryFromQuery(source, panelType);
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
if (panelType === PANEL_TYPES.LIST) {

View File

@@ -2,6 +2,7 @@ import type {
Querybuildertypesv5AggregationBucketDTO,
Querybuildertypesv5ExecStatsDTO,
Querybuildertypesv5RawDataDTO,
Querybuildertypesv5RequestTypeDTO,
Querybuildertypesv5ScalarDataDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
@@ -44,6 +45,13 @@ export function getRawResults(
return (data.data?.results ?? []) as Querybuildertypesv5RawDataDTO[];
}
/** Response request-type discriminator (raw/trace/scalar/time_series); detects a stale cross-type response. */
export function getResponseType(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5RequestTypeDTO | undefined {
return response?.data?.type;
}
/** Exec stats (incl. per-query `stepIntervals`) from the response top level. */
export function getExecStats(
response: QueryRangeV5200 | undefined,

View File

@@ -13,7 +13,7 @@ import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import {
@@ -49,7 +49,7 @@ function PanelEditorPage(): JSX.Element {
newKind
? createDefaultPanel(
newKind,
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
buildPluginSpec(getPanelDefinition(newKind).sections),
buildDefaultQueries(newKind),
)
: existingPanel,

View File

@@ -26,3 +26,19 @@
padding: 0px;
}
}
.renameFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Wrap so the tooltip has a hoverable target even when the button is disabled. */
.menuItemWrap {
display: block;
width: 100%;
}
.menuItemWrap button:disabled {
pointer-events: none;
}

View File

@@ -1,6 +1,7 @@
import { useMutation } from 'react-query';
import { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { generatePath } from 'react-router-dom';
import { Popover } from 'antd';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import {
@@ -8,18 +9,28 @@ import {
Expand,
EllipsisVertical,
Link2,
LockKeyhole,
PenLine,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import {
cloneDashboardV2,
invalidateListDashboardsForUserV2,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import DeleteActionItem from './DeleteActionItem';
import RenameDashboardModal from './RenameDashboardModal';
import styles from './ActionsPopover.module.scss';
interface Props {
@@ -42,6 +53,7 @@ function ActionsPopover({
const [, setCopy] = useCopyToClipboard();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isRenameOpen, setIsRenameOpen] = useState(false);
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
// by the caller; open the copy so it can be tweaked right away.
@@ -58,85 +70,159 @@ function ActionsPopover({
},
});
const queryClient = useQueryClient();
const { user } = useAppContext();
const isAuthor = user?.email === createdBy;
// Author/admin can lock-unlock (mirrors the detail-page gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) && createdBy !== 'integration';
const { mutate: runLockToggle, isLoading: isTogglingLock } = useMutation({
mutationFn: () =>
isLocked
? unlockDashboardV2({ id: dashboardId })
: lockDashboardV2({ id: dashboardId }),
onSuccess: async () => {
toast.success(isLocked ? 'Dashboard unlocked' : 'Dashboard locked');
await invalidateListDashboardsForUserV2(queryClient);
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
return (
<Popover
content={
<div className={styles.content}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Expand size={14} />}
onClick={onView}
testId="dashboard-action-view"
>
View
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<SquareArrowOutUpRight size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
testId="dashboard-action-open-new-tab"
>
Open in New Tab
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Link2 size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
testId="dashboard-action-copy-link"
>
Copy Link
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
>
<Button
size="icon"
variant="ghost"
color="secondary"
testId="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
<>
<Popover
content={
// Stop clicks inside the menu (incl. disabled items) from bubbling to the
// row's onClick, which would navigate to the dashboard.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- wrapper only guards propagation, not an interactive control
<div className={styles.content} onClick={(e): void => e.stopPropagation()}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Expand size={14} />}
onClick={onView}
testId="dashboard-action-view"
>
View
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<SquareArrowOutUpRight size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
testId="dashboard-action-open-new-tab"
>
Open in New Tab
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Link2 size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
testId="dashboard-action-copy-link"
>
Copy Link
</Button>
<Tooltip
placement="left"
title={
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<PenLine size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsRenameOpen(true);
}
}}
testId="dashboard-action-rename"
>
Rename
</Button>
</span>
</Tooltip>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
{canToggleLock && (
<Button
color="secondary"
className={styles.menuItem}
prefix={<LockKeyhole size={14} />}
loading={isTogglingLock}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runLockToggle();
}}
testId="dashboard-action-lock"
>
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
)}
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
>
<EllipsisVertical size={14} />
</Button>
</Popover>
<Button
size="icon"
variant="ghost"
color="secondary"
testId="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
>
<EllipsisVertical size={14} />
</Button>
</Popover>
<RenameDashboardModal
open={isRenameOpen}
dashboardId={dashboardId}
currentName={dashboardName}
onClose={(): void => setIsRenameOpen(false)}
/>
</>
);
}

View File

@@ -7,8 +7,10 @@ import { CircleAlert, Trash2 } from '@signozhq/icons';
import { toast } from '@signozhq/ui/sonner';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import deleteDashboard from 'api/v1/dashboards/id/delete';
import { invalidateListDashboardsV2 } from 'api/generated/services/dashboard';
import {
deleteDashboardV2,
invalidateListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -39,12 +41,12 @@ function DeleteActionItem({
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
const { mutate: runDelete } = useMutation({
mutationFn: () => deleteDashboard({ id: dashboardId }),
mutationFn: () => deleteDashboardV2({ id: dashboardId }),
onSuccess: async () => {
toast.success(
t('dashboard:delete_dashboard_success', { name: dashboardName }),
);
await invalidateListDashboardsV2(queryClient);
await invalidateListDashboardsForUserV2(queryClient);
},
onError: (error: APIError) => {
showErrorModal(error);
@@ -52,7 +54,7 @@ function DeleteActionItem({
});
const openConfirm = useCallback((): void => {
const { destroy } = modal.confirm({
modal.confirm({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the
@@ -70,14 +72,13 @@ function DeleteActionItem({
/>
),
okText: 'Delete',
okButtonProps: {
danger: true,
onClick: (e): void => {
e.preventDefault();
e.stopPropagation();
runDelete(undefined, { onSettled: () => destroy() });
},
},
okButtonProps: { danger: true },
// Returning a promise keeps the Delete button in a loading state and blocks
// re-clicks until the mutation settles, then closes the confirm.
onOk: () =>
new Promise<void>((resolve) => {
runDelete(undefined, { onSettled: () => resolve() });
}),
cancelButtonProps: {
onClick: (e): void => {
e.stopPropagation();
@@ -101,23 +102,25 @@ function DeleteActionItem({
<>
<Divider />
<Tooltip placement="left" title={tooltip}>
<Button
variant="ghost"
color="destructive"
className={styles.menuItem}
prefix={<Trash2 size={14} />}
disabled={isDisabled}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) {
openConfirm();
}
}}
testId="dashboard-action-delete"
>
Delete Dashboard
</Button>
<span className={styles.menuItemWrap}>
<Button
variant="ghost"
color="destructive"
className={styles.menuItem}
prefix={<Trash2 size={14} />}
disabled={isDisabled}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) {
openConfirm();
}
}}
testId="dashboard-action-delete"
>
Delete Dashboard
</Button>
</span>
</Tooltip>
{contextHolder}
</>

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
// eslint-disable-next-line no-restricted-imports -- list rename targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
patchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './ActionsPopover.module.scss';
interface Props {
open: boolean;
dashboardId: string;
currentName: string;
onClose: () => void;
}
/** Renames a dashboard from the list via a `/spec/display/name` patch, then refreshes the list. */
function RenameDashboardModal({
open,
dashboardId,
currentName,
onClose,
}: Props): JSX.Element {
const [name, setName] = useState(currentName);
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
// Reset the field to the current name whenever the modal (re)opens.
useEffect(() => {
if (open) {
setName(currentName);
}
}, [open, currentName]);
const { mutate: runRename, isLoading } = useMutation({
mutationFn: () => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: name.trim(),
},
];
return patchDashboardV2({ id: dashboardId }, ops);
},
onSuccess: async () => {
toast.success('Dashboard renamed');
await invalidateListDashboardsForUserV2(queryClient);
onClose();
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
const trimmed = name.trim();
const canSave = trimmed.length > 0 && trimmed !== currentName && !isLoading;
return (
<DialogWrapper
title="Rename dashboard"
open={open}
width="narrow"
onOpenChange={(next): void => {
if (!next) {
onClose();
}
}}
footer={
<div className={styles.renameFooter}>
<Button
variant="ghost"
color="secondary"
size="md"
onClick={onClose}
testId="rename-dashboard-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
disabled={!canSave}
loading={isLoading}
onClick={(): void => runRename()}
testId="rename-dashboard-submit"
>
Save
</Button>
</div>
}
>
<Input
value={name}
autoFocus
placeholder="Dashboard name"
testId="rename-dashboard-input"
onChange={(e): void => setName(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' && canSave) {
runRename();
}
}}
/>
</DialogWrapper>
);
}
export default RenameDashboardModal;

View File

@@ -52,6 +52,7 @@
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.tagsWithActions {
@@ -62,7 +63,15 @@
justify-content: flex-end;
}
.pinBtn {
.lockIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--l3-foreground);
}
.pinButton {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -79,16 +88,16 @@
color 0.12s;
}
.row:hover .pinBtn {
.row:hover .pinButton {
color: var(--l3-foreground);
}
.pinBtn:hover {
.pinButton:hover {
background: var(--l1-background);
color: var(--bg-amber-500);
}
.pinBtnOn {
.pinButtonOn {
color: var(--bg-amber-500);
svg {
@@ -96,6 +105,24 @@
}
}
/* Pinned rows show the filled pin by default; the unpin action only appears when
the pin button itself is hovered. */
.pinnedIcon {
display: inline-flex;
}
.unpinIcon {
display: none;
}
.pinButton:hover .pinnedIcon {
display: none;
}
.pinButton:hover .unpinIcon {
display: inline-flex;
}
.tags {
display: flex;
flex-wrap: wrap;
@@ -182,10 +209,3 @@
align-items: center;
gap: 6px;
}
:global(.titleTooltipOverlay) {
:global(.ant-tooltip-content) :global(.ant-tooltip-inner) {
max-height: 400px;
overflow: auto;
}
}

View File

@@ -1,7 +1,8 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Badge } from '@signozhq/ui/badge';
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { CalendarClock, LockKeyhole, Pin, PinOff } from '@signozhq/icons';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
@@ -73,25 +74,31 @@ function DashboardRow({
togglePin(id, isPinned);
};
// Only long titles are truncated, so only they need the full-name tooltip;
// wrapping conditionally avoids an empty hanging tooltip for short names.
const titleLink = (
<div className={styles.titleLink} onClick={onClickHandler}>
<img src={image} alt="dashboard-image" className={styles.icon} />
<Typography.Text
data-testid={`dashboard-title-${index}`}
className={styles.title}
>
{name}
</Typography.Text>
</div>
);
return (
<div className={styles.row} onClick={onClickHandler}>
<div className={styles.titleWithAction}>
<div className={styles.titleBlock}>
<Tooltip
title={name.length > 50 ? name : ''}
placement="left"
overlayClassName="titleTooltipOverlay"
>
<div className={styles.titleLink} onClick={onClickHandler}>
<img src={image} alt="dashboard-image" className={styles.icon} />
<Typography.Text
data-testid={`dashboard-title-${index}`}
className={styles.title}
>
{name}
</Typography.Text>
</div>
</Tooltip>
{name.length > 50 ? (
<TooltipSimple title={name} side="bottom" disableHoverableContent>
{titleLink}
</TooltipSimple>
) : (
titleLink
)}
</div>
<div className={styles.tagsWithActions}>
@@ -111,17 +118,44 @@ function DashboardRow({
)}
</div>
<button
type="button"
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
{isLocked && (
<TooltipSimple
title="This dashboard is locked"
side="top"
disableHoverableContent
>
<span className={styles.lockIcon} data-testid={`dashboard-lock-${index}`}>
<LockKeyhole size={14} />
</span>
</TooltipSimple>
)}
<TooltipSimple
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
side="top"
disableHoverableContent
>
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
</button>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={cx(styles.pinButton, { [styles.pinButtonOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
>
{isPinned ? (
<>
<Pin size={14} className={styles.pinnedIcon} />
<PinOff size={14} className={styles.unpinIcon} />
</>
) : (
<Pin size={14} />
)}
</Button>
</TooltipSimple>
{canAct && (
<ActionsPopover

View File

@@ -102,8 +102,8 @@ function FilterZone({
/>
{!isEmpty && (
<Button
variant="ghost"
color="secondary"
variant="outlined"
color="primary"
size="sm"
prefix={<X size={12} />}
onClick={onClearAll}

View File

@@ -3,7 +3,7 @@ import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { ArrowDown, ArrowUp, Check, HdmiPort } from '@signozhq/icons';
import { ArrowDown, ArrowUp, Check, Columns3 } from '@signozhq/icons';
import {
DashboardtypesListOrderDTO,
@@ -191,7 +191,7 @@ function ListHeader({
aria-label="Columns"
testId="configure-columns-trigger"
>
<HdmiPort size={14} />
<Columns3 size={14} />
</Button>
</Tooltip>
</Popover>

View File

@@ -58,6 +58,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
variables: [],
},
});
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);

View File

@@ -20,7 +20,11 @@ import JsonEditor from './JsonEditor';
import styles from './NewDashboardModal.module.scss';
function ImportJsonPanel(): JSX.Element {
interface Props {
onClose: () => void;
}
function ImportJsonPanel({ onClose }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { t } = useTranslation(['dashboard', 'common']);
const { showErrorModal } = useErrorModal();
@@ -59,6 +63,7 @@ function ImportJsonPanel(): JSX.Element {
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
const payload = normalizeToPostable(parsed);
const response = await createDashboardV2(payload);
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);

View File

@@ -43,12 +43,12 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
{
key: 'template',
label: 'From a template',
children: <TemplatesPanel />,
children: <TemplatesPanel onClose={onClose} />,
},
{
key: 'import',
label: 'Import JSON',
children: <ImportJsonPanel />,
children: <ImportJsonPanel onClose={onClose} />,
},
]}
/>

View File

@@ -1,136 +1,66 @@
import { useState } from 'react';
import { generatePath } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { toast } from '@signozhq/ui/sonner';
import { ExternalLink, LoaderCircle } from '@signozhq/icons';
import { AxiosError } from 'axios';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { openInNewTab } from 'utils/navigation';
import { normalizeToPostable } from './importUtils';
import JsonEditor from './JsonEditor';
import { useDashboardTemplates } from './templatesData';
import styles from './NewDashboardModal.module.scss';
// Browse the template gallery (mock data until the API lands): pick one on the
// left to preview its JSON on the right, then use it or open the docs.
function TemplatesPanel(): JSX.Element {
interface Props {
onClose: () => void;
}
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
// so the action creates a blank dashboard.
function TemplatesPanel({ onClose }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const { data, isLoading } = useDashboardTemplates(true);
const templates = data ?? [];
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const selected = templates.find((t) => t.id === selectedId) ?? templates[0];
const handleUse = async (): Promise<void> => {
if (!selected) {
const handleCreate = async (): Promise<void> => {
if (creating) {
return;
}
try {
setCreating(true);
logEvent('Dashboard List: Use template clicked', { template: selected.id });
const parsed = JSON.parse(selected.json) as Record<string, unknown>;
const created = await createDashboardV2(normalizeToPostable(parsed));
logEvent('Dashboard List: Use template clicked', {});
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: 'Sample Dashboard' },
layouts: [],
panels: {},
variables: [],
},
});
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);
} catch (e) {
showErrorModal(e as APIError);
toast.error(
(e as AxiosError).toString() || 'Failed to create from template',
);
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
setCreating(false);
}
};
if (isLoading) {
return (
<div className={styles.panel}>
<div className={styles.loading}>
<LoaderCircle size={18} className={styles.spinner} />
<span>Loading templates</span>
</div>
</div>
);
}
return (
<div className={styles.panel}>
<div className={styles.templatesLayout}>
<div className={styles.templatesList}>
{templates.map((template) => (
<button
key={template.id}
type="button"
className={cx(styles.templateItem, {
[styles.templateItemActive]: selected?.id === template.id,
})}
data-testid={`template-${template.id}`}
onClick={(): void => setSelectedId(template.id)}
>
<span className={styles.templateName}>{template.name}</span>
<span className={styles.templateCat}>{template.category}</span>
</button>
))}
</div>
{selected && (
<div className={styles.templatesPreview}>
<div className={styles.previewHead}>
<div>
<Typography.Text className={styles.cardName}>
{selected.name}
</Typography.Text>
<Typography.Text className={styles.cardDesc}>
{selected.description}
</Typography.Text>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
suffix={<ExternalLink size={13} />}
onClick={(): void => openInNewTab(selected.href)}
testId="template-docs"
>
Docs
</Button>
</div>
<JsonEditor value={selected.json} readOnly height="240px" />
<div className={styles.footer}>
<Button
variant="solid"
color="primary"
size="md"
loading={creating}
testId="use-template"
onClick={(): void => {
void handleUse();
}}
>
Use template
</Button>
</div>
</div>
)}
</div>
<div className={styles.requestRow}>
<RequestDashboardBtn />
<div className="new-dashboard-templates-modal">
<DashboardTemplatesContent
onCreateNewDashboard={(): void => {
void handleCreate();
}}
/>
</div>
</div>
);

View File

@@ -1,106 +0,0 @@
import { useQuery, type UseQueryResult } from 'react-query';
export interface DashboardTemplate {
id: string;
name: string;
description: string;
category: string;
href: string;
// Importable dashboard definition previewed in the gallery (mock for now).
json: string;
}
// A representative dashboard definition for a template — mock until the API
// returns real ones.
const buildTemplateJson = (
name: string,
description: string,
category: string,
): string =>
JSON.stringify(
{
schemaVersion: 'v6',
generateName: true,
tags: [{ key: 'category', value: category.toLowerCase() }],
spec: {
display: { name, description },
layouts: [],
panels: {},
variables: [],
},
},
null,
2,
);
// Mock catalogue until the templates API lands. Mirrors the public gallery at
// https://signoz.io/docs/dashboards/dashboard-templates/overview/
const BASE_TEMPLATES: Omit<DashboardTemplate, 'json'>[] = [
{
id: 'apm',
name: 'APM Metrics',
description: 'Latency, error rate, and throughput across your services.',
category: 'APM',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/apm/',
},
{
id: 'hostmetrics',
name: 'Host Metrics',
description: 'CPU, memory, disk, and network for your hosts.',
category: 'Infra',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/hostmetrics/',
},
{
id: 'kubernetes',
name: 'Kubernetes Pod Metrics',
description: 'Pod, node, and container health for your clusters.',
category: 'Infra',
href:
'https://signoz.io/docs/dashboards/dashboard-templates/kubernetes-pod-metrics-detailed/',
},
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Connections, throughput, and query performance.',
category: 'Databases',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/postgresql/',
},
{
id: 'redis',
name: 'Redis',
description: 'Memory, commands, and hit-rate for Redis instances.',
category: 'Databases',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/redis/',
},
{
id: 'nginx',
name: 'NGINX',
description: 'Request rate, connections, and error responses.',
category: 'Web servers',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/nginx/',
},
];
const MOCK_TEMPLATES: DashboardTemplate[] = BASE_TEMPLATES.map((t) => ({
...t,
json: buildTemplateJson(t.name, t.description, t.category),
}));
// TODO(@AshwinBhatkal): replace with the real templates API when available.
// The small delay simulates the network round-trip so the loading state is
// exercised (a real API call won't resolve instantly).
const fetchDashboardTemplates = (): Promise<DashboardTemplate[]> =>
new Promise((resolve) => {
setTimeout(() => resolve(MOCK_TEMPLATES), 600);
});
export function useDashboardTemplates(
enabled: boolean,
): UseQueryResult<DashboardTemplate[]> {
return useQuery({
queryKey: ['dashboard-templates'],
queryFn: fetchDashboardTemplates,
enabled,
staleTime: Infinity,
});
}

View File

@@ -54,5 +54,17 @@
}
.submit {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
}
.cmdHint {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
background: var(--l2-background);
}

View File

@@ -8,8 +8,9 @@ import {
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { CornerDownLeft, Search } from '@signozhq/icons';
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
import cx from 'classnames';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import {
applyKeySuggestion,
@@ -40,6 +41,8 @@ function SearchBar({
// than picking a suggestion (arrow keys engage selection).
const [highlighted, setHighlighted] = useState(-1);
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const active = useMemo(() => getActiveKeyToken(value), [value]);
const suggestions = useMemo(
() => (active ? matchKeys(suggestionKeys, active.token) : []),
@@ -55,6 +58,11 @@ function SearchBar({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onSubmit();
return;
}
if (showSuggestions && e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
@@ -90,7 +98,7 @@ function SearchBar({
<Button
variant="ghost"
color="secondary"
size="icon"
size="sm"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
@@ -100,7 +108,15 @@ function SearchBar({
}}
onClick={onSubmit}
>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
Run query
<span className={styles.cmdHint}>
{isMac ? (
<Command size={12} color={Color.BG_VANILLA_400} />
) : (
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
)}
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</span>
</Button>
}
value={value}

View File

@@ -306,7 +306,7 @@ function SaveView(): JSX.Element {
Manage your saved views for {ROUTES_VS_SOURCEPAGE[pathname]}.{' '}
<Typography.Link
className="learn-more"
href="https://signoz.io/docs/product-features/saved-view/?utm_source=product&utm_medium=views-tab"
href="https://signoz.io/docs/metrics-management/metrics-explorer/?utm_source=product&utm_medium=views-tab#saved-views-in-metrics-explorer"
target="_blank"
>
Learn more

View File

@@ -54,7 +54,7 @@ export default function ServiceTopLevelOperations(): JSX.Element {
SigNoz calculates the RED metrics for a service using the entry-point spans.
For more details, you can check out our
<a
href="https://signoz.io/docs/userguide/metrics/#open-the-services-section"
href="https://signoz.io/docs/userguide/metrics/"
target="_blank"
rel="noreferrer"
>

View File

@@ -867,7 +867,7 @@ function Success(props: ISuccessProps): JSX.Element {
suffix={<ArrowUpRight size={14} />}
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/userguide/traces/#missing-spans',
'https://signoz.io/docs/traces-management/troubleshooting/faqs/#q-why-are-some-spans-missing-from-a-trace',
'_blank',
)
}

View File

@@ -1,15 +1,15 @@
const DOCLINKS = {
TRACES_EXPLORER_EMPTY_STATE:
'https://signoz.io/docs/instrumentation/overview/?utm_source=product&utm_medium=traces-explorer-empty-state',
USER_GUIDE: 'https://signoz.io/docs/userguide/',
USER_GUIDE: 'https://signoz.io/docs/introduction/',
TRACES_DETAILS_LINK:
'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
METRICS_EXPLORER_EMPTY_STATE:
'https://signoz.io/docs/userguide/send-metrics-cloud/',
'https://signoz.io/docs/metrics-management/send-metrics/',
EXTERNAL_API_MONITORING:
'https://signoz.io/docs/external-api-monitoring/overview/',
QUERY_CLICKHOUSE_TRACES:
'https://signoz.io/docs/userguide/writing-clickhouse-traces-query/#timestamp-bucketing-for-distributed_signoz_index_v3',
'https://signoz.io/docs/userguide/writing-clickhouse-traces-query/#timestamp-bucketing',
QUERY_CLICKHOUSE_LOGS:
'https://signoz.io/docs/userguide/logs_clickhouse_queries/',
QUERY_CLICKHOUSE_METRICS:

View File

@@ -39,48 +39,48 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "gauge_sum_latest",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationLatest, metrictypes.SpaceAggregationSum),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "gauge_avg_avg",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "gauge_min_min",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMin, metrictypes.SpaceAggregationMin),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`min`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`min`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "gauge_max_max",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMax, metrictypes.SpaceAggregationMax),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`max`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`max`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "counter_sum_rate",
query: reducedQuery("test.metric.sum", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationRate, metrictypes.SpaceAggregationSum),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), "test.metric.sum", uint64(1746999600000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000)},
},
},
{
name: "counter_avg_increase",
query: reducedQuery("test.metric", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationIncrease, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746999600000), uint64(1747172760000), "test.metric", uint64(1746999600000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746999600000), uint64(1747172760000), false, "test.metric", uint64(1746999600000), uint64(1747172760000)},
},
},
{
@@ -103,16 +103,16 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "histogram_p99",
query: reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, `le`, argMax(`sum`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli, `le`) GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "summary_avg",
query: reducedQuery("test.metric", metrictypes.SummaryType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
},
},
}

View File

@@ -338,19 +338,24 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
// dedup recomputed buckets: latest computed_at wins per (series, 60s bucket)
dedup := sqlbuilder.NewSelectBuilder()
dedup.Select("reduced_fingerprint AS fingerprint", "unix_milli")
dedup.SelectMore(fmt.Sprintf("argMax(%s, computed_at) AS value", value))
if weight != "" {
dedup.SelectMore(fmt.Sprintf("argMax(%s, computed_at) AS weight", weight))
dedup.Select("points.reduced_fingerprint AS fingerprint", "points.unix_milli AS unix_milli")
for _, g := range query.GroupBy {
dedup.SelectMore(fmt.Sprintf("`%s`", g.Name))
}
dedup.From(fmt.Sprintf("%s.%s", DBName, WhichReducedSamplesTableToUse(agg.Type)))
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.computed_at) AS value", value))
if weight != "" {
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.computed_at) AS weight", weight))
}
dedup.From(fmt.Sprintf("%s.%s AS points", DBName, WhichReducedSamplesTableToUse(agg.Type)))
dedup.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.reduced_fingerprint = filtered_time_series.fingerprint")
dedup.Where(
dedup.In("metric_name", agg.MetricName),
dedup.GTE("unix_milli", start),
dedup.LT("unix_milli", end),
)
dedup.GroupBy("reduced_fingerprint", "unix_milli")
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse)
dedup.GroupBy("fingerprint", "unix_milli")
dedup.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse, timeSeriesCTEArgs...)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("fingerprint")
@@ -364,13 +369,11 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
// denominator is reduced with avg
sb.SelectMore("avg(weight) AS per_series_weight")
}
sb.From(fmt.Sprintf("(%s) AS points", dedupQuery))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.From(fmt.Sprintf("(%s)", dedupQuery))
sb.GroupBy("fingerprint", "ts")
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
initArgs := append(append([]any{}, dedupArgs...), timeSeriesCTEArgs...)
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, initArgs...)
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, dedupArgs...)
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, true
}

View File

@@ -2,6 +2,7 @@ import os
from collections.abc import Callable, Generator
from datetime import datetime
from typing import Any
from uuid import uuid4
import clickhouse_connect
import clickhouse_connect.driver
@@ -10,37 +11,93 @@ import docker
import docker.errors
import pytest
from testcontainers.clickhouse import ClickHouseContainer
from testcontainers.core.container import Network
from testcontainers.core.container import DockerContainer, Network
from fixtures import reuse, types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLICKHOUSE_USERNAME = "signoz"
CLICKHOUSE_PASSWORD = "password"
@pytest.fixture(name="clickhouse", scope="package")
def clickhouse(
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
zookeeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerClickhouse:
"""
Package-scoped fixture for Clickhouse TestContainer.
CUSTOM_FUNCTION_CONFIG = """
<functions>
<function>
<type>executable</type>
<name>histogramQuantile</name>
<return_type>Float64</return_type>
<argument>
<type>Array(Float64)</type>
<name>buckets</name>
</argument>
<argument>
<type>Array(Float64)</type>
<name>counts</name>
</argument>
<argument>
<type>Float64</type>
<name>quantile</name>
</argument>
<format>CSV</format>
<command>./histogramQuantile</command>
</function>
</functions>
"""
# Distributed inserts to a remote shard are async by default. We force
# sycn at the profile level for deterministic tests.
CLUSTER_USERS_CONFIG = """
<clickhouse>
<profiles>
<default>
<insert_distributed_sync>1</insert_distributed_sync>
</default>
</profiles>
</clickhouse>
"""
def render_remote_servers(shard_hosts: list[tuple[str, int]], secret: str | None = None) -> str:
"""Render the <remote_servers> block for a cluster named `cluster` with one
single-replica shard per (host, port).
"""
shards = "".join(
f"""
<shard>
<replica>
<host>{host}</host>
<port>{port}</port>
</replica>
</shard>"""
for host, port in shard_hosts
)
def create() -> types.TestContainerClickhouse:
version = request.config.getoption("--clickhouse-version")
# Multi-node clusters need `secret` because distributed queries otherwise
# authenticate as the `default` user, which the docker entrypoint restricts
# to localhost when a custom user is configured.
secret_block = (
f"""
<secret>{secret}</secret>"""
if secret
else ""
)
container = ClickHouseContainer(
image=f"clickhouse/clickhouse-server:{version}",
port=9000,
username="signoz",
password="password",
)
return f"""
<remote_servers>
<cluster>{secret_block}{shards}
</cluster>
</remote_servers>"""
cluster_config = f"""
def render_node_config(
zookeeper_address: str,
zookeeper_port: int,
shard: str,
remote_servers: str,
distributed_ddl_path: str = "/clickhouse/task_queue/ddl",
) -> str:
return f"""
<clickhouse>
<logger>
<level>information</level>
@@ -55,33 +112,23 @@ def clickhouse(
</logger>
<macros>
<shard>01</shard>
<shard>{shard}</shard>
<replica>01</replica>
</macros>
<zookeeper>
<node>
<host>{zookeeper.container_configs["2181"].address}</host>
<port>{zookeeper.container_configs["2181"].port}</port>
<host>{zookeeper_address}</host>
<port>{zookeeper_port}</port>
</node>
</zookeeper>
<remote_servers>
<cluster>
<shard>
<replica>
<host>127.0.0.1</host>
<port>9000</port>
</replica>
</shard>
</cluster>
</remote_servers>
{remote_servers}
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
<distributed_ddl>
<path>/clickhouse/task_queue/ddl</path>
<path>{distributed_ddl_path}</path>
<profile>default</profile>
</distributed_ddl>
@@ -122,38 +169,66 @@ def clickhouse(
</clickhouse>
"""
custom_function_config = """
<functions>
<function>
<type>executable</type>
<name>histogramQuantile</name>
<return_type>Float64</return_type>
<argument>
<type>Array(Float64)</type>
<name>buckets</name>
</argument>
<argument>
<type>Array(Float64)</type>
<name>counts</name>
</argument>
<argument>
<type>Float64</type>
<name>quantile</name>
</argument>
<format>CSV</format>
<command>./histogramQuantile</command>
</function>
</functions>
"""
tmp_dir = tmpfs("clickhouse")
def install_histogram_quantile(container: ClickHouseContainer) -> None:
wrapped = container.get_wrapped_container()
exit_code, output = wrapped.exec_run(
[
"bash",
"-c",
(
'version="v0.0.1" && '
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
"cd /tmp && "
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
"tar -xzf histogram-quantile.tar.gz && "
"mkdir -p /var/lib/clickhouse/user_scripts && "
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
),
],
)
if exit_code != 0:
raise RuntimeError(f"Failed to install histogramQuantile binary: {output.decode()}")
def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional-arguments
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
keeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "clickhouse",
version: str | None = None,
) -> types.TestContainerClickhouse:
coordinator = next(iter(keeper.container_configs.values()))
def create() -> types.TestContainerClickhouse:
clickhouse_version = version or request.config.getoption("--clickhouse-version")
container = ClickHouseContainer(
image=f"clickhouse/clickhouse-server:{clickhouse_version}",
port=9000,
username=CLICKHOUSE_USERNAME,
password=CLICKHOUSE_PASSWORD,
)
cluster_config = render_node_config(
zookeeper_address=coordinator.address,
zookeeper_port=coordinator.port,
shard="01",
remote_servers=render_remote_servers([("127.0.0.1", 9000)]),
)
tmp_dir = tmpfs(cache_key)
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
f.write(cluster_config)
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
with open(custom_function_file_path, "w", encoding="utf-8") as f:
f.write(custom_function_config)
f.write(CUSTOM_FUNCTION_CONFIG)
container.with_volume_mapping(cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml")
container.with_volume_mapping(
@@ -163,27 +238,7 @@ def clickhouse(
container.with_network(network)
container.start()
# Download and install the histogramQuantile binary
wrapped = container.get_wrapped_container()
exit_code, output = wrapped.exec_run(
[
"bash",
"-c",
(
'version="v0.0.1" && '
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
"cd /tmp && "
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
"tar -xzf histogram-quantile.tar.gz && "
"mkdir -p /var/lib/clickhouse/user_scripts && "
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
),
],
)
if exit_code != 0:
raise RuntimeError(f"Failed to install histogramQuantile binary: {output.decode()}")
install_histogram_quantile(container)
connection = clickhouse_connect.get_client(
user=container.username,
@@ -253,7 +308,7 @@ def clickhouse(
return reuse.wrap(
request,
pytestconfig,
"clickhouse",
cache_key,
empty=lambda: types.TestContainerSQL(
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
conn=None,
@@ -265,6 +320,334 @@ def clickhouse(
)
@pytest.fixture(name="clickhouse", scope="package")
def clickhouse(
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
zookeeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerClickhouse:
"""
Package-scoped fixture for Clickhouse TestContainer.
"""
return create_clickhouse(
tmpfs=tmpfs,
network=network,
keeper=zookeeper,
request=request,
pytestconfig=pytestconfig,
)
def local_series_counts(
node_conns: list[clickhouse_connect.driver.client.Client],
table: str,
metric_name: str,
) -> list[int]:
"""Distinct series per node via the LOCAL (non-distributed) table."""
return [
int(
conn.query(
f"SELECT count(DISTINCT fingerprint) FROM signoz_metrics.{table} WHERE metric_name = %(metric_name)s",
parameters={"metric_name": metric_name},
).result_rows[0][0]
)
for conn in node_conns
]
def assert_spans_shards(
node_conns: list[clickhouse_connect.driver.client.Client],
table: str,
metric_name: str,
total: int,
) -> None:
"""Guard for distributed tests: a green run on a cluster proves nothing
unless the seeded series actually landed on more than one shard."""
counts = local_series_counts(node_conns, table, metric_name)
assert sum(counts) == total, f"expected {total} series in {table} across shards, got {counts}"
assert min(counts) > 0, f"seeded series in {table} all landed on one shard: {counts}"
@pytest.fixture(name="clickhouse_node_conns", scope="function")
def clickhouse_node_conns(
clickhouse: types.TestContainerClickhouse,
) -> Generator[list[clickhouse_connect.driver.client.Client], Any]:
"""Per-node clients (index 0 = the initiator) for asserting shard-local
state via the local, non-distributed tables. Empty for single-node
fixtures, which don't populate `nodes`."""
conns = [
clickhouse_connect.get_client(
user=clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME"],
password=clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD"],
host=node.host_configs["8123"].address,
port=node.host_configs["8123"].port,
)
for node in clickhouse.nodes
]
yield conns
for conn in conns:
conn.close()
KEEPER_CONFIG = """
<clickhouse>
<listen_host>0.0.0.0</listen_host>
<keeper_server>
<tcp_port>9181</tcp_port>
<server_id>1</server_id>
<log_storage_path>/var/lib/clickhouse-keeper/coordination/log</log_storage_path>
<snapshot_storage_path>/var/lib/clickhouse-keeper/coordination/snapshots</snapshot_storage_path>
<coordination_settings>
<operation_timeout_ms>10000</operation_timeout_ms>
<session_timeout_ms>30000</session_timeout_ms>
<raft_logs_level>warning</raft_logs_level>
</coordination_settings>
<raft_configuration>
<server>
<id>1</id>
<hostname>localhost</hostname>
<port>9234</port>
</server>
</raft_configuration>
</keeper_server>
</clickhouse>
"""
def create_clickhouse_keeper(
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "clickhousekeeper",
version: str | None = None,
) -> types.TestContainerDocker:
def create() -> types.TestContainerDocker:
keeper_version = version or request.config.getoption("--clickhouse-version")
tmp_dir = tmpfs(cache_key)
keeper_config_file_path = os.path.join(tmp_dir, "keeper_config.xml")
with open(keeper_config_file_path, "w", encoding="utf-8") as f:
f.write(KEEPER_CONFIG)
container = DockerContainer(image=f"clickhouse/clickhouse-keeper:{keeper_version}")
container.with_volume_mapping(keeper_config_file_path, "/etc/clickhouse-keeper/keeper_config.xml")
container.with_exposed_ports(9181)
container.with_network(network=network)
container.start()
return types.TestContainerDocker(
id=container.get_wrapped_container().id,
host_configs={
"9181": types.TestContainerUrlConfig(
scheme="tcp",
address=container.get_container_host_ip(),
port=container.get_exposed_port(9181),
)
},
container_configs={
"9181": types.TestContainerUrlConfig(
scheme="tcp",
address=container.get_wrapped_container().name,
port=9181,
)
},
)
def delete(container: types.TestContainerDocker):
client = docker.from_env()
try:
client.containers.get(container_id=container.id).stop()
client.containers.get(container_id=container.id).remove(v=True)
except docker.errors.NotFound:
logger.info(
"Skipping removal of ClickHouse Keeper, Keeper(%s) not found. Maybe it was manually removed?",
{"id": container.id},
)
def restore(cache: dict) -> types.TestContainerDocker:
return types.TestContainerDocker.from_cache(cache)
return reuse.wrap(
request,
pytestconfig,
cache_key,
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
create,
delete,
restore,
)
def create_clickhouse_cluster( # pylint: disable=too-many-arguments,too-many-positional-arguments
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
keeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "clickhouse_cluster",
shards: int = 2,
version: str | None = None,
) -> types.TestContainerClickhouse:
"""
To some extent, taken inspiration from how ClickHouse's own integration
harness composes real clusters: deterministic hostnames
(network aliases), per-node shard macros, and a shared cluster definition
named `cluster`.
`conn`/`env` point at node 1 i.e the initiator every query-service query and
migration goes through. Per-node containers are exposed via `nodes` so
tests can assert shard-local state. `keeper` is any coordination service
(ZooKeeper or ClickHouse Keeper).
"""
coordinator = next(iter(keeper.container_configs.values()))
def create() -> types.TestContainerClickhouse:
clickhouse_version = version or request.config.getoption("--clickhouse-version")
# Unique aliases per creation: docker allows duplicate network aliases
# (DNS round-robin), so a stale cluster must never share names with a
# fresh one.
suffix = uuid4().hex[:6]
aliases = [f"signoz-ch-{suffix}-{i:02d}" for i in range(1, shards + 1)]
remote_servers = render_remote_servers([(alias, 9000) for alias in aliases], secret=cache_key)
# Own DDL queue path: the keeper instance may be shared with other
# environments under --reuse; its DDL queue stays separate.
distributed_ddl_path = f"/clickhouse/{cache_key}-{suffix}/task_queue/ddl"
nodes: list[types.TestContainerDocker] = []
started: list[ClickHouseContainer] = []
try:
for i, alias in enumerate(aliases, start=1):
node_config = render_node_config(
zookeeper_address=coordinator.address,
zookeeper_port=coordinator.port,
shard=f"{i:02d}",
remote_servers=remote_servers,
distributed_ddl_path=distributed_ddl_path,
)
tmp_dir = tmpfs(f"clickhouse-{suffix}-{i:02d}")
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
f.write(node_config)
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
with open(custom_function_file_path, "w", encoding="utf-8") as f:
f.write(CUSTOM_FUNCTION_CONFIG)
users_config_file_path = os.path.join(tmp_dir, "users.xml")
with open(users_config_file_path, "w", encoding="utf-8") as f:
f.write(CLUSTER_USERS_CONFIG)
container = ClickHouseContainer(
image=f"clickhouse/clickhouse-server:{clickhouse_version}",
port=9000,
username=CLICKHOUSE_USERNAME,
password=CLICKHOUSE_PASSWORD,
)
container.with_volume_mapping(cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml")
container.with_volume_mapping(custom_function_file_path, "/etc/clickhouse-server/custom-function.xml")
container.with_volume_mapping(users_config_file_path, "/etc/clickhouse-server/users.d/integration-cluster.xml")
container.with_network(network)
container.with_network_aliases(alias)
container.start()
started.append(container)
install_histogram_quantile(container)
nodes.append(
types.TestContainerDocker(
id=container.get_wrapped_container().id,
host_configs={
"9000": types.TestContainerUrlConfig(
"tcp",
container.get_container_host_ip(),
container.get_exposed_port(9000),
),
"8123": types.TestContainerUrlConfig(
"tcp",
container.get_container_host_ip(),
container.get_exposed_port(8123),
),
},
container_configs={
"9000": types.TestContainerUrlConfig("tcp", alias, 9000),
"8123": types.TestContainerUrlConfig("tcp", alias, 8123),
},
)
)
except Exception:
for container in started:
container.stop()
raise
connection = clickhouse_connect.get_client(
user=CLICKHOUSE_USERNAME,
password=CLICKHOUSE_PASSWORD,
host=nodes[0].host_configs["8123"].address,
port=nodes[0].host_configs["8123"].port,
)
return types.TestContainerClickhouse(
container=nodes[0],
conn=connection,
env={
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": f"tcp://{CLICKHOUSE_USERNAME}:{CLICKHOUSE_PASSWORD}@{aliases[0]}:{9000}",
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME": CLICKHOUSE_USERNAME,
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD": CLICKHOUSE_PASSWORD,
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
},
nodes=nodes,
)
def delete(resource: types.TestContainerClickhouse) -> None:
client = docker.from_env()
for node in resource.nodes or [resource.container]:
try:
client.containers.get(container_id=node.id).stop()
client.containers.get(container_id=node.id).remove(v=True)
except docker.errors.NotFound:
logger.info(
"Skipping removal of Clickhouse cluster node, node(%s) not found. Maybe it was manually removed?",
{"id": node.id},
)
def restore(cache: dict) -> types.TestContainerClickhouse:
nodes = [types.TestContainerDocker.from_cache(node) for node in cache["nodes"]]
env = cache["env"]
host_config = nodes[0].host_configs["8123"]
conn = clickhouse_connect.get_client(
user=env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME"],
password=env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD"],
host=host_config.address,
port=host_config.port,
)
return types.TestContainerClickhouse(
container=nodes[0],
conn=conn,
env=env,
nodes=nodes,
)
return reuse.wrap(
request,
pytestconfig,
cache_key,
empty=lambda: types.TestContainerClickhouse(
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
conn=None,
env={},
),
create=create,
delete=delete,
restore=restore,
)
@pytest.fixture(name="check_query_log")
def check_query_log(
signoz: types.SigNoz,

View File

@@ -18,19 +18,22 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@pytest.fixture(name="zeus", scope="package")
def zeus(
ZEUS_NETWORK_ALIAS = "signoz-zeus-it"
def create_zeus(
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "zeus",
alias: str | None = None,
) -> types.TestContainerDocker:
"""
Package-scoped fixture for running zeus
"""
def create() -> types.TestContainerDocker:
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_network(network)
if alias:
container.with_network_aliases(alias)
container.start()
return types.TestContainerDocker(
@@ -42,7 +45,7 @@ def zeus(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
container_configs={"8080": types.TestContainerUrlConfig("http", alias or container.get_wrapped_container().name, 8080)},
)
def delete(container: types.TestContainerDocker):
@@ -62,7 +65,7 @@ def zeus(
return reuse.wrap(
request,
pytestconfig,
"zeus",
cache_key,
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
create,
delete,
@@ -70,6 +73,18 @@ def zeus(
)
@pytest.fixture(name="zeus", scope="package")
def zeus(
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerDocker:
"""
Package-scoped fixture for running zeus
"""
return create_zeus(network=network, request=request, pytestconfig=pytestconfig)
@pytest.fixture(name="gateway", scope="package")
def gateway(
network: Network,

51
tests/fixtures/metricreduction.py vendored Normal file
View File

@@ -0,0 +1,51 @@
import datetime
from collections.abc import Sequence
from fixtures.metrics import MetricsBufferSample, MetricsBufferTimeSeries
def build_ruled_gauge_buffer(
metric_name: str,
base_epoch: int,
services: Sequence[str],
pods_per_service: int,
minutes: int,
value: float = 1.0,
) -> tuple[list[MetricsBufferTimeSeries], list[MetricsBufferSample]]:
"""Collector-shaped buffer rows for a gauge under a reduction rule that
keeps `service`: per raw series a raw series row (is_reduced=false, full
labels, reduced_fingerprint -> group) plus the group's reduced series row
(is_reduced=true, kept labels), and one raw sample per series per minute
carrying both fingerprints. Returns (time_series, samples) for
insert_buffer_metrics."""
reduced_series = {
service: MetricsBufferTimeSeries(
metric_name=metric_name,
labels={"service": service},
timestamp=datetime.datetime.fromtimestamp(base_epoch, tz=datetime.UTC),
is_reduced=True,
)
for service in services
}
raw_series = [
MetricsBufferTimeSeries(
metric_name=metric_name,
labels={"service": service, "pod": f"pod-{service}-{i}"},
timestamp=datetime.datetime.fromtimestamp(base_epoch, tz=datetime.UTC),
reduced_fingerprint=reduced_series[service].fingerprint,
)
for service in services
for i in range(pods_per_service)
]
samples = [
MetricsBufferSample(
metric_name=metric_name,
fingerprint=ts.fingerprint,
timestamp=datetime.datetime.fromtimestamp(base_epoch + minute * 60, tz=datetime.UTC),
value=value,
reduced_fingerprint=ts.reduced_fingerprint,
)
for ts in raw_series
for minute in range(minutes)
]
return raw_series + list(reduced_series.values()), samples

View File

@@ -11,6 +11,14 @@ import pytest
from fixtures import types
from fixtures.time import parse_timestamp
_REDUCED_METRICS_TABLES_TO_TRUNCATE = [
"time_series_v4_reduced",
"samples_v4_reduced_last_60s",
"samples_v4_reduced_sum_60s",
"time_series_v4_buffer",
"samples_v4_buffer",
]
class MetricsTimeSeries(ABC):
"""Represents a row in the time_series_v4 table."""
@@ -414,6 +422,267 @@ class Metrics(ABC):
return metrics
class MetricsReducedTimeSeries(ABC):
"""Represents a row in the time_series_v4_reduced table i.e what
the time_series_v4_reduced_mv materializes for a metric under a
reduction rule. One row per kept-label group. `fingerprint` holds the
reduced fingerprint and `labels` contains only the kept labels.
The fingerprint recipe (md5, like MetricsTimeSeries) does not match the
collector's real hash; it only needs to be consistent with the
reduced_fingerprint used in the reduced samples rows.
"""
def __init__( # pylint: disable=too-many-arguments
self,
metric_name: str,
kept_labels: dict[str, str],
timestamp: datetime.datetime,
temporality: str = "Unspecified",
description: str = "",
unit: str = "",
type_: str = "Gauge",
is_monotonic: bool = False,
env: str = "default",
) -> None:
kept_labels = dict(kept_labels)
kept_labels["__name__"] = metric_name
self.env = env
# mirror time_series_v4_reduced_mv: monotonic cumulative counters are
# reduced as deltas
if temporality == "Cumulative" and is_monotonic:
temporality = "Delta"
self.temporality = temporality
self.metric_name = metric_name
self.description = description
self.unit = unit
self.type = type_
self.is_monotonic = is_monotonic
self.labels = json.dumps(kept_labels, separators=(",", ":"))
self.attrs = kept_labels
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
self.normalized = False
fingerprint_str = metric_name + self.labels
self.fingerprint = np.uint64(int(hashlib.md5(fingerprint_str.encode()).hexdigest()[:16], 16))
def to_row(self) -> list:
return [
self.env,
self.temporality,
self.metric_name,
self.description,
self.unit,
self.type,
self.is_monotonic,
self.fingerprint,
self.unix_milli,
self.labels,
self.attrs,
{},
{},
self.normalized,
]
class MetricsReducedSampleLast60s(ABC):
"""Represents a row in the samples_v4_reduced_last_60s table. One 60s
bucket per reduced group, as the samples_v4_reduced_last_60s_mv refresh
would emit it (gauges and non-monotonic cumulative sums)."""
def __init__( # pylint: disable=too-many-arguments
self,
metric_name: str,
reduced_fingerprint: np.uint64,
timestamp: datetime.datetime,
sum_last: float,
min_value: float,
max_value: float,
sum_values: float,
count_series: int,
count_samples: int,
temporality: str = "Unspecified",
env: str = "default",
computed_at: datetime.datetime | None = None,
) -> None:
self.env = env
self.temporality = temporality
self.metric_name = metric_name
self.reduced_fingerprint = reduced_fingerprint
# buckets are 60s-aligned: intDiv(unix_milli, 60000) * 60000
self.unix_milli = np.int64((int(timestamp.timestamp() * 1e3) // 60000) * 60000)
self.sum_last = np.float64(sum_last)
self.min = np.float64(min_value)
self.max = np.float64(max_value)
self.sum_values = np.float64(sum_values)
self.count_series = np.uint64(count_series)
self.count_samples = np.uint64(count_samples)
# the refresh stamps now(); default to shortly after the bucket closes
if computed_at is None:
computed_at = datetime.datetime.fromtimestamp(int(self.unix_milli) / 1e3, tz=datetime.UTC) + datetime.timedelta(seconds=180)
self.computed_at = computed_at
def to_row(self) -> list:
return [
self.env,
self.temporality,
self.metric_name,
self.reduced_fingerprint,
self.unix_milli,
self.sum_last,
self.min,
self.max,
self.sum_values,
self.count_series,
self.count_samples,
self.computed_at,
]
class MetricsReducedSampleSum60s(ABC):
"""Represents a row in the samples_v4_reduced_sum_60s table. One 60s
bucket per reduced group for delta counters and histograms."""
def __init__( # pylint: disable=too-many-arguments
self,
metric_name: str,
reduced_fingerprint: np.uint64,
timestamp: datetime.datetime,
sum_value: float,
count_series: int,
count_samples: int,
temporality: str = "Delta",
env: str = "default",
computed_at: datetime.datetime | None = None,
) -> None:
self.env = env
self.temporality = temporality
self.metric_name = metric_name
self.reduced_fingerprint = reduced_fingerprint
self.unix_milli = np.int64((int(timestamp.timestamp() * 1e3) // 60000) * 60000)
self.sum = np.float64(sum_value)
self.count_series = np.uint64(count_series)
self.count_samples = np.uint64(count_samples)
if computed_at is None:
computed_at = datetime.datetime.fromtimestamp(int(self.unix_milli) / 1e3, tz=datetime.UTC) + datetime.timedelta(seconds=180)
self.computed_at = computed_at
def to_row(self) -> list:
return [
self.env,
self.temporality,
self.metric_name,
self.reduced_fingerprint,
self.unix_milli,
self.sum,
self.count_series,
self.count_samples,
self.computed_at,
]
class MetricsBufferTimeSeries(ABC):
"""Represents a row in the time_series_v4_buffer table. This is the collector's
universal landing target under cardinality control. For a ruled metric the
collector writes two rows per series: the raw one (is_reduced=false, full
labels, reduced_fingerprint pointing at its group) and the group's reduced
one (is_reduced=true, kept labels, fingerprint = reduced fingerprint)."""
def __init__( # pylint: disable=too-many-arguments
self,
metric_name: str,
labels: dict[str, str],
timestamp: datetime.datetime,
reduced_fingerprint: np.uint64 | int = 0,
is_reduced: bool = False,
temporality: str = "Unspecified",
description: str = "",
unit: str = "",
type_: str = "Gauge",
is_monotonic: bool = False,
env: str = "default",
) -> None:
labels = dict(labels)
labels["__name__"] = metric_name
self.env = env
self.temporality = temporality
self.metric_name = metric_name
self.description = description
self.unit = unit
self.type = type_
self.is_monotonic = is_monotonic
self.reduced_fingerprint = np.uint64(reduced_fingerprint)
self.is_reduced = is_reduced
self.labels = json.dumps(labels, separators=(",", ":"))
self.attrs = labels
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
self.normalized = False
fingerprint_str = metric_name + self.labels
self.fingerprint = np.uint64(int(hashlib.md5(fingerprint_str.encode()).hexdigest()[:16], 16))
def to_row(self) -> list:
return [
self.env,
self.temporality,
self.metric_name,
self.description,
self.unit,
self.type,
self.is_monotonic,
self.fingerprint,
self.reduced_fingerprint,
self.is_reduced,
self.unix_milli,
self.labels,
self.attrs,
{},
{},
self.normalized,
]
class MetricsBufferSample(ABC):
"""Represents a row in the samples_v4_buffer table. Ruled samples carry
the raw fingerprint plus the group's reduced_fingerprint; unruled samples
have reduced_fingerprint = 0."""
def __init__( # pylint: disable=too-many-arguments
self,
metric_name: str,
fingerprint: np.uint64,
timestamp: datetime.datetime,
value: float,
reduced_fingerprint: np.uint64 | int = 0,
is_monotonic: bool = False,
temporality: str = "Unspecified",
env: str = "default",
flags: int = 0,
) -> None:
self.env = env
self.temporality = temporality
self.metric_name = metric_name
self.fingerprint = fingerprint
self.reduced_fingerprint = np.uint64(reduced_fingerprint)
self.is_monotonic = is_monotonic
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
self.value = np.float64(value)
self.flags = np.uint32(flags)
def to_row(self) -> list:
return [
self.env,
self.temporality,
self.metric_name,
self.fingerprint,
self.reduced_fingerprint,
self.is_monotonic,
self.unix_milli,
self.value,
self.flags,
]
def insert_metrics_to_clickhouse(conn, metrics: list[Metrics]) -> None:
"""
Insert metrics into ClickHouse tables.
@@ -576,6 +845,163 @@ def insert_metrics(
)
def insert_reduced_metrics_to_clickhouse(
conn,
time_series: list[MetricsReducedTimeSeries],
last_samples: list[MetricsReducedSampleLast60s] | None = None,
sum_samples: list[MetricsReducedSampleSum60s] | None = None,
) -> None:
"""Insert reduced series into distributed_time_series_v4_reduced and 60s
buckets into the reduced samples tables. These tables exist only when
the schema migrator version includes the metrics cardinality-control
migration."""
if time_series:
conn.insert(
database="signoz_metrics",
table="distributed_time_series_v4_reduced",
column_names=[
"env",
"temporality",
"metric_name",
"description",
"unit",
"type",
"is_monotonic",
"fingerprint",
"unix_milli",
"labels",
"attrs",
"scope_attrs",
"resource_attrs",
"__normalized",
],
data=[ts.to_row() for ts in time_series],
)
if last_samples:
conn.insert(
database="signoz_metrics",
table="distributed_samples_v4_reduced_last_60s",
column_names=[
"env",
"temporality",
"metric_name",
"reduced_fingerprint",
"unix_milli",
"sum_last",
"min",
"max",
"sum_values",
"count_series",
"count_samples",
"computed_at",
],
data=[sample.to_row() for sample in last_samples],
)
if sum_samples:
conn.insert(
database="signoz_metrics",
table="distributed_samples_v4_reduced_sum_60s",
column_names=[
"env",
"temporality",
"metric_name",
"reduced_fingerprint",
"unix_milli",
"sum",
"count_series",
"count_samples",
"computed_at",
],
data=[sample.to_row() for sample in sum_samples],
)
def insert_buffer_metrics_to_clickhouse(
conn,
time_series: list[MetricsBufferTimeSeries],
samples: list[MetricsBufferSample],
) -> None:
if time_series:
conn.insert(
database="signoz_metrics",
table="distributed_time_series_v4_buffer",
column_names=[
"env",
"temporality",
"metric_name",
"description",
"unit",
"type",
"is_monotonic",
"fingerprint",
"reduced_fingerprint",
"is_reduced",
"unix_milli",
"labels",
"attrs",
"scope_attrs",
"resource_attrs",
"__normalized",
],
data=[ts.to_row() for ts in time_series],
)
if samples:
conn.insert(
database="signoz_metrics",
table="distributed_samples_v4_buffer",
column_names=[
"env",
"temporality",
"metric_name",
"fingerprint",
"reduced_fingerprint",
"is_monotonic",
"unix_milli",
"value",
"flags",
],
data=[sample.to_row() for sample in samples],
)
@pytest.fixture(name="insert_reduced_metrics", scope="function")
def insert_reduced_metrics(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[..., None], Any]:
def _insert_reduced_metrics(
time_series: list[MetricsReducedTimeSeries],
last_samples: list[MetricsReducedSampleLast60s] | None = None,
sum_samples: list[MetricsReducedSampleSum60s] | None = None,
) -> None:
insert_reduced_metrics_to_clickhouse(clickhouse.conn, time_series, last_samples, sum_samples)
yield _insert_reduced_metrics
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
for table in _REDUCED_METRICS_TABLES_TO_TRUNCATE:
clickhouse.conn.query(f"TRUNCATE TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' SYNC")
@pytest.fixture(name="insert_buffer_metrics", scope="function")
def insert_buffer_metrics(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[..., None], Any]:
def _insert_buffer_metrics(
time_series: list[MetricsBufferTimeSeries],
samples: list[MetricsBufferSample],
) -> None:
insert_buffer_metrics_to_clickhouse(clickhouse.conn, time_series, samples)
yield _insert_buffer_metrics
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
for table in _REDUCED_METRICS_TABLES_TO_TRUNCATE:
clickhouse.conn.query(f"TRUNCATE TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' SYNC")
@pytest.fixture(name="remove_metrics_ttl_and_storage_settings", scope="function")
def remove_metrics_ttl_and_storage_settings(signoz: types.SigNoz):
"""

View File

@@ -8,27 +8,30 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def create_migrator(
def create_migrator( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "migrator",
env_overrides: dict | None = None,
version: str | None = None,
) -> types.Operation:
"""
Factory function for running schema migrations.
Accepts optional env_overrides to customize the migrator environment.
Accepts optional env_overrides to customize the migrator environment, and
an optional version to pin a schema-migrator release different from the
--schema-migrator-version option.
"""
def create() -> None:
version = request.config.getoption("--schema-migrator-version")
migrator_version = version or request.config.getoption("--schema-migrator-version")
client = docker.from_env()
environment = dict(env_overrides) if env_overrides else {}
container = client.containers.run(
image=f"signoz/signoz-schema-migrator:{version}",
image=f"signoz/signoz-schema-migrator:{migrator_version}",
command=f"sync --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
detach=True,
auto_remove=False,
@@ -47,7 +50,7 @@ def create_migrator(
container.remove()
container = client.containers.run(
image=f"signoz/signoz-schema-migrator:{version}",
image=f"signoz/signoz-schema-migrator:{migrator_version}",
command=f"async --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
detach=True,
auto_remove=False,

View File

@@ -189,6 +189,35 @@ def make_query_request(
)
def aligned_epoch(ago: timedelta, step_seconds: int = DEFAULT_STEP_INTERVAL) -> int:
"""Epoch seconds for `now - ago`, floored to a step boundary so seeded
points land exactly on the query's toStartOfInterval buckets."""
return (int((datetime.now(tz=UTC) - ago).timestamp()) // step_seconds) * step_seconds
def query_metric_values( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
token: str,
metric_name: str,
start_epoch: int,
end_epoch: int,
time_agg: str,
space_agg: str,
step_interval: int = DEFAULT_STEP_INTERVAL,
) -> list[dict]:
"""Run a single metrics builder query over [start_epoch, end_epoch) in
epoch seconds and return its series values sorted by timestamp."""
response = make_query_request(
signoz,
token,
start_ms=start_epoch * 1000,
end_ms=end_epoch * 1000,
queries=[build_builder_query("A", metric_name, time_agg, space_agg, step_interval=step_interval)],
)
assert response.status_code == HTTPStatus.OK, response.text
return sorted(get_series_values(response.json(), "A"), key=lambda v: v["timestamp"])
def build_builder_query(
name: str,
metric_name: str,

View File

@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Literal
from urllib.parse import urljoin
@@ -84,11 +84,16 @@ class TestContainerClickhouse:
container: TestContainerDocker
conn: clickhouse_connect.driver.client.Client
env: dict[str, str]
# Per-node containers when running a multi-node cluster. Empty for the
# default single-node setup; nodes[0] is the node `conn`/`env` point at
# (the initiator every query goes through).
nodes: list[TestContainerDocker] = field(default_factory=list)
def __cache__(self) -> dict:
return {
"container": self.container.__cache__(),
"env": self.env,
"nodes": [node.__cache__() for node in self.nodes],
}
def __log__(self) -> str:

View File

@@ -0,0 +1,52 @@
import clickhouse_connect.driver.client
from fixtures import types
TOTAL_ROWS = 64
def test_topology(
clickhouse: types.TestContainerClickhouse,
clickhouse_node_conns: list[clickhouse_connect.driver.client.Client],
) -> None:
aliases = {node.container_configs["9000"].address for node in clickhouse.nodes}
# Every node sees the same 2-shard cluster definition and identifies
# exactly itself as the local replica
for i, conn in enumerate(clickhouse_node_conns, start=1):
rows = conn.query("SELECT shard_num, host_name, is_local FROM system.clusters WHERE cluster = 'cluster' ORDER BY shard_num").result_rows
assert [row[0] for row in rows] == [1, 2], f"node {i}: expected 2 shards, got {rows}"
assert {row[1] for row in rows} == aliases, f"node {i}: cluster hosts {rows} != node aliases {aliases}"
local = [row[0] for row in rows if row[2]]
assert local == [i], f"node {i}: expected to be local for shard {i} only, got {local}"
def test_replicated_distributed_round_trip(
clickhouse: types.TestContainerClickhouse,
clickhouse_node_conns: list[clickhouse_connect.driver.client.Client],
) -> None:
# ON CLUSTER DDL reaches both nodes, Replicated engines register with the
# keeper via per-node macros, and a sharded Distributed insert scatters rows
# across shards while the distributed read returns the union.
conn = clickhouse.conn
try:
conn.query("CREATE DATABASE IF NOT EXISTS it_cluster ON CLUSTER 'cluster'")
conn.query("CREATE TABLE it_cluster.events ON CLUSTER 'cluster' (id UInt64, payload String) ENGINE = ReplicatedMergeTree ORDER BY id")
conn.query("CREATE TABLE it_cluster.distributed_events ON CLUSTER 'cluster' AS it_cluster.events ENGINE = Distributed('cluster', 'it_cluster', 'events', cityHash64(id))")
conn.insert(
database="it_cluster",
table="distributed_events",
column_names=["id", "payload"],
data=[[i, f"payload-{i:03d}"] for i in range(TOTAL_ROWS)],
)
distributed_count = int(conn.query("SELECT count() FROM it_cluster.distributed_events").result_rows[0][0])
assert distributed_count == TOTAL_ROWS
local_counts = [int(node_conn.query("SELECT count() FROM it_cluster.events").result_rows[0][0]) for node_conn in clickhouse_node_conns]
assert sum(local_counts) == TOTAL_ROWS, f"local counts {local_counts} do not add up to {TOTAL_ROWS}"
assert min(local_counts) > 0, f"all rows landed on one shard: {local_counts}"
finally:
conn.query("DROP DATABASE IF EXISTS it_cluster ON CLUSTER 'cluster' SYNC")

Some files were not shown because too many files have changed in this diff Show More