Compare commits

...

3 Commits

Author SHA1 Message Date
aks07
d4cd6a9687 feat: add shared reusable components for trace details v3
Adds reusable components to periscope and DetailsPanel:

- PrettyView: JSON tree viewer with search and pinning
- JsonView: Monaco-based JSON viewer
- DataViewer: Pretty/JSON mode switcher
- FloatingPanel: Draggable/resizable panel (react-rnd)
- ResizableBox: Vertical/horizontal resize with handle
- KeyValueLabel: Key-value display (column/row)
- DetailsPanel: Drawer infrastructure and header
2026-04-10 09:35:55 +05:30
Nikhil Soni
e543776efc chore: send obfuscate query in the clickhouse query panel update (#10848)
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
* chore: send query in the clickhouse query panel update

* chore: obfuscate query to avoid sending sensitive values
2026-04-09 14:15:10 +00:00
Pandey
621127b7fb feat(audit): wire auditor into DI graph and service lifecycle (#10891)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(audit): wire auditor into DI graph and service lifecycle

Register the auditor in the factory service registry so it participates
in application lifecycle (start/stop/health). Community uses noopauditor,
enterprise uses otlphttpauditor with licensing gate. Pass the auditor
instance to the audit middleware instead of nil.

* feat(audit): use NamedMap provider pattern with config-driven selection

Switch from single-factory callback to NamedMap + factory.NewProviderFromNamedMap
so the config's Provider field selects the auditor implementation. Add
NewAuditorProviderFactories() with noop as the community default. Enterprise
extends the map with otlphttpauditor. Add auditor section to conf/example.yaml
and set default provider to "noop" in config.

* chore: move auditor config to end of example.yaml
2026-04-09 11:44:05 +00:00
41 changed files with 1711 additions and 51 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
@@ -93,6 +94,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return signoz.NewAuditorProviderFactories()
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},

View File

@@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
@@ -24,6 +25,7 @@ import (
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
@@ -133,6 +135,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
func(licensing licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
factories := signoz.NewAuditorProviderFactories()
if err := factories.Add(otlphttpauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler)

View File

@@ -364,3 +364,34 @@ serviceaccount:
analytics:
# toggle service account analytics
enabled: true
##################### Auditor #####################
auditor:
# Specifies the auditor provider to use.
# noop: discards all audit events (community default).
# otlphttp: exports audit events via OTLP HTTP (enterprise).
provider: noop
# The async channel capacity for audit events. Events are dropped when full (fail-open).
buffer_size: 1000
# The maximum number of events per export batch.
batch_size: 100
# The maximum time between export flushes.
flush_interval: 1s
otlphttp:
# The target scheme://host:port/path of the OTLP HTTP endpoint.
endpoint: http://localhost:4318/v1/logs
# Whether to use HTTP instead of HTTPS.
insecure: false
# The maximum duration for an export attempt.
timeout: 10s
# Additional HTTP headers sent with every export request.
headers: {}
retry:
# Whether to retry on transient failures.
enabled: true
# The initial wait time before the first retry.
initial_interval: 5s
# The upper bound on backoff interval.
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s

View File

@@ -227,7 +227,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)

View File

@@ -139,10 +139,12 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",

View File

@@ -0,0 +1,40 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--l1-border);
height: 36px;
background: var(--l2-background);
&__icon-btn {
flex-shrink: 0;
}
&__title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
}
}

View File

@@ -0,0 +1,59 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import { X } from '@signozhq/icons';
import './DetailsHeader.styles.scss';
export interface HeaderAction {
key: string;
component: ReactNode; // check later if we can use direct btn itself or not.
}
export interface DetailsHeaderProps {
title: string;
onClose: () => void;
actions?: HeaderAction[];
closePosition?: 'left' | 'right';
className?: string;
}
function DetailsHeader({
title,
onClose,
actions,
closePosition = 'right',
className,
}: DetailsHeaderProps): JSX.Element {
const closeButton = (
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onClose}
aria-label="Close"
className="details-header__icon-btn"
>
<X size={14} />
</Button>
);
return (
<div className={`details-header ${className || ''}`}>
{closePosition === 'left' && closeButton}
<span className="details-header__title">{title}</span>
{actions && (
<div className="details-header__actions">
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
)}
{closePosition === 'right' && closeButton}
</div>
);
}
export default DetailsHeader;

View File

@@ -0,0 +1,7 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -0,0 +1,36 @@
import { DrawerWrapper } from '@signozhq/drawer';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function DetailsPanelDrawer({
isOpen,
onClose,
children,
className,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
type="panel"
showOverlay={false}
allowOutsideClick
className={`details-panel-drawer ${className || ''}`}
content={<div className="details-panel-drawer__body">{children}</div>}
/>
);
}
export default DetailsPanelDrawer;

View File

@@ -0,0 +1,8 @@
export type {
DetailsHeaderProps,
HeaderAction,
} from './DetailsHeader/DetailsHeader';
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
export { default as useDetailsPanel } from './useDetailsPanel';

View File

@@ -0,0 +1,10 @@
export interface DetailsPanelState {
isOpen: boolean;
open: () => void;
close: () => void;
}
export interface UseDetailsPanelOptions {
entityId: string | undefined;
onClose?: () => void;
}

View File

