Compare commits

...

9 Commits

Author SHA1 Message Date
nityanandagohain
3fee26e65b fix: skip resource filter if it exceeds a threshold 2026-06-01 17:30:55 +05:30
Nityananda Gohain
bfc50ee9c3 chore: use distinct fingerprint in resource filter (#11521)
* chore: use distinct fingerprint in resource filter

* fix: use group by instead of distinct
2026-06-01 09:47:56 +00:00
Tushar Vats
4b08ba1330 fix: qb warnings (#11518) 2026-06-01 09:43:22 +00:00
Ashwin Bhatkal
557a7120df feat: v2 dashboards list page (#11404)
* feat: add useIsDashboardV2 hook

* feat(dashboards-list-v2): page shell

* feat(dashboards-list-v2): loading / error / empty / no-results state components

* feat(dashboards-list-v2): SearchBar input component

* feat(dashboards-list-v2): CreateDashboardDropdown (new / import JSON entry)

* feat(dashboards-list-v2): DashboardRow + per-row ActionsPopover with v2 delete

* feat(dashboards-list-v2): ListHeader with sort/order + Configure Metadata trigger

* feat(dashboards-list-v2): JSON import modal (Monaco editor + sample)

* feat(dashboards-list-v2): Configure Metadata modal + persisted visible-columns store

* feat(dashboards-list-v2): wire list to v2 API with pagination, URL state, debounced search

* chore: park v2 dashboard pages until backend lands on main

* refactor(dashboards-list-v2): extract lastUpdatedLabel into utils

Dedupe the relative-time formatter that was copied into both DashboardRow
and ConfigureMetadataModal. Single source in DashboardsListPageV2/utils.ts.

* refactor(dashboards-list-v2): group state components under states/

Move EmptyState, ErrorState, LoadingState, and NoResultsState under
components/states/. They're a coherent family (interchangeable view
branches in the list orchestrator) and grouping them sets up shared
styling extraction next.

Pure relocation — git mv preserves history; only DashboardsList.tsx's
imports change.

* refactor(dashboards-list-v2): extract shared state wrapper styles

Pull the dashed-border card layout, body text, and learn-more CTA into
states/states.module.scss. EmptyState, ErrorState, and NoResultsState
now compose from the shared base and only declare what differs
(padding, gap, color, font-weight). LoadingState is a different shape
(skeleton stack) and stays untouched.

Cascaded properties are byte-equivalent — pure de-duplication.

* refactor(dashboards-list-v2): port to @signozhq/ui primitives

Replace antd Button/Input usages and bespoke <button> rows with the
@signozhq/ui equivalents across the four review-flagged surfaces:

  - EmptyState "Learn more" → Button variant="link" color="primary"
  - ErrorState "Retry" → Button variant="outlined" color="secondary"
    with prefix icon; drops the sub-pixel .retryButton overrides in
    favour of the variant's tokenized layout.
  - ErrorState "Contact Support" → Button variant="link" color="primary"
  - SearchBar Input → signoz Input (data-testid → testId)
  - ActionsPopover rows → Button variant="ghost" color="secondary"
    (color="destructive" for Delete) with prefix icons
  - ActionsPopover trigger → Button size="icon" variant="ghost"

The signoz button variant handles the icon-slot alignment that the
removed .actionItem styling reimplemented manually, so the bulk of
ActionsPopover SCSS is deleted. A minimal .menuItem class still
left-aligns + fills the popover row, and .deleteDivider keeps the
hairline separator above the destructive action.

Popover itself stays on antd — migrating to @signozhq/ui/dropdown-menu
is deferred (same TODO as CreateDashboardDropdown).

* style(dashboards-list-v2): use font tokens across new SCSS

Replace literal font-size/weight values across the V2 page SCSS with
the shared design-token custom properties already used elsewhere:

  - 14px → var(--font-size-sm)
  - 12px → var(--font-size-xs)
  - 400  → var(--font-weight-normal)
  - 500  → var(--font-weight-medium)
  - 600  → var(--font-weight-semibold)

ErrorState.module.scss was tokenized alongside its .retryButton
removal in the previous commit. Three literals remain because they
have no clean token equivalent: 11px (sub-token), 8px (avatar
initial), and 12.805px (sub-pixel artifact from a Figma export,
a separate concern).

* style(dashboards-list-v2): replace sub-pixel padding in CreateDashboardDropdown

Round 5.937px / 11.875px / 1.484px to clean integer pixel values
(6px / 12px / 2px). The sub-pixel values were the "trial-and-error"
artifact reviewer flagged — likely a Figma scaling round-trip.

Difference is sub-pixel and not visible on any standard DPR.

* feat(dashboards-list-v2): treat search input as DSL with explicit submission

What the user types is what gets sent to the API as the `query`
param — no more wrapping the input in a synthesized
`name CONTAINS '…' or description CONTAINS '…'` clause.

This gives users the full DSL surface (name, description, created_by,
created_at, updated_at, locked, public, source, tag-key equality)
instead of the name+description CONTAINS approximation.

Submission is explicit (Enter, blur, or a return-key icon button
in the input's suffix slot) so an in-progress DSL string never
hits the API mid-typing. On error, the toolbar (search + create)
stays visible above the ErrorState so users can edit and retry
without losing their place.

  - Drop buildSearchDSL helper and the 300ms debounce.
  - Pass searchInput.trim() to the API; empty input clears filter.
  - SearchBar gains onSubmit (Enter / blur / suffix click).
  - Move ErrorState below the toolbar in DashboardsList layout.
  - Sync local input from searchString on URL changes (back/fwd).
  - Placeholder updated to hint at DSL syntax.

Trade-off: a bare "foo" no longer matches anything; users now need
to type "name CONTAINS 'foo'". This is the new UX.

* fix(dashboards-list-v2): tighten ActionsPopover button styling

  - Drop variant="ghost" on the three non-destructive row buttons so
    they pick up the signoz Button default.
  - Use <Divider /> from @signozhq/ui above the destructive Delete
    button instead of a bespoke .deleteDivider class.
  - Drop the surrounding .content padding and the now-unused
    .deleteDivider rule. Keep .menuItem for left-alignment + width.

* fix(dashboards-list-v2): surface DSL parse errors on 400 responses

When the BE rejects the search query with a 4xx, show the server-
provided detail (e.g. `invalid filter query: unsupported expression
"asdas" — every term must be of the form \`key OP value\``) instead
of the generic "Something went wrong" panel.

  - ErrorState gains optional httpStatus + errorMessage props.
    For 4xx it renders a two-line layout (title + cleaned detail)
    and drops the Retry button (retrying the same bad query won't
    help). Contact Support stays.
  - Add formatQueryErrorMessage util: strips the "invalid filter
    query:" prefix that the FE title already implies, and rewrites
    backtick-quoted format hints to use double quotes.
  - DashboardsList extracts status + message via toAPIError and
    passes them through. Search toolbar remains visible above the
    error, so the user can fix and resubmit.

* chore: fix lint error due to rebase
2026-06-01 08:48:50 +00:00
Yunus M
11eb6e112b feat: rename AI Assistant to Noz and introduce Noz component with hov… (#11510)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* feat: rename AI Assistant to Noz and introduce Noz component with hover animation

* fix(ai-assistant): render Noz empty-state icon at true brand color

The .emptyIcon wrapper around the large empty-state Noz mascot applied
opacity: 0.85. Since opacity composites against the background, the brand
red (#E5484D) measured as #E96469 over the light panel and #C53F44 over
the dark panel instead of its true value. Removed the opacity so the
mascot keeps #E5484D in both themes.

---------

Co-authored-by: makeavish <makeavish786@gmail.com>
2026-05-30 06:21:57 +00:00
Vinicius Lourenço
0d035ef57d feat(tanstack-text): add support for forwardRef (#11464)
Some checks failed
build-staging / prepare (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
build-staging / js-build (push) Has been cancelled
2026-05-30 04:14:25 +00:00
Vinicius Lourenço
72dd544288 Revert "refactor: replace antd Tabs with @signozhq/ui Tabs (#11392)" (#11507)
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
This reverts commit da1b09c479.
2026-05-29 19:30:42 +00:00
Pandey
53b2b2f017 fix(telemetrystore): fix clickhouse connection-pool slot leak (acquire conn timeout) (#11506)
* fix(telemetrystore): upgrade clickhouse-go to v2.44.0 to fix connection-pool slot leak

clickhouse-go v2.43.0 introduced connection-pool slot leaks triggered by context
cancellation: acquire() failed to release the pool slot when idle.Get returned a
cancellation error (ClickHouse/clickhouse-go#1759), and batch.Close() never released
the connection when closeQuery() failed on a cancelled context
(ClickHouse/clickhouse-go#1795). Both leak slots until the pool is exhausted and every
query fails with 'acquire conn timeout'. Both are fixed in v2.44.0.

v2.44.0 adds HasData() to the driver.Rows interface, which the test mock did not
implement. Swap the mock to the SigNoz fork github.com/SigNoz/clickhouse-go-mock
v0.14.0, which implements HasData() and tracks v2.44.0.

* feat(telemetrystore): emit clickhouse connection-pool metrics

Register OTel observable gauges that report the clickhouse connection-pool stats
from driver.Stats() on each collection cycle:
signoz.telemetrystore.connection.{open,idle,max_open,max_idle}. Plotting open against
max_open makes pool saturation (and leaks like the one fixed in the previous commit)
directly observable in Prometheus.
2026-05-30 01:06:16 +05:30
Vikrant Gupta
b568f3e5cb chore(ci): remove unused frontend build variables (#11504) 2026-05-29 17:13:17 +00:00
118 changed files with 5211 additions and 403 deletions

View File

@@ -58,8 +58,6 @@ jobs:
run: |
mkdir -p frontend
echo 'CI=1' > frontend/.env
echo 'VITE_INTERCOM_APP_ID="${{ secrets.INTERCOM_APP_ID }}"' >> frontend/.env
echo 'VITE_SEGMENT_ID="${{ secrets.SEGMENT_ID }}"' >> frontend/.env
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env

View File

@@ -24,8 +24,6 @@ jobs:
- name: dotenv-frontend
working-directory: frontend
run: |
echo 'VITE_INTERCOM_APP_ID="${{ secrets.INTERCOM_APP_ID }}"' > .env
echo 'VITE_SEGMENT_ID="${{ secrets.SEGMENT_ID }}"' >> .env
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env

View File

@@ -52,6 +52,7 @@ jobs:
- rootuser
- serviceaccount
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
sqlstore-provider:
- postgres

View File

@@ -26,7 +26,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
)
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {

View File

@@ -1,8 +1,9 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot, Sparkles } from '@signozhq/icons';
import { Dot } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from 'container/AIAssistant/events';
@@ -21,6 +22,7 @@ import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { Typography } from '@signozhq/ui/typography';
interface HeaderRightSectionProps {
enableAnnouncements: boolean;
@@ -107,21 +109,22 @@ function HeaderRightSection({
</span>
) : null}
<TooltipSimple title="AI Assistant">
<TooltipSimple title="Noz">
<Button
variant="solid"
color="secondary"
className="noz-wave"
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open AI Assistant, 1 action needs your response'
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
: 'Open AI Assistant'
? 'Open Noz, 1 action needs your response'
: `Open Noz, ${pendingUserInputCount} actions need your response`
: 'Open Noz'
}
prefix={<Sparkles size={14} color="var(--primary)" />}
prefix={<Noz size={20} />}
>
AI Assistant
<Typography.Text>Noz</Typography.Text>
</Button>
</TooltipSimple>
</div>

View File

@@ -0,0 +1,86 @@
@keyframes noz-wave-wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
75% {
transform: rotate(-20deg);
}
}
.noz {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
overflow: visible;
svg {
display: block;
overflow: visible;
}
:global {
.noz-arm-left {
transform-origin: 4.18383px 13.4752px;
transform: rotate(0deg);
transition: transform 0.55s cubic-bezier(0.34, 1.7, 0.5, 1);
will-change: transform;
}
.noz-arm-wiggle {
transform-origin: 4.18383px 13.4752px;
transform: rotate(0deg);
will-change: transform;
}
.noz-head {
transform-origin: 12.02px 18.37px;
transform: rotate(0deg);
transition: transform 0.5s cubic-bezier(0.34, 1.7, 0.5, 1);
will-change: transform;
}
}
&:hover {
:global(.noz-arm-left) {
transform: rotate(145deg) scale(1, 1.7);
}
:global(.noz-arm-wiggle) {
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
}
:global(.noz-head) {
transform: rotate(9deg);
}
}
}
:global(.noz-wave):hover {
:global(.noz-arm-left) {
transform: rotate(145deg) scale(1, 1.7);
}
:global(.noz-arm-wiggle) {
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
}
:global(.noz-head) {
transform: rotate(9deg);
}
}
@media (prefers-reduced-motion: reduce) {
.noz {
:global(.noz-arm-left),
:global(.noz-arm-wiggle),
:global(.noz-head) {
transition: none;
animation: none !important;
}
}
}

View File

@@ -0,0 +1,100 @@
import cx from 'classnames';
import styles from './Noz.module.scss';
interface NozProps {
size?: number;
className?: string;
}
/**
* Noz — the SigNoz AI assistant mascot. Waves on hover.
*
* Hover behavior:
* - Hovering the icon itself triggers the wave.
* - To make a parent element (e.g. a Button) trigger the wave on its own
* hover, add the class `noz-wave` to that parent.
*/
export default function Noz({ size = 24, className }: NozProps): JSX.Element {
return (
<span
className={cx(styles.noz, className)}
style={{ width: size, height: size }}
aria-hidden="true"
>
<svg
viewBox="-2 0.5 28 28"
width={size}
height={size}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* body */}
<rect
x="4.35938"
y="8.49908"
width="15.4569"
height="11.978"
rx="1.76147"
fill="#E5484D"
/>
{/* legs */}
<rect
x="6.87012"
y="19.0679"
width="3.34679"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
<rect
x="13.916"
y="19.0679"
width="3.34679"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
{/* right arm (static) */}
<rect
x="18.7598"
y="13.4752"
width="2.11376"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
{/* left arm: outer group does the "lift", inner does the wiggle */}
<g className="noz-arm-left">
<g className="noz-arm-wiggle">
<rect
x="3.12695"
y="13.4752"
width="2.11376"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
</g>
</g>
{/* head: face + eye + hat tilt together */}
<g className="noz-head">
<circle cx="12.0217" cy="14.4881" r="3.87523" fill="#F5F5F5" />
<path
d="M12.0237 12.8024C12.0237 13.7328 11.2673 14.4892 10.337 14.4892C10.0339 14.4892 9.74926 14.4101 9.50152 14.2678C9.47517 14.5551 9.49888 14.8502 9.57795 15.1428C9.93901 16.4921 11.3279 17.2933 12.6773 16.9323C14.0267 16.5712 14.8279 15.1823 14.4668 13.8329C14.1453 12.6285 13.0041 11.8616 11.8023 11.967C11.942 12.2121 12.0237 12.4967 12.0237 12.8024Z"
fill="#0A0C10"
/>
<path
d="M8.33833 7.94578L9.83358 4.31319C10.1302 3.59261 10.6676 2.99939 11.355 2.63299L13.9181 1.26684C14.1327 1.15169 14.3804 1.34885 14.3194 1.58439L13.6703 4.06892C13.6511 4.14046 13.6424 4.21374 13.6424 4.28876C13.6424 4.39868 13.6633 4.5086 13.7052 4.61154L15.0382 7.94578H11.4248L11.6307 7.32813L12.3356 7.09259C12.449 7.05421 12.5257 6.94778 12.5257 6.82739C12.5257 6.707 12.449 6.60057 12.3356 6.56218L11.6307 6.32664L11.3951 5.62176C11.3568 5.51009 11.2503 5.43333 11.1299 5.43333C11.0096 5.43333 10.9031 5.5101 10.8647 5.6235L10.6292 6.32839L9.92431 6.56393C9.8109 6.60231 9.73413 6.70874 9.73413 6.82913C9.73413 6.94952 9.8109 7.05595 9.92431 7.09434L10.6292 7.32988L10.8351 7.94752H8.33833V7.94578ZM12.1 3.43558C12.0808 3.378 12.0285 3.33962 11.9674 3.33962C11.9064 3.33962 11.854 3.378 11.8348 3.43558L11.7179 3.78802L11.3655 3.90492C11.3079 3.92411 11.2695 3.97645 11.2695 4.03752C11.2695 4.09859 11.3079 4.15093 11.3655 4.17012L11.7179 4.28702L11.8348 4.63946C11.854 4.69704 11.9064 4.73542 11.9674 4.73542C12.0285 4.73542 12.0808 4.69704 12.1 4.63946L12.2169 4.28702L12.5694 4.17012C12.6269 4.15093 12.6653 4.09859 12.6653 4.03752C12.6653 3.97645 12.6269 3.92411 12.5694 3.90492L12.2169 3.78802L12.1 3.43558ZM7.78 7.91088H15.5965C15.9053 7.91088 16.1548 8.16038 16.1548 8.4692C16.1548 8.77803 15.9053 9.02753 15.5965 9.02753H7.78C7.47118 9.02753 7.22168 8.77803 7.22168 8.4692C7.22168 8.16038 7.47118 7.91088 7.78 7.91088Z"
fill="#4E74F8"
/>
</g>
</svg>
</span>
);
}
Noz.defaultProps = {
size: 24,
className: undefined,
};

View File

@@ -1,12 +0,0 @@
.route-tab-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px;
}
.route-tab-extra {
display: flex;
align-items: center;
}

View File

@@ -70,7 +70,7 @@ describe('RouteTab component', () => {
</Router>,
);
expect(history.location.pathname).toBe('/');
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
expect(history.location.pathname).toBe('/tab2');
});
@@ -87,7 +87,7 @@ describe('RouteTab component', () => {
/>
</Router>,
);
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
expect(onChangeHandler).toHaveBeenCalled();
});
});

View File

@@ -1,17 +1,10 @@
import './RouteTab.styles.scss';
import {
generatePath,
matchPath,
useLocation,
useParams,
} from 'react-router-dom';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import { Tabs, TabsProps } from 'antd';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { RouteTabProps } from './types';
@@ -23,13 +16,11 @@ interface Params {
function RouteTab({
routes,
activeKey,
defaultActiveKey,
onChangeHandler,
history,
showRightSection = true,
tabBarExtraContent,
hideTabBar = false,
}: RouteTabProps): JSX.Element {
showRightSection,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>();
const location = useLocation();
@@ -55,38 +46,38 @@ function RouteTab({
}
};
const resolvedActiveKey = currentRoute?.key || activeKey;
const extraContent =
tabBarExtraContent ??
(showRightSection && (
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
));
const items = routes.map(({ Component, name, route, key }) => ({
label: name,
key,
tabKey: route,
children: <Component />,
}));
return (
<TabsRoot
value={resolvedActiveKey}
defaultValue={defaultActiveKey ?? resolvedActiveKey}
onValueChange={onChange}
>
{!hideTabBar && (
<div className="route-tab-header">
<TabsList>
{routes.map(({ name, key }) => (
<TabsTrigger key={key} value={key}>
{name}
</TabsTrigger>
))}
</TabsList>
{extraContent && <div className="route-tab-extra">{extraContent}</div>}
</div>
)}
{routes.map(({ key, Component }) => (
<TabsContent key={key} value={key}>
<Component />
</TabsContent>
))}
</TabsRoot>
<Tabs
onChange={onChange}
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}
defaultActiveKey={currentRoute?.key || activeKey}
animated
items={items}
tabBarExtraContent={
showRightSection && (
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
)
}
{...rest}
/>
);
}
RouteTab.defaultProps = {
onChangeHandler: undefined,
showRightSection: true,
};
export default RouteTab;

View File

@@ -1,5 +1,5 @@
import { TabsProps } from 'antd';
import { History } from 'history';
import { ReactNode } from 'react';
export type TabRoutes = {
name: React.ReactNode;
@@ -10,11 +10,8 @@ export type TabRoutes = {
export interface RouteTabProps {
routes: TabRoutes[];
activeKey: string | undefined;
defaultActiveKey?: string;
activeKey: TabsProps['activeKey'];
onChangeHandler?: (key: string) => void;
history: History<unknown>;
showRightSection?: boolean;
tabBarExtraContent?: ReactNode;
hideTabBar?: boolean;
showRightSection: boolean;
}

View File

@@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
@@ -22,21 +22,19 @@ type WithDangerousHtml = BaseProps & {
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
function TanStackTableText({
children,
className,
dangerouslySetInnerHTML,
...rest
}: TanStackTableTextProps): JSX.Element {
return (
const TanStackTableText = forwardRef<HTMLSpanElement, TanStackTableTextProps>(
({ children, className, dangerouslySetInnerHTML, ...rest }, ref) => (
<span
ref={ref}
className={cx(tableStyles.tableCellText, className)}
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
{...rest}
>
{children}
</span>
);
}
),
);
TanStackTableText.displayName = 'TanStackTableText';
export default TanStackTableText;

View File

@@ -76,4 +76,19 @@
.cmd-item-icon {
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
svg {
height: 16px !important;
width: 16px !important;
}
&.noz-icon {
svg {
height: 20px !important;
width: 20px !important;
}
}
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import cx from 'classnames';
import { useLocation } from 'react-router-dom';
import {
CommandDialog,
@@ -162,7 +163,11 @@ export function CmdKPalette({
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
<span
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>

View File

@@ -42,4 +42,5 @@ export enum LOCALSTORAGE {
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
}

View File

@@ -15,10 +15,10 @@ import {
ListMinus,
ScrollText,
Settings,
Sparkles,
TowerControl,
Workflow,
} from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import { ROLES } from 'types/roles';
export type CmdAction = {
@@ -292,11 +292,11 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
if (aiAssistant) {
actions.unshift({
id: 'ai-assistant',
name: 'Open AI Assistant',
name: 'Open Noz',
shortcut: ['cmd+j'],
keywords: 'ai assistant chat ask sparkles copilot',
section: 'AI Assistant',
icon: <Sparkles size={14} />,
keywords: 'noz ai assistant chat ask sparkles copilot',
section: 'Noz',
icon: <Noz size={16} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: aiAssistant.open,
});

View File

@@ -4,7 +4,8 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
import { Maximize2, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
@@ -46,9 +47,9 @@ export default function AIAssistantDrawer(): JSX.Element {
closeIcon={null}
title={
<div>
<div>
<MessageSquare size={16} />
<span>AI Assistant</span>
<div className="noz-wave">
<Noz size={16} />
<span>Noz</span>
</div>
<div>

View File

@@ -4,7 +4,8 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -142,15 +143,15 @@ export default function AIAssistantModal(): JSX.Element | null {
className={styles.backdrop}
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
aria-label="Noz"
onClick={handleBackdropClick}
>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={16} color="var(--primary)" />
<span>AI Assistant</span>
<div className={`${styles.title} noz-wave`}>
<Noz size={16} />
<span>Noz</span>
<kbd className={styles.shortcut}>
<span></span>
<span>J</span>

View File

@@ -3,7 +3,8 @@ import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import { History, Maximize2, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -137,9 +138,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={18} color="var(--primary)" />
<span>AI Assistant</span>
<div className={`${styles.title} noz-wave`}>
<Noz size={18} />
<span>Noz</span>
</div>
<div className={styles.actions}>

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
@@ -42,15 +42,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
}
return (
<TooltipSimple title="AI Assistant">
<TooltipSimple title="Noz">
<Button
variant="solid"
color="primary"
className={styles.trigger}
className={`${styles.trigger} noz-wave`}
onClick={handleOpen}
aria-label="Open AI Assistant"
aria-label="Open Noz"
>
<Bot size={20} />
<Noz size={24} />
</Button>
</TooltipSimple>
);

View File

@@ -28,7 +28,6 @@
.emptyIcon {
margin-bottom: 4px;
opacity: 0.85;
}
.emptyTitle {

View File

@@ -7,8 +7,8 @@ import {
ChartBar,
Search,
Zap,
Sparkles,
} from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -177,10 +177,10 @@ export default function VirtualizedMessages({
if (messages.length === 0 && !showStreamingSlot) {
return (
<div className={styles.empty}>
<div className={styles.emptyIcon}>
<Sparkles size={24} color="var(--primary)" />
<div className={`${styles.emptyIcon} noz-wave`}>
<Noz size={48} />
</div>
<h3 className={styles.emptyTitle}>SigNoz AI Assistant</h3>
<h3 className={styles.emptyTitle}>Noz</h3>
<p className={styles.emptySubtitle}>
Ask questions about your traces, logs, metrics, and infrastructure.
</p>

View File

@@ -0,0 +1,70 @@
.settings-tabs {
.ant-tabs-nav-list {
height: 32px;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
transition: opacity 0.1s !important;
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px;
}
.ant-tabs-tab:not(:last-child) {
border-right: 1px solid var(--l1-border) !important;
}
.overview-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.variables-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.public-dashboard-btn {
width: 150px;
display: flex;
align-items: center;
justify-content: center;
&.disabled-btn {
opacity: 0.5;
cursor: not-allowed;
}
}
.ant-tabs-ink-bar {
display: none;
}
.ant-tabs-tab-active {
.overview-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.variables-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
}
}
.ant-tabs-nav::before {
border-bottom: none;
}
}

View File

@@ -1,4 +1,4 @@
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import { Button, Tabs, Tooltip } from 'antd';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Braces, Globe, Table } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -9,6 +9,8 @@ import DashboardVariableSettings from './DashboardVariableSettings';
import GeneralDashboardSettings from './General';
import PublicDashboardSetting from './PublicDashboard';
import './DashboardSettingsContent.styles.scss';
function DashboardSettings({
variablesSettingsTabHandle,
}: {
@@ -19,26 +21,49 @@ function DashboardSettings({
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const publicDashboardItem: TabItemProps = {
const publicDashboardItem = {
label: (
<Tooltip
title={
user?.role !== USER_ROLES.ADMIN
? 'Only admins can publish / manage public dashboards'
: ''
}
placement="right"
>
<Button
type="text"
icon={<Globe size={14} />}
className={`public-dashboard-btn ${
user?.role !== USER_ROLES.ADMIN ? 'disabled-btn' : ''
}`}
>
Publish
</Button>
</Tooltip>
),
key: 'public-dashboard',
label: 'Publish',
prefixIcon: <Globe size={14} />,
children: <PublicDashboardSetting />,
disabled: user?.role !== USER_ROLES.ADMIN,
disabledReason: 'Only admins can publish / manage public dashboards',
};
const items: TabItemProps[] = [
const items = [
{
label: (
<Button type="text" icon={<Table size={14} />} className="overview-btn">
Overview
</Button>
),
key: 'general',
label: 'Overview',
prefixIcon: <Table size={14} />,
children: <GeneralDashboardSettings />,
},
{
label: (
<Button type="text" icon={<Braces size={14} />} className="variables-btn">
Variables
</Button>
),
key: 'variables',
label: 'Variables',
prefixIcon: <Braces size={14} />,
children: (
<DashboardVariableSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
@@ -48,7 +73,7 @@ function DashboardSettings({
...(enablePublicDashboard ? [publicDashboardItem] : []),
];
return <Tabs items={items} />;
return <Tabs items={items} animated className="settings-tabs" />;
}
export default DashboardSettings;

View File

@@ -1,4 +1,5 @@
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import { Button, Tabs, TabsProps } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { CableCar, Group } from '@signozhq/icons';
import { IntegrationDetailedProps } from 'types/api/integrations/types';
@@ -21,11 +22,18 @@ function IntegrationDetailContent(
): JSX.Element {
const { activeDetailTab, integrationData, integrationId, setActiveDetailTab } =
props;
const items: TabItemProps[] = [
const items: TabsProps['items'] = [
{
key: 'overview',
label: 'Overview',
prefixIcon: <CableCar size={14} />,
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<CableCar size={14} />}
>
<Typography.Text className="typography">Overview</Typography.Text>
</Button>
),
children: (
<Overview
categories={integrationData.categories}
@@ -36,8 +44,15 @@ function IntegrationDetailContent(
},
{
key: 'configuration',
label: 'Configure',
prefixIcon: <ConfigureIcon />,
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<ConfigureIcon />}
>
<Typography.Text className="typography">Configure</Typography.Text>
</Button>
),
children: (
<Configure
configuration={integrationData.configuration}
@@ -47,8 +62,15 @@ function IntegrationDetailContent(
},
{
key: 'dataCollected',
label: 'Data Collected',
prefixIcon: <Group size={14} />,
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<Group size={14} />}
>
<Typography.Text className="typography">Data Collected</Typography.Text>
</Button>
),
children: (
<DataCollected
logsData={integrationData.data_collected.logs}
@@ -59,7 +81,11 @@ function IntegrationDetailContent(
];
return (
<div className="integration-detail-container">
<Tabs value={activeDetailTab} items={items} onChange={setActiveDetailTab} />
<Tabs
activeKey={activeDetailTab}
items={items}
onChange={setActiveDetailTab}
/>
</div>
);
}

View File

@@ -168,6 +168,45 @@
padding: 10px 16px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
.integration-tab-btns {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 8px 18px 8px !important;
.typography {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.integration-tab-btns:hover {
&.ant-btn-text {
background-color: unset !important;
}
}
.ant-tabs-nav-list {
gap: 24px;
}
.ant-tabs-nav {
padding: 0px !important;
}
.ant-tabs-tab {
padding: 0 !important;
}
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px !important;
}
}
.uninstall-integration-bar {

View File

@@ -27,7 +27,7 @@ export default function NavItem({
showIcon?: boolean;
dataTestId?: string;
}): JSX.Element {
const { label, icon, isBeta, isNew } = item;
const { label, icon, isBeta, isNew, isEarlyAccess } = item;
const handleTogglePinClick = (
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
@@ -53,7 +53,11 @@ export default function NavItem({
>
{showIcon && <div className="nav-item-active-marker" />}
<div className={cx('nav-item-data', isBeta ? 'beta-tag' : '')}>
{showIcon && <div className="nav-item-icon">{icon}</div>}
{showIcon && (
<div className={cx('nav-item-icon', isEarlyAccess ? 'noz-wave' : '')}>
{icon}
</div>
)}
<div className="nav-item-label">{label}</div>
@@ -73,6 +77,12 @@ export default function NavItem({
</div>
)}
{isEarlyAccess && (
<div className="nav-item-early-access">
<Badge color="robin">Early Access</Badge>
</div>
)}
{onTogglePin && !isPinned && (
<Tooltip title="Add to shortcuts" placement="right">
<Pin

View File

@@ -579,7 +579,8 @@
}
.nav-item-beta,
.nav-item-new {
.nav-item-new,
.nav-item-early-access {
display: none;
}
@@ -623,6 +624,23 @@
background: var(--l3-background);
}
.sidenav-early-access-tag {
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings:
'case' on,
'cpsp' on,
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 9px;
font-style: normal;
font-weight: 300;
line-height: 12px;
letter-spacing: 0.3px;
text-transform: uppercase;
border-radius: 12px;
}
&:not(.pinned) {
.nav-item {
.nav-item-data {
@@ -839,7 +857,8 @@
}
.nav-item-beta,
.nav-item-new {
.nav-item-new,
.nav-item-early-access {
display: block;
}
}
@@ -1012,7 +1031,8 @@
}
.nav-item-beta,
.nav-item-new {
.nav-item-new,
.nav-item-early-access {
display: block;
}
}

View File

@@ -44,6 +44,7 @@ import {
SidebarItem,
} from './sideNav.types';
import { Style } from '@signozhq/design-tokens';
import Noz from 'components/Noz/Noz';
export const getStartedMenuItem = {
key: ROUTES.GET_STARTED,
@@ -92,9 +93,10 @@ const AI_ASSISTANT_NAV_KEY = '/ai-assistant/new';
export const aiAssistantMenuItem = {
key: AI_ASSISTANT_NAV_KEY,
label: 'AI Assistant',
icon: <Sparkles size={16} className="ai-assistant-icon" />,
label: 'Noz',
icon: <Noz size={16} />,
itemKey: 'ai-assistant',
isEarlyAccess: true,
};
export const shortcutMenuItem = {

View File

@@ -14,6 +14,7 @@ export interface SidebarItem {
label?: ReactNode;
isBeta?: boolean;
isNew?: boolean;
isEarlyAccess?: boolean;
isPinned?: boolean;
children?: SidebarItem[];
isExternal?: boolean;

View File

@@ -0,0 +1,10 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsDashboardV2(): boolean {
const { featureFlags } = useAppContext();
return Boolean(
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_DASHBOARD_V2)
?.active,
);
}

View File

@@ -9,7 +9,7 @@ import { AIAssistantEvents } from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { VariantContext } from 'container/AIAssistant/VariantContext';
import { Sparkles } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import styles from './AIAssistantPage.module.scss';
import ConversationsList from 'container/AIAssistant/components/ConversationsList';
@@ -116,9 +116,9 @@ export default function AIAssistantPage(): JSX.Element {
<VariantContext.Provider value="page">
<div className={styles.page}>
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={16} color="var(--primary)" />
<span>AI Assistant</span>
<div className={`${styles.title} noz-wave`}>
<Noz size={18} />
<span>Noz</span>
</div>
</div>

View File

@@ -0,0 +1,5 @@
function DashboardPageV2(): JSX.Element {
return <>DashboardPageV2</>;
}
export default DashboardPageV2;

View File

@@ -1,8 +1,3 @@
function DashboardPageV2(): JSX.Element {
return (
<div>
<h1>Dashboard Page V2</h1>
</div>
);
}
import DashboardPageV2 from './DashboardPageV2';
export default DashboardPageV2;

View File

@@ -0,0 +1,35 @@
.page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
gap: 8px;
height: 48px;
}
.headerLeft {
display: flex;
align-items: center;
gap: 8px;
}
.icon {
color: var(--l2-foreground);
}
.text {
color: var(--muted-foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
import { Typography } from '@signozhq/ui/typography';
import { LayoutGrid } from '@signozhq/icons';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardsList from './components/DashboardsList';
import styles from './DashboardsListPageV2.module.scss';
function DashboardsListPageV2(): JSX.Element {
const [showBanner, setShowBanner] = useState(true);
return (
<div className={styles.page}>
{showBanner && (
<AnnouncementBanner
type="warning"
onClose={(): void => setShowBanner(false)}
>
You&apos;re on the V2 dashboards page. If you landed here unintentionally,
please reach out to Ashwin.
</AnnouncementBanner>
)}
<div className={styles.header}>
<div className={styles.headerLeft}>
<LayoutGrid size={14} className={styles.icon} />
<Typography.Text className={styles.text}>Dashboards</Typography.Text>
</div>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
<DashboardsList />
</div>
);
}
export default DashboardsListPageV2;

View File

@@ -0,0 +1,28 @@
.content {
display: flex;
flex-direction: column;
}
// Make signoz ghost-Button rows fill the popover and left-align their label.
.menuItem {
width: 100%;
justify-content: flex-start;
}
:global(.dashboardActionsPopover) {
:global(.ant-popover-inner) {
width: 200px;
height: auto;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0px;
}
}

View File

@@ -0,0 +1,103 @@
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import {
Expand,
EllipsisVertical,
Link2,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import DeleteActionItem from './DeleteActionItem';
import styles from './ActionsPopover.module.scss';
interface Props {
link: string;
dashboardId: string;
dashboardName: string;
createdBy: string;
isLocked: boolean;
onView: (event: React.MouseEvent<HTMLElement>) => void;
}
function ActionsPopover({
link,
dashboardId,
dashboardName,
createdBy,
isLocked,
onView,
}: Props): JSX.Element {
const [, setCopy] = useCopyToClipboard();
return (
<Popover
content={
<div className={styles.content}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Expand size={14} />}
onClick={onView}
testId="dashboard-action-view"
>
View
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<SquareArrowOutUpRight size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
testId="dashboard-action-open-new-tab"
>
Open in New Tab
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Link2 size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
testId="dashboard-action-copy-link"
>
Copy Link
</Button>
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
>
<Button
size="icon"
variant="ghost"
color="secondary"
testId="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
>
<EllipsisVertical size={14} />
</Button>
</Popover>
);
}
export default ActionsPopover;

View File

@@ -0,0 +1,122 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from 'react-query';
import { Modal, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { CircleAlert, Trash2 } from '@signozhq/icons';
import { toast } from '@signozhq/ui/sonner';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import deleteDashboard from 'api/v1/dashboards/id/delete';
import { invalidateListDashboardsV2 } from 'api/generated/services/dashboard';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import styles from './ActionsPopover.module.scss';
interface Props {
dashboardId: string;
dashboardName: string;
createdBy: string;
isLocked: boolean;
}
function DeleteActionItem({
dashboardId,
dashboardName,
createdBy,
isLocked,
}: Props): JSX.Element {
const { t } = useTranslation(['dashboard']);
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const queryClient = useQueryClient();
const [modal, contextHolder] = Modal.useModal();
const isAuthor = user?.email === createdBy;
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
const { mutate: runDelete } = useMutation({
mutationFn: () => deleteDashboard({ id: dashboardId }),
onSuccess: async () => {
toast.success(
t('dashboard:delete_dashboard_success', { name: dashboardName }),
);
await invalidateListDashboardsV2(queryClient);
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
const openConfirm = useCallback((): void => {
const { destroy } = modal.confirm({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
{' '}
{dashboardName}{' '}
</span>
dashboard?
</Typography.Title>
),
icon: (
<CircleAlert
style={{ color: 'var(--danger-background)', marginInlineEnd: '12px' }}
size="3xl"
/>
),
okText: 'Delete',
okButtonProps: {
danger: true,
onClick: (e): void => {
e.preventDefault();
e.stopPropagation();
runDelete(undefined, { onSettled: () => destroy() });
},
},
centered: true,
});
}, [modal, dashboardName, runDelete]);
const tooltip = ((): string => {
if (!isLocked) {
return '';
}
if (user.role === USER_ROLES.ADMIN || isAuthor) {
return t('dashboard:locked_dashboard_delete_tooltip_admin_author');
}
return t('dashboard:locked_dashboard_delete_tooltip_editor');
})();
return (
<>
<Divider />
<Tooltip placement="left" title={tooltip}>
<Button
variant="ghost"
color="destructive"
className={styles.menuItem}
prefix={<Trash2 size={14} />}
disabled={isDisabled}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) {
openConfirm();
}
}}
testId="dashboard-action-delete"
>
Delete Dashboard
</Button>
</Tooltip>
{contextHolder}
</>
);
}
export default DeleteActionItem;

View File

@@ -0,0 +1,164 @@
.content {
display: flex;
flex-direction: column;
gap: 14px;
}
.preview {
display: flex;
padding: 12px 14.634px;
flex-direction: column;
align-items: flex-start;
gap: 7.317px;
border-radius: 4px;
border: 0.915px solid var(--l1-border);
background: var(--l2-background);
}
.previewHeader {
display: flex;
gap: 10px;
align-items: center;
}
.previewIcon {
height: 14px;
width: 14px;
}
.previewTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 12.805px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 18.293px;
letter-spacing: -0.064px;
}
.previewDetails {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.previewRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.formattedTime {
display: inline-flex;
gap: 8px;
align-items: center;
color: var(--l2-foreground);
}
.formattedTimeText {
font-family: Inter;
font-size: 12.805px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 16.463px;
letter-spacing: -0.064px;
color: var(--l2-foreground);
}
.user {
display: flex;
align-items: center;
gap: 8px;
}
.userTag {
width: 12px;
height: 12px;
display: flex;
justify-content: center;
align-items: center;
color: var(--l2-foreground);
font-size: 8px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: normal;
letter-spacing: -0.05px;
border-radius: 12.805px;
background-color: var(--l1-background);
}
.userLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12.805px;
font-weight: var(--font-weight-normal);
line-height: 16.463px;
letter-spacing: -0.064px;
}
.action {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0px 0px 0px 14.634px;
}
.actionLeft {
display: flex;
gap: 10px;
align-items: center;
}
.connectionLine {
border-top: 1px dashed var(--l1-border);
min-width: 20px;
flex-grow: 1;
margin: 0px 8px;
}
.actionRight {
display: flex;
align-items: center;
}
.saveChanges {
display: flex;
width: 100%;
height: 32px;
padding: 8px 16px;
justify-content: center;
align-items: center;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
}
:global(.configureMetadataModalRoot) {
:global(.ant-modal-content) {
width: 500px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--card);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
}
:global(.ant-modal-header) {
background: var(--card);
padding: 16px;
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
}
:global(.ant-modal-body) {
padding: 14px 16px;
}
:global(.ant-modal-footer) {
margin-top: 0px;
padding: 4px 16px 16px 16px;
}
}

View File

@@ -0,0 +1,218 @@
import { useEffect, useState } from 'react';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Switch } from '@signozhq/ui/switch';
import { CalendarClock, Check, Clock4 } from '@signozhq/icons';
import { get } from 'lodash-es';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';
import { lastUpdatedLabel, type DashboardListItem } from '../../utils';
import {
DynamicColumns,
useDashboardsListVisibleColumnsStore,
type DashboardDynamicColumns,
} from './useDynamicColumns';
import styles from './ConfigureMetadataModal.module.scss';
interface Props {
open: boolean;
previewDashboard: DashboardListItem | undefined;
onClose: () => void;
}
function ConfigureMetadataModal({
open,
previewDashboard,
onClose,
}: Props): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const storedColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
);
const setStoredColumns = useDashboardsListVisibleColumnsStore(
(s) => s.setVisibleColumns,
);
const [draftColumns, setDraftColumns] =
useState<DashboardDynamicColumns>(storedColumns);
useEffect(() => {
if (open) {
setDraftColumns(storedColumns);
}
}, [open, storedColumns]);
const handleSave = (): void => {
setStoredColumns(draftColumns);
onClose();
};
const previewImage = previewDashboard?.image || Base64Icons[0];
const previewName = previewDashboard?.spec?.display?.name;
const previewCreatedBy = previewDashboard?.createdBy;
const previewUpdatedBy = previewDashboard?.updatedBy;
const previewUpdatedAt = previewDashboard?.updatedAt;
const formattedCreatedAt = previewDashboard
? formatTimezoneAdjustedTimestamp(
get(previewDashboard, 'createdAt', '') as string,
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
)
: '';
return (
<Modal
open={open}
onCancel={onClose}
title="Configure Metadata"
footer={
<Button
type="text"
icon={<Check size={14} />}
className={styles.saveChanges}
onClick={handleSave}
>
Save Changes
</Button>
}
rootClassName="configureMetadataModalRoot"
>
<div className={styles.content}>
<div className={styles.preview}>
<section className={styles.previewHeader}>
<img
src={previewImage}
alt="dashboard-image"
className={styles.previewIcon}
/>
<Typography.Text className={styles.previewTitle}>
{previewName}
</Typography.Text>
</section>
<section className={styles.previewDetails}>
<section className={styles.previewRow}>
{draftColumns.createdAt && (
<span className={styles.formattedTime}>
<CalendarClock size={14} />
<Typography.Text className={styles.formattedTimeText}>
{formattedCreatedAt}
</Typography.Text>
</span>
)}
{draftColumns.createdBy && (
<div className={styles.user}>
<Typography.Text className={styles.userTag}>
{previewCreatedBy?.substring(0, 1).toUpperCase()}
</Typography.Text>
<Typography.Text className={styles.userLabel}>
{previewCreatedBy}
</Typography.Text>
</div>
)}
</section>
<section className={styles.previewRow}>
{draftColumns.updatedAt && (
<span className={styles.formattedTime}>
<CalendarClock size={14} />
<Typography.Text className={styles.formattedTimeText}>
{lastUpdatedLabel(previewUpdatedAt)}
</Typography.Text>
</span>
)}
{draftColumns.updatedBy && (
<div className={styles.user}>
<Typography.Text className={styles.userTag}>
{previewUpdatedBy?.substring(0, 1).toUpperCase()}
</Typography.Text>
<Typography.Text className={styles.userLabel}>
{previewUpdatedBy}
</Typography.Text>
</div>
)}
</section>
</section>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<CalendarClock size={14} />
<Typography.Text>Created at</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
value
disabled
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.CREATED_AT]: check,
}))
}
/>
</div>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<CalendarClock size={14} />
<Typography.Text>Created by</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
value
disabled
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.CREATED_BY]: check,
}))
}
/>
</div>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<Clock4 size={14} />
<Typography.Text>Updated at</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
value={draftColumns.updatedAt}
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.UPDATED_AT]: check,
}))
}
/>
</div>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<Clock4 size={14} />
<Typography.Text>Updated by</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
value={draftColumns.updatedBy}
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.UPDATED_BY]: check,
}))
}
/>
</div>
</div>
</div>
</Modal>
);
}
export default ConfigureMetadataModal;

View File

@@ -0,0 +1,52 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
export interface DashboardDynamicColumns {
createdAt: boolean;
createdBy: boolean;
updatedAt: boolean;
updatedBy: boolean;
}
export enum DynamicColumns {
CREATED_AT = 'createdAt',
CREATED_BY = 'createdBy',
UPDATED_AT = 'updatedAt',
UPDATED_BY = 'updatedBy',
}
const DEFAULT_COLUMNS: DashboardDynamicColumns = {
createdAt: true,
createdBy: true,
updatedAt: false,
updatedBy: false,
};
interface DashboardsListVisibleColumnsState {
visibleColumns: DashboardDynamicColumns;
setVisibleColumns: (next: DashboardDynamicColumns) => void;
}
export const useDashboardsListVisibleColumnsStore =
create<DashboardsListVisibleColumnsState>()(
persist(
(set) => ({
visibleColumns: DEFAULT_COLUMNS,
setVisibleColumns: (next): void => {
set({ visibleColumns: next });
},
}),
{
name: LOCALSTORAGE.DASHBOARDS_LIST_VISIBLE_COLUMNS,
merge: (persisted, current) => ({
...current,
visibleColumns: {
...DEFAULT_COLUMNS,
...((persisted as Partial<DashboardsListVisibleColumnsState>)
?.visibleColumns ?? {}),
},
}),
},
),
);

View File

@@ -0,0 +1,34 @@
.menuItem {
display: flex;
align-items: center;
gap: 8px;
}
.templatesItem {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
width: 100%;
}
.primaryButton {
padding: 6px 12px;
}
.textButton {
display: flex;
width: 153px;
align-items: center;
height: 32px;
padding: 6px 12px;
justify-content: center;
gap: 6px;
border-radius: 2px;
background: var(--primary-background);
color: var(--l1-foreground);
}
:global(.createDashboardMenuOverlay) {
width: 200px;
}

View File

@@ -0,0 +1,119 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Dropdown to @signozhq/ui/dropdown-menu
import { Button, Dropdown, MenuProps } from 'antd';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import {
ExternalLink,
Github,
LayoutGrid,
Plus,
Radius,
} from '@signozhq/icons';
import styles from './CreateDashboardDropdown.module.scss';
interface Props {
canCreate: boolean;
onCreate: () => void;
onImportJSON: () => void;
variant?: 'primary' | 'text';
}
const TEMPLATES_HREF =
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
function CreateDashboardDropdown({
canCreate,
onCreate,
onImportJSON,
variant = 'primary',
}: Props): JSX.Element {
const items: MenuProps['items'] = useMemo(() => {
const menuItems: MenuProps['items'] = [
{
key: 'import-json',
label: (
<div
className={styles.menuItem}
data-testid="import-json-menu-cta"
onClick={onImportJSON}
>
<Radius size={14} /> Import JSON
</div>
),
},
{
key: 'view-templates',
label: (
<a
href={TEMPLATES_HREF}
target="_blank"
rel="noopener noreferrer"
data-testid="view-templates-menu-cta"
>
<div className={styles.templatesItem}>
<div className={styles.menuItem}>
<Github size={14} /> View templates
</div>
<ExternalLink size={14} />
</div>
</a>
),
},
];
if (canCreate) {
menuItems.unshift({
key: 'create-dashboard',
label: (
<div
className={styles.menuItem}
data-testid="create-dashboard-menu-cta"
onClick={onCreate}
>
<LayoutGrid size={14} /> Create dashboard
</div>
),
});
}
return menuItems;
}, [canCreate, onCreate, onImportJSON]);
return (
<Dropdown
overlayClassName="createDashboardMenuOverlay"
menu={{ items }}
placement="bottomRight"
trigger={['click']}
>
{variant === 'primary' ? (
<Button
type="primary"
className={cx('periscope-btn primary', styles.primaryButton)}
icon={<Plus size={14} />}
data-testid="new-dashboard-cta"
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
New dashboard
</Button>
) : (
<Button
type="text"
className={styles.textButton}
icon={<Plus size={14} />}
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
New Dashboard
</Button>
)}
</Dropdown>
);
}
export default CreateDashboardDropdown;

View File

@@ -0,0 +1,152 @@
.row {
padding: 12px 16px 16px 16px;
border: 1px solid var(--l1-border);
border-top: none;
background: var(--l2-background);
cursor: pointer;
}
.titleWithAction {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
min-height: 24px;
}
.titleBlock {
display: flex;
align-items: center;
gap: 6px;
line-height: 20px;
flex: 1 1 auto;
min-width: 0;
}
.titleLink {
display: flex;
align-items: center;
gap: 8px;
}
.icon {
display: inline-block;
line-height: 20px;
height: 14px;
width: 14px;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 20px;
letter-spacing: -0.07px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tagsWithActions {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
justify-content: flex-end;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
height: 28px;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
.details {
margin-top: 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 24px;
}
.createdAt {
display: flex;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
font-family: Inter;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
line-height: 18px;
letter-spacing: -0.07px;
}
.createdBy {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 14px;
height: 14px;
border-radius: 50px;
background: var(--l1-border);
display: flex;
justify-content: center;
align-items: center;
}
.avatarText {
color: var(--l2-foreground);
font-size: 8px;
font-weight: var(--font-weight-normal);
line-height: normal;
letter-spacing: -0.05px;
}
.byLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
line-height: 18px;
letter-spacing: -0.07px;
}
.updatedBy {
display: flex;
align-items: center;
gap: 6px;
}
:global(.titleTooltipOverlay) {
:global(.ant-tooltip-content) :global(.ant-tooltip-inner) {
max-height: 400px;
overflow: auto;
}
}

View File

@@ -0,0 +1,154 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Badge } from '@signozhq/ui/badge';
import { CalendarClock } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTimezone } from 'providers/Timezone';
import { isModifierKeyPressed } from 'utils/app';
import type { DashboardListItem } from '../../utils';
import { lastUpdatedLabel, tagsToStrings } from '../../utils';
import ActionsPopover from '../ActionsPopover/ActionsPopover';
import styles from './DashboardRow.module.scss';
interface Props {
dashboard: DashboardListItem;
index: number;
canAct: boolean;
showUpdatedAt: boolean;
showUpdatedBy: boolean;
}
function DashboardRow({
dashboard,
index,
canAct,
showUpdatedAt,
showUpdatedBy,
}: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const id = dashboard.id;
const name = dashboard.spec?.display?.name ?? '';
const image = dashboard.image || Base64Icons[0];
const createdBy = dashboard.createdBy ?? '';
const updatedBy = dashboard.updatedBy ?? '';
const createdAt = dashboard.createdAt ?? '';
const updatedAt = dashboard.updatedAt ?? '';
const isLocked = !!dashboard.locked;
const tags = tagsToStrings(dashboard.tags);
const link = generatePath(ROUTES.DASHBOARD, { dashboardId: id });
const formattedCreatedAt = formatTimezoneAdjustedTimestamp(
createdAt,
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
);
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
event.stopPropagation();
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
logEvent('Dashboard List: Clicked on dashboard', {
dashboardId: id,
dashboardName: name,
});
};
return (
<div className={styles.row} onClick={onClickHandler}>
<div className={styles.titleWithAction}>
<div className={styles.titleBlock}>
<Tooltip
title={name.length > 50 ? name : ''}
placement="left"
overlayClassName="titleTooltipOverlay"
>
<div className={styles.titleLink} onClick={onClickHandler}>
<img src={image} alt="dashboard-image" className={styles.icon} />
<Typography.Text
data-testid={`dashboard-title-${index}`}
className={styles.title}
>
{name}
</Typography.Text>
</div>
</Tooltip>
</div>
<div className={styles.tagsWithActions}>
{tags.length > 0 && (
<div className={styles.tags}>
{tags.slice(0, 3).map((tag) => (
<Badge className={styles.tag} key={tag}>
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge className={styles.tag} key={tags[3]}>
+ <span> {tags.length - 3} </span>
</Badge>
)}
</div>
)}
</div>
{canAct && (
<ActionsPopover
link={link}
dashboardId={id}
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
onView={onClickHandler}
/>
)}
</div>
<div className={styles.details}>
<div className={styles.createdAt}>
<CalendarClock size={14} />
<Typography.Text>{formattedCreatedAt}</Typography.Text>
</div>
{createdBy && (
<div className={styles.createdBy}>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{createdBy.substring(0, 1).toUpperCase()}
</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{createdBy}</Typography.Text>
</div>
)}
{showUpdatedAt && (
<div className={styles.createdAt}>
<CalendarClock size={14} />
<Typography.Text>{lastUpdatedLabel(updatedAt)}</Typography.Text>
</div>
)}
{updatedBy && showUpdatedBy && (
<div className={styles.updatedBy}>
<Typography.Text className={styles.byLabel}>
Last Updated By -
</Typography.Text>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{updatedBy.substring(0, 1).toUpperCase()}
</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{updatedBy}</Typography.Text>
</div>
)}
</div>
</div>
);
}
export default DashboardRow;

View File

@@ -0,0 +1,96 @@
.container {
margin-top: 30px;
margin-bottom: 30px;
display: flex;
justify-content: center;
width: 100%;
}
.viewContent {
width: calc(100% - 30px);
max-width: 836px;
:global(.ant-table-wrapper) :global(.ant-table-cell) {
padding: 0 !important;
border: none !important;
background: var(--l1-background) !important;
}
:global(.ant-table-wrapper)
:global(.ant-table-tbody)
:global(.ant-table-row)
:global(.ant-table-cell)
> div {
// Row content is the only child of the td; it carries the borders.
}
:global(.ant-table-wrapper)
:global(.ant-table-tbody)
:global(.ant-table-row:last-child)
:global(.ant-table-cell)
> div {
border-radius: 0 0 6px 6px;
}
:global(.ant-pagination-item) {
display: flex;
justify-content: center;
align-items: center;
}
:global(.ant-pagination-item) > a {
color: var(--l2-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
line-height: 20px;
}
:global(.ant-pagination-item-active) {
background-color: var(--primary-background);
}
:global(.ant-pagination-item-active) > a {
color: var(--foreground) !important;
font-weight: var(--font-weight-medium);
}
}
.titleContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 28px;
letter-spacing: -0.09px;
}
.subtitle {
color: var(--l2-foreground);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.integrationsContainer {
margin: 16px 0;
}
.integrationsContent {
max-width: 100%;
width: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin: 16px 0;
}

View File

@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { generatePath } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import logEvent from 'api/common/logEvent';
import {
createDashboardV2,
useListDashboardsV2,
} from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
import useComponentPermission from 'hooks/useComponentPermission';
import { toast } from '@signozhq/ui/sonner';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import {
usePage,
useSearch,
useSortColumn,
useSortOrder,
type SortColumn,
type SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import type { DashboardListItem } from '../../utils';
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
import { useDashboardsListVisibleColumnsStore } from '../ConfigureMetadataModal/useDynamicColumns';
import CreateDashboardDropdown from '../CreateDashboardDropdown/CreateDashboardDropdown';
import ImportJSONModal from '../ImportJSONModal/ImportJSONModal';
import ListHeader from '../ListHeader/ListHeader';
import EmptyState from '../states/EmptyState/EmptyState';
import ErrorState from '../states/ErrorState/ErrorState';
import LoadingState from '../states/LoadingState/LoadingState';
import NoResultsState from '../states/NoResultsState/NoResultsState';
import SearchBar from '../SearchBar/SearchBar';
import DashboardsListContent from './DashboardsListContent';
import styles from './DashboardsList.module.scss';
const PAGE_SIZE = 20;
function DashboardsList(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { t } = useTranslation('dashboard');
const { showErrorModal } = useErrorModal();
const { isCloudUser } = useGetTenantLicense();
const { user } = useAppContext();
const [action, canCreateNewDashboard] = useComponentPermission(
['action', 'create_new_dashboards'],
user.role,
);
const [searchString, setSearchString] = useSearch();
const [sortColumn, setSortColumn] = useSortColumn();
const [sortOrder, setSortOrder] = useSortOrder();
const [page, setPage] = usePage();
const [searchInput, setSearchInput] = useState(searchString);
// Keep the local input in sync with external searchString changes
// (browser back/forward, deep link). User typing only mutates
// searchInput, so this won't fight with in-flight edits.
useEffect(() => {
setSearchInput(searchString);
}, [searchString]);
const handleSubmitSearch = useCallback((): void => {
const next = searchInput.trim();
if (next === searchString) {
return;
}
void setSearchString(next);
void setPage(1);
}, [searchInput, searchString, setSearchString, setPage]);
const listParams = useMemo(
() => ({
query: searchString.trim() || undefined,
sort: sortColumn,
order: sortOrder,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
}),
[searchString, sortColumn, sortOrder, page],
);
const {
data: response,
isLoading,
isFetching,
error,
refetch,
} = useListDashboardsV2(listParams, { query: { keepPreviousData: true } });
const apiError = useMemo(
() => (error ? toAPIError(error) : undefined),
[error],
);
const errorHttpStatus = apiError?.getHttpStatusCode();
const errorMessage = apiError?.getErrorMessage();
const dashboards = useMemo<DashboardListItem[]>(
() => response?.data?.dashboards ?? [],
[response],
);
const total = response?.data?.total ?? 0;
const [isImportOpen, setIsImportOpen] = useState(false);
const [isConfigureOpen, setIsConfigureOpen] = useState(false);
const visibleColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
);
const [creating, setCreating] = useState(false);
const handleCreateNew = useCallback(async (): Promise<void> => {
try {
logEvent('Dashboard List: Create dashboard clicked', {});
setCreating(true);
const created = await createDashboardV2({
schemaVersion: 'v6',
// Backend requires `name` (immutable, server-side identifier);
// asking it to generate one keeps the UI's "new dashboard" flow.
generateName: true,
tags: null,
spec: {
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
},
});
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);
} catch (e) {
showErrorModal(e as APIError);
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
} finally {
setCreating(false);
}
}, [safeNavigate, showErrorModal, t]);
const handleImportToggle = useCallback((): void => {
logEvent('Dashboard List V2: Import JSON clicked', {});
setIsImportOpen((s) => !s);
}, []);
const onSortChange = useCallback(
(column: SortColumn): void => {
void setSortColumn(column);
void setPage(1);
},
[setSortColumn, setPage],
);
const onOrderChange = useCallback(
(order: SortOrder): void => {
void setSortOrder(order);
void setPage(1);
},
[setSortOrder, setPage],
);
const visitLoggedRef = useRef(false);
useEffect(() => {
if (!visitLoggedRef.current && !isLoading && response !== undefined) {
logEvent('Dashboard List V2: Page visited', { number: dashboards.length });
visitLoggedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
return (
<div className={styles.container}>
<div className={styles.viewContent}>
<div className={styles.titleContainer}>
<Typography.Title className={styles.title}>Dashboards</Typography.Title>
<Typography.Text className={styles.subtitle}>
Create and manage dashboards for your workspace.
</Typography.Text>
{isCloudUser && (
<div className={styles.integrationsContainer}>
<div className={styles.integrationsContent}>
<RequestDashboardBtn />
</div>
</div>
)}
</div>
{isLoading ? (
<LoadingState />
) : !error && dashboards.length === 0 && !searchString && page === 1 ? (
<EmptyState
createDropdown={
canCreateNewDashboard ? (
<CreateDashboardDropdown
canCreate={!!canCreateNewDashboard}
onCreate={handleCreateNew}
onImportJSON={handleImportToggle}
variant="text"
/>
) : null
}
/>
) : (
<>
<div className={styles.toolbar}>
<SearchBar
value={searchInput}
onChange={setSearchInput}
onSubmit={handleSubmitSearch}
/>
{canCreateNewDashboard && (
<CreateDashboardDropdown
canCreate={!!canCreateNewDashboard}
onCreate={handleCreateNew}
onImportJSON={handleImportToggle}
/>
)}
</div>
{error ? (
<ErrorState
isCloudUser={!!isCloudUser}
onRetry={(): void => {
refetch();
}}
httpStatus={errorHttpStatus}
errorMessage={errorMessage}
/>
) : dashboards.length === 0 ? (
<NoResultsState searchString={searchInput} />
) : (
<>
<ListHeader
sortColumn={sortColumn}
onSortChange={onSortChange}
sortOrder={sortOrder}
onOrderChange={onOrderChange}
onConfigureMetadata={(): void => setIsConfigureOpen(true)}
/>
<DashboardsListContent
dashboards={dashboards}
page={page}
pageSize={PAGE_SIZE}
total={total}
onPageChange={setPage}
canAct={!!action}
showUpdatedAt={visibleColumns.updatedAt}
showUpdatedBy={visibleColumns.updatedBy}
loading={creating || isFetching}
/>
</>
)}
</>
)}
<ImportJSONModal
open={isImportOpen}
onClose={(): void => setIsImportOpen(false)}
/>
<ConfigureMetadataModal
open={isConfigureOpen}
previewDashboard={dashboards[0]}
onClose={(): void => setIsConfigureOpen(false)}
/>
</div>
</div>
);
}
export default DashboardsList;

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { Table } from 'antd';
import type { TableProps } from 'antd/lib';
import type { DashboardListItem } from '../../utils';
import DashboardRow from '../DashboardRow/DashboardRow';
interface Props {
dashboards: DashboardListItem[];
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
canAct: boolean;
showUpdatedAt: boolean;
showUpdatedBy: boolean;
loading: boolean;
}
function DashboardsListContent({
dashboards,
page,
pageSize,
total,
onPageChange,
canAct,
showUpdatedAt,
showUpdatedBy,
loading,
}: Props): JSX.Element {
const columns: TableProps<DashboardListItem>['columns'] = useMemo(
() => [
{
title: 'Dashboards',
key: 'dashboard',
render: (_, dashboard, index): JSX.Element => (
<DashboardRow
dashboard={dashboard}
index={index}
canAct={canAct}
showUpdatedAt={showUpdatedAt}
showUpdatedBy={showUpdatedBy}
/>
),
},
],
[canAct, showUpdatedAt, showUpdatedBy],
);
const paginationConfig = total > pageSize && {
pageSize,
showSizeChanger: false,
onChange: onPageChange,
current: page,
total,
hideOnSinglePage: true,
};
return (
<Table
columns={columns}
dataSource={dashboards.map((d) => ({ ...d, key: d.id }))}
showSorterTooltip
loading={loading}
showHeader={false}
pagination={paginationConfig}
/>
);
}
export default DashboardsListContent;

View File

@@ -0,0 +1,3 @@
import DashboardsList from './DashboardsList';
export default DashboardsList;

View File

@@ -0,0 +1,73 @@
.contentContainer {
display: flex;
flex-direction: column;
}
.contentHeader {
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--l1-border);
}
.footer {
display: flex;
flex-direction: column;
gap: 8px;
}
.jsonError {
display: flex;
align-items: center;
gap: 8px;
}
.errorText {
color: var(--warning-background);
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
:global(.importJsonModalWrapper) {
:global(.ant-modal-content) {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0;
}
:global(.margin) {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
backdrop-filter: blur(20px);
}
:global(.view-lines) {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
backdrop-filter: blur(20px);
}
:global(.ant-modal-footer) {
margin-top: 0;
padding: 16px;
border-top: 1px solid var(--l1-border);
}
}

View File

@@ -0,0 +1,223 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { red } from '@ant-design/colors';
import MEditor, { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Button, Flex, Modal, Upload, UploadProps } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import {
CircleAlert,
ExternalLink,
Github,
MonitorDot,
MoveRight,
Sparkles,
} from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import sampleDashboard from './sampleDashboard.json';
import styles from './ImportJSONModal.module.scss';
import { normalizeToPostable } from './ImportJSONModalUtils';
interface Props {
open: boolean;
onClose: () => void;
}
function ImportJSONModal({ open, onClose }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { t } = useTranslation(['dashboard', 'common']);
const [isUploadError, setIsUploadError] = useState(false);
const [isCreateError, setIsCreateError] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [editorValue, setEditorValue] = useState('');
const { showErrorModal } = useErrorModal();
const isDarkMode = useIsDarkMode();
const handleUpload: UploadProps['onChange'] = (info) => {
const lastFile = info.fileList[info.fileList.length - 1];
if (!lastFile?.originFileObj) {
return;
}
const reader = new FileReader();
reader.onload = (event): void => {
try {
const target = event.target?.result;
if (!target) {
return;
}
const parsed = JSON.parse(target.toString());
setEditorValue(JSON.stringify(parsed, null, 2));
setIsUploadError(false);
} catch {
setIsUploadError(true);
}
};
reader.readAsText(lastFile.originFileObj);
};
const handleImport = async (): Promise<void> => {
try {
setIsCreating(true);
logEvent('Dashboard List V2: Import and next clicked', {});
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
const payload = normalizeToPostable(parsed);
const response = await createDashboardV2(payload);
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);
logEvent('Dashboard List V2: New dashboard imported successfully', {
dashboardId: response.data?.id,
});
} catch (error) {
showErrorModal(error as APIError);
setIsCreateError(true);
toast.error(
error instanceof Error ? error.message : t('error_loading_json'),
);
} finally {
setIsCreating(false);
}
};
const handleClose = (): void => {
setIsUploadError(false);
setIsCreateError(false);
onClose();
};
const setEditorTheme = (monaco: Monaco): void => {
monaco.editor.defineTheme('my-theme', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
],
colors: { 'editor.background': Color.BG_INK_300 },
});
};
const renderError = (msg: string): JSX.Element => (
<div className={styles.jsonError}>
<CircleAlert size="md" color={red[7]} />
<Typography className={styles.errorText}>{msg}</Typography>
</div>
);
return (
<Modal
wrapClassName="importJsonModalWrapper"
open={open}
centered
closable
keyboard
maskClosable
onCancel={handleClose}
destroyOnClose
width="60vw"
footer={
<div className={styles.footer}>
{isCreateError && renderError(t('error_loading_json'))}
{isUploadError && renderError(t('error_upload_json'))}
<div className={styles.actions}>
<Flex gap="small">
<Upload
accept=".json"
showUploadList={false}
multiple={false}
onChange={handleUpload}
beforeUpload={(): boolean => false}
action="none"
>
<Button
type="default"
className="periscope-btn"
icon={<MonitorDot size={14} />}
onClick={(): void => {
logEvent('Dashboard List V2: Upload JSON file clicked', {});
}}
>
{t('upload_json_file')}
</Button>
</Upload>
<Button
type="default"
className="periscope-btn"
icon={<Sparkles size={14} />}
onClick={(): void => {
setEditorValue(JSON.stringify(sampleDashboard, null, 2));
setIsUploadError(false);
logEvent('Dashboard List V2: Load sample clicked', {});
}}
>
Load sample
</Button>
<a
href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
target="_blank"
rel="noopener noreferrer"
>
<Button
type="default"
className="periscope-btn"
icon={<Github size={14} />}
>
{t('view_template')}&nbsp;
<ExternalLink size={14} />
</Button>
</a>
</Flex>
<Button
onClick={handleImport}
loading={isCreating}
className="periscope-btn primary"
type="primary"
>
{t('import_and_next')} &nbsp; <MoveRight size={14} />
</Button>
</div>
</div>
}
>
<div className={styles.contentContainer}>
<div className={styles.contentHeader}>
<Typography.Text>{t('import_json')}</Typography.Text>
</div>
<MEditor
language="json"
height="40vh"
onChange={(newValue): void => setEditorValue(newValue || '')}
value={editorValue}
options={{
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 14,
fontFamily: 'Space Mono',
}}
theme={isDarkMode ? 'my-theme' : 'light'}
onMount={(_, monaco): void => {
document.fonts.ready.then(() => {
monaco.editor.remeasureFonts();
});
}}
beforeMount={setEditorTheme}
/>
</div>
</Modal>
);
}
export default ImportJSONModal;

View File

@@ -0,0 +1,50 @@
import {
DashboardtypesDashboardSpecDTO,
DashboardtypesPostableDashboardV2DTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
// Accept either a complete PostableDashboardV2 (flat shape with `spec` and
// top-level `name` / `image` / `tags` / `schemaVersion`) or a bare spec — wrap
// the latter with defaults so users can paste either shape that exists in the
// wild (e.g. testdata/perses.json is a bare spec). The legacy nested
// `{ metadata: { ... }, spec }` shape is also accepted and flattened.
//
// The backend requires `name` (immutable identifier); if the payload doesn't
// carry one, fall back to `generateName: true` so the server assigns one.
export function normalizeToPostable(
parsed: Record<string, unknown>,
): DashboardtypesPostableDashboardV2DTO {
const hasSpec = 'spec' in parsed;
const legacyMeta = parsed.metadata as
| {
schemaVersion?: string;
name?: string;
image?: string;
tags?: TagtypesPostableTagDTO[] | null;
}
| undefined;
const resolvedName = (parsed.name as string | undefined) ?? legacyMeta?.name;
if (hasSpec) {
return {
schemaVersion:
(parsed.schemaVersion as string) || legacyMeta?.schemaVersion || 'v6',
...(resolvedName ? { name: resolvedName } : { generateName: true }),
image: (parsed.image as string) ?? legacyMeta?.image,
tags:
(parsed.tags as TagtypesPostableTagDTO[] | null) ??
legacyMeta?.tags ??
null,
spec: parsed.spec as DashboardtypesDashboardSpecDTO,
};
}
return {
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: parsed as unknown as DashboardtypesDashboardSpecDTO,
};
}

View File

@@ -0,0 +1,154 @@
{
"display": {
"name": "NV dashboard with sections",
"description": ""
},
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "signoz/Datasource",
"spec": {}
}
}
},
"panels": {
"b424e23b": {
"kind": "Panel",
"spec": {
"display": {
"name": ""
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"formatting": {
"unit": "s",
"decimalPrecision": "2"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "container.cpu.time",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": ""
}
}
}
}
}
]
}
},
"251df4d5": {
"kind": "Panel",
"spec": {
"display": {
"name": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": false
},
"formatting": {
"unit": "recommendations",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {"fillOnlyBelow": true}
},
"legend": {
"position": "bottom"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "app_recommendations_counter",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": ""
}
}
}
}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {
"title": "Bravo"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/b424e23b"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Alpha"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/251df4d5"
}
}
]
}
}
]
}

View File

@@ -0,0 +1,182 @@
.wrapper {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
height: 44px;
flex-shrink: 0;
border-radius: 6px 6px 0px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
}
.label {
color: var(--l2-foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.rightActions {
display: flex;
gap: 4px;
color: white;
}
// Shared trigger button for the sort + configure-group icons in the right
// actions cluster. Provides a square hover/active background so users know
// which icon they're targeting.
.iconTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: inherit;
cursor: pointer;
transition: background 120ms ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
outline: none;
}
&:active,
&[aria-expanded='true'] {
background: color-mix(in srgb, var(--l1-foreground) 20%, transparent);
}
}
.sortContent {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 140px;
}
.sortHeading {
color: var(--l3-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: var(--font-weight-semibold);
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 12px 18px 6px 14px;
}
.sortDivider {
width: 100%;
height: 1px;
background: var(--l1-border);
margin: 4px 0;
}
.sortButton {
text-align: start;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
color: var(--l2-foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: normal;
letter-spacing: 0.14px;
padding: 12px 18px 12px 14px;
height: auto;
}
.configureContent {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 4px;
}
.configureItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--l2-foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
line-height: normal;
letter-spacing: 0.14px;
text-align: left;
cursor: pointer;
transition: background 120ms ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--l1-foreground) 10%, transparent);
outline: none;
}
&:active {
background: color-mix(in srgb, var(--l1-foreground) 18%, transparent);
}
}
.configureIcon {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
align-items: center;
justify-content: center;
}
:global(.sortDashboardsPopover) {
:global(.ant-popover-inner) {
display: flex;
padding: 0px;
align-items: center;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
gap: 16px;
}
}
:global(.configureGroupPopover) {
:global(.ant-popover-inner) {
display: flex;
align-items: center;
border-radius: 4px;
padding: 0px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
gap: 16px;
}
}

View File

@@ -0,0 +1,145 @@
import { Button, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
ArrowDownWideNarrow,
Check,
Ellipsis,
HdmiPort,
} from '@signozhq/icons';
import type {
SortColumn,
SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import styles from './ListHeader.module.scss';
interface Props {
sortColumn: SortColumn;
onSortChange: (column: SortColumn) => void;
sortOrder: SortOrder;
onOrderChange: (order: SortOrder) => void;
onConfigureMetadata: () => void;
}
function ListHeader({
sortColumn,
onSortChange,
sortOrder,
onOrderChange,
onConfigureMetadata,
}: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<Typography.Text className={styles.label}>All Dashboards</Typography.Text>
<section className={styles.rightActions}>
<Tooltip title="Sort">
<Popover
trigger="click"
content={
<div className={styles.sortContent}>
<Typography.Text className={styles.sortHeading}>
Sort By
</Typography.Text>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('name')}
data-testid="sort-by-name"
>
Name
{sortColumn === 'name' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('created_at')}
data-testid="sort-by-last-created"
>
Last created
{sortColumn === 'created_at' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('updated_at')}
data-testid="sort-by-last-updated"
>
Last updated
{sortColumn === 'updated_at' && <Check size={14} />}
</Button>
<div className={styles.sortDivider} />
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange('asc')}
data-testid="sort-order-asc"
>
Ascending
{sortOrder === 'asc' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange('desc')}
data-testid="sort-order-desc"
>
Descending
{sortOrder === 'desc' && <Check size={14} />}
</Button>
</div>
}
rootClassName="sortDashboardsPopover"
placement="bottomRight"
arrow={false}
>
<button
type="button"
className={styles.iconTrigger}
data-testid="sort-by"
aria-label="Sort"
>
<ArrowDownWideNarrow size={14} />
</button>
</Popover>
</Tooltip>
<Popover
trigger="click"
content={
<div className={styles.configureContent}>
<button
type="button"
className={styles.configureItem}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
onConfigureMetadata();
}}
data-testid="configure-metadata-trigger"
>
<span className={styles.configureIcon}>
<HdmiPort size={14} />
</span>
<span>Configure metadata</span>
</button>
</div>
}
rootClassName="configureGroupPopover"
placement="bottomRight"
arrow={false}
>
<button
type="button"
className={styles.iconTrigger}
aria-label="More options"
>
<Ellipsis size={14} />
</button>
</Popover>
</section>
</div>
);
}
export default ListHeader;

