Compare commits

..

3 Commits

Author SHA1 Message Date
Yunus M
9c4046d84a fix: revert the resizeable quick filter changes (#12058) 2026-07-09 19:00:19 +00:00
Vikrant Gupta
a91cee2b95 chore(sql): enable txlock immediate by default (#12048)
* chore(sql): enable txlock immediate by default

* chore(sql): remove the redundant flag from integration tests
2026-07-09 16:05:13 +00:00
Ashwin Bhatkal
51036d6cc4 feat(public-dashboard): integrate v2 (Perses-spec) public dashboards (#12032)
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
* feat(public-dashboard): detect v1 vs v2 schema for the public viewer

Anonymous public viewers have no feature flags, so the schema can't be read from
use_dashboard_v2. Probe the v2 model endpoint first and fall back to v1 only on the
'dashboard_invalid_data' (HTTP 501) schema-mismatch signal. Probing v2 first also stops
the v1 endpoint from serving v2 dashboards with un-redacted queries.

* feat(public-dashboard): fetch v2 public panel data by key

Adds a by-key fetcher over the anonymous /api/v2/public/dashboards/{id}/panels/{key}/query_range
endpoint (the generated client omits the startTime/endTime params) and a store-free
usePublicPanelQuery that mirrors usePanelQuery's PanelQueryData shape. No variables and no
pagination — the public endpoint supports neither.

* feat(public-dashboard): render v2 public dashboards read-only

Adds a read-only v2 viewer that reuses the authenticated V2 panel renderers
(PanelHeader with hideActions, PanelBody, panel registry) and the pure layoutsToSections
util, with a forked read-only grid. The public page branches on the resolved schema:
v1 keeps the existing container, v2 renders the new viewer. Dashboard variables are not
rendered — the public endpoint does not substitute them.

* feat(public-dashboard): match the standard auto-refresh control

Replace the hand-rolled 'Off' select (which was styled inconsistently and clipped its
options) with a PublicAutoRefresh that mirrors the app's DateTimeSelectionV2 refresh cluster:
a grouped refresh button + auto-refresh popover (Auto Refresh checkbox + full interval list),
portal-rendered so nothing clips. It's prop-driven — the public viewer keeps managing its own
time window — so the container now tracks enabled + interval and exposes a manual refresh.
Also nudge the header-right gap 8→12px.

* feat(public-dashboard): declare v2 query_range params, drop the wrapper, address review

Declare startTime/endTime as query params on the v2 public query_range endpoint via
RequestQuery and regenerate the OpenAPI spec + orval client, so the generated
getPublicDashboardPanelQueryRangeV2 carries them. usePublicPanelQuery now calls the
generated fetcher directly and the hand-written wrapper is removed.

Also from review: drop the defensive panelDefinition guard so an unsupported kind
surfaces loudly, use lodash noop, and trim excessive comments across the v2 files.

* fix: bind query params from PublicWidgetQueryRangeParams

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-07-09 13:27:17 +00:00
59 changed files with 1398 additions and 990 deletions

View File

@@ -45,15 +45,9 @@ jobs:
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
runs-on: ubuntu-latest
timeout-minutes: 30
env:
SIGNOZ_BUILDX_GHA_SCOPE: signoz-e2e
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: expose-gha-runtime
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -76,15 +76,9 @@ jobs:
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
runs-on: ubuntu-latest
env:
SIGNOZ_BUILDX_GHA_SCOPE: signoz-integration
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: expose-gha-runtime
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -34,8 +34,6 @@ DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
JS_BUILD_CONTEXT = $(SRC)/frontend
DOCKER_BUILDX_PRUNE_FLAGS ?= --force
SIGNOZ_INTEGRATION_BUILD_CACHE_DIR ?= /tmp/signoz-integration-buildx-cache
##############################################################
# directories
@@ -212,7 +210,7 @@ py-lint: ## Run ruff check across the shared tests project
.PHONY: py-test-setup
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
@cd tests && SIGNOZ_INTEGRATION_BUILD_CACHE_DIR=$(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
.PHONY: py-test-teardown
py-test-teardown: ## Tear down the shared SigNoz backend
@@ -231,21 +229,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
@find tests -type f -name "*.pyo" -delete 2>/dev/null || true
@echo ">> python cache cleaned"
.PHONY: py-docker-clean
py-docker-clean: ## Remove Docker image and build caches used by python integration tests
@echo ">> removing SigNoz integration test image"
@docker image rm -f signoz:integration 2>/dev/null || true
@echo ">> removing local integration buildx cache directories"
@rm -rf $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR)-next
@echo ">> pruning docker buildx cache with flags: $(DOCKER_BUILDX_PRUNE_FLAGS)"
@docker buildx prune $(DOCKER_BUILDX_PRUNE_FLAGS)
.PHONY: py-test-clean
py-test-clean: ## Tear down python test stack and remove python/Docker test caches
@$(MAKE) py-test-teardown || true
@$(MAKE) py-clean
@$(MAKE) py-docker-clean
##############################################################
# generate commands

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.13
FROM golang:1.25-bookworm
ARG OS="linux"
@@ -23,8 +21,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -32,9 +29,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
RUN chmod 755 /root /root/signoz

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.13
FROM node:22-bookworm AS build
WORKDIR /opt/
@@ -32,8 +30,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -41,9 +38,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

View File

@@ -129,7 +129,7 @@ sqlstore:
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
transaction_mode: deferred
transaction_mode: immediate
##################### APIServer #####################
apiserver:

View File

@@ -18168,6 +18168,16 @@ paths:
public dashboard. The panel is addressed by its key in spec.panels.
operationId: GetPublicDashboardPanelQueryRangeV2
parameters:
- in: query
name: startTime
required: false
schema:
type: string
- in: query
name: endTime
required: false
schema:
type: string
- in: path
name: id
required: true

View File

@@ -41,6 +41,7 @@ import type {
GetPublicDashboardDataV2200,
GetPublicDashboardDataV2PathParameters,
GetPublicDashboardPanelQueryRangeV2200,
GetPublicDashboardPanelQueryRangeV2Params,
GetPublicDashboardPanelQueryRangeV2PathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
@@ -1912,20 +1913,25 @@ export const invalidateGetPublicDashboardDataV2 = async (
*/
export const getPublicDashboardPanelQueryRangeV2 = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
method: 'GET',
params,
signal,
});
};
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
id,
key,
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
) => {
return [
`/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
...(params ? [params] : []),
] as const;
};
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
@@ -1933,6 +1939,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
@@ -1945,11 +1952,12 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
const queryKey =
queryOptions?.queryKey ??
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
> = ({ signal }) =>
getPublicDashboardPanelQueryRangeV2({ id, key }, params, signal);
return {
queryKey,
@@ -1978,6 +1986,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
@@ -1988,6 +1997,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
{ id, key },
params,
options,
);
@@ -2004,10 +2014,16 @@ export function useGetPublicDashboardPanelQueryRangeV2<
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
queryClient: QueryClient,
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
{
queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey(
{ id, key },
params,
),
},
options,
);

View File

@@ -11570,6 +11570,19 @@ export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
id: string;
key: string;
};
export type GetPublicDashboardPanelQueryRangeV2Params = {
/**
* @type string
* @description undefined
*/
startTime?: string;
/**
* @type string
* @description undefined
*/
endTime?: string;
};
export type GetPublicDashboardPanelQueryRangeV2200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -31,12 +31,6 @@ export enum LOCALSTORAGE {
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -7,6 +7,7 @@ export const REACT_QUERY_KEY = {
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_RESOLVED: 'GET_PUBLIC_DASHBOARD_RESOLVED',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
GET_ALL_LICENCES: 'GET_ALL_LICENCES',

View File

@@ -163,23 +163,12 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.api-quick-filter-left-section {
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
width: 260px;
}
.api-module-right-section {
flex: 1;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -4,49 +4,21 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import DomainList from './Domains/DomainList';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="api-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<section className="api-quick-filter-left-section">
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
@@ -55,7 +27,7 @@ function Explorer(): JSX.Element {
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
/>
</ResizableBox>
</section>
<DomainList />
</div>
</Sentry.ErrorBoundary>

View File

@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
@@ -17,8 +16,6 @@ import {
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
</div>
)}
<div
className={`${styles.listContainer}${

View File

@@ -44,20 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
@@ -136,9 +142,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.quickFiltersToggleContainer {

View File

@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
@@ -17,8 +16,6 @@ import {
} from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
</div>
)}
<div
className={`${styles.listContainer}${

View File

@@ -44,20 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
@@ -136,9 +142,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.quickFiltersToggleContainer {

View File

@@ -44,15 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.quick-filters) {
@@ -117,9 +128,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.categorySelectorSection {
@@ -210,16 +219,8 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -23,8 +22,6 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</ResizableBox>
</div>
)}
<div

View File

@@ -44,15 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.quick-filters) {
@@ -117,9 +128,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.categorySelectorSection {
@@ -210,16 +219,8 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -23,8 +22,6 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</ResizableBox>
</div>
)}
<div

View File

@@ -3,23 +3,12 @@
flex-direction: row;
.meter-explorer-quick-filters-section {
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;
width: 280px;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.meter-explorer-content-section {
@@ -97,9 +86,7 @@
&.quick-filters-open {
.meter-explorer-content-section {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 280px);
}
}

View File

@@ -7,7 +7,6 @@ import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
@@ -19,8 +18,6 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -33,10 +30,6 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
const {
handleRunQuery,
@@ -62,16 +55,6 @@ function Explorer(): JSX.Element {
const [showQuickFilters, setShowQuickFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
@@ -144,19 +127,10 @@ function Explorer(): JSX.Element {
'quick-filters-open': showQuickFilters,
})}
>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
<div
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-meter-explorer"
@@ -168,7 +142,7 @@ function Explorer(): JSX.Element {
setShowQuickFilters(!showQuickFilters);
}}
/>
</ResizableBox>
</div>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">

View File

@@ -0,0 +1,94 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
PublicDashboardSchema,
useGetResolvedPublicDashboard,
} from '../useGetResolvedPublicDashboard';
jest.mock('api/generated/services/dashboard', () => ({
getPublicDashboardDataV2: jest.fn(),
}));
jest.mock('api/dashboard/public/getPublicDashboardData', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockV2 = getPublicDashboardDataV2 as jest.Mock;
const mockV1 = getPublicDashboardDataAPI as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
// A schema mismatch on the v2 endpoint surfaces as an AxiosError with HTTP 501 and this
// error code; anything else must NOT trigger the v1 fallback.
const schemaMismatchError = {
isAxiosError: true,
response: {
status: 501,
data: { error: { code: 'dashboard_invalid_data', message: 'not in v6' } },
},
};
describe('useGetResolvedPublicDashboard', () => {
beforeEach(() => {
mockV2.mockReset();
mockV1.mockReset();
});
it('returns the v2 model when the v2 endpoint succeeds and never calls v1', async () => {
mockV2.mockResolvedValue({ status: 'success', data: { dashboard: {} } });
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-1'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V2);
expect(mockV2).toHaveBeenCalledWith({ id: 'id-1' });
expect(mockV1).not.toHaveBeenCalled();
});
it('falls back to v1 when the v2 endpoint reports a schema mismatch', async () => {
mockV2.mockRejectedValue(schemaMismatchError);
mockV1.mockResolvedValue({
httpStatusCode: 200,
data: { dashboard: { data: { title: 'v1' } } },
});
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-2'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V1);
expect(mockV1).toHaveBeenCalledWith({ id: 'id-2' });
});
it('surfaces a non-schema-mismatch v2 error without falling back to v1', async () => {
mockV2.mockRejectedValue({
isAxiosError: true,
response: { status: 500, data: { error: { code: 'internal' } } },
});
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-3'), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockV1).not.toHaveBeenCalled();
});
it('does not fetch without an id', () => {
renderHook(() => useGetResolvedPublicDashboard(''), { wrapper });
expect(mockV2).not.toHaveBeenCalled();
expect(mockV1).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
import { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
import { AxiosError, isAxiosError } from 'axios';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { ErrorV2Resp } from 'types/api';
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
export enum PublicDashboardSchema {
V1 = 'v1',
V2 = 'v2',
}
export type ResolvedPublicDashboard =
| {
schema: PublicDashboardSchema.V2;
data: DashboardtypesGettablePublicDashboardDataV2DTO;
}
| { schema: PublicDashboardSchema.V1; data: PublicDashboardDataProps };
// The v2 endpoint rejects non-v6 rows with this code — our signal that it's a v1 dashboard.
const V2_SCHEMA_MISMATCH_CODE = 'dashboard_invalid_data';
function isV2SchemaMismatch(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
const { response } = error as AxiosError<ErrorV2Resp>;
return response?.data?.error?.code === V2_SCHEMA_MISMATCH_CODE;
}
// Probe v2 first, fall back to v1 only on a schema mismatch. v1-first is unsafe: it 200s for a
// v2 dashboard with queries un-redacted. Other v2 errors re-throw rather than mis-render as v1.
async function resolvePublicDashboard(
id: string,
): Promise<ResolvedPublicDashboard> {
try {
const v2 = await getPublicDashboardDataV2({ id });
return { schema: PublicDashboardSchema.V2, data: v2.data };
} catch (error) {
if (!isV2SchemaMismatch(error)) {
throw error;
}
const v1 = await getPublicDashboardDataAPI({ id });
return { schema: PublicDashboardSchema.V1, data: v1.data };
}
}
export const useGetResolvedPublicDashboard = (
id: string,
): UseQueryResult<ResolvedPublicDashboard, Error> =>
useQuery<ResolvedPublicDashboard, Error>({
queryFn: () => resolvePublicDashboard(id),
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, id],
enabled: !!id,
});

View File

@@ -20,23 +20,12 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.all-errors-quick-filter-section {
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
width: 260px;
}
.all-errors-right-section {
flex: 1;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -18,18 +18,12 @@ import Toolbar from 'container/Toolbar/Toolbar';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { routes } from './config';
import { useAllErrorsQueryState } from './QueryStateContext';
import './AllErrors.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function AllErrors(): JSX.Element {
const { pathname } = useLocation();
const { handleRunQuery } = useQueryBuilder();
@@ -61,38 +55,17 @@ function AllErrors(): JSX.Element {
setShowFilters((prev) => !prev);
};
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="all-errors-quick-filter-section"
handleTestId="quick-filters-resize-handle"
>
<section className={cx('all-errors-quick-filter-section')}>
<QuickFilters
className="qf-exceptions"
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</ResizableBox>
</section>
)}
<section
className={cx(

View File

@@ -20,7 +20,10 @@ export interface UseGetQueryRangeV5Args {
* The retry callback gets the raw AxiosError this path rejects with (not yet normalized to
* APIError — that happens later at the display boundary), so inspect it at the axios level.
*/
function retryUnlessClientError(failureCount: number, error: Error): boolean {
export function retryUnlessClientError(
failureCount: number,
error: Error,
): boolean {
if (isAxiosError(error)) {
if (error.code === 'ERR_CANCELED') {
return false;

View File

@@ -41,18 +41,15 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.log-quick-filter-left-section {
width: 260px;
height: 100%;
overflow: visible;
min-height: 0;
position: relative;
z-index: 2;
.resizable-box__content {
display: flex;
flex-direction: column;
}
display: flex;
flex-direction: column;
.quick-filters-container {
flex: 1;
@@ -61,9 +58,7 @@
}
.log-module-right-section {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -26,8 +26,6 @@ import {
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { defaultTo, isEmpty, isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { EventSourceProvider } from 'providers/EventSource';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
@@ -46,23 +44,9 @@ import { ExplorerViews } from './utils';
import './LogsExplorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function LogsExplorer(): JSX.Element {
const [showLiveLogs, setShowLiveLogs] = useState<boolean>(false);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
@@ -242,25 +226,14 @@ function LogsExplorer(): JSX.Element {
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="log-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</ResizableBox>
</section>
)}
<section className={cx('log-module-right-section')}>
<Toolbar

View File

@@ -0,0 +1,23 @@
// Mirrors `.refresh-actions` from DateTimeSelectionV2: one bordered, rounded container
// holding borderless buttons split by a divider.
.refreshActions {
display: flex;
flex-direction: row;
border-radius: 2px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
.refreshButton {
display: flex;
border-right: 1px solid var(--l2-border);
}
:global(.ant-btn) {
display: flex;
padding: 4px 8px;
align-items: center;
box-shadow: none;
border: none;
background: transparent;
}
}

View File

@@ -0,0 +1,88 @@
import { Check, ChevronDown, RefreshCw } from '@signozhq/icons';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import { Button, Popover } from 'antd';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import { popupContainer } from 'utils/selectPopupContainer';
import styles from './PublicAutoRefresh.module.scss';
// Reuse the app-wide auto-refresh popover styling.
import 'container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss';
interface PublicAutoRefreshProps {
enabled: boolean;
/** Selected interval key, e.g. `30s`. */
interval: string;
disabled?: boolean;
onToggle: (enabled: boolean) => void;
onIntervalChange: (key: string) => void;
onRefresh: () => void;
}
// Prop-driven mirror of the app's refresh + auto-refresh cluster (the public viewer owns its
// own time window, not Redux global time).
function PublicAutoRefresh({
enabled,
interval,
disabled = false,
onToggle,
onIntervalChange,
onRefresh,
}: PublicAutoRefreshProps): JSX.Element {
return (
<div className={styles.refreshActions}>
<div className={styles.refreshButton}>
<Button
icon={<RefreshCw size={16} />}
onClick={onRefresh}
title="Refresh"
data-testid="public-dashboard-refresh"
/>
</div>
<Popover
getPopupContainer={popupContainer}
placement="bottomRight"
rootClassName="auto-refresh-root"
trigger={['click']}
content={
<div className="auto-refresh-menu">
<Checkbox
onChange={(value): void => onToggle(value === true)}
value={enabled}
disabled={disabled}
className="auto-refresh-checkbox"
>
Auto Refresh
</Checkbox>
<Typography.Text disabled={disabled} className="refresh-interval-text">
Refresh Interval
</Typography.Text>
{refreshIntervalOptions
.filter((option) => option.label !== 'off')
.map((option) => (
<Button
type="text"
className="refresh-interval-btns"
key={option.label + option.value}
onClick={(): void => onIntervalChange(option.key)}
>
{option.label}
{option.key === interval && enabled && <Check size={14} />}
</Button>
))}
</div>
}
>
<Button
title="Set auto refresh"
data-testid="public-dashboard-auto-refresh"
>
<ChevronDown size={14} />
</Button>
</Popover>
</div>
);
}
export default PublicAutoRefresh;

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PublicAutoRefresh from '../PublicAutoRefresh';
const props = {
enabled: false,
interval: '30s',
onToggle: jest.fn(),
onIntervalChange: jest.fn(),
onRefresh: jest.fn(),
};
describe('PublicAutoRefresh', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the refresh and auto-refresh controls', () => {
render(<PublicAutoRefresh {...props} />);
expect(screen.getByTestId('public-dashboard-refresh')).toBeInTheDocument();
expect(
screen.getByTestId('public-dashboard-auto-refresh'),
).toBeInTheDocument();
});
it('calls onRefresh when the refresh button is clicked', async () => {
render(<PublicAutoRefresh {...props} />);
await userEvent.click(screen.getByTestId('public-dashboard-refresh'));
expect(props.onRefresh).toHaveBeenCalledTimes(1);
});
it('changes the interval from the menu', async () => {
render(<PublicAutoRefresh {...props} />);
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
await userEvent.click(await screen.findByText('5 seconds'));
expect(props.onIntervalChange).toHaveBeenCalledWith('5s');
});
it('toggles auto-refresh from the menu', async () => {
render(<PublicAutoRefresh {...props} />);
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
await userEvent.click(await screen.findByRole('checkbox'));
expect(props.onToggle).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,70 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--l0-background);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--l2-border);
}
.headerLeft {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.brandLogo {
height: 24px;
width: 24px;
}
.brandName {
font-weight: 600;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-vanilla-400);
}
.headerRight {
display: flex;
align-items: center;
gap: 12px;
}
.content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.section {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.sectionTitle {
padding: 8px 4px 0;
font-weight: 600;
color: var(--text-vanilla-400);
}

View File

@@ -0,0 +1,159 @@
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import GetMinMax from 'lib/getMinMax';
import { layoutsToSections } from 'pages/DashboardPageV2/DashboardContainer/utils';
import { useMemo, useState } from 'react';
import { useInterval } from 'react-use';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import PublicAutoRefresh from './PublicAutoRefresh/PublicAutoRefresh';
import PublicSectionGrid from './PublicSectionGrid/PublicSectionGrid';
import { getStartTimeAndEndTimeFromTimeRange } from './utils';
import styles from './PublicDashboardV2.module.scss';
interface PublicDashboardV2Props {
publicDashboardId: string;
data: DashboardtypesGettablePublicDashboardDataV2DTO;
}
// Read-only viewer for a v2 (Perses-spec) public dashboard; reuses the V2 panel renderers.
// Variables aren't rendered — the public endpoint doesn't substitute them.
function PublicDashboardV2({
publicDashboardId,
data,
}: PublicDashboardV2Props): JSX.Element {
const { dashboard, publicDashboard } = data;
const sections = useMemo(
() => layoutsToSections(dashboard?.spec?.layouts, dashboard?.spec?.panels),
[dashboard?.spec?.layouts, dashboard?.spec?.panels],
);
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
);
const [selectedTimeRange, setSelectedTimeRange] = useState(() =>
getStartTimeAndEndTimeFromTimeRange(
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
),
);
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
const handleTimeChange = (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
): void => {
if (dateTimeRange) {
setSelectedTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else if (interval !== 'custom') {
const { maxTime, minTime } = GetMinMax(interval);
setSelectedTimeRange({
startTime: Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000),
endTime: Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000),
});
}
setSelectedTimeRangeLabel(interval as string);
};
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState<boolean>(false);
const [autoRefreshInterval, setAutoRefreshInterval] = useState<string>('30s');
// Auto-refresh applies only to a rolling relative range, not a fixed custom window.
const isAutoRefreshPaused = selectedTimeRangeLabel === 'custom';
const refreshIntervalMs = useMemo(
() =>
autoRefreshEnabled
? refreshIntervalOptions.find(
(option) => option.key === autoRefreshInterval,
)?.value || 0
: 0,
[autoRefreshEnabled, autoRefreshInterval],
);
useInterval(
() => handleTimeChange(selectedTimeRangeLabel as Time),
isAutoRefreshPaused || refreshIntervalMs === 0 ? null : refreshIntervalMs,
);
const handleRefresh = (): void =>
handleTimeChange(selectedTimeRangeLabel as Time);
const startMs = selectedTimeRange.startTime * 1000;
const endMs = selectedTimeRange.endTime * 1000;
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.headerLeft}>
<div className={styles.brand}>
<img src={signozBrandLogoUrl} alt="SigNoz" className={styles.brandLogo} />
<Typography className={styles.brandName}>SigNoz</Typography>
</div>
<Typography.Text className={styles.title}>
{dashboard?.spec?.display?.name}
</Typography.Text>
</div>
{isTimeRangeEnabled && (
<div className={styles.headerRight}>
<PublicAutoRefresh
enabled={autoRefreshEnabled}
interval={autoRefreshInterval}
disabled={isAutoRefreshPaused}
onToggle={setAutoRefreshEnabled}
onIntervalChange={setAutoRefreshInterval}
onRefresh={handleRefresh}
/>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
isModalTimeSelection
modalSelectedInterval={selectedTimeRangeLabel as Time}
disableUrlSync
showRecentlyUsed={false}
modalInitialStartTime={startMs}
modalInitialEndTime={endMs}
/>
</div>
)}
</div>
<div className={styles.content}>
{sections.map((section) => (
<section key={section.id} className={styles.section}>
{section.title && (
<Typography.Text className={styles.sectionTitle}>
{section.title}
</Typography.Text>
)}
<PublicSectionGrid
items={section.items}
publicDashboardId={publicDashboardId}
startMs={startMs}
endMs={endMs}
isVisible
/>
</section>
))}
</div>
</div>
);
}
export default PublicDashboardV2;

View File

@@ -0,0 +1,10 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
}

View File

@@ -0,0 +1,78 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { noop } from 'lodash-es';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { usePublicPanelQuery } from '../hooks/usePublicPanelQuery';
import styles from './PublicPanel.module.scss';
interface PublicPanelProps {
panel: DashboardtypesPanelDTO;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** True once the panel is on screen — gates the fetch. */
isVisible?: boolean;
}
const PUBLIC_DASHBOARD_PREFERENCE: DashboardPreference = {
syncMode: DashboardCursorSync.None,
};
// Read-only v2 public panel: reuses the V2 header/body renderers with interactions disabled.
function PublicPanel({
panel,
panelKey,
publicDashboardId,
startMs,
endMs,
isVisible,
}: PublicPanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
const { data, isFetching, isPreviousData, error, refetch } =
usePublicPanelQuery({
panel,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled: isVisible !== false,
});
return (
<div className={styles.panel} data-panel-root={panelKey}>
<PanelHeader
panelId={panelKey}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
hideActions
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelKey}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={noop}
dashboardPreference={PUBLIC_DASHBOARD_PREFERENCE}
enableDrillDown={false}
/>
</div>
);
}
export default PublicPanel;

View File

@@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
import PublicPanel from '../PublicPanel';
jest.mock('../../hooks/usePublicPanelQuery', () => ({
usePublicPanelQuery: jest.fn(),
}));
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
() => ({
__esModule: true,
default: ({ hideActions }: { hideActions?: boolean }): JSX.Element => (
<div data-testid="panel-header" data-hide-actions={String(!!hideActions)} />
),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody',
() => ({
__esModule: true,
default: ({
enableDrillDown,
panelId,
}: {
enableDrillDown?: boolean;
panelId: string;
}): JSX.Element => (
<div
data-testid="panel-body"
data-drilldown={String(!!enableDrillDown)}
data-panel-id={panelId}
/>
),
}),
);
const mockQuery = usePublicPanelQuery as jest.Mock;
const queryResult = {
data: { response: undefined, requestPayload: undefined, legendMap: {} },
isLoading: false,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
};
const timeseriesPanel = {
kind: 'Panel',
spec: {
display: { name: 'panel-1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const commonProps = {
panelKey: 'p1',
publicDashboardId: 'pub-1',
startMs: 1000,
endMs: 2000,
};
describe('PublicPanel', () => {
beforeEach(() => {
mockQuery.mockReset();
mockQuery.mockReturnValue(queryResult);
});
it('renders the reused header/body read-only (hideActions, no drill-down)', () => {
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
expect(screen.getByTestId('panel-header')).toHaveAttribute(
'data-hide-actions',
'true',
);
const body = screen.getByTestId('panel-body');
expect(body).toHaveAttribute('data-drilldown', 'false');
expect(body).toHaveAttribute('data-panel-id', 'p1');
});
it('fetches by panel key and time', () => {
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
panelKey: 'p1',
publicDashboardId: 'pub-1',
startMs: 1000,
endMs: 2000,
}),
);
});
it('gates the fetch when off screen', () => {
render(
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,
);
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
});

View File

@@ -0,0 +1,66 @@
import type { GridItem } from 'pages/DashboardPageV2/DashboardContainer/utils';
import GridLayout, { type Layout, WidthProvider } from 'react-grid-layout';
import PublicPanel from '../PublicPanel/PublicPanel';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface PublicSectionGridProps {
items: GridItem[];
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** True once the section is on screen — forwarded to gate panel fetches. */
isVisible?: boolean;
}
// Fixed (non-editable) grid for one section of a v2 public dashboard.
function PublicSectionGrid({
items,
publicDashboardId,
startMs,
endMs,
isVisible,
}: PublicSectionGridProps): JSX.Element {
const layout: Layout[] = items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
static: true,
}));
return (
<ResponsiveGridLayout
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={layout}
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
// Empty cell for an orphan layout item (panel id missing from the map).
<div key={item.id}>
{item.panel && (
<PublicPanel
panel={item.panel}
panelKey={item.id}
publicDashboardId={publicDashboardId}
startMs={startMs}
endMs={endMs}
isVisible={isVisible}
/>
)}
</div>
))}
</ResponsiveGridLayout>
);
}
export default PublicSectionGrid;

View File

@@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import PublicDashboardV2 from '../PublicDashboardV2';
const mockGrid = jest.fn();
jest.mock('../PublicSectionGrid/PublicSectionGrid', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockGrid(props);
return <div data-testid="public-section-grid" />;
},
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="datetime-selection" />,
}));
jest.mock('../PublicAutoRefresh/PublicAutoRefresh', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="auto-refresh" />,
}));
function buildData(
timeRangeEnabled: boolean,
): DashboardtypesGettablePublicDashboardDataV2DTO {
return {
dashboard: {
schemaVersion: 'v6',
spec: {
display: { name: 'My V2 Dashboard' },
layouts: [
{
kind: 'Grid',
spec: {
display: { title: 'Section A' },
items: [
{
x: 0,
y: 0,
width: 6,
height: 6,
content: { $ref: '#/spec/panels/p1' },
},
],
},
},
],
panels: {
p1: {
kind: 'Panel',
spec: {
display: { name: 'p1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
},
},
variables: [],
},
},
publicDashboard: { timeRangeEnabled, defaultTimeRange: '30m' },
} as unknown as DashboardtypesGettablePublicDashboardDataV2DTO;
}
describe('PublicDashboardV2', () => {
beforeEach(() => mockGrid.mockReset());
it('renders the dashboard title, section title, and a grid per section', () => {
render(
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
);
expect(screen.getByText('My V2 Dashboard')).toBeInTheDocument();
expect(screen.getByText('Section A')).toBeInTheDocument();
expect(screen.getByTestId('public-section-grid')).toBeInTheDocument();
const gridProps = mockGrid.mock.calls[0][0];
expect(gridProps.publicDashboardId).toBe('pub-1');
expect(gridProps.items).toHaveLength(1);
expect(gridProps.items[0].id).toBe('p1');
expect(typeof gridProps.startMs).toBe('number');
expect(typeof gridProps.endMs).toBe('number');
// Times are handed to the endpoint in milliseconds.
expect(gridProps.endMs).toBeGreaterThan(gridProps.startMs);
});
it('shows the time controls only when the publisher enabled the time range', () => {
const { rerender } = render(
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
);
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
expect(screen.getByTestId('auto-refresh')).toBeInTheDocument();
rerender(
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(false)} />,
);
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
expect(screen.queryByTestId('auto-refresh')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,106 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { usePublicPanelQuery } from '../usePublicPanelQuery';
jest.mock('api/generated/services/dashboard', () => ({
getPublicDashboardPanelQueryRangeV2: jest.fn(),
}));
const mockFetch = getPublicDashboardPanelQueryRangeV2 as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
// A timeseries panel with a single runnable builder query (non-metrics signal → runnable).
const panel = {
kind: 'Panel',
spec: {
display: { name: 'panel-1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { visualization: {} } },
queries: [
{
kind: 'time_series',
spec: {
name: 'A',
plugin: {
kind: 'signoz/BuilderQuery',
spec: { name: 'A', signal: 'logs', legend: 'My legend' },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
const args = {
panel,
panelKey: 'panel-1',
publicDashboardId: 'pub-1',
startMs: 1000,
endMs: 2000,
};
describe('usePublicPanelQuery', () => {
beforeEach(() => mockFetch.mockReset());
it('fetches by panel key + time and exposes the response as PanelQueryData', async () => {
mockFetch.mockResolvedValue({
status: 'success',
data: { type: 'time_series', data: { results: [] } },
});
const { result } = renderHook(() => usePublicPanelQuery(args), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(mockFetch).toHaveBeenCalledWith(
{ id: 'pub-1', key: 'panel-1' },
{ startTime: '1000', endTime: '2000' },
expect.anything(),
);
expect(result.current.data.response?.status).toBe('success');
expect(result.current.data.legendMap).toStrictEqual({ A: 'My legend' });
expect(result.current.data.requestPayload?.start).toBe(1000);
expect(result.current.data.requestPayload?.end).toBe(2000);
// The public endpoint has no paging support.
expect(result.current.pagination).toBeUndefined();
});
it('does not fetch without a public dashboard id', () => {
renderHook(() => usePublicPanelQuery({ ...args, publicDashboardId: '' }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not fetch when the panel has no runnable queries', () => {
const emptyPanel = {
kind: 'Panel',
spec: {
display: { name: 'empty' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
renderHook(() => usePublicPanelQuery({ ...args, panel: emptyPanel }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not fetch when disabled', () => {
renderHook(() => usePublicPanelQuery({ ...args, enabled: false }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,136 @@
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesPanelDTO,
GetPublicDashboardPanelQueryRangeV2200,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
buildQueryRangeRequest,
extractLegendMap,
hasRunnableQueries,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import type {
PanelQueryData,
PanelPagination,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
export interface UsePublicPanelQueryArgs {
panel: DashboardtypesPanelDTO;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** Gate the fetch (default true). */
enabled?: boolean;
}
// Same shape as `usePanelQuery` so the V2 renderers consume it unchanged.
export interface UsePublicPanelQueryResult {
data: PanelQueryData;
isLoading: boolean;
isFetching: boolean;
isPreviousData: boolean;
error: Error | null;
refetch: () => void;
cancelQuery: () => void;
/** Always undefined — the public endpoint has no paging (#5557). */
pagination?: PanelPagination;
}
/**
* Fetches one v2 public panel by key + time. The public endpoint holds the query server-side
* (no body, variables, or paging); we still build the request DTO locally so the renderers get
* the `requestPayload`/`legendMap` they need — it is not sent.
*/
export function usePublicPanelQuery({
panel,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled = true,
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const { queries } = panel.spec;
const pluginSpec = panel.spec.plugin.spec;
const visualization =
pluginSpec && 'visualization' in pluginSpec
? pluginSpec.visualization
: undefined;
const fillGaps = Boolean(
visualization && 'fillSpans' in visualization && visualization.fillSpans,
);
// For the renderers only — not sent to the server.
const requestPayload = useMemo(
() =>
buildQueryRangeRequest({
queries,
panelType,
startMs,
endMs,
fillGaps,
variables: {},
}),
[queries, panelType, startMs, endMs, fillGaps],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
const runnable = useMemo(() => hasRunnableQueries(queries), [queries]);
// Redacted payloads are identical across panels — key on panel + time to avoid cache collisions.
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_WIDGET_DATA,
publicDashboardId,
panelKey,
startMs,
endMs,
],
[publicDashboardId, panelKey, startMs, endMs],
);
const response = useQuery<GetPublicDashboardPanelQueryRangeV2200, Error>({
queryKey,
queryFn: ({ signal }) =>
getPublicDashboardPanelQueryRangeV2(
{ id: publicDashboardId, key: panelKey },
{ startTime: String(startMs), endTime: String(endMs) },
signal,
),
enabled: enabled && runnable && !!publicDashboardId && !!panelKey,
retry: retryUnlessClientError,
});
const data = useMemo<PanelQueryData>(
() => ({ response: response.data, requestPayload, legendMap }),
[response.data, requestPayload, legendMap],
);
const queryClient = useQueryClient();
const cancelQuery = useCallback((): void => {
void queryClient.cancelQueries(queryKey);
}, [queryClient, queryKey]);
return {
data,
isLoading: response.isLoading,
isFetching: response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,
cancelQuery,
pagination: undefined,
};
}

View File

@@ -0,0 +1,31 @@
import dayjs from 'dayjs';
const CUSTOM_TIME_REGEX = /^(\d+)([mhdw])$/;
const UNIT_TO_DAYJS = {
m: 'minutes',
h: 'hours',
d: 'days',
w: 'weeks',
} as const;
// Relative range (`30m`/`6h`/`7d`/`1w`) → `{ startTime, endTime }` in unix seconds; default 30m.
export function getStartTimeAndEndTimeFromTimeRange(timeRange: string): {
startTime: number;
endTime: number;
} {
const match = timeRange.match(CUSTOM_TIME_REGEX);
if (match) {
const timeValue = parseInt(match[1] as string, 10);
const unit = UNIT_TO_DAYJS[match[2] as keyof typeof UNIT_TO_DAYJS];
return {
startTime: dayjs().subtract(timeValue, unit).unix(),
endTime: dayjs().unix(),
};
}
return {
startTime: dayjs().subtract(30, 'minutes').unix(),
endTime: dayjs().unix(),
};
}

View File

@@ -1,11 +1,15 @@
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetPublicDashboardData } from 'hooks/dashboard/useGetPublicDashboardData';
import {
PublicDashboardSchema,
useGetResolvedPublicDashboard,
} from 'hooks/dashboard/useGetResolvedPublicDashboard';
import { Frown } from '@signozhq/icons';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import PublicDashboardContainer from '../../container/PublicDashboardContainer';
import PublicDashboardV2 from './PublicDashboardV2/PublicDashboardV2';
import './PublicDashboard.styles.scss';
@@ -14,27 +18,28 @@ function PublicDashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const {
data: publicDashboardData,
isLoading: isLoadingPublicDashboardData,
isFetching: isFetchingPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardData(dashboardId || '');
data: resolved,
isLoading,
isFetching,
isError,
} = useGetResolvedPublicDashboard(dashboardId || '');
const isLoading =
isLoadingPublicDashboardData || isFetchingPublicDashboardData;
const isError = isErrorPublicDashboardData;
const isBusy = isLoading || isFetching;
return (
<div className="public-dashboard-page">
{publicDashboardData && (
{resolved?.schema === PublicDashboardSchema.V2 && (
<PublicDashboardV2 publicDashboardId={dashboardId} data={resolved.data} />
)}
{resolved?.schema === PublicDashboardSchema.V1 && (
<PublicDashboardContainer
publicDashboardId={dashboardId}
publicDashboardData={publicDashboardData}
publicDashboardData={{ httpStatusCode: 200, data: resolved.data }}
/>
)}
{isError && !isLoading && (
{isError && !isBusy && (
<div className="public-dashboard-error-container">
<div className="perilin-bg" />

View File

@@ -76,8 +76,7 @@
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;
width: 260px;
height: 100%;
min-height: 100vh;
@@ -85,14 +84,9 @@
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
> .ant-card-body {
padding: 0;
width: 258px;
}
}
@@ -107,8 +101,6 @@
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 260px);
}
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
@@ -40,8 +41,6 @@ import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { TOOLBAR_VIEWS } from 'pages/TracesExplorer/constants';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -63,10 +62,6 @@ import TimeSeriesView from './TimeSeriesView';
import './TracesExplorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function TracesExplorer(): JSX.Element {
const {
panelType,
@@ -122,16 +117,6 @@ function TracesExplorer(): JSX.Element {
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_TRACES,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
@@ -265,29 +250,16 @@ function TracesExplorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="trace-explorer-page">
{isOpen && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="filter"
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</ResizableBox>
)}
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,

View File

@@ -1,5 +1,6 @@
.resizable-box {
position: relative;
overflow: hidden;
&--disabled {
flex: 1;
@@ -17,17 +18,6 @@
z-index: 10;
background: var(--l2-border);
// Extend the interactive area beyond the 1px visual line so the handle
// is easy to grab and double-click, without changing its appearance.
&::before {
content: '';
position: absolute;
top: -4px;
right: -4px;
bottom: -4px;
left: -4px;
}
&:hover,
&:active {
background: var(--primary);
@@ -65,29 +55,4 @@
right: 0;
}
}
// Visible grip indicator (opt-in via the `withHandle` prop). Purely visual —
// pointer events fall through to the handle so it still owns drag + reset.
&__grip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 26px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l1-background);
color: var(--l2-foreground);
pointer-events: none;
}
&__handle:hover &__grip,
&__handle:active &__grip {
border-color: var(--primary);
color: var(--primary);
}
}

View File

@@ -1,4 +1,3 @@
import { GripVertical } from '@signozhq/icons';
import { useCallback, useRef, useState } from 'react';
import './ResizableBox.styles.scss';
@@ -12,28 +11,15 @@ export interface ResizableBoxProps {
// resize (width). Dragging the handle away from the content grows the box;
// dragging it toward the content shrinks it.
handle?: ResizableBoxHandle;
// Canonical default size, and the target that double-click reset restores to.
defaultHeight?: number;
minHeight?: number;
maxHeight?: number;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
// Starting size when different from the default (e.g. a persisted value).
// Falls back to defaultWidth/defaultHeight when omitted, preserving the
// behavior of callers that don't opt in.
initialWidth?: number;
initialHeight?: number;
// When true, double-clicking the handle resets the size to
// defaultWidth/defaultHeight and fires onResize with that value.
resetToDefaultOnDoubleClick?: boolean;
// When true, renders a visible grip indicator on the handle so it is
// discoverable as a draggable affordance.
withHandle?: boolean;
onResize?: (size: number) => void;
disabled?: boolean;
className?: string;
handleTestId?: string;
}
function ResizableBox({
@@ -45,22 +31,13 @@ function ResizableBox({
defaultWidth = 200,
minWidth = 50,
maxWidth = Infinity,
initialWidth,
initialHeight,
resetToDefaultOnDoubleClick = false,
withHandle = false,
onResize,
disabled = false,
className,
handleTestId,
}: ResizableBoxProps): JSX.Element {
const isHorizontal = handle === 'left' || handle === 'right';
const isStartHandle = handle === 'top' || handle === 'left';
const [size, setSize] = useState(
isHorizontal
? (initialWidth ?? defaultWidth)
: (initialHeight ?? defaultHeight),
);
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback(
@@ -106,21 +83,6 @@ function ResizableBox({
],
);
const handleDoubleClick = useCallback((): void => {
if (!resetToDefaultOnDoubleClick) {
return;
}
const nextSize = isHorizontal ? defaultWidth : defaultHeight;
setSize(nextSize);
onResize?.(nextSize);
}, [
resetToDefaultOnDoubleClick,
isHorizontal,
defaultWidth,
defaultHeight,
onResize,
]);
const containerStyle = disabled
? undefined
: isHorizontal
@@ -137,22 +99,7 @@ function ResizableBox({
style={containerStyle}
>
<div className="resizable-box__content">{children}</div>
{!disabled && (
<div
role="separator"
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
className={handleClass}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
data-testid={handleTestId}
>
{withHandle && (
<span className="resizable-box__grip">
<GripVertical size={12} />
</span>
)}
</div>
)}
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
</div>
);
}

View File

@@ -1,137 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import ResizableBox from '../ResizableBox';
const HANDLE_TEST_ID = 'resize-handle';
describe('ResizableBox', () => {
it('starts at defaultWidth when initialWidth is omitted', () => {
render(
<ResizableBox
handle="right"
defaultWidth={260}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
expect(box.style.width).toBe('260px');
});
it('starts at initialWidth when provided', () => {
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={340}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
expect(box.style.width).toBe('340px');
});
it('resets to defaultWidth and fires onResize on double-click when enabled', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={480}
onResize={onResize}
resetToDefaultOnDoubleClick
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
expect(box.style.width).toBe('480px');
fireEvent.doubleClick(handle);
expect(box.style.width).toBe('260px');
expect(onResize).toHaveBeenCalledWith(260);
});
it('does nothing on double-click when reset is not enabled', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={480}
onResize={onResize}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
fireEvent.doubleClick(handle);
expect(box.style.width).toBe('480px');
expect(onResize).not.toHaveBeenCalled();
});
it('renders a visible grip only when withHandle is set', () => {
const { rerender, container } = render(
<ResizableBox
handle="right"
defaultWidth={260}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
expect(container.querySelector('.resizable-box__grip')).toBeNull();
rerender(
<ResizableBox
handle="right"
defaultWidth={260}
withHandle
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
expect(container.querySelector('.resizable-box__grip')).not.toBeNull();
});
it('clamps drag to maxWidth and reports the clamped size via onResize', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
minWidth={240}
maxWidth={500}
onResize={onResize}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
fireEvent.mouseDown(handle, { clientX: 0 });
fireEvent.mouseMove(document, { clientX: 1000 });
fireEvent.mouseUp(document);
expect(box.style.width).toBe('500px');
expect(onResize).toHaveBeenLastCalledWith(500);
});
});

View File

@@ -1,89 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import usePanelWidth from '../usePanelWidth';
jest.mock('api/browser/localstorage/get');
jest.mock('api/browser/localstorage/set');
const mockedGet = getLocalStorageKey as jest.MockedFunction<
typeof getLocalStorageKey
>;
const mockedSet = setLocalStorageKey as jest.MockedFunction<
typeof setLocalStorageKey
>;
const ARGS = {
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
defaultWidth: 260,
minWidth: 240,
maxWidth: 500,
};
describe('usePanelWidth', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('returns defaultWidth when nothing is persisted', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(260);
});
it('returns the persisted width when present', () => {
mockedGet.mockReturnValue('340');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(340);
});
it('clamps an out-of-bounds persisted width on read', () => {
mockedGet.mockReturnValue('9999');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(500);
});
it('falls back to defaultWidth for an invalid persisted value', () => {
mockedGet.mockReturnValue('not-a-number');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(260);
});
it('persists a clamped width (debounced)', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
act(() => {
result.current.persistWidth(320);
jest.advanceTimersByTime(200);
});
expect(mockedSet).toHaveBeenCalledWith(
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
'320',
);
});
it('clamps below-min widths before persisting', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
act(() => {
result.current.persistWidth(10);
jest.advanceTimersByTime(200);
});
expect(mockedSet).toHaveBeenCalledWith(
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
'240',
);
});
});

View File

@@ -1,68 +0,0 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import debounce from 'lodash-es/debounce';
import { useCallback, useMemo, useRef } from 'react';
const PERSIST_DEBOUNCE_MS = 150;
interface UsePanelWidthArgs {
/** Per-page localStorage key the width is persisted under. */
storageKey: LOCALSTORAGE;
/** Canonical default width, used when nothing is persisted. */
defaultWidth: number;
minWidth: number;
maxWidth: number;
}
interface UsePanelWidthReturn {
/** Width to start from: the persisted value (clamped) or the default. */
initialWidth: number;
/** Clamp and persist a width. Debounced to avoid a write per mousemove. */
persistWidth: (width: number) => void;
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
/**
* Per-page localStorage persistence for a resizable panel width. Mirrors the
* getLocalStorageKey/setLocalStorageKey idiom used for the trace span-details
* panel position. Pairs with ResizableBox: feed initialWidth into its
* initialWidth prop and persistWidth into its onResize.
*/
function usePanelWidth({
storageKey,
defaultWidth,
minWidth,
maxWidth,
}: UsePanelWidthArgs): UsePanelWidthReturn {
// Read once on mount. Kept in a ref so a re-render doesn't re-read storage.
const initialWidthRef = useRef<number | null>(null);
if (initialWidthRef.current === null) {
const stored = getLocalStorageKey(storageKey);
const parsed = stored !== null && stored !== '' ? Number(stored) : NaN;
initialWidthRef.current = Number.isFinite(parsed)
? clamp(parsed, minWidth, maxWidth)
: defaultWidth;
}
const debouncedWrite = useMemo(
() =>
debounce((width: number): void => {
setLocalStorageKey(storageKey, String(width));
}, PERSIST_DEBOUNCE_MS),
[storageKey],
);
const persistWidth = useCallback(
(width: number): void => {
debouncedWrite(clamp(width, minWidth, maxWidth));
},
[debouncedWrite, minWidth, maxWidth],
);
return { initialWidth: initialWidthRef.current, persistWidth };
}
export default usePanelWidth;

View File

@@ -466,6 +466,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Summary: "Get query range result (v2)",
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
Request: nil,
RequestQuery: new(dashboardtypes.PublicWidgetQueryRangeParams),
RequestContentType: "",
Response: new(querybuildertypesv5.QueryRangeResponse),
ResponseContentType: "application/json",

View File

@@ -418,7 +418,13 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
return
}
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
params := new(dashboardtypes.PublicWidgetQueryRangeParams)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, params.StartTime, params.EndTime)
if err != nil {
render.Error(rw, err)
return

View File

@@ -60,7 +60,7 @@ func newConfig() factory.Config {
Path: "/var/lib/signoz/signoz.db",
Mode: "wal",
BusyTimeout: 10000 * time.Millisecond, // increasing the defaults from https://github.com/mattn/go-sqlite3/blob/master/sqlite3.go#L1098 because of transpilation from C to GO
TransactionMode: "deferred",
TransactionMode: "immediate",
},
}

View File

@@ -10,6 +10,12 @@ import (
// Gettable
// ════════════════════════════════════════════════════════════════════════
// PublicWidgetQueryRangeParams are the query params of the public panel query-range endpoint.
type PublicWidgetQueryRangeParams struct {
StartTime string `query:"startTime" required:"false"`
EndTime string `query:"endTime" required:"false"`
}
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
type GettablePublicDashboardDataV2 struct {
Dashboard *GettableDashboardV2 `json:"dashboard"`

View File

@@ -62,12 +62,6 @@ def pytest_addoption(parser: pytest.Parser):
default="delete",
help="sqlite mode",
)
parser.addoption(
"--sqlite-transaction-mode",
action="store",
default="deferred",
help="sqlite transaction mode",
)
parser.addoption(
"--postgres-version",
action="store",

View File

@@ -17,8 +17,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
ZEUS_NETWORK_ALIAS = "signoz-zeus"
@pytest.fixture(name="zeus", scope="package")
def zeus(
@@ -33,7 +31,6 @@ def zeus(
def create() -> types.TestContainerDocker:
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_network(network)
container.with_network_aliases(ZEUS_NETWORK_ALIAS)
container.start()
return types.TestContainerDocker(
@@ -45,7 +42,7 @@ def zeus(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", ZEUS_NETWORK_ALIAS, 8080)},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
)
def delete(container: types.TestContainerDocker):

View File

@@ -1,19 +1,14 @@
import os
import platform
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from http import HTTPStatus
from os import path
from pathlib import Path
import docker
import docker.errors
import pytest
import requests
from testcontainers.core.container import DockerContainer, Network
from testcontainers.core.image import DockerImage
from fixtures import reuse, types
from fixtures.logger import setup_logger
@@ -21,110 +16,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@dataclass
class SigNozImageBuild:
process: subprocess.Popen
command: list[str]
cache_path: Path | None = None
next_cache_path: Path | None = None
reader: threading.Thread | None = None
def _stream_build_output(pipe, log) -> None:
try:
for line in iter(pipe.readline, ""):
log.info("buildx: %s", line.rstrip())
finally:
pipe.close()
def start_signoz_image_build(pytestconfig: pytest.Config, dockerfile_path: str, arch: str, zeus_url: str) -> SigNozImageBuild:
root = pytestconfig.rootpath.parent
command = [
"docker",
"buildx",
"build",
"--load",
"--progress",
"plain",
"--tag",
"signoz:integration",
"--file",
dockerfile_path,
"--build-arg",
f"TARGETARCH={arch}",
"--build-arg",
f"ZEUSURL={zeus_url}",
str(root),
]
cache_path = None
next_cache_path = None
if os.environ.get("ACTIONS_RUNTIME_TOKEN"):
# Running in GitHub Actions — use BuildKit's native GHA cache backend.
# Avoids the local-write races and partial exports seen with type=local.
scope = os.environ.get("SIGNOZ_BUILDX_GHA_SCOPE", "signoz-integration")
command.extend(["--cache-from", f"type=gha,scope={scope}"])
command.extend(["--cache-to", f"type=gha,scope={scope},mode=max"])
elif build_cache_dir := os.environ.get("SIGNOZ_INTEGRATION_BUILD_CACHE_DIR"):
# Local cache for developer machines / non-GHA CI.
cache_path = Path(build_cache_dir)
next_cache_path = Path(f"{build_cache_dir}-next")
cache_path.parent.mkdir(parents=True, exist_ok=True)
shutil.rmtree(next_cache_path, ignore_errors=True)
if cache_path.exists():
command.extend(["--cache-from", f"type=local,src={cache_path}"])
command.extend(["--cache-to", f"type=local,dest={next_cache_path},mode=max"])
logger.info("Building SigNoz integration image with %s", " ".join(command))
process = subprocess.Popen(
command,
cwd=root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
reader = threading.Thread(target=_stream_build_output, args=(process.stdout, logger), daemon=True)
reader.start()
return SigNozImageBuild(
process=process,
command=command,
cache_path=cache_path,
next_cache_path=next_cache_path,
reader=reader,
)
def wait_for_signoz_image_build(build: SigNozImageBuild) -> None:
returncode = build.process.wait()
if build.reader is not None:
build.reader.join(timeout=5)
if returncode != 0:
raise subprocess.CalledProcessError(returncode, build.command)
if build.cache_path and build.next_cache_path and build.next_cache_path.exists():
shutil.rmtree(build.cache_path, ignore_errors=True)
shutil.move(build.next_cache_path, build.cache_path)
def stop_signoz_image_build(build: SigNozImageBuild) -> None:
if build.process.poll() is not None:
return
build.process.terminate()
try:
build.process.wait(timeout=10)
except subprocess.TimeoutExpired:
build.process.kill()
build.process.wait()
if build.reader is not None:
build.reader.join(timeout=5)
def create_signoz(
network: Network,
zeus: types.TestContainerDocker,
@@ -142,6 +33,9 @@ def create_signoz(
"""
def create() -> types.SigNoz:
# Run the migrations for clickhouse
request.getfixturevalue("migrator")
# Get the no-web flag
with_web = pytestconfig.getoption("--with-web")
@@ -154,15 +48,19 @@ def create_signoz(
if with_web:
dockerfile_path = "cmd/enterprise/Dockerfile.with-web.integration"
# The SigNoz image build does not depend on ClickHouse migrations, so
# build it while the migrator container runs.
image_build = start_signoz_image_build(pytestconfig, dockerfile_path, arch, zeus.container_configs["8080"].base())
try:
request.getfixturevalue("migrator")
wait_for_signoz_image_build(image_build)
except Exception: # pylint: disable=broad-exception-caught
stop_signoz_image_build(image_build)
raise
# Docker build context is the repo root — one up from pytest's
# rootdir (tests/).
self = DockerImage(
path=str(pytestconfig.rootpath.parent),
dockerfile_path=dockerfile_path,
tag="signoz:integration",
buildargs={
"TARGETARCH": arch,
"ZEUSURL": zeus.container_configs["8080"].base(),
},
)
self.build()
env = (
{

View File

@@ -30,7 +30,6 @@ def sqlite(
assert result.fetchone()[0] == 1
mode = pytestconfig.getoption("--sqlite-mode")
transaction_mode = pytestconfig.getoption("--sqlite-transaction-mode")
return types.TestContainerSQL(
container=types.TestContainerDocker(
id="",
@@ -42,7 +41,6 @@ def sqlite(
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
"SIGNOZ_SQLSTORE_SQLITE_PATH": str(path),
"SIGNOZ_SQLSTORE_SQLITE_MODE": mode,
"SIGNOZ_SQLSTORE_SQLITE_TRANSACTION__MODE": transaction_mode,
},
)