@@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
function useDetailsPanel({
entityId,
onClose,
}: UseDetailsPanelOptions): DetailsPanelState {
const [isOpen, setIsOpen] = useState<boolean>(false);
const prevEntityIdRef = useRef<string>('');
useEffect(() => {
const currentId = entityId || '';
if (currentId && currentId !== prevEntityIdRef.current) {
setIsOpen(true);
}
prevEntityIdRef.current = currentId;
}, [entityId]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
return { isOpen, open, close };
}
export default useDetailsPanel;

View File

@@ -677,6 +677,18 @@ function NewWidget({
queryType: currentQuery.queryType,
isNewPanel,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
...(currentQuery.queryType === EQueryType.CLICKHOUSE && {
clickhouseQueryCount: currentQuery.clickhouse_sql.length,
clickhouseQueries: currentQuery.clickhouse_sql.map((q) => ({
name: q.name,
query: (q.query ?? '')
.replace(/--[^\n]*/g, '') // strip line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
.replace(/'(?:[^'\\]|\\.|'')*'/g, "'?'") // replace single-quoted strings (handles \' and '' escapes)
.replace(/\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, '?'), // replace numeric literals (int, float, scientific)
disabled: q.disabled,
})),
}),
});
setSaveModal(true);
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -0,0 +1,54 @@
.data-viewer {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
// Toolbar — view mode switcher + copy button
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
}
&__mode-select {
min-width: 90px;
height: 28px !important;
.ant-select-selector {
border-radius: 4px !important;
border-color: var(--l2-border) !important;
font-size: 12px !important;
}
}
&__copy-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--l1-foreground);
background: var(--l3-background);
}
}
// Shared content container — no scroll, each view handles its own
&__content {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 4px;
padding: 8px;
min-height: 0;
overflow: hidden;
}
}

View File

@@ -0,0 +1,67 @@
import { useMemo, useState } from 'react';
import { Copy } from '@signozhq/icons';
// TODO: Replace antd Select with @signozhq/ui component when moving to design library
import { Select } from 'antd';
import { JsonView } from 'periscope/components/JsonView';
import { PrettyView } from 'periscope/components/PrettyView';
import { PrettyViewProps } from 'periscope/components/PrettyView';
import { copyToClipboard } from './utils';
import './DataViewer.styles.scss';
type ViewMode = 'pretty' | 'json';
export interface DataViewerProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>;
drawerKey?: string;
prettyViewProps?: Omit<PrettyViewProps, 'data' | 'drawerKey'>;
}
function DataViewer({
data,
drawerKey = 'default',
prettyViewProps,
}: DataViewerProps): JSX.Element {
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
return (
<div className="data-viewer">
<div className="data-viewer__toolbar">
<Select
className="data-viewer__mode-select"
size="small"
value={viewMode}
onChange={(value: ViewMode): void => setViewMode(value)}
options={[
{ label: 'Pretty', value: 'pretty' },
{ label: 'JSON', value: 'json' },
]}
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
/>
<button
type="button"
className="data-viewer__copy-btn"
onClick={(): void => copyToClipboard(data)}
aria-label="Copy JSON"
>
<Copy size={14} />
</button>
</div>
<div className="data-viewer__content">
{viewMode === 'pretty' && (
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
)}
{viewMode === 'json' && <JsonView data={jsonString} />}
</div>
</div>
);
}
export default DataViewer;

View File

@@ -0,0 +1,2 @@
export type { DataViewerProps } from './DataViewer';
export { default as DataViewer } from './DataViewer';

View File

@@ -0,0 +1,12 @@
import { toast } from '@signozhq/sonner';
export function copyToClipboard(value: unknown): void {
const text =
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
// eslint-disable-next-line no-restricted-properties
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
}

View File