View File

@@ -0,0 +1,24 @@
.submit {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
border-radius: 3px;
color: inherit;
cursor: pointer;
transition: background 120ms ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
outline: none;
}
&:active {
background: color-mix(in srgb, var(--l1-foreground) 20%, transparent);
}
}

View File

@@ -0,0 +1,49 @@
import { ChangeEvent, KeyboardEvent, MouseEvent } from 'react';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { CornerDownLeft, Search } from '@signozhq/icons';
import styles from './SearchBar.module.scss';
interface Props {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
}
function SearchBar({ value, onChange, onSubmit }: Props): JSX.Element {
return (
<Input
placeholder="Search with DSL (e.g. name CONTAINS 'foo')"
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<button
type="button"
className={styles.submit}
aria-label="Run search"
data-testid="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Prevent the input's blur from firing first and double-submitting.
e.preventDefault();
}}
onClick={onSubmit}
>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</button>
}
value={value}
testId="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
onChange(e.target.value)
}
onBlur={onSubmit}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
onSubmit();
}
}}
/>
);
}
export default SearchBar;

View File

@@ -0,0 +1,40 @@
.wrapper {
composes: cardWrapper from '../states.module.scss';
padding: 105px 141px;
}
.image {
width: 32px;
height: 32px;
}
.copy {
margin-top: 4px;
}
.noDashboard {
composes: bodyText from '../states.module.scss';
color: var(--l1-foreground);
font-weight: var(--font-weight-medium);
}
.info {
composes: bodyText from '../states.module.scss';
color: var(--l2-foreground);
font-weight: var(--font-weight-normal);
}
.actions {
display: flex;
gap: 24px;
align-items: center;
margin-top: 24px;
}
.learnMore {
composes: learnMoreLink from '../states.module.scss';
}
.learnMoreArrow {
composes: learnMoreArrow from '../states.module.scss';
}

