mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-02 03:52:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c9b1e82a |
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
frontend/src/lib/logsZoomOutUtils.ts
Normal file
111
frontend/src/lib/logsZoomOutUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user