@@ -0,0 +1,22 @@
.floating-panel {
z-index: 999;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
// Inner div fills the Rnd-controlled size
&__inner {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
// Drag handle — any child with this class becomes the drag target
.floating-panel__drag-handle {
cursor: move;
}
}

View File

@@ -0,0 +1,80 @@
import { ComponentProps } from 'react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import './FloatingPanel.styles.scss';
type EnableResizing = ComponentProps<typeof Rnd>['enableResizing'];
export interface FloatingPanelProps {
isOpen: boolean;
children: React.ReactNode;
defaultPosition?: { x: number; y: number };
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
enableResizing?: EnableResizing;
className?: string;
}
function FloatingPanel({
isOpen,
children,
defaultPosition,
width = 560,
height = 600,
minWidth = 400,
minHeight = 300,
enableResizing,
className,
}: FloatingPanelProps): JSX.Element | null {
if (!isOpen) {
return null;
}
const initialPosition = defaultPosition || {
x: window.innerWidth - width - 24,
y: 80,
};
return createPortal(
<Rnd
default={{
x: initialPosition.x,
y: initialPosition.y,
width,
height,
}}
dragHandleClassName="floating-panel__drag-handle"
minWidth={minWidth}
minHeight={minHeight}
onDrag={(_e, d): void | false => {
const HEADER_HEIGHT = 40;
// Top: don't allow header to go above viewport
if (d.y < 0) {
return false;
}
// Left: don't allow panel to go off-screen left
if (d.x < 0) {
return false;
}
// Bottom: only header needs to be visible
if (d.y > window.innerHeight - HEADER_HEIGHT) {
return false;
}
// Right: at least the close button (~40px) stays visible
if (d.x > window.innerWidth - 40) {
return false;
}
}}
className={`floating-panel ${className || ''}`}
enableResizing={enableResizing}
>
<div className="floating-panel__inner">{children}</div>
</Rnd>,
document.body,
);
}
export default FloatingPanel;

View File

@@ -0,0 +1,2 @@
export type { FloatingPanelProps } from './FloatingPanel';
export { default as FloatingPanel } from './FloatingPanel';

View File

@@ -0,0 +1,22 @@
.json-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
&__footer {
flex-shrink: 0;
height: 36px;
display: flex;
align-items: center;
border-top: 1px solid var(--l2-border);
}
&__wrap-toggle {
display: flex;
gap: 8px;
margin-left: 12px;
align-items: center;
}
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Switch, Typography } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import './JsonView.styles.scss';
export interface JsonViewProps {
data: string;
height?: string;
}
const editorOptions: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,
wordWrap: 'on',
minimap: { enabled: false },
fontWeight: 400,
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
fontSize: 12,
lineHeight: '18px',
colorDecorators: true,
scrollBeyondLastLine: false,
decorationsOverviewRuler: false,
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
folding: false,
};
function setEditorTheme(monaco: Monaco): void {
monaco.editor.defineTheme('signoz-dark', {
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': '#00000000', // transparent
},
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
fontSize: 12,
fontWeight: 'normal',
lineHeight: 18,
letterSpacing: -0.06,
});
}
function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState(true);
const isDarkMode = useIsDarkMode();
return (
<div className="json-view">
<MEditor
value={data}
language="json"
options={{ ...editorOptions, wordWrap: isWrapWord ? 'on' : 'off' }}
onChange={(): void => {}}
height={height}
theme={isDarkMode ? 'signoz-dark' : 'light'}
beforeMount={setEditorTheme}
/>
<div className="json-view__footer">
<div className="json-view__wrap-toggle">
<Typography.Text>Wrap text</Typography.Text>
<Switch
checked={isWrapWord}
onChange={(checked): void => setIsWrapWord(checked)}
size="small"
/>
</div>
</div>
</div>
);
}
export default JsonView;

View File

@@ -0,0 +1,2 @@
export type { JsonViewProps } from './JsonView';
export { default as JsonView } from './JsonView';

View File