View File

@@ -0,0 +1,54 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ArrowUpRight } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
import styles from './EmptyState.module.scss';
import { openInNewTab } from 'utils/navigation';
interface Props {
createDropdown?: ReactNode;
}
const LEARN_MORE_HREF =
'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state';
function EmptyState({ createDropdown }: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<img src={dashboardsUrl} alt="dashboards" className={styles.image} />
<section className={styles.copy}>
<Typography.Text className={styles.noDashboard}>
No dashboards yet.{' '}
</Typography.Text>
<Typography.Text className={styles.info}>
Create a dashboard to start visualizing your data
</Typography.Text>
</section>
{createDropdown ? (
<section className={styles.actions}>
{createDropdown}
<Button
variant="link"
color="primary"
className={styles.learnMore}
testId="learn-more"
onClick={(): void => {
logEvent('Dashboard List: Learn more clicked', {});
openInNewTab(LEARN_MORE_HREF);
}}
>
Learn more
</Button>
<ArrowUpRight size={16} className={styles.learnMoreArrow} />
</section>
) : null}
</div>
);
}
export default EmptyState;

View File

@@ -0,0 +1,36 @@
.wrapper {
composes: cardWrapper from '../states.module.scss';
padding: 105px 141px;
gap: 4px;
}
.img {
max-width: 100%;
}
.errorText {
composes: bodyText from '../states.module.scss';
color: var(--l1-foreground);
font-weight: var(--font-weight-medium);
}
.errorDetail {
composes: bodyText from '../states.module.scss';
color: var(--l2-foreground);
font-weight: var(--font-weight-normal);
}
.actionButtons {
display: flex;
gap: 24px;
align-items: center;
margin-top: 20px;
}
.learnMore {
composes: learnMoreLink from '../states.module.scss';
}
.learnMoreArrow {
composes: learnMoreArrow from '../states.module.scss';
}

