Compare commits

..

1 Commits

Author SHA1 Message Date
Ishan Uniyal
21c9b1e82a feat: zoom out func ladder added 2026-03-02 06:21:09 +05:30
8 changed files with 214 additions and 25 deletions

View File

@@ -1,7 +1,9 @@
import { MutableRefObject, useEffect } from 'react';
import { useQueryClient } from 'react-query';
import { Button, Tooltip } from 'antd';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { ZoomOut } from 'lucide-react';
import RunQueryBtn from '../RunQueryBtn/RunQueryBtn';
@@ -13,6 +15,7 @@ interface RightToolbarActionsProps {
listQueryKeyRef?: MutableRefObject<any>;
chartQueryKeyRef?: MutableRefObject<any>;
showLiveLogs?: boolean;
onZoomOut?: () => void;
}
export default function RightToolbarActions({
@@ -21,6 +24,7 @@ export default function RightToolbarActions({
listQueryKeyRef,
chartQueryKeyRef,
showLiveLogs,
onZoomOut,
}: RightToolbarActionsProps): JSX.Element {
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -58,6 +62,14 @@ export default function RightToolbarActions({
return (
<div className="right-toolbar-actions-container">
<Tooltip title="Zoom out">
<Button
type="text"
icon={<ZoomOut size={16} />}
className="zoom-out-btn"
onClick={(): void => onZoomOut?.()}
/>
</Tooltip>
<RunQueryBtn
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}

View File

@@ -19,6 +19,20 @@
display: flex;
align-items: center;
gap: 8px;
.zoom-out-btn {
display: flex;
align-items: center;
justify-content: center;
color: var(--bg-vanilla-100);
border: none;
box-shadow: none;
&:hover {
color: var(--bg-vanilla-100);
background: rgba(255, 255, 255, 0.1);
}
}
}
}
@@ -49,5 +63,16 @@
.toolbar {
border-top: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-vanilla-300);
.right-toolbar-actions-container {
.zoom-out-btn {
color: var(--bg-ink-200);
&:hover {
color: var(--bg-ink-300);
background: rgba(0, 0, 0, 0.06);
}
}
}
}
}

View File

@@ -0,0 +1,111 @@
/**
* Logs Explorer zoom-out ladder:
* - 3x until 1 day: 15m → 45m → 2h15m → 6h45m → 20h15m
* - Then fixed: 1d → 2d → 3d → 1w → 2w → 1m
* - After 1 month: wrap to 15m
*/
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
const MS_PER_MIN = 60 * 1000;
const MS_PER_HOUR = 60 * MS_PER_MIN;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const MS_PER_WEEK = 7 * MS_PER_DAY;
/** Ladder steps in milliseconds (ordered from smallest to largest) */
const ZOOM_OUT_LADDER_MS: number[] = [
15 * MS_PER_MIN, // 15m
45 * MS_PER_MIN, // 45m
2 * MS_PER_HOUR + 15 * MS_PER_MIN, // 2h15m
6 * MS_PER_HOUR + 45 * MS_PER_MIN, // 6h45m
20 * MS_PER_HOUR + 15 * MS_PER_MIN, // 20h15m
1 * MS_PER_DAY, // 1d
2 * MS_PER_DAY, // 2d
3 * MS_PER_DAY, // 3d
1 * MS_PER_WEEK, // 1w
2 * MS_PER_WEEK, // 2w
30 * MS_PER_DAY, // 1m (approx)
];
const LADDER_LAST_INDEX = ZOOM_OUT_LADDER_MS.length - 1;
const MIN_DURATION = ZOOM_OUT_LADDER_MS[0];
const MAX_DURATION = ZOOM_OUT_LADDER_MS[LADDER_LAST_INDEX];
/** Preset labels for ladder steps supported by GetMinMax (shows "Last 15 minutes" etc. instead of "Custom") */
const PRESET_FOR_DURATION_MS: Record<number, Time> = {
[15 * MS_PER_MIN]: '15m',
[45 * MS_PER_MIN]: '45m',
[1 * MS_PER_DAY]: '1d',
[3 * MS_PER_DAY]: '3d',
[1 * MS_PER_WEEK]: '1w',
[2 * MS_PER_WEEK]: '2w',
[30 * MS_PER_DAY]: '1month',
};
/**
* Returns the next duration in the zoom-out ladder for the given current duration.
* If at or past 1 month, returns 15m (wrap).
*/
export function getNextDurationInLadder(durationMs: number): number {
if (durationMs >= MAX_DURATION) {
return MIN_DURATION; // Wrap: 1m → 15m
}
// Find the smallest ladder step that is strictly greater than current duration
for (let i = 0; i < ZOOM_OUT_LADDER_MS.length; i++) {
if (ZOOM_OUT_LADDER_MS[i] > durationMs) {
return ZOOM_OUT_LADDER_MS[i];
}
}
return MIN_DURATION;
}
export interface ZoomOutResult {
range: [number, number];
/** Preset key (e.g. '15m') when range matches a preset - use for display instead of "Custom Date Range" */
preset: Time | null;
}
/**
* Computes the next zoomed-out time range.
* Phase 1 (center-anchored): While new end <= now, expand from center.
* Phase 2 (end-anchored at now): When new end would exceed now, anchor end at now and move start backward.
*
* @returns ZoomOutResult with range and preset (or null if no change)
*/
export function getNextZoomOutRange(
startMs: number,
endMs: number,
): ZoomOutResult | null {
const nowMs = Date.now();
const durationMs = endMs - startMs;
if (durationMs <= 0) {
return null;
}
const newDurationMs = getNextDurationInLadder(durationMs);
const centerMs = startMs + durationMs / 2;
const computedEndMs = centerMs + newDurationMs / 2;
let newStartMs: number;
let newEndMs: number;
if (computedEndMs <= nowMs) {
// Phase 1: center-anchored
newStartMs = centerMs - newDurationMs / 2;
newEndMs = computedEndMs;
} else {
// Phase 2: end-anchored at now
newStartMs = nowMs - newDurationMs;
newEndMs = nowMs;
}
const preset = PRESET_FOR_DURATION_MS[newDurationMs] ?? null;
return {
range: [Math.round(newStartMs), Math.round(newEndMs)],
preset,
};
}

View File

@@ -1,4 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import * as Sentry from '@sentry/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -9,6 +12,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
import LogsExplorerViewsContainer from 'container/LogsExplorerViews';
@@ -27,13 +31,19 @@ import {
ICurrentQueryData,
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { getNextZoomOutRange } from 'lib/logsZoomOutUtils';
import { defaultTo, isEmpty, isEqual, isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { EventSourceProvider } from 'providers/EventSource';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
explorerViewToPanelType,
panelTypeToExplorerView,
@@ -263,6 +273,50 @@ function LogsExplorer(): JSX.Element {
setShowLiveLogs(false);
}, []);
const dispatch = useDispatch();
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const urlQuery = useUrlQuery();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const handleZoomOut = useCallback((): void => {
if (showLiveLogs) {
return;
}
const minMs = Math.floor((minTime ?? 0) / 1e6);
const maxMs = Math.floor((maxTime ?? 0) / 1e6);
const result = getNextZoomOutRange(minMs, maxMs);
if (!result) {
return;
}
const [newStartMs, newEndMs] = result.range;
const { preset } = result;
if (preset) {
dispatch(UpdateTimeInterval(preset));
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, preset);
} else {
dispatch(UpdateTimeInterval('custom', [newStartMs, newEndMs]));
urlQuery.set(QueryParams.startTime, String(newStartMs));
urlQuery.set(QueryParams.endTime, String(newEndMs));
urlQuery.delete(QueryParams.relativeTime);
}
urlQuery.delete(QueryParams.activeLogId);
safeNavigate(`${location.pathname}?${urlQuery.toString()}`);
}, [
dispatch,
location.pathname,
maxTime,
minTime,
safeNavigate,
showLiveLogs,
urlQuery,
]);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<EventSourceProvider>
@@ -301,6 +355,7 @@ function LogsExplorer(): JSX.Element {
chartQueryKeyRef={chartQueryKeyRef}
isLoadingQueries={isLoadingQueries}
showLiveLogs={showLiveLogs}
onZoomOut={handleZoomOut}
/>
}
showLiveLogs={showLiveLogs}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -522,6 +523,13 @@ func (b *MetricQueryStatementBuilder) buildSpatialAggregationCTE(
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
_ map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, []any, error) {
if query.Aggregations[0].SpaceAggregation.IsZero() {
return "", nil, errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"invalid space aggregation, should be one of the following: [`sum`, `avg`, `min`, `max`, `count`, `p50`, `p75`, `p90`, `p95`, `p99`]",
)
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("ts")

View File

@@ -122,7 +122,7 @@ func TestStatementBuilder(t *testing.T) {
expectedErr: nil,
},
{
name: "test_histogram_percentile1",
name: "test_histogram_percentile",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
@@ -132,7 +132,6 @@ func TestStatementBuilder(t *testing.T) {
MetricName: "signoz_latency",
Type: metrictypes.HistogramType,
Temporality: metrictypes.Delta,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
@@ -188,7 +187,7 @@ func TestStatementBuilder(t *testing.T) {
expectedErr: nil,
},
{
name: "test_histogram_percentile2",
name: "test_histogram_percentile",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
@@ -198,7 +197,6 @@ func TestStatementBuilder(t *testing.T) {
MetricName: "http_server_duration_bucket",
Type: metrictypes.HistogramType,
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
@@ -213,7 +211,7 @@ func TestStatementBuilder(t *testing.T) {
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `service.name`, `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(30)) AS ts, `service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `service.name`, `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, `service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `service.name`, `le`) SELECT ts, `service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `service.name`, ts ORDER BY `service.name`, ts",
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", false, "http_server_duration_bucket", uint64(1747947360000), uint64(1747983420000), 0},
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", false, "http_server_duration_bucket", uint64(1747947390000), uint64(1747983420000), 0},
},
expectedErr: nil,
},

View File

@@ -3,7 +3,6 @@ package metrictypes
import (
"database/sql/driver"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -136,10 +135,6 @@ func (t *Type) Scan(src interface{}) error {
return nil
}
func (t Type) IsPercentileSpaceAggregationAllowed() bool {
return t == HistogramType || t == ExpHistogramType || t == SummaryType
}
var (
GaugeType = Type{valuer.NewString("gauge")}
SumType = Type{valuer.NewString("sum")}
@@ -190,10 +185,6 @@ func (TimeAggregation) Enum() []any {
}
}
func (t TimeAggregation) IsValid() bool {
return slices.ContainsFunc(t.Enum(), func(v any) bool { return v == t })
}
type SpaceAggregation struct {
valuer.String
}
@@ -227,10 +218,6 @@ func (SpaceAggregation) Enum() []any {
}
}
func (s SpaceAggregation) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
func (s SpaceAggregation) IsPercentile() bool {
return s == SpaceAggregationPercentile50 ||
s == SpaceAggregationPercentile75 ||

View File

@@ -215,13 +215,6 @@ func (q *QueryBuilderQuery[T]) validateAggregations(requestType RequestType) err
aggId,
)
}
if !v.SpaceAggregation.IsValid() {
return errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"invalid space aggregation, should be one of the following: [`sum`, `avg`, `min`, `max`, `count`, `p50`, `p75`, `p90`, `p95`, `p99`]",
)
}
case TraceAggregation:
if v.Expression == "" {
aggId := fmt.Sprintf("aggregation #%d", i+1)