@@ -1,43 +1,67 @@
.key-value-label {
display: flex;
align-items: center;
border: 1px solid var(--bg-slate-400);
border: 1px solid var(--l2-border);
border-radius: 2px;
flex-wrap: wrap;
&--row {
align-items: center;
flex-direction: row;
flex-wrap: wrap;
}
&--column {
flex-direction: column;
border: none;
border-radius: 0;
gap: 4px;
padding: 8px;
.key-value-label__key {
background: transparent;
border-radius: 0;
padding: 0;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: 20px;
}
.key-value-label__value {
background: transparent;
padding: 0;
font-size: 14px;
line-height: 20px;
}
}
&__key,
&__value {
padding: 1px 6px;
padding: 6px;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.005em;
&,
&:hover {
color: var(--text-vanilla-400);
color: var(--l1-foreground);
}
}
&__key {
background: var(--bg-ink-400);
background: var(--l2-background);
border-radius: 2px 0 0 2px;
}
&__value {
background: var(--bg-slate-400);
}
}
.lightMode {
.key-value-label {
border-color: var(--bg-vanilla-400);
&__key,
&__value {
color: var(--text-ink-400);
}
&__key {
background: var(--bg-vanilla-300);
}
&__value {
background: var(--bg-vanilla-200);
&__value {
background: var(--l3-background);
&--clickable {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
}

View File

@@ -1,29 +1,61 @@
import { useMemo } from 'react';
import { Tooltip } from 'antd';
import TrimmedText from '../TrimmedText/TrimmedText';
import './KeyValueLabel.styles.scss';
// Rethink this component later
type KeyValueLabelProps = {
badgeKey: string | React.ReactNode;
badgeValue: string;
badgeValue: string | React.ReactNode;
direction?: 'row' | 'column';
maxCharacters?: number;
onClick?: () => void;
};
export default function KeyValueLabel({
badgeKey,
badgeValue,
direction = 'row',
maxCharacters = 20,
onClick,
}: KeyValueLabelProps): JSX.Element | null {
const isUrl = useMemo(() => /^https?:\/\//.test(badgeValue), [badgeValue]);
if (!badgeKey || !badgeValue) {
return null;
}
const renderValue = (): JSX.Element => {
if (typeof badgeValue !== 'string') {
return <div className="key-value-label__value">{badgeValue}</div>;
}
return (
<Tooltip title={badgeValue}>
<div
className={`key-value-label__value ${
onClick ? 'key-value-label__value--clickable' : ''
}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onClick();
}
}
: undefined
}
>
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
</div>
</Tooltip>
);
};
return (
<div className="key-value-label">
<div className={`key-value-label key-value-label--${direction}`}>
<div className="key-value-label__key">
{typeof badgeKey === 'string' ? (
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
@@ -31,26 +63,13 @@ export default function KeyValueLabel({
badgeKey
)}
</div>
{isUrl ? (
<a
href={badgeValue}
target="_blank"
rel="noopener noreferrer"
className="key-value-label__value"
>
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
</a>
) : (
<Tooltip title={badgeValue}>
<div className="key-value-label__value">
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
</div>
</Tooltip>
)}
{renderValue()}
</div>
);
}
KeyValueLabel.defaultProps = {
maxCharacters: 20,
direction: 'row',
onClick: undefined,
};

View File

@@ -0,0 +1,191 @@
.pretty-view {
padding: 0;
flex: 1;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&__search-input {
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 8px;
width: 100%;
border: 1px solid var(--l2-border) !important;
border-radius: 4px;
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
font-size: 12px !important;
line-height: 18px !important;
background: var(--l1-background) !important;
outline: none !important;
box-shadow: none !important;
&:focus {
border-color: var(--primary) !important;
outline: none !important;
box-shadow: none !important;
}
}
// Font spec: SF Mono, 12px, 400, 18px line-height, -0.5% letter-spacing
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
// Override react-json-tree inline styles
ul {
margin: 0 !important;
padding-left: 16px !important;
list-style: none !important;
background-color: transparent !important;
flex: 1;
}
> ul,
&__pinned > ul {
padding-left: 0 !important;
}
// Force font on all tree elements
li,
span,
label {
font-family: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
letter-spacing: inherit !important;
}
// Fix react-json-tree text-indent wrapping
li {
text-indent: 0 !important;
margin-left: 0 !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
}
.pretty-view__actions {
margin-right: 16px;
}
// Leaf node row — hover highlights only this row
&__row {
border-radius: 2px;
display: flex !important;
align-items: baseline;
&:hover {
background-color: var(--l3-background);
.pretty-view__actions {
opacity: 1;
}
}
// Push actions to the right edge
> span {
flex: 1;
}
}
// Nested node (object/array) — hover only on the label line, not children
&__nested-row {
> label,
> span:not(ul span) {
border-radius: 2px;
padding: 1px 2px;
display: inline !important; // keep item string inline with label
}
// Highlight label + item string on hover, show actions
&:hover > label,
&:hover > label + span {
background-color: var(--l3-background);
}
&:hover > label + span .pretty-view__actions {
opacity: 1;
}
// In nested rows, value-row should not take full width
.pretty-view__value-row {
width: auto;
}
}
// Value + actions wrapper (from valueRenderer / getItemString)
&__value-row {
display: inline-flex;
align-items: center;
gap: 6px;
}
// In leaf rows, value-row takes full width to push ... to the right
&__row &__value-row {
justify-content: space-between;
width: 100%;
}
// ... actions button — hidden by default, shown on row hover
&__actions {
opacity: 0;
display: inline-flex;
align-items: center;
cursor: pointer;
color: var(--l2-foreground);
transition: opacity 0.1s ease;
padding: 0 2px;
&:hover {
color: var(--l1-foreground);
}
}
// Pinned items section
&__pinned {
border-bottom: 1px solid var(--l2-border);
padding-bottom: 8px;
margin-bottom: 8px;
padding-left: 0px;
li {
margin-left: 0 !important;
padding-left: 0 !important;
display: flex !important;
align-items: baseline;
}
}
&__pinned-header {
font-size: 11px;
font-weight: 500;
color: var(--l3-foreground);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px;
}
&__pinned-label {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
&__pinned-icon {
flex-shrink: 0;
color: var(--text-robin-400);
cursor: pointer;
fill: var(--text-robin-400);
transition: fill 0.1s ease;
&:hover {
color: var(--text-robin-300);
fill: transparent;
}
}
}

View File

@@ -0,0 +1,300 @@
import { useCallback, useMemo } from 'react';
import { JSONTree, KeyPath } from 'react-json-tree';
import { Copy, Ellipsis, Pin, PinOff } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
// TODO: Replace antd Dropdown with @signozhq/ui component when moving to design library
import { Dropdown } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { darkTheme, lightTheme, themeExtension } from './constants';
import usePinnedFields from './hooks/usePinnedFields';
import useSearchFilter, { filterTree } from './hooks/useSearchFilter';
import {
copyToClipboard,
keyPathToDisplayString,
keyPathToForward,
serializeKeyPath,
} from './utils';
import './PrettyView.styles.scss';
export interface FieldContext {
fieldKey: string;
fieldKeyPath: (string | number)[];
fieldValue: unknown;
isNested: boolean;
}
export interface PrettyViewAction {
key: string;
label: React.ReactNode;
icon?: React.ReactNode;
onClick: (context: FieldContext) => void;
}
export interface PrettyViewProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>;
actions?: PrettyViewAction[];
searchable?: boolean;
showPinned?: boolean;
drawerKey?: string;
}
function PrettyView({
data,
actions,
searchable = true,
showPinned = false,
drawerKey = 'default',
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { searchQuery, setSearchQuery, filteredData } = useSearchFilter(data);
const {
isPinned,
togglePin,
pinnedEntries,
pinnedData,
displayKeyToForwardPath,
} = usePinnedFields(data, drawerKey);
const filteredPinnedData = useMemo(() => {
const trimmed = searchQuery.trim();
if (!trimmed) {
return pinnedData;
}
return filterTree(pinnedData, trimmed) || {};
}, [pinnedData, searchQuery]);
const theme = useMemo(
() => ({
extend: isDarkMode ? darkTheme : lightTheme,
...themeExtension,
}),
[isDarkMode],
);
const shouldExpandNodeInitially = useCallback(
(
_keyPath: readonly (string | number)[],
_data: unknown,
level: number,
): boolean => level < 5,
[],
);
const buildMenuItems = useCallback(
(context: FieldContext): MenuProps['items'] => {
// todo: drive dropdown through config.
const copyItem = {
key: 'copy',
label: 'Copy',
icon: <Copy size={12} />,
onClick: (): void => {
copyToClipboard(context.fieldValue);
},
};
const items: NonNullable<MenuProps['items']> = [copyItem];
// Pin action only for leaf nodes
if (!context.isNested) {
// Resolve the correct forward path — pinned tree uses display keys
// which don't match the original serialized path
const resolvedPath =
displayKeyToForwardPath[context.fieldKey] || context.fieldKeyPath;
const serialized = serializeKeyPath(resolvedPath);
const pinned = isPinned(serialized);
items.push({
key: 'pin',
label: pinned ? 'Unpin field' : 'Pin field',
icon: pinned ? <PinOff size={12} /> : <Pin size={12} />,
onClick: (): void => {
togglePin(resolvedPath);
},
});
}
if (actions && actions.length > 0) {
//todo: why this divider?
items.push({ type: 'divider' as const, key: 'divider' });
actions.forEach((action) => {
items.push({
key: action.key,
label: action.label,
icon: action.icon,
onClick: (): void => {
action.onClick(context);
},
});
});
}
return items;
},
[actions, isPinned, togglePin, displayKeyToForwardPath],
);
const renderWithActions = useCallback(
({
content,
fieldKey,
fieldKeyPath,
value,
isNested,
}: {
content: React.ReactNode;
fieldKey: string;
fieldKeyPath: (string | number)[];
value: unknown;
isNested: boolean;
}): React.ReactNode => {
const context: FieldContext = {
fieldKey,
fieldKeyPath,
fieldValue: value,
isNested,
};
const menuItems = buildMenuItems(context);
return (
<span className="pretty-view__value-row">
<span>{content}</span>
<Dropdown
menu={{
items: menuItems,
onClick: (e): void => {
e.domEvent.stopPropagation();
},
}}
trigger={['click']}
placement="bottomLeft"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
>
<span
className="pretty-view__actions"
onClick={(e): void => e.stopPropagation()}
role="button"
tabIndex={0}
>
<Ellipsis size={12} />
</span>
</Dropdown>
</span>
);
},
[buildMenuItems],
);
const getItemString = useCallback(
(
_nodeType: string,
nodeData: unknown,
itemType: React.ReactNode,
itemString: string,
keyPath: KeyPath,
): // eslint-disable-next-line max-params
React.ReactNode => {
const forwardPath = keyPathToForward(keyPath);
return renderWithActions({
content: (
<>
{itemType} {itemString}
</>
),
fieldKey: keyPathToDisplayString(keyPath),
fieldKeyPath: forwardPath,
value: nodeData,
isNested: true,
});
},
[renderWithActions],
);
const valueRenderer = useCallback(
(
valueAsString: unknown,
value: unknown,
...keyPath: KeyPath
): React.ReactNode => {
const forwardPath = keyPathToForward(keyPath);
return renderWithActions({
content: String(valueAsString),
fieldKey: keyPathToDisplayString(keyPath),
fieldKeyPath: forwardPath,
value,
isNested: typeof value === 'object' && value !== null,
});
},
[renderWithActions],
);
const pinnedLabelRenderer = useCallback(
(keyPath: KeyPath): React.ReactNode => {
const displayKey = String(keyPath[0]);
const entry = pinnedEntries.find((e) => e.displayKey === displayKey);
return (
<span className="pretty-view__pinned-label">
<Pin
size={12}
className="pretty-view__pinned-icon"
onClick={(): void => {
if (entry) {
togglePin(entry.forwardPath);
}
}}
/>
<span>{displayKey}</span>
</span>
);
},
[togglePin, pinnedEntries],
);
return (
<div className="pretty-view">
{searchable && (
<Input
className="pretty-view__search-input"
type="text"
placeholder="Search for a field..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
)}
{showPinned && Object.keys(filteredPinnedData).length > 0 && (
<div className="pretty-view__pinned">
<div className="pretty-view__pinned-header">PINNED ITEMS</div>
<JSONTree
key={`pinned-${searchQuery}`}
data={filteredPinnedData}
theme={theme}
invertTheme={false}
hideRoot
shouldExpandNodeInitially={shouldExpandNodeInitially}
valueRenderer={valueRenderer}
getItemString={getItemString}
labelRenderer={pinnedLabelRenderer}
/>
</div>
)}
<JSONTree
key={searchQuery}
data={filteredData}
theme={theme}
invertTheme={false}
hideRoot
shouldExpandNodeInitially={shouldExpandNodeInitially}
valueRenderer={valueRenderer}
getItemString={getItemString}
/>
</div>
);
}
export default PrettyView;

View File

@@ -0,0 +1,62 @@
// Dark theme — SigNoz design palette
export const darkTheme = {
scheme: 'signoz-dark',
author: 'signoz',
base00: 'transparent',
base01: '#161922',
base02: '#1d212d',
base03: '#62687C',
base04: '#ADB4C2',
base05: '#ADB4C2',
base06: '#ADB4C2',
base07: '#ADB4C2',
base08: '#EA6D71',
base09: '#7CEDBD',
base0A: '#7CEDBD',
base0B: '#ADB4C2',
base0C: '#23C4F8',
base0D: '#95ACFB',
base0E: '#95ACFB',
base0F: '#AD7F58',
};
// Light theme
export const lightTheme = {
scheme: 'signoz-light',
author: 'signoz',
base00: 'transparent',
base01: '#F9F9FB',
base02: '#EFF0F3',
base03: '#80828D',
base04: '#62636C',
base05: '#62636C',
base06: '#62636C',
base07: '#1E1F24',
base08: '#E5484D',
base09: '#168757',
base0A: '#168757',
base0B: '#62636C',
base0C: '#157594',
base0D: '#2F48A0',
base0E: '#2F48A0',
base0F: '#684C35',
};
export const themeExtension = {
value: ({
style,
}: {
style: Record<string, unknown>;
}): { style: Record<string, unknown>; className: string } => ({
style: { ...style },
className: 'pretty-view__row',
}),
nestedNode: ({
style,
}: {
style: Record<string, unknown>;
}): { style: Record<string, unknown>; className: string } => ({
style: { ...style },
className: 'pretty-view__nested-row',
}),
};

View File

@@ -0,0 +1,127 @@
import { useCallback, useMemo, useState } from 'react';
import {
deserializeKeyPath,
keyPathToDisplayString,
resolveValueByKeys,
serializeKeyPath,
} from '../utils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyRecord = Record<string, any>;
const STORAGE_PREFIX = 'pinnedFields';
function loadFromStorage(storageKey: string): string[] {
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveToStorage(storageKey: string, keys: string[]): void {
localStorage.setItem(storageKey, JSON.stringify(keys));
}
export interface PinnedEntry {
serializedKey: string;
displayKey: string;
forwardPath: (string | number)[];
value: unknown;
}
export interface UsePinnedFieldsReturn {
isPinned: (serializedKey: string) => boolean;
togglePin: (forwardPath: (string | number)[]) => void;
pinnedEntries: PinnedEntry[];
pinnedData: AnyRecord;
displayKeyToForwardPath: Record<string, (string | number)[]>;
}
function usePinnedFields(
data: AnyRecord,
drawerKey: string,
): UsePinnedFieldsReturn {
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
// Stored as serialized keyPath arrays (JSON strings)
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
() => new Set(loadFromStorage(storageKey)),
);
const togglePin = useCallback(
(forwardPath: (string | number)[]): void => {
const serialized = serializeKeyPath(forwardPath);
setPinnedSerializedKeys((prev) => {
const next = new Set(prev);
if (next.has(serialized)) {
next.delete(serialized);
} else {
next.add(serialized);
}
saveToStorage(storageKey, Array.from(next));
return next;
});
},
[storageKey],
);
const isPinned = useCallback(
(serializedKey: string): boolean => pinnedSerializedKeys.has(serializedKey),
[pinnedSerializedKeys],
);
const pinnedEntries = useMemo(
(): PinnedEntry[] =>
Array.from(pinnedSerializedKeys)
.map((serializedKey) => {
const forwardPath = deserializeKeyPath(serializedKey);
if (!forwardPath) {
return null;
}
return {
serializedKey,
displayKey: keyPathToDisplayString(
[...forwardPath].reverse() as readonly (string | number)[],
),
forwardPath,
value: resolveValueByKeys(data, forwardPath),
};
})
.filter(
(entry): entry is PinnedEntry =>
entry !== null && entry.value !== undefined,
),
[pinnedSerializedKeys, data],
);
// Flat object for the pinned JSONTree — use display key as the object key
const pinnedData = useMemo(
() =>
Object.fromEntries(
pinnedEntries.map((entry) => [entry.displayKey, entry.value]),
),
[pinnedEntries],
);
// Map from display key to original forward path — for unpin from pinned tree
const displayKeyToForwardPath = useMemo(
(): Record<string, (string | number)[]> =>
Object.fromEntries(
pinnedEntries.map((entry) => [entry.displayKey, entry.forwardPath]),
),
[pinnedEntries],
);
return {
isPinned,
togglePin,
pinnedEntries,
pinnedData,
displayKeyToForwardPath,
};
}
export default usePinnedFields;

View File

@@ -0,0 +1,82 @@
import { useMemo, useState } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyRecord = Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyValue = any;
function isLeaf(value: unknown): boolean {
return value === null || value === undefined || typeof value !== 'object';
}
function matchesQuery(text: string, lowerQuery: string): boolean {
return text.toLowerCase().includes(lowerQuery);
}
// Filter a single value (leaf, array, or object) against the query.
// Returns the filtered value or null if no match.
function filterValue(value: AnyValue, query: string): AnyValue | null {
if (isLeaf(value)) {
return matchesQuery(String(value), query.toLowerCase()) ? value : null;
}
if (Array.isArray(value)) {
return filterArray(value, query);
}
return filterTree(value, query);
}
// Recursively filter an array, keeping only elements that match
function filterArray(arr: AnyValue[], query: string): AnyValue[] | null {
const results = arr
.map((item) => filterValue(item, query))
.filter((item) => item !== null);
return results.length > 0 ? results : null;
}
// Recursively filter the data tree, keeping only branches with matching keys or values
export function filterTree(obj: AnyRecord, query: string): AnyRecord | null {
const result: AnyRecord = {};
const lowerQuery = query.toLowerCase();
let hasMatch = false;
for (const [key, value] of Object.entries(obj)) {
if (matchesQuery(key, lowerQuery)) {
result[key] = value;
hasMatch = true;
continue;
}
const filtered = filterValue(value, query);
if (filtered !== null) {
result[key] = filtered;
hasMatch = true;
}
}
return hasMatch ? result : null;
}
interface UseSearchFilterReturn {
searchQuery: string;
setSearchQuery: (query: string) => void;
filteredData: AnyRecord;
}
function useSearchFilter(data: AnyRecord): UseSearchFilterReturn {
const [searchQuery, setSearchQuery] = useState('');
const filteredData = useMemo(() => {
const trimmed = searchQuery.trim();
if (!trimmed) {
return data;
}
return filterTree(data, trimmed) || {};
}, [data, searchQuery]);
return { searchQuery, setSearchQuery, filteredData };
}
export default useSearchFilter;

View File

@@ -0,0 +1,2 @@
export type { PrettyViewProps } from './PrettyView';
export { default as PrettyView } from './PrettyView';

View File

@@ -0,0 +1,58 @@
import { toast } from '@signozhq/sonner';
export function copyToClipboard(value: unknown): void {
const text =
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
// eslint-disable-next-line no-restricted-properties
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
}
// Resolve a value from a nested object using an array of keys (not dot-notation)
// e.g. resolveValueByKeys({ tagMap: { 'cloud.account.id': 'x' } }, ['tagMap', 'cloud.account.id']) → 'x'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function resolveValueByKeys(
data: Record<string, any>,
keys: (string | number)[],
): unknown {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return keys.reduce((obj: any, key) => obj?.[key], data);
}
// Convert react-json-tree's reversed keyPath to forward order
// e.g. ['cloud.account.id', 'tagMap'] → ['tagMap', 'cloud.account.id']
export function keyPathToForward(
keyPath: readonly (string | number)[],
): (string | number)[] {
return [...keyPath].reverse();
}
// Display-friendly string for a keyPath
// e.g. ['tagMap', 'cloud.account.id'] → 'tagMap.cloud.account.id'
export function keyPathToDisplayString(
keyPath: readonly (string | number)[],
): string {
return [...keyPath].reverse().join('.');
}
// Serialize keyPath for storage/comparison (JSON stringified array)
export function serializeKeyPath(keyPath: (string | number)[]): string {
return JSON.stringify(keyPath);
}
export function deserializeKeyPath(
serialized: string,
): (string | number)[] | null {
try {
const parsed = JSON.parse(serialized);
if (Array.isArray(parsed)) {
return parsed;
}
return null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,42 @@
.resizable-box {
position: relative;
overflow: hidden;
&--disabled {
flex: 1;
min-height: 0;
}
&__content {
width: 100%;
height: 100%;
overflow: hidden;
}
&__handle {
position: absolute;
z-index: 10;
background: var(--l2-border);
&:hover,
&:active {
background: var(--primary);
}
&--vertical {
bottom: 0;
left: 0;
right: 0;
height: 4px;
cursor: row-resize;
}
&--horizontal {
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
}
}
}

View File

@@ -0,0 +1,88 @@
import { useCallback, useRef, useState } from 'react';
import './ResizableBox.styles.scss';
export interface ResizableBoxProps {
children: React.ReactNode;
direction?: 'vertical' | 'horizontal';
defaultHeight?: number;
minHeight?: number;
maxHeight?: number;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
onResize?: (size: number) => void;
disabled?: boolean;
className?: string;
}
function ResizableBox({
children,
direction = 'vertical',
defaultHeight = 200,
minHeight = 50,
maxHeight = Infinity,
defaultWidth = 200,
minWidth = 50,
maxWidth = Infinity,
onResize,
disabled = false,
className,
}: ResizableBoxProps): JSX.Element {
const isHorizontal = direction === 'horizontal';
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent): void => {
e.preventDefault();
const startPos = isHorizontal ? e.clientX : e.clientY;
const startSize = size;
const min = isHorizontal ? minWidth : minHeight;
const max = isHorizontal ? maxWidth : maxHeight;
const onMouseMove = (moveEvent: MouseEvent): void => {
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - startPos;
const newSize = Math.min(max, Math.max(min, startSize + delta));
setSize(newSize);
onResize?.(newSize);
};
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
);
const containerStyle = disabled
? undefined
: isHorizontal
? { width: size }
: { height: size };
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
return (
<div
ref={containerRef}
className={`resizable-box ${disabled ? 'resizable-box--disabled' : ''} ${
className || ''
}`}
style={containerStyle}
>
<div className="resizable-box__content">{children}</div>
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
</div>
);
}
export default ResizableBox;

View File

@@ -0,0 +1,2 @@
export type { ResizableBoxProps } from './ResizableBox';
export { default as ResizableBox } from './ResizableBox';

View File

@@ -6538,6 +6538,11 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3"
integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==
"@types/lodash@^4.17.0", "@types/lodash@^4.17.15":
version "4.17.24"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.24.tgz#4ae334fc62c0e915ca8ed8e35dcc6d4eeb29215f"
integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==
"@types/mdast@^3.0.0":
version "3.0.12"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.12.tgz#beeb511b977c875a5b0cc92eab6fcac2f0895514"
@@ -8952,7 +8957,7 @@ color-string@^1.9.0:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color@^4.2.1:
color@^4.2.1, color@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/color/-/color-4.2.3.tgz"
integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
@@ -9384,6 +9389,11 @@ csstype@^3.1.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
csstype@^3.1.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
version "3.2.3"
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
@@ -16819,6 +16829,11 @@ rc-virtual-list@^3.11.1, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2:
rc-resize-observer "^1.0.0"
rc-util "^5.36.0"
re-resizable@^6.11.2:
version "6.11.2"
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3"
integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==
react-addons-update@15.6.3:
version "15.6.3"
resolved "https://registry.yarnpkg.com/react-addons-update/-/react-addons-update-15.6.3.tgz#c449c309154024d04087b206d0400e020547b313"
@@ -16826,6 +16841,16 @@ react-addons-update@15.6.3:
dependencies:
object-assign "^4.1.0"
react-base16-styling@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.10.0.tgz#5d5f019bd4dc5870c3e92fd9d5410533a0bbb0c6"
integrity sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==
dependencies:
"@types/lodash" "^4.17.0"
color "^4.2.3"
csstype "^3.1.3"
lodash-es "^4.17.21"
react-beautiful-dnd@13.1.1:
version "13.1.1"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"
@@ -16890,6 +16915,14 @@ react-draggable@^4.0.0, react-draggable@^4.0.3:
clsx "^1.1.1"
prop-types "^15.8.1"
react-draggable@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.5.0.tgz#0b274ccb6965fcf97ed38fcf7e3cc223bc48cdf5"
integrity sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==
dependencies:
clsx "^2.1.1"
prop-types "^15.8.1"
react-error-boundary@4.0.11:
version "4.0.11"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.11.tgz#36bf44de7746714725a814630282fee83a7c9a1c"
@@ -16980,6 +17013,14 @@ react-is@^18.3.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react-json-tree@0.20.0:
version "0.20.0"
resolved "https://registry.yarnpkg.com/react-json-tree/-/react-json-tree-0.20.0.tgz#1e7fd26f093bd49d733f80dd9b6d40142e09f7c9"
integrity sha512-h+f9fUNAxzBx1rbrgUF7+zSWKGHDtt2VPYLErIuB0JyKGnWgFMM21ksqQyb3EXwXNnoMW2rdE5kuAaubgGOx2Q==
dependencies:
"@types/lodash" "^4.17.15"
react-base16-styling "^0.10.0"
react-kapsule@^2.5:
version "2.5.7"
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
@@ -17099,6 +17140,15 @@ react-resizable@^3.0.4:
prop-types "15.x"
react-draggable "^4.0.3"
react-rnd@10.5.3:
version "10.5.3"
resolved "https://registry.yarnpkg.com/react-rnd/-/react-rnd-10.5.3.tgz#f8c484034276a561e8aa2fbf6a94580596621013"
integrity sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q==
dependencies:
re-resizable "^6.11.2"
react-draggable "^4.5.0"
tslib "2.6.2"
react-router-dom-v5-compat@6.27.0:
version "6.27.0"
resolved "https://registry.yarnpkg.com/react-router-dom-v5-compat/-/react-router-dom-v5-compat-6.27.0.tgz#19429aefa130ee151e6f4487d073d919ec22d458"
@@ -19184,6 +19234,11 @@ tsconfig-paths@^4.2.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@2.6.2, tslib@^2.0.0:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
tslib@2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01"
@@ -19194,11 +19249,6 @@ tslib@^1.14.1, tslib@^1.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0:
version "2.5.0"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz"

View File

@@ -63,6 +63,7 @@ type RetryConfig struct {
func newConfig() factory.Config {
return Config{
Provider: "noop",
BufferSize: 1000,
BatchSize: 100,
FlushInterval: time.Second,

View File

@@ -208,7 +208,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/emailing"
@@ -123,6 +124,9 @@ type Config struct {
// ServiceAccount config
ServiceAccount serviceaccount.Config `mapstructure:"serviceaccount"`
// Auditor config
Auditor auditor.Config `mapstructure:"auditor"`
}
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
@@ -153,6 +157,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
user.NewConfigFactory(),
identn.NewConfigFactory(),
serviceaccount.NewConfigFactory(),
auditor.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)

View File

@@ -3,6 +3,8 @@ package signoz
import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/noopauditor"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
@@ -312,6 +314,12 @@ func NewGlobalProviderFactories(identNConfig identn.Config) factory.NamedMap[fac
)
}
func NewAuditorProviderFactories() factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return factory.MustNewNamedMap(
noopauditor.NewFactory(),
)
}
func NewFlaggerProviderFactories(registry featuretypes.Registry) factory.NamedMap[factory.ProviderFactory[flagger.FlaggerProvider, flagger.Config]] {
return factory.MustNewNamedMap(
configflagger.NewFactory(registry),

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/apiserver"
@@ -75,6 +76,7 @@ type SigNoz struct {
QueryParser queryparser.QueryParser
Flagger flagger.Flagger
Gateway gateway.Gateway
Auditor auditor.Auditor
}
func New(
@@ -94,6 +96,7 @@ func New(
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
) (*SigNoz, error) {
// Initialize instrumentation
@@ -371,6 +374,12 @@ func New(
return nil, err
}
// Initialize auditor from the variant-specific provider factories
auditor, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Auditor, auditorProviderFactories(licensing), config.Auditor.Provider)
if err != nil {
return nil, err
}
// Initialize authns
store := sqlauthnstore.NewStore(sqlstore)
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
@@ -470,6 +479,7 @@ func New(
factory.NewNamedService(factory.MustNewName("tokenizer"), tokenizer),
factory.NewNamedService(factory.MustNewName("authz"), authz),
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
)
if err != nil {
return nil, err
@@ -516,5 +526,6 @@ func New(
QueryParser: queryParser,
Flagger: flagger,
Gateway: gateway,
Auditor: auditor,
}, nil
}