View File

@@ -0,0 +1,81 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ArrowUpRight, RotateCw } from '@signozhq/icons';
import { handleContactSupport } from 'container/Integrations/utils';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import { formatQueryErrorMessage } from '../../../utils';
import styles from './ErrorState.module.scss';
interface Props {
isCloudUser: boolean;
onRetry: () => void;
httpStatus?: number;
errorMessage?: string;
}
const GENERIC_MESSAGE =
'Something went wrong :/ Please retry or contact support.';
const INVALID_QUERY_FALLBACK = 'Please review the syntax and try again.';
function ErrorState({
isCloudUser,
onRetry,
httpStatus,
errorMessage,
}: Props): JSX.Element {
// 4xx responses are client errors — the same request will keep failing.
// Surface the BE-provided detail (e.g. DSL parse errors) and skip Retry.
const isClientError =
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500;
const cleanedDetail = formatQueryErrorMessage(errorMessage);
return (
<div className={styles.wrapper}>
<img src={awwSnapUrl} alt="something went wrong" className={styles.img} />
{isClientError ? (
<>
<Typography.Text className={styles.errorText}>
Invalid query
</Typography.Text>
<Typography.Text className={styles.errorDetail}>
{cleanedDetail || INVALID_QUERY_FALLBACK}
</Typography.Text>
</>
) : (
<Typography.Text className={styles.errorText}>
{GENERIC_MESSAGE}
</Typography.Text>
)}
<section className={styles.actionButtons}>
{!isClientError && (
<Button
variant="outlined"
color="secondary"
prefix={<RotateCw size={16} />}
onClick={onRetry}
testId="dashboards-list-retry"
>
Retry
</Button>
)}
<Button
variant="link"
color="primary"
className={styles.learnMore}
onClick={(): void => handleContactSupport(isCloudUser)}
testId="dashboards-list-contact-support"
>
Contact Support
</Button>
<ArrowUpRight size={16} className={styles.learnMoreArrow} />
</section>
</div>
);
}
export default ErrorState;

View File

@@ -0,0 +1,11 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.skeleton {
height: 125px;
width: 100%;
}

View File

@@ -0,0 +1,16 @@
import { Skeleton } from 'antd';
import styles from './LoadingState.module.scss';
function LoadingState(): JSX.Element {
return (
<div className={styles.wrapper}>
<Skeleton.Input active size="large" className={styles.skeleton} />
<Skeleton.Input active size="large" className={styles.skeleton} />
<Skeleton.Input active size="large" className={styles.skeleton} />
<Skeleton.Input active size="large" className={styles.skeleton} />
</div>
);
}
export default LoadingState;

View File

@@ -0,0 +1,5 @@
.wrapper {
composes: cardWrapper from '../states.module.scss';
padding: 105px 190px;
gap: 8px;
}

View File

@@ -0,0 +1,22 @@
import { Typography } from '@signozhq/ui/typography';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './NoResultsState.module.scss';
interface Props {
searchString: string;
}
function NoResultsState({ searchString }: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<img src={emptyStateUrl} alt="img" height={32} width={32} />
<Typography.Text>
No dashboards found for {searchString}. Create a new dashboard?
</Typography.Text>
</div>
);
}
export default NoResultsState;

View File

@@ -0,0 +1,34 @@
// Shared building blocks for the dashboards-list view states.
// Composed via CSS-modules `composes:` from each state's own SCSS.
.cardWrapper {
display: flex;
flex-direction: column;
height: 320px;
margin-top: 16px;
justify-content: center;
align-items: flex-start;
border-radius: 6px;
border: 1px dashed var(--l1-border);
}
.bodyText {
font-family: Inter;
font-size: var(--font-size-sm);
font-style: normal;
line-height: 18px;
letter-spacing: -0.07px;
}
.learnMoreLink {
composes: bodyText;
color: var(--bg-robin-400);
font-weight: var(--font-weight-medium);
padding: 0px;
}
.learnMoreArrow {
margin-left: -20px;
color: var(--bg-robin-400);
cursor: pointer;
}

View File

@@ -0,0 +1,36 @@
import {
parseAsInteger,
parseAsString,
parseAsStringLiteral,
useQueryState,
type Options,
type UseQueryStateReturn,
} from 'nuqs';
export const SORT_COLUMNS = ['updated_at', 'created_at', 'name'] as const;
export type SortColumn = (typeof SORT_COLUMNS)[number];
export const SORT_ORDERS = ['asc', 'desc'] as const;
export type SortOrder = (typeof SORT_ORDERS)[number];
const opts: Options = { history: 'push' };
export const useSortColumn = (): UseQueryStateReturn<SortColumn, SortColumn> =>
useQueryState(
'sort',
parseAsStringLiteral(SORT_COLUMNS)
.withDefault('updated_at')
.withOptions(opts),
);
export const useSortOrder = (): UseQueryStateReturn<SortOrder, SortOrder> =>
useQueryState(
'order',
parseAsStringLiteral(SORT_ORDERS).withDefault('desc').withOptions(opts),
);
export const usePage = (): UseQueryStateReturn<number, number> =>
useQueryState('page', parseAsInteger.withDefault(1).withOptions(opts));
export const useSearch = (): UseQueryStateReturn<string, string> =>
useQueryState('search', parseAsString.withDefault('').withOptions(opts));

View File

@@ -1,9 +1,3 @@
function DashboardsListPageV2(): JSX.Element {
return (
<div>
<h1>Dashboards List Page V2</h1>
</div>
);
}
import DashboardsListPageV2 from './DashboardsListPageV2';
export default DashboardsListPageV2;

View File

@@ -0,0 +1,52 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,
): string[] =>
(tags ?? []).map((tag) =>
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
);
export const lastUpdatedLabel = (time: string | undefined): string => {
if (!time || isEmpty(time)) {
return 'No updates yet!';
}
const diff = dayjs();
const ref = dayjs(time);
const months = diff.diff(ref, 'months');
if (months > 0) {
return `Last Updated ${months} months ago`;
}
const days = diff.diff(ref, 'days');
if (days > 0) {
return `Last Updated ${days} days ago`;
}
const hours = diff.diff(ref, 'hours');
if (hours > 0) {
return `Last Updated ${hours} hrs ago`;
}
const minutes = diff.diff(ref, 'minutes');
if (minutes > 0) {
return `Last Updated ${minutes} mins ago`;
}
const seconds = diff.diff(ref, 'seconds');
return `Last Updated ${seconds} sec ago`;
};
// Normalize BE query-parse error messages for display:
// - Drop the "invalid filter query:" prefix (the UI already says "Invalid query").
// - Backticks → double quotes for the format hint that follows the em-dash.
// - Trim surrounding whitespace.
export const formatQueryErrorMessage = (raw: string | undefined): string => {
if (!raw) {
return '';
}
return raw
.replace(/^invalid filter query:\s*/i, '')
.replace(/`([^`]+)`/g, '"$1"')
.trim();
};

View File

@@ -1,3 +1,26 @@
.service-route-tab {
margin-bottom: 64px;
.ant-tabs-nav {
&::before {
border-bottom: 1px solid var(--l1-border);
}
.ant-tabs-nav-wrap {
.ant-tabs-nav-list {
.ant-tabs-ink-bar {
background-color: var(--primary-background) !important;
}
.ant-tabs-tab {
font-size: 13px;
font-family: 'Inter';
color: var(--l1-foreground);
line-height: 20px;
letter-spacing: -0.07px;
gap: 10px;
}
.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--accent-primary);
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { useParams } from 'react-router-dom';
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import { Tabs, TabsProps } from 'antd';
import { QueryParams } from 'constants/query';
import DBCall from 'container/MetricsApplication/Tabs/DBCall';
import External from 'container/MetricsApplication/Tabs/External';
@@ -24,7 +24,7 @@ function MetricsApplication(): JSX.Element {
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const items: TabItemProps[] = [
const items: TabsProps['items'] = [
{
label: TAB_KEY_VS_LABEL[MetricsApplicationTab.OVER_METRICS],
key: MetricsApplicationTab.OVER_METRICS,
@@ -53,8 +53,9 @@ function MetricsApplication(): JSX.Element {
<ApDexApplication />
<Tabs
items={items}
value={activeKey}
activeKey={activeKey}
className="service-route-tab"
destroyInactiveTabPane
onChange={onTabChange}
/>
</div>

View File

@@ -0,0 +1,9 @@
.pipeline-tabs {
.ant-tabs-content {
padding: 0 16px;
}
.ant-tabs-tabpane-hidden {
display: none !important;
}
}

View File

@@ -2,7 +2,8 @@ import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import * as Sentry from '@sentry/react';
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import type { TabsProps } from 'antd';
import { Tabs } from 'antd';
import getPipeline from 'api/pipeline/get';
import Spinner from 'components/Spinner';
import ChangeHistory from 'container/PipelinePage/Layouts/ChangeHistory';
@@ -12,6 +13,8 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall
import { SuccessResponse } from 'types/api';
import { Pipeline } from 'types/api/pipeline/def';
import './Pipelines.styles.scss';
const pipelineRefetchInterval = (
pipelineResponse: SuccessResponse<Pipeline> | undefined,
): number | false => {
@@ -43,7 +46,7 @@ function Pipelines(): JSX.Element {
refetchInterval: pipelineRefetchInterval,
});
const tabItems: TabItemProps[] = useMemo(
const tabItems: TabsProps['items'] = useMemo(
() => [
{
key: 'pipelines',
@@ -80,7 +83,11 @@ function Pipelines(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<Tabs defaultValue="pipelines" items={tabItems} />
<Tabs
className="pipeline-tabs"
defaultActiveKey="pipelines"
items={tabItems}
/>
</Sentry.ErrorBoundary>
);
}

View File

@@ -340,7 +340,7 @@ function SettingsPage(): JSX.Element {
routes={routes}
activeKey={pathname}
history={history}
hideTabBar
tabBarStyle={{ display: 'none' }}
/>
</div>
</div>

View File

@@ -71,6 +71,88 @@
flex-direction: column;
gap: 25px;
padding-top: 16px;
.flamegraph-waterfall-toggle {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
height: 31px;
color: var(--l2-foreground);
padding: 5px 20px;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px !important;
}
}
.span-list-toggle {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
height: 31px;
padding: 5px 20px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px !important;
}
}
.trace-visualisation-tabs {
.ant-tabs-tab {
border-radius: 2px 0px 0px 0px;
background: var(--l2-background);
border-radius: 2px 2px 0px 0px;
border: 1px solid var(--l1-border);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
height: 31px;
}
.ant-tabs-tab-active {
background-color: var(--l1-background);
.ant-btn {
color: var(--l1-foreground) !important;
}
}
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px;
border-left: 0px;
}
.ant-tabs-ink-bar {
height: 1px !important;
background: var(--l1-background) !important;
}
.ant-tabs-nav-list {
transform: translate(15px, 0px) !important;
}
.ant-tabs-nav::before {
border-bottom: 1px solid var(--l1-border);
}
.ant-tabs-nav {
margin: 0px;
padding: 0px !important;
}
}
}
}
}

View File

@@ -5,7 +5,7 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/resizable';
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import { Button, Tabs } from 'antd';
import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
import cx from 'classnames';
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
@@ -86,11 +86,18 @@ function TraceDetailsV2(): JSX.Element {
}
}, [noData]);
const items: TabItemProps[] = [
const items = [
{
label: (
<Button
type="text"
icon={<FlamegraphImg />}
className="flamegraph-waterfall-toggle"
>
Flamegraph
</Button>
),
key: 'flamegraph',
label: 'Flamegraph',
prefixIcon: <FlamegraphImg />,
children: (
<>
<TraceFlamegraph
@@ -138,7 +145,11 @@ function TraceDetailsV2(): JSX.Element {
totalSpans={traceData?.payload?.totalSpansCount || 0}
notFound={noData}
/>
{!noData ? <Tabs items={items} /> : <NoData />}
{!noData ? (
<Tabs items={items} animated className="trace-visualisation-tabs" />
) : (
<NoData />
)}
</ResizablePanel>
<ResizableHandle withHandle className="resizable-handle" />

View File

@@ -22,6 +22,21 @@ $dark-theme: 'darkMode';
&__tabs {
margin-top: 148px;
.ant-tabs {
&-nav {
&::before {
border-color: var(--l1-border);
.#{$light-theme} & {
border-color: var(--l1-border);
}
}
}
&-nav-wrap {
justify-content: center;
}
}
}
&__modal {

View File

@@ -2,7 +2,7 @@
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import type { TabItemProps } from '@signozhq/ui/tabs';
import type { TabsProps } from 'antd';
import {
Alert,
Button,
@@ -14,8 +14,8 @@ import {
Row,
Skeleton,
Space,
Tabs,
} from 'antd';
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
@@ -154,7 +154,7 @@ export default function WorkspaceBlocked(): JSX.Element {
/>
));
const tabItems: TabItemProps[] = [
const tabItems: TabsProps['items'] = [
{
key: 'whyChooseSignoz',
label: t('whyChooseSignoz'),
@@ -398,8 +398,8 @@ export default function WorkspaceBlocked(): JSX.Element {
<div className="workspace-locked__tabs">
<Tabs
items={tabItems}
defaultValue="youAreInGoodCompany"
onChange={handleTabClick}
defaultActiveKey="youAreInGoodCompany"
onTabClick={handleTabClick}
/>
</div>
</>

View File

@@ -825,6 +825,5 @@ body.ai-assistant-panel-open {
// overrides
:root {
--input-focus-outline-width: 0;
--tab-list-primary-gap: 12px;
--radius-2: 4px;
}

View File

@@ -48,7 +48,9 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts"
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
],
"include": [
"./src",

4
go.mod
View File

@@ -5,8 +5,9 @@ go 1.25.7
require (
dario.cat/mergo v1.0.2
github.com/AfterShip/clickhouse-sql-parser v0.4.16
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.3
github.com/antlr4-go/antlr/v4 v4.13.1
@@ -58,7 +59,6 @@ require (
github.com/smartystreets/goconvey v1.8.1
github.com/soheilhy/cmux v0.1.5
github.com/spf13/cobra v1.10.2
github.com/srikanthccv/ClickHouse-go-mock v0.13.0
github.com/stretchr/testify v1.11.1
github.com/swaggest/jsonschema-go v0.3.78
github.com/swaggest/rest v0.2.75

10
go.sum
View File

@@ -89,8 +89,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/Code-Hex/go-generics-cache v1.5.1 h1:6vhZGc5M7Y/YD8cIUcY8kcuQLB4cHR7U+0KMqAA0KcU=
github.com/Code-Hex/go-generics-cache v1.5.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@@ -104,6 +104,8 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/SigNoz/clickhouse-go-mock v0.14.0 h1:yYoGMJk0UDVvLCk12dover/zoRIuznW3BfoqUhMoJSY=
github.com/SigNoz/clickhouse-go-mock v0.14.0/go.mod h1:tXmSXVLWz7N/yVLCianszmNIN4cHozXvwEXCOLzzqzI=
github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72gAA=
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
@@ -1062,8 +1064,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/srikanthccv/ClickHouse-go-mock v0.13.0 h1:/b7DQphGkh29ocNtLh4DGmQxQYA0CfHz65Wy2zAH2GM=
github.com/srikanthccv/ClickHouse-go-mock v0.13.0/go.mod h1:LiiyBUdXNwB/1DE9rgK/8q9qjVYsTzg6WXQ/3mU3TeY=
github.com/srikanthccv/ClickHouse-go-mock v0.12.0 h1:KUzaWTwuqMc2uf5FylM/oAcTFdE2DdZjvISm9V0/NAA=
github.com/srikanthccv/ClickHouse-go-mock v0.12.0/go.mod h1:1oUmLtXEXOyS0EEWVKlKEfLfv9y02agCMAvD3tVnhlo=
github.com/stackitcloud/stackit-sdk-go/core v0.23.0 h1:zPrOhf3Xe47rKRs1fg/AqKYUiJJRYjdcv+3qsS50mEs=
github.com/stackitcloud/stackit-sdk-go/core v0.23.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -6,7 +6,7 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/require"
"github.com/DATA-DOG/go-sqlmock"

View File

@@ -679,21 +679,7 @@ func (bc *bucketCache) mergeAndDeduplicateBuckets(existing, fresh []*qbtypes.Cac
// deduplicateWarnings removes duplicate warnings.
func (bc *bucketCache) deduplicateWarnings(warnings []string) []string {
if len(warnings) == 0 {
return nil
}
seen := make(map[string]bool, len(warnings))
unique := make([]string, 0, len(warnings)) // Pre-allocate capacity
for _, warning := range warnings {
if !seen[warning] {
seen[warning] = true
unique = append(unique, warning)
}
}
return unique
return dedupeWarnings(warnings)
}
// trimResultToFluxBoundary trims the result to exclude data points beyond the flux boundary.

View File

@@ -7,6 +7,12 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
)
type SkipResourceFingerprint struct {
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
Threshold uint64 `yaml:"threshold" mapstructure:"threshold"`
}
// Config represents the configuration for the querier.
type Config struct {
// CacheTTL is the TTL for cached query results
@@ -15,6 +21,8 @@ type Config struct {
FluxInterval time.Duration `yaml:"flux_interval" mapstructure:"flux_interval"`
// MaxConcurrentQueries is the maximum number of concurrent queries for missing ranges
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
// SkipResourceFingerprint configures when the resource fingerprint subquery is skipped in favor of main-table filtering.
SkipResourceFingerprint SkipResourceFingerprint `yaml:"skip_resource_fingerprint" mapstructure:"skip_resource_fingerprint"`
}
// NewConfigFactory creates a new config factory for querier.
@@ -28,6 +36,10 @@ func newConfig() factory.Config {
CacheTTL: 168 * time.Hour,
FluxInterval: 5 * time.Minute,
MaxConcurrentQueries: 4,
SkipResourceFingerprint: SkipResourceFingerprint{
Enabled: false,
Threshold: 100000,
},
}
}
@@ -42,6 +54,9 @@ func (c Config) Validate() error {
if c.MaxConcurrentQueries <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_concurrent_queries must be positive, got %v", c.MaxConcurrentQueries)
}
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "skip_resource_fingerprint.threshold must be > 0 when enabled")
}
return nil
}

View File

@@ -642,6 +642,12 @@ func (q *querier) run(
},
}
// Warnings can arrive duplicated: the bucket cache returns the cached
// portion's warnings alongside an identical warning emitted by every
// freshly-executed missing range (see mergeResults), and distinct queries
// can surface the same warning. Collapse exact duplicates before building
// the response.
warnings = dedupeWarnings(warnings)
if len(warnings) != 0 {
warns := make([]qbtypes.QueryWarnDataAdditional, len(warnings))
for i, warning := range warnings {
@@ -1125,6 +1131,8 @@ func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if qe.GetSource() == telemetrytypes.SourceMeter {
clampStep(qe, meterRecommended, meterMin, &warnings)
// we don't want to return warnings for meter metrics.
warnings = nil
} else {
clampStep(qe, metricRecommended, metricMin, &warnings)
}
@@ -1140,3 +1148,21 @@ func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end
}
return warnings
}
// dedupeWarnings removes exact-duplicate warning messages while preserving the
// order of first occurrence. Returns nil for an empty input. Warning counts are
// tiny (a handful per request), so a linear scan beats the allocation and
// hashing overhead of a map.
func dedupeWarnings(warnings []string) []string {
if len(warnings) == 0 {
return nil
}
unique := make([]string, 0, len(warnings))
// N^2 is faster than map-based deduping for small warning counts, and it preserves order of first occurrence without extra bookkeeping.
for _, warning := range warnings {
if !slices.Contains(unique, warning) {
unique = append(unique, warning)
}
}
return unique
}

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"

View File

@@ -88,6 +88,8 @@ func newProvider(
traceAggExprRewriter,
telemetryStore,
flagger,
cfg.SkipResourceFingerprint.Enabled,
cfg.SkipResourceFingerprint.Threshold,
)
// Create trace operator statement builder
@@ -121,6 +123,9 @@ func newProvider(
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
flagger,
telemetryStore,
cfg.SkipResourceFingerprint.Enabled,
cfg.SkipResourceFingerprint.Threshold,
)
// Create audit statement builder

View File

@@ -26,7 +26,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/valuer"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View File

@@ -26,7 +26,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/valuer"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View File

@@ -25,7 +25,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
)
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {

View File

@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
pql "github.com/prometheus/prometheus/promql"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"

View File

@@ -89,6 +89,9 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
fl,
nil,
false,
100000,
)
return querier.New(
@@ -134,6 +137,8 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
traceAggExprRewriter,
telemetryStore,
fl,
false,
100000,
)
return querier.New(

View File

@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"

View File

@@ -127,7 +127,7 @@ func TestStatementBuilder(t *testing.T) {
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.audit.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.audit.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", "019b-5678-efgh-9012", "%signoz.audit.resource.id%", "%signoz.audit.resource.id\":\"019b-5678-efgh-9012%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
@@ -144,7 +144,7 @@ func TestStatementBuilder(t *testing.T) {
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},

View File

@@ -1205,6 +1205,9 @@ func buildJSONTestStatementBuilder(t *testing.T, addIndexes bool) (*logQueryStat
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
return statementBuilder, mockMetadataStore

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryresourcefilter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -19,13 +20,14 @@ import (
)
type logQueryStatementBuilder struct {
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fl flagger.Flagger
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
resourceFilterResolver *telemetryresourcefilter.ResourceFingerprintResolver[qbtypes.LogAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fl flagger.Flagger
skipResourceFingerprintEnabled bool
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
@@ -42,10 +44,13 @@ func NewLogQueryStatementBuilder(
fullTextColumn *telemetrytypes.TelemetryFieldKey,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
fl flagger.Flagger,
telemetryStore telemetrystore.TelemetryStore,
skipResourceFingerprintEnable bool,
skipResourceFingerprintThreshold uint64,
) *logQueryStatementBuilder {
logsSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrylogs")
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.LogAggregation](
resourceFilterResolver := telemetryresourcefilter.NewResolver[qbtypes.LogAggregation](
settings,
DBName,
LogsResourceV2TableName,
@@ -55,18 +60,21 @@ func NewLogQueryStatementBuilder(
fullTextColumn,
jsonKeyToKey,
fl,
telemetryStore,
skipResourceFingerprintThreshold,
)
return &logQueryStatementBuilder{
logger: logsSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
aggExprRewriter: aggExprRewriter,
fl: fl,
fullTextColumn: fullTextColumn,
jsonKeyToKey: jsonKeyToKey,
logger: logsSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
resourceFilterResolver: resourceFilterResolver,
aggExprRewriter: aggExprRewriter,
fl: fl,
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
fullTextColumn: fullTextColumn,
jsonKeyToKey: jsonKeyToKey,
}
}
@@ -271,9 +279,11 @@ func (b *logQueryStatementBuilder) buildListQuery(
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" {
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -315,7 +325,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -373,9 +383,11 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" {
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -419,7 +431,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Add FROM clause
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -531,9 +543,11 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" && !skipResourceCTE {
}
if frag != "" && !skipResourceCTE {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -576,7 +590,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -640,6 +654,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
skipResourceFilter bool,
) (querybuilder.PreparedWhereClause, error) {
var preparedWhereClause querybuilder.PreparedWhereClause
@@ -656,7 +671,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
ConditionBuilder: b.cb,
FieldKeys: keys,
BodyJSONEnabled: bodyJSONEnabled,
SkipResourceFilter: true,
SkipResourceFilter: skipResourceFilter,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
@@ -707,33 +722,30 @@ func (b *logQueryStatementBuilder) maybeAttachResourceFilter(
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (cteSQL string, cteArgs []any, err error) {
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
stmt, err := b.buildResourceFilterCTE(ctx, query, start, end, variables)
if b.skipResourceFingerprintEnabled {
decision, err := b.resourceFilterResolver.Resolve(ctx, query, start, end, variables)
if err != nil {
return "", nil, true, err
}
switch decision {
case qbtypes.ResourceFilterResolveKindNoOp:
return "", nil, true, nil
case qbtypes.ResourceFilterResolveKindFallback:
return "", nil, false, nil
}
}
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
ctx, start, end, qbtypes.RequestTypeRaw, query, variables,
)
if err != nil {
return "", nil, err
return "", nil, true, err
}
if stmt == nil {
return "", nil, nil
return "", nil, true, nil
}
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
}
func (b *logQueryStatementBuilder) buildResourceFilterCTE(
ctx context.Context,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
return b.resourceFilterStmtBuilder.Build(
ctx,
start,
end,
qbtypes.RequestTypeRaw,
query,
variables,
)
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
}

View File

@@ -2,19 +2,36 @@ package telemetrylogs
import (
"context"
"regexp"
"testing"
"time"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/require"
)
type regexQueryMatcher struct{}
func (m *regexQueryMatcher) Match(expectedSQL, actualSQL string) error {
re, err := regexp.Compile(expectedSQL)
if err != nil {
return err
}
if !re.MatchString(actualSQL) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected query to match %s, got %s", expectedSQL, actualSQL)
}
return nil
}
func TestStatementBuilderTimeSeries(t *testing.T) {
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
@@ -55,7 +72,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
expectedErr: nil,
@@ -127,7 +144,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
expectedErr: nil,
@@ -160,7 +177,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`materialized.key.name`) GLOBAL IN (SELECT `materialized.key.name` FROM __limit_cte) GROUP BY ts, `materialized.key.name`",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`materialized.key.name`) GLOBAL IN (SELECT `materialized.key.name` FROM __limit_cte) GROUP BY ts, `materialized.key.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
},
@@ -212,6 +229,9 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -253,7 +273,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
@@ -281,7 +301,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
@@ -353,6 +373,9 @@ func TestStatementBuilderListQuery(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -424,7 +447,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "hello", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
@@ -499,6 +522,9 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -575,6 +601,9 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -615,7 +644,7 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (LOWER(body) LIKE LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (LOWER(body) LIKE LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "%error%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
@@ -644,7 +673,7 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND LOWER(body) LIKE LOWER(?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND LOWER(body) LIKE LOWER(?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "%error%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
@@ -670,6 +699,9 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -894,6 +926,9 @@ func TestAdjustKey(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -1039,6 +1074,9 @@ func TestStmtBuilderBodyField(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
@@ -1138,6 +1176,9 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
@@ -1157,3 +1198,110 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
})
}
}
// TestSkipResourceFingerprintLogs exercises the three resolver outcomes for
// logs: use-CTE (count < threshold), fallback (count >= threshold), and the
// legacy path (feature disabled).
func TestSkipResourceFingerprintLogs(t *testing.T) {
const (
startMs = uint64(1747947419000)
endMs = uint64(1747983448000)
threshold = uint64(10)
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "service.name = 'redis-manual'",
},
Limit: 5,
}
t.Run("disabled uses the legacy CTE", func(t *testing.T) {
sb := newSkipResourceFingerprintLogsBuilder(t, nil, false, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
})
t.Run("CTE attached when count below threshold", func(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{uint64(2)}))
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
require.NoError(t, mock.ExpectationsWereMet())
})
t.Run("fallback when count at or above threshold", func(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{threshold}))
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.NotContains(t, stmt.Query, "__resource_filter AS")
require.NotContains(t, stmt.Query, "resource_fingerprint")
require.Contains(t, stmt.Query, "service.name")
require.NoError(t, mock.ExpectationsWereMet())
})
}
func newSkipResourceFingerprintLogsBuilder(
t *testing.T,
telemetryStore telemetrystore.TelemetryStore,
skipEnable bool,
threshold uint64,
) *logQueryStatementBuilder {
t.Helper()
fl := flaggertest.New(t)
fm := NewFieldMapper(fl)
cb := NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
aggExprRewriter := querybuilder.NewAggExprRewriter(
instrumentationtest.New().ToProviderSettings(),
DefaultFullTextColumn,
fm,
cb,
GetBodyJSONKey,
fl,
)
return NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
telemetryStore,
skipEnable,
threshold,
)
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/stretchr/testify/require"

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