Compare commits

..

15 Commits

Author SHA1 Message Date
ahmadshaheer
bd300adbc6 chore: fix the failing test 2025-12-10 16:24:23 +04:30
ahmadshaheer
675728acf5 chore: remove unnecessary tests 2025-12-10 16:22:16 +04:30
ahmadshaheer
b63d1b0d1f fix: remove default 'log' context for fieldContext in ExplorerColumnsRenderer 2025-12-10 16:10:00 +04:30
ahmadshaheer
f47b5cc4d6 chore: overall improvements 2025-12-10 16:07:20 +04:30
ahmadshaheer
f362200b22 fix: show conflict tooltip icon if onDragColumn is false as well + refactor 2025-12-10 16:03:48 +04:30
ahmadshaheer
07bb88e0ec fix: adjust the tests based on the latest changes 2025-12-10 15:39:12 +04:30
ahmadshaheer
6786767158 fix: overall bugfixes and improvements 2025-12-10 12:15:06 +04:30
ahmadshaheer
67082e9ff8 chore: fix the failing tests by adding optional chaining to .keys 2025-12-10 09:31:47 +04:30
ahmadshaheer
2040903fe5 chore: fix the failing test 2025-12-09 20:49:09 +04:30
ahmadshaheer
b4dd5cb245 chore: write tests for duplicate attribute flows 2025-12-09 19:45:44 +04:30
ahmadshaheer
ee84efa73d fix: hide the info icon from conflicting column if all of them are selected 2025-12-09 18:16:44 +04:30
ahmadshaheer
ac11393491 feat: improve the UX for a single selected conflicting field 2025-12-09 17:48:02 +04:30
ahmadshaheer
9ad0ac694a chore: fix the failing test 2025-12-09 11:20:11 +04:30
ahmadshaheer
e27b50c0fa feat: handle duplicate attribute types and contexts in select options 2025-12-08 18:25:08 +04:30
ahmadshaheer
4e4942f646 chore: add field variant badges component 2025-12-08 11:26:31 +04:30
125 changed files with 4646 additions and 9340 deletions

View File

@@ -73,19 +73,3 @@ jobs:
shell: bash
run: |
make docker-build-enterprise
openapi:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)

View File

@@ -13,7 +13,6 @@ func main() {
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.RegisterGenerate(cmd.RootCmd, logger)
cmd.Execute(logger)
}

View File

@@ -13,7 +13,6 @@ func main() {
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.RegisterGenerate(cmd.RootCmd, logger)
cmd.Execute(logger)
}

View File

@@ -1,21 +0,0 @@
package cmd
import (
"log/slog"
"github.com/spf13/cobra"
)
func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
var generateCmd = &cobra.Command{
Use: "generate",
Short: "Generate artifacts",
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
}
registerGenerateOpenAPI(generateCmd)
parentCmd.AddCommand(generateCmd)
}

View File

@@ -1,41 +0,0 @@
package cmd
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/version"
"github.com/spf13/cobra"
)
func registerGenerateOpenAPI(parentCmd *cobra.Command) {
openapiCmd := &cobra.Command{
Use: "openapi",
Short: "Generate OpenAPI schema for SigNoz",
RunE: func(currCmd *cobra.Command, args []string) error {
return runGenerateOpenAPI(currCmd.Context())
},
}
parentCmd.AddCommand(openapiCmd)
}
func runGenerateOpenAPI(ctx context.Context) error {
instrumentation, err := instrumentation.New(ctx, instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}, version.Info, "signoz")
if err != nil {
return err
}
openapi, err := signoz.NewOpenAPI(ctx, instrumentation)
if err != nil {
return err
}
if err := openapi.CreateAndWrite("docs/api/openapi.yml"); err != nil {
return err
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -94,6 +94,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// routes available only in ee version
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
// paid plans specific routes
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)

View File

@@ -243,11 +243,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.MetricExplorerRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
err := s.signoz.APIServer.AddToRouter(r)
if err != nil {
return nil, err
}
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
@@ -258,7 +253,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
handler = handlers.CompressHandler(handler)
err = web.AddToRouter(r)
err := web.AddToRouter(r)
if err != nil {
return nil, err
}

View File

@@ -48,7 +48,6 @@
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/checkbox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
@@ -105,6 +104,7 @@
"i18next-http-backend": "^1.3.2",
"jest": "^27.5.1",
"js-base64": "^3.7.2",
"kbar": "0.1.0-beta.48",
"less": "^4.1.2",
"less-loader": "^10.2.0",
"lodash-es": "^4.17.21",

View File

@@ -4,7 +4,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
@@ -24,9 +24,9 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall
import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useCallback, useEffect, useState } from 'react';
@@ -364,10 +364,10 @@ function App(): JSX.Element {
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<CmdKProvider>
<KBarCommandPaletteProvider>
<KBarCommandPalette />
<NotificationProvider>
<ErrorModalProvider>
{isLoggedInState && <CmdKPalette userRole={user.role} />}
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
@@ -398,7 +398,7 @@ function App(): JSX.Element {
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
</KBarCommandPaletteProvider>
</CompatRouter>
</Router>
</ConfigProvider>

View File

@@ -14,8 +14,6 @@ import '@signozhq/badge';
import '@signozhq/button';
import '@signozhq/calendar';
import '@signozhq/callout';
import '@signozhq/checkbox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/input';
import '@signozhq/popover';

View File

@@ -132,117 +132,34 @@ function CeleryTaskBar({
[selectedFilters, celerySuccessStateData],
);
const onGraphClick = useCallback(
(
widgetData: Widgets,
xValue: number,
_yValue: number,
_mouseX: number,
_mouseY: number,
data?: {
[key: string]: string;
},
): void => {
const { start, end } = getStartAndEndTimesInMilliseconds(xValue);
// Extract entity and value from data
const [firstDataPoint] = Object.entries(data || {});
const [entity, value] = (firstDataPoint || ([] as unknown)) as [
string,
string,
];
if (!isEmpty(entity) || !isEmpty(value)) {
onClick?.({
entity,
value,
timeRange: [start, end],
widgetData,
});
}
const onGraphClick = (
widgetData: Widgets,
xValue: number,
_yValue: number,
_mouseX: number,
_mouseY: number,
data?: {
[key: string]: string;
},
[onClick],
);
): void => {
const { start, end } = getStartAndEndTimesInMilliseconds(xValue);
const onAllStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celerySlowestTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
// Extract entity and value from data
const [firstDataPoint] = Object.entries(data || {});
const [entity, value] = (firstDataPoint || ([] as unknown)) as [
string,
string,
];
const onFailedStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celeryFailedTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
const onRetryStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celeryRetryTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
const onSuccessStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celerySuccessTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
if (!isEmpty(entity) || !isEmpty(value)) {
onClick?.({
entity,
value,
timeRange: [start, end],
widgetData,
});
}
};
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
@@ -268,7 +185,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onAllStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celerySlowestTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
dataAvailable={checkIfDataExists}
/>
@@ -279,7 +205,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onFailedStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celeryFailedTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
/>
)}
@@ -289,7 +224,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onRetryStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celeryRetryTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
/>
)}
@@ -299,7 +243,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onSuccessStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celerySuccessTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
/>
)}

View File

@@ -0,0 +1,80 @@
.field-variant-badges-container {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.field-badge {
&.data-type {
display: flex;
height: 20px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
background: color-mix(in srgb, var(--bg-vanilla-100) 8%, transparent);
white-space: nowrap;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&.type-tag {
display: flex;
align-items: center;
height: 20px;
padding: 0px 6px;
justify-content: center;
gap: 4px;
border-radius: 50px;
text-transform: capitalize;
white-space: nowrap;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.text {
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&.attribute {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
color: var(--bg-sienna-400);
.dot {
background-color: var(--bg-sienna-400);
}
.text {
color: var(--bg-sienna-400);
}
}
&.resource {
background: color-mix(in srgb, var(--bg-aqua-400) 10%, transparent);
color: var(--bg-aqua-400);
.dot {
background-color: var(--bg-aqua-400);
}
.text {
color: var(--bg-aqua-400);
}
}
}
}

View File

@@ -0,0 +1,69 @@
import './FieldVariantBadges.styles.scss';
import cx from 'classnames';
/**
* Field contexts that should display badges
*/
export enum AllowedFieldContext {
Attribute = 'attribute',
Resource = 'resource',
}
const ALLOWED_FIELD_CONTEXTS = new Set<string>([
AllowedFieldContext.Attribute,
AllowedFieldContext.Resource,
]);
interface FieldVariantBadgesProps {
fieldDataType?: string;
fieldContext?: string;
}
/**
* Determines if a fieldContext badge should be displayed
* Only shows badges for contexts in ALLOWED_FIELD_CONTEXTS
*/
const shouldShowFieldContextBadge = (
fieldContext: string | undefined | null,
): boolean => {
if (!fieldContext) {
return false;
}
return ALLOWED_FIELD_CONTEXTS.has(fieldContext);
};
function FieldVariantBadges({
fieldDataType,
fieldContext,
}: FieldVariantBadgesProps): JSX.Element | null {
// If neither value exists, don't render anything
if (!fieldDataType && !fieldContext) {
return null;
}
// Check if fieldContext should be displayed
const showFieldContext =
fieldContext && shouldShowFieldContextBadge(fieldContext);
return (
<span className="field-variant-badges-container">
{fieldDataType && (
<span className="field-badge data-type">{fieldDataType}</span>
)}
{showFieldContext && (
<section className={cx('field-badge type-tag', fieldContext)}>
<div className="dot" />
<span className="text">{fieldContext}</span>
</section>
)}
</span>
);
}
FieldVariantBadges.defaultProps = {
fieldDataType: undefined,
fieldContext: undefined,
};
export default FieldVariantBadges;

View File

@@ -0,0 +1,152 @@
.kbar-command-palette__positioner {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
z-index: 50;
}
.kbar-command-palette__animator {
width: 100%;
max-width: 600px;
}
.kbar-command-palette__card {
background: var(--bg-ink-500);
color: var(--text-vanilla-100);
border-radius: 3px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.kbar-command-palette__search {
padding: 12px 16px;
font-size: 13px;
border: none;
border-bottom: 1px solid var(--border-ink-200);
color: var(--text-vanilla-100);
outline: none;
background-color: var(--bg-ink-500);
}
.kbar-command-palette__section {
padding: 8px 16px 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-robin-500);
font-family: 'Inter', sans-serif;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.kbar-command-palette__item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: var(--bg-ink-400);
}
.kbar-command-palette__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: #444;
}
.kbar-command-palette__shortcut {
margin-left: auto;
display: flex;
gap: 4px;
}
.kbar-command-palette__key {
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
background: var(--bg-ink-300);
color: var(--text-vanilla-300);
text-transform: uppercase;
font-family: 'Space Mono', monospace;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
}
.lightMode {
.kbar-command-palette__positioner {
background: rgba(0, 0, 0, 0.5);
}
.kbar-command-palette__card {
background: #fff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.kbar-command-palette__search {
border-bottom: 1px solid #e5e5e5;
color: var(--text-ink-500);
background-color: var(--bg-vanilla-100);
}
.kbar-command-palette__item {
color: var(--text-ink-500);
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: #f5f5f5;
}
.kbar-command-palette__icon {
color: #444;
}
.kbar-command-palette__key {
background: #eee;
color: #555;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,69 @@
import './KBarCommandPalette.scss';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarResults,
KBarSearch,
useMatches,
} from 'kbar';
function Results(): JSX.Element {
const { results } = useMatches();
const renderResults = ({
item,
active,
}: {
item: any;
active: boolean;
}): JSX.Element =>
typeof item === 'string' ? (
<div className="kbar-command-palette__section">{item}</div>
) : (
<div
className={`kbar-command-palette__item ${
active ? 'kbar-command-palette__item--active' : ''
}`}
>
{item.icon}
<span>{item.name}</span>
{item.shortcut?.length ? (
<span className="kbar-command-palette__shortcut">
{item.shortcut.map((sc: string) => (
<kbd key={sc} className="kbar-command-palette__key">
{sc}
</kbd>
))}
</span>
) : null}
</div>
);
return (
<div className="kbar-command-palette__results-container">
<KBarResults items={results} onRender={renderResults} />
</div>
);
}
function KBarCommandPalette(): JSX.Element {
return (
<KBarPortal>
<KBarPositioner className="kbar-command-palette__positioner">
<KBarAnimator className="kbar-command-palette__animator">
<div className="kbar-command-palette__card">
<KBarSearch
className="kbar-command-palette__search"
placeholder="Search or type a command..."
/>
<Results />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
}
export default KBarCommandPalette;

View File

@@ -0,0 +1,170 @@
import { renderHook, RenderHookResult } from '@testing-library/react';
import { ColumnType } from 'antd/es/table';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
mockAllAvailableKeys,
mockConflictingFieldsByContext,
mockConflictingFieldsByDatatype,
} from 'container/OptionsMenu/__tests__/mockData';
import { FontSize } from 'container/OptionsMenu/types';
import { renderColumnHeader } from 'tests/columnHeaderHelpers';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { useTableView } from '../useTableView';
const COLUMN_UNDEFINED_ERROR = 'statusCodeColumn is undefined';
const SERVICE_NAME_COLUMN_UNDEFINED_ERROR = 'serviceNameColumn is undefined';
// Mock useTimezone hook
jest.mock('providers/Timezone', () => ({
useTimezone: (): {
formatTimezoneAdjustedTimestamp: (input: string | number) => string;
} => ({
formatTimezoneAdjustedTimestamp: jest.fn((input: string | number): string => {
if (typeof input === 'string') {
return new Date(input).toISOString();
}
return new Date(input / 1e6).toISOString();
}),
}),
}));
// Mock useIsDarkMode hook
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
describe('useTableView - Column Headers', () => {
const HTTP_STATUS_CODE = 'http.status_code';
const mockLogs: ILog[] = [
({
id: '1',
body: 'Test log',
timestamp: '2024-01-01T00:00:00Z',
[HTTP_STATUS_CODE]: '200',
} as unknown) as ILog,
];
const renderUseTableView = (
fields: TelemetryFieldKey[],
allAvailableKeys = mockAllAvailableKeys,
): RenderHookResult<ReturnType<typeof useTableView>, unknown> =>
renderHook(() =>
useTableView({
logs: mockLogs,
fields: fields as IField[],
linesPerRow: 1,
fontSize: FontSize.SMALL,
allAvailableKeys,
}),
);
it('shows datatype in column header for conflicting columns', () => {
const fields: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // string variant
];
const { result } = renderUseTableView(fields);
const { columns } = result.current;
const statusCodeColumn = columns.find(
(col): col is ColumnType<Record<string, unknown>> =>
'dataIndex' in col && col.dataIndex === HTTP_STATUS_CODE,
);
expect(statusCodeColumn).toBeDefined();
expect(statusCodeColumn?.title).toBeDefined();
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
expect(container.textContent).toContain('http.status_code (string)');
expect(container.textContent).toContain('string');
});
it('shows tooltip icon when unselected conflicting variant exists', () => {
const fields: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // Only string variant selected
];
const { result } = renderUseTableView(fields, mockAllAvailableKeys); // Contains number variant
const { columns } = result.current;
const statusCodeColumn = columns.find(
(col): col is ColumnType<Record<string, unknown>> =>
'dataIndex' in col && col.dataIndex === HTTP_STATUS_CODE,
);
expect(statusCodeColumn).toBeDefined();
// Verify that _hasUnselectedConflict metadata is set correctly
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBe(true);
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).toBeInTheDocument();
});
it('hides tooltip icon when all conflicting variants are selected', () => {
const fields: TelemetryFieldKey[] = [
...mockConflictingFieldsByDatatype, // Both variants selected
];
const { result } = renderUseTableView(fields);
const { columns } = result.current;
const statusCodeColumn = columns.find(
(col): col is ColumnType<Record<string, unknown>> =>
'dataIndex' in col && col.dataIndex === HTTP_STATUS_CODE,
);
expect(statusCodeColumn).toBeDefined();
// Verify that _hasUnselectedConflict metadata is NOT set when all variants are selected
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBeUndefined();
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).not.toBeInTheDocument();
});
it('shows context in header for attribute/resource conflicting fields', () => {
// When same datatype but different contexts, it shows context
const fields: TelemetryFieldKey[] = [
mockConflictingFieldsByContext[0], // resource variant
mockConflictingFieldsByContext[1], // attribute variant - both have same datatype
];
const { result } = renderUseTableView(fields);
const { columns } = result.current;
const serviceNameColumn = columns.find(
(col): col is ColumnType<Record<string, unknown>> =>
'dataIndex' in col && col.dataIndex === 'service.name',
);
expect(serviceNameColumn).toBeDefined();
if (!serviceNameColumn) {
throw new Error(SERVICE_NAME_COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(serviceNameColumn);
expect(container.textContent).toContain('service.name (resource)');
expect(container.textContent).toContain('resource');
});
});

View File

@@ -1,4 +1,5 @@
import { ColumnsType, ColumnType } from 'antd/es/table';
import { TelemetryFieldKey } from 'api/v5/v5';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -28,6 +29,7 @@ export type UseTableViewProps = {
activeLogIndex?: number;
activeContextLog?: ILog | null;
isListViewPanel?: boolean;
allAvailableKeys?: TelemetryFieldKey[];
} & LogsTableViewProps;
export type ActionsColumnProps = {

View File

@@ -5,6 +5,12 @@ import { ColumnsType } from 'antd/es/table';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getColumnTitleWithTooltip,
getFieldVariantsByName,
getUniqueColumnKey,
hasMultipleVariants,
} from 'container/OptionsMenu/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
@@ -31,6 +37,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
fontSize,
appendTo = 'center',
isListViewPanel,
allAvailableKeys,
} = props;
const isDarkMode = useIsDarkMode();
@@ -50,30 +57,50 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
);
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
// Group fields by name to analyze variants
const fieldVariantsByName = getFieldVariantsByName(fields);
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
.map(({ name }) => ({
title: name,
dataIndex: name,
accessorKey: name,
id: name.toLowerCase().replace(/\./g, '_'),
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{field}
</Typography.Paragraph>
),
}),
}));
.map((field) => {
const hasVariants = hasMultipleVariants(
field.name || '',
fields,
allAvailableKeys,
);
const variants = fieldVariantsByName[field.name] || [];
const { title, hasUnselectedConflict } = getColumnTitleWithTooltip(
field,
hasVariants,
variants,
fields,
allAvailableKeys,
);
return {
title,
dataIndex: field.name,
accessorKey: field.name,
id: getUniqueColumnKey(field),
key: getUniqueColumnKey(field),
// Store metadata for header enhancement (will be rendered via custom header component)
...(hasUnselectedConflict && { _hasUnselectedConflict: true }),
render: (cellField): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{cellField}
</Typography.Paragraph>
),
}),
};
});
if (isListViewPanel) {
return [...fieldColumns];
@@ -177,6 +204,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
fontSize,
formatTimezoneAdjustedTimestamp,
bodyColumnStyle,
allAvailableKeys,
]);
return { columns, dataSource: flattenLogData };

View File

@@ -314,6 +314,23 @@
background-color: var(--bg-ink-200);
cursor: pointer;
}
.name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
cursor: pointer;
}
}
&::-webkit-scrollbar {
@@ -402,12 +419,20 @@
cursor: pointer;
}
.name {
flex: 1;
overflow: hidden;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: calc(100% - 26px);
gap: 8px;
min-width: 0;
.name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
cursor: pointer;
}

View File

@@ -6,8 +6,14 @@ import './LogsFormatOptionsMenu.styles.scss';
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
getNamesWithVariants,
getUniqueColumnKey,
hasMultipleVariants,
} from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import {
Check,
@@ -26,6 +32,7 @@ interface LogsFormatOptionsMenuProps {
config: OptionsMenuConfig;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function OptionsMenu({
items,
selectedOptionFormat,
@@ -50,6 +57,11 @@ function OptionsMenu({
const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false);
// Detect which column names have multiple variants in dropdown options
const namesWithVariantsInOptions = getNamesWithVariants(
addColumn?.options || [],
);
const onChange = useCallback(
(key: LogViewMode) => {
if (!format) return;
@@ -301,33 +313,46 @@ function OptionsMenu({
)}
<div className="column-format-new-options" ref={listRef}>
{addColumn?.options?.map(({ label, value }, index) => (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
{addColumn?.options?.map((option, index) => {
const { label, value, fieldDataType, fieldContext } = option;
return (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name">
<Tooltip placement="left" title={label}>
{label}
</Tooltip>
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name-wrapper">
<Tooltip placement="left" title={label}>
<span className="name">{label}</span>
</Tooltip>
{fieldDataType &&
typeof label === 'string' &&
namesWithVariantsInOptions.has(label) && (
<span className="field-variant-badges">
<FieldVariantBadges
fieldDataType={fieldDataType}
fieldContext={fieldContext}
/>
</span>
)}
</div>
</div>
</div>
))}
);
})}
</div>
</div>
</div>
@@ -416,22 +441,38 @@ function OptionsMenu({
)}
<div className="column-format">
{addColumn?.value?.map(({ name }) => (
<div className="column-name" key={name}>
<div className="name">
<Tooltip placement="left" title={name}>
{name}
{addColumn?.value?.map((column) => {
const uniqueKey = getUniqueColumnKey(column);
const showBadge = hasMultipleVariants(
column.name || '',
addColumn?.value || [],
addColumn?.allAvailableKeys,
);
return (
<div className="column-name" key={uniqueKey}>
<Tooltip placement="left" title={column.name}>
<div className="name-wrapper">
<span className="name">{column.name}</span>
{showBadge && (
<span className="field-variant-badges">
<FieldVariantBadges
fieldDataType={column.fieldDataType}
fieldContext={column.fieldContext}
/>
</span>
)}
</div>
</Tooltip>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(uniqueKey)}
/>
)}
</div>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(name)}
/>
)}
</div>
))}
);
})}
{addColumn && addColumn?.value?.length === 0 && (
<div className="column-name no-columns-selected">
No columns selected

View File

@@ -0,0 +1,198 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import {
mockAllAvailableKeys,
mockConflictingFieldsByContext,
mockConflictingFieldsByDatatype,
} from 'container/OptionsMenu/__tests__/mockData';
import { FontSize } from 'container/OptionsMenu/types';
import { getOptionsFromKeys } from 'container/OptionsMenu/utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
const mockUpdateFormatting = jest.fn();
const mockUpdateColumns = jest.fn();
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
}),
}));
describe('LogsFormatOptionsMenu - Badge Display', () => {
const FORMAT_BUTTON_TEST_ID = 'periscope-btn-format-options';
const HTTP_STATUS_CODE = 'http.status_code';
beforeEach(() => {
jest.clearAllMocks();
});
function setup(configOverrides = {}): any {
const items = [
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
{ key: 'list', label: 'Default' },
{ key: 'table', label: 'Column', data: { title: 'columns' } },
];
const formatOnChange = jest.fn();
const maxLinesOnChange = jest.fn();
const fontSizeOnChange = jest.fn();
const onSelect = jest.fn();
const onRemove = jest.fn();
const onSearch = jest.fn();
const onFocus = jest.fn();
const onBlur = jest.fn();
const defaultConfig = {
format: { value: 'table', onChange: formatOnChange },
maxLines: { value: 2, onChange: maxLinesOnChange },
fontSize: { value: FontSize.SMALL, onChange: fontSizeOnChange },
addColumn: {
isFetching: false,
value: [],
options: [],
onFocus,
onBlur,
onSearch,
onSelect,
onRemove,
allAvailableKeys: mockAllAvailableKeys,
...configOverrides,
},
};
const { getByTestId } = render(
<LogsFormatOptionsMenu
items={items}
selectedOptionFormat="table"
config={defaultConfig}
/>,
);
return {
getByTestId,
formatOnChange,
maxLinesOnChange,
fontSizeOnChange,
onSelect,
onRemove,
onSearch,
onFocus,
onBlur,
};
}
it('shows badges in dropdown options when searching for conflicting attributes', () => {
const options = getOptionsFromKeys(mockConflictingFieldsByDatatype, []);
expect(options).toBeDefined();
expect(options).toHaveLength(2);
expect(options?.[0]?.hasMultipleVariants).toBe(true);
expect(options?.[1]?.hasMultipleVariants).toBe(true);
expect(options?.[0]?.fieldDataType).toBe('string');
expect(options?.[1]?.fieldDataType).toBe('number');
});
it('shows badges in selected columns list after selecting conflicting attribute', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const selectedColumns: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // Only string variant selected
];
const { getByTestId } = setup({
value: selectedColumns,
});
// Open the popover menu
const formatButton = getByTestId(FORMAT_BUTTON_TEST_ID);
await user.click(formatButton);
// Wait for selected columns section to appear
await waitFor(() => {
expect(screen.getByText(HTTP_STATUS_CODE)).toBeInTheDocument();
});
// Badge should appear even though only one variant is selected
// because allAvailableKeys contains the conflicting variant
const datatypeBadge = screen.queryByText('string');
expect(datatypeBadge).toBeInTheDocument();
});
it('shows context badge only for attribute/resource conflicting fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const selectedColumns: TelemetryFieldKey[] = [
mockConflictingFieldsByContext[0], // resource variant
];
const { getByTestId } = setup({
value: selectedColumns,
});
// Open the popover menu
const formatButton = getByTestId(FORMAT_BUTTON_TEST_ID);
await user.click(formatButton);
// Wait for selected columns section
await waitFor(() => {
expect(screen.getByText('service.name')).toBeInTheDocument();
});
// Context badge should appear for resource
const contextBadge = screen.queryByText('resource');
expect(contextBadge).toBeInTheDocument();
});
it('shows datatype badge for conflicting fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const selectedColumns: TelemetryFieldKey[] = [
{
name: HTTP_STATUS_CODE,
fieldDataType: 'string',
fieldContext: 'span', // span context
signal: 'traces',
},
];
const { getByTestId } = setup({
value: selectedColumns,
allAvailableKeys: [
...mockAllAvailableKeys,
{
name: HTTP_STATUS_CODE,
fieldDataType: 'number',
fieldContext: 'span',
signal: 'traces',
},
],
});
// Open the popover menu
const formatButton = getByTestId(FORMAT_BUTTON_TEST_ID);
await user.click(formatButton);
// Wait for selected columns section
await waitFor(() => {
expect(screen.getByText(HTTP_STATUS_CODE)).toBeInTheDocument();
});
// Datatype badge should appear
const datatypeBadge = screen.queryByText('string');
expect(datatypeBadge).toBeInTheDocument();
// Context badge should NOT appear for span context
const contextBadge = screen.queryByText('span');
expect(contextBadge).not.toBeInTheDocument();
});
});

View File

@@ -1,13 +1,18 @@
/* eslint-disable react/jsx-props-no-spreading */
import { Table } from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Table, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import cx from 'classnames';
import {
ColumnTitleIcon,
ColumnTitleWrapper,
} from 'container/OptionsMenu/styles';
import { dragColumnParams } from 'hooks/useDragColumns/configs';
import { getColumnWidth, RowData } from 'lib/query/createTableColumnsFromQuery';
import { debounce, set } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
import React, {
SyntheticEvent,
useCallback,
useEffect,
@@ -71,20 +76,48 @@ function ResizeTable({
const mergedColumns = useMemo(
() =>
columnsData.map((col, index) => ({
...col,
...(onDragColumn && {
title: (
<DragSpanStyle className="dragHandler">
{col?.title?.toString() || ''}
</DragSpanStyle>
),
}),
onHeaderCell: (column: ColumnsType<unknown>[number]): unknown => ({
width: column.width,
onResize: handleResize(index),
}),
})) as ColumnsType<any>,
columnsData.map((col, index) => {
const columnRecord = col as Record<string, unknown>;
const hasUnselectedConflict = columnRecord._hasUnselectedConflict === true;
const titleText = col?.title?.toString();
// Render tooltip icon when there's a conflict, regardless of drag functionality
// Only wrap in DragSpanStyle when drag is enabled
const tooltipIcon = hasUnselectedConflict ? (
<Tooltip title="The same column with a different type or context exists">
<ColumnTitleIcon>
<InfoCircleOutlined />
</ColumnTitleIcon>
</Tooltip>
) : null;
const titleWithWrapper = (
<ColumnTitleWrapper>
{titleText}
{tooltipIcon}
</ColumnTitleWrapper>
);
let titleElement: React.ReactNode = titleText;
if (hasUnselectedConflict || onDragColumn) {
if (onDragColumn) {
titleElement = (
<DragSpanStyle className="dragHandler">{titleWithWrapper}</DragSpanStyle>
);
} else {
titleElement = titleWithWrapper;
}
}
return {
...col,
title: titleElement,
onHeaderCell: (column: ColumnsType<unknown>[number]): unknown => ({
width: column.width,
onResize: handleResize(index),
}),
};
}) as ColumnsType<RowData>,
[columnsData, onDragColumn, handleResize],
);

View File

@@ -1,208 +0,0 @@
/**
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
*/
import '@testing-library/jest-dom/extend-expect';
// ---- Mocks (must run BEFORE importing the component) ----
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { render, screen, userEvent } from 'tests/test-utils';
import { CmdKPalette } from '../cmdKPalette';
const HOME_LABEL = 'Go to Home';
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: jest.fn(),
});
});
afterAll(() => {
// restore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete (HTMLElement.prototype as any).scrollIntoView;
});
// mock history.push / replace / go / location
jest.mock('lib/history', () => {
const location = { pathname: '/', search: '', hash: '' };
const stack: { pathname: string; search: string }[] = [
{ pathname: '/', search: '' },
];
const push = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
stack.push({ pathname, search });
return undefined;
});
const replace = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
if (stack.length > 0) {
stack[stack.length - 1] = { pathname, search };
} else {
stack.push({ pathname, search });
}
return undefined;
});
const listen = jest.fn();
const go = jest.fn((n: number) => {
if (n < 0 && stack.length > 1) {
stack.pop();
}
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
location.pathname = top.pathname;
location.search = top.search;
});
return {
push,
replace,
listen,
go,
location,
__stack: stack,
};
});
// Mock ResizeObserver for Jest/jsdom
class ResizeObserver {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
observe() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
unobserve() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
disconnect() {}
}
(global as any).ResizeObserver = ResizeObserver;
// mock cmdK provider hook (open state + setter)
const mockSetOpen = jest.fn();
jest.mock('providers/cmdKProvider', (): unknown => ({
useCmdK: (): {
open: boolean;
setOpen: jest.Mock;
openCmdK: jest.Mock;
closeCmdK: jest.Mock;
} => ({
open: true,
setOpen: mockSetOpen,
openCmdK: jest.fn(),
closeCmdK: jest.fn(),
}),
}));
// mock notifications hook
jest.mock('hooks/useNotifications', (): unknown => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
// mock theme hook
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
theme: string;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
theme: 'dark',
}),
}));
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
jest.mock('react-query', (): unknown => {
const actual = jest.requireActual('react-query');
return {
...actual,
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
};
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
// ---- Tests ----
describe('CmdKPalette', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders navigation and settings groups and items', () => {
render(<CmdKPalette userRole="ADMIN" />);
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
expect(screen.getByText('Open Sidebar')).toBeInTheDocument();
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
});
test('clicking a navigation item calls history.push with correct route', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const homeItem = screen.getByText(HOME_LABEL);
await userEvent.click(homeItem);
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
});
test('role-based filtering (basic smoke)', () => {
render(<CmdKPalette userRole="VIEWER" />);
// VIEWER still sees basic navigation items
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('keyboard shortcut opens palette via setOpen', () => {
render(<CmdKPalette userRole="ADMIN" />);
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
window.dispatchEvent(event);
expect(mockSetOpen).toHaveBeenCalledWith(true);
});
test('items render with icons when provided', () => {
render(<CmdKPalette userRole="ADMIN" />);
const iconHolders = document.querySelectorAll('.cmd-item-icon');
expect(iconHolders.length).toBeGreaterThan(0);
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('closing the palette via handleInvoke sets open to false', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const dashItem = screen.getByText('Go to Dashboards');
await userEvent.click(dashItem);
// last call from handleInvoke should set open to false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,55 +0,0 @@
/* Overlay stays below content */
[data-slot='dialog-overlay'] {
z-index: 50;
}
/* Dialog content always above overlay */
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
}
.cmdk-section-heading [cmdk-group-heading] {
text-transform: uppercase;
color: var(--bg-slate-100);
}
/* Hide scrollbar but keep scroll */
.cmdk-list-scroll {
scrollbar-width: none; /* Firefox */
}
.cmdk-list-scroll::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.cmdk-list-scroll {
-webkit-overflow-scrolling: touch;
}
.cmdk-input-wrapper {
margin-left: 8px;
}
.cmdk-item-light:hover {
cursor: pointer;
background-color: var(--bg-vanilla-200) !important;
}
.cmdk-item-light[data-selected='true'] {
background-color: var(--bg-vanilla-300) !important;
color: var(--bg-ink-500);
}
.cmdk-item {
cursor: pointer;
}
[cmdk-item] svg {
width: auto;
height: auto;
}
.cmd-item-icon {
margin-right: 8px;
}

View File

@@ -1,336 +0,0 @@
import './cmdKPalette.scss';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@signozhq/command';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import {
BellDot,
BugIcon,
DraftingCompass,
Expand,
HardDrive,
Home,
LayoutGrid,
ListMinus,
ScrollText,
Settings,
} from 'lucide-react';
import React, { useEffect } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from '../../providers/App/App';
import { useCmdK } from '../../providers/cmdKProvider';
type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: UserRole[];
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {
userRole: UserRole;
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// toggle palette with ⌘/Ctrl+K
function handleGlobalCmdK(
e: KeyboardEvent,
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen(true);
}
}
const cmdKEffect = (): void | (() => void) => {
const listener = (e: KeyboardEvent): void => {
handleGlobalCmdK(e, setOpen);
};
window.addEventListener('keydown', listener);
return (): void => {
window.removeEventListener('keydown', listener);
setOpen(false);
};
};
useEffect(cmdKEffect, [setOpen]);
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
}
function onClickHandler(key: string): void {
history.push(key);
}
function handleOpenSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}
function handleCloseSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}
const actions: CmdAction[] = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
icon: <Home size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.HOME),
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
icon: <LayoutGrid size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
icon: <HardDrive size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.APPLICATION),
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
icon: <DraftingCompass size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
icon: <ScrollText size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LOGS),
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
icon: <BellDot size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
icon: <BugIcon size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
icon: <ListMinus size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
},
// Settings
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleOpenSidebar(),
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleCloseSidebar(),
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.DARK),
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
},
];
// RBAC filter: show action if no roles set OR current user role is included
const permitted = actions.filter(
(a) => !a.roles || a.roles.includes(userRole),
);
// group permitted actions by section
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
const map = new Map<string, CmdAction[]>();
permitted.forEach((a) => {
const section = a.section ?? 'Other';
const existing = map.get(section);
if (existing) {
existing.push(a);
} else {
map.set(section, [a]);
}
});
return Array.from(map.entries());
})();
const handleInvoke = (action: CmdAction): void => {
try {
action.perform();
} catch (e) {
console.error('Error invoking action', e);
} finally {
setOpen(false);
}
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@@ -35,6 +35,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import useTabVisibility from 'hooks/useTabFocus';
import { useKBar } from 'kbar';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -185,6 +186,19 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const { query, disabled } = useKBar((state) => ({
disabled: state.disabled,
}));
// disable the kbar command palette when not logged in
useEffect(() => {
if (isLoggedIn) {
query.disable(false);
} else {
query.disable(true);
}
}, [isLoggedIn, query, disabled]);
const changelogForTenant = isCloudUserVal
? DeploymentType.CLOUD_ONLY
: DeploymentType.OSS_ONLY;

View File

@@ -26,7 +26,6 @@ import {
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -77,7 +76,6 @@ function WidgetGraphComponent({
const isFullViewOpen = params.get(QueryParams.expandedWidgetId) === widget.id;
const lineChartRef = useRef<ToggleGraphProps>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>(
Array(queryResponse.data?.payload?.data?.result?.length || 0).fill(true),
);
@@ -112,7 +110,7 @@ function WidgetGraphComponent({
const updateDashboardMutation = useUpdateDashboard();
const onDeleteHandler = useCallback((): void => {
const onDeleteHandler = (): void => {
if (!selectedDashboard) return;
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
@@ -140,15 +138,9 @@ function WidgetGraphComponent({
setDeleteModal(false);
},
});
}, [
selectedDashboard,
widget.id,
updateDashboardMutation,
setLayouts,
setSelectedDashboard,
]);
};
const onCloneHandler = useCallback(async (): Promise<void> => {
const onCloneHandler = async (): Promise<void> => {
if (!selectedDashboard) return;
const uuid = v4();
@@ -212,18 +204,9 @@ function WidgetGraphComponent({
},
},
);
}, [
selectedDashboard,
widget,
updateDashboardMutation,
setLayouts,
setSelectedDashboard,
notifications,
safeNavigate,
pathname,
]);
};
const handleOnView = useCallback((): void => {
const handleOnView = (): void => {
const queryParams = {
[QueryParams.expandedWidgetId]: widget.id,
};
@@ -242,17 +225,17 @@ function WidgetGraphComponent({
pathname,
search: newSearch,
});
}, [widget.id, search, pathname, safeNavigate]);
};
const handleOnDelete = useCallback((): void => {
const handleOnDelete = (): void => {
onToggleModal(setDeleteModal);
}, [onToggleModal]);
};
const onDeleteModelHandler = useCallback((): void => {
const onDeleteModelHandler = (): void => {
onToggleModal(setDeleteModal);
}, [onToggleModal]);
};
const onToggleModelHandler = useCallback((): void => {
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
@@ -271,84 +254,63 @@ function WidgetGraphComponent({
pathname,
search: createQueryParams(updatedQueryParams),
});
}, [search, queryResponse.data?.payload, widget.id, pathname, safeNavigate]);
};
const [searchTerm, setSearchTerm] = useState<string>('');
// Memoize the isButtonEnabled value to prevent recalculation
const isGraphClickButtonEnabled = useMemo(
() =>
(widget?.query?.builder?.queryData &&
Array.isArray(widget.query.builder.queryData)
? widget.query.builder.queryData
: []
).some(
(q) =>
q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS,
),
[widget?.query?.builder?.queryData],
);
const graphClick = useGraphClickToShowButton({
graphRef: currentGraphRef?.current ? currentGraphRef : graphRef,
isButtonEnabled: isGraphClickButtonEnabled,
isButtonEnabled: (widget?.query?.builder?.queryData &&
Array.isArray(widget.query.builder.queryData)
? widget.query.builder.queryData
: []
).some(
(q) =>
q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS,
),
buttonClassName: 'view-onclick-show-button',
});
const navigateToExplorer = useNavigateToExplorer();
const graphClickHandler = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
metric?: { [key: string]: string },
queryData?: { queryName: string; inFocusOrNot: boolean },
): void => {
const customTracesTimeRange = getCustomTimeRangeWindowSweepInMS(
customTimeRangeWindowForCoRelation,
);
const { start, end } = getStartAndEndTimesInMilliseconds(
xValue,
customTracesTimeRange,
);
handleGraphClick({
xValue,
yValue,
mouseX,
mouseY,
metric,
queryData,
widget,
navigateToExplorerPages,
navigateToExplorer,
notifications,
graphClick,
...(customTimeRangeWindowForCoRelation
? { customTracesTimeRange: { start, end } }
: {}),
});
},
[
const graphClickHandler = (
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
metric?: { [key: string]: string },
queryData?: { queryName: string; inFocusOrNot: boolean },
): void => {
const customTracesTimeRange = getCustomTimeRangeWindowSweepInMS(
customTimeRangeWindowForCoRelation,
);
const { start, end } = getStartAndEndTimesInMilliseconds(
xValue,
customTracesTimeRange,
);
handleGraphClick({
xValue,
yValue,
mouseX,
mouseY,
metric,
queryData,
widget,
navigateToExplorerPages,
navigateToExplorer,
notifications,
graphClick,
],
);
...(customTimeRangeWindowForCoRelation
? { customTracesTimeRange: { start, end } }
: {}),
});
};
const { truncatedText, fullText } = useGetResolvedText({
text: widget.title as string,
maxLength: 100,
});
// Use the provided onClickHandler if available, otherwise use the default graphClickHandler
// Both should be stable references due to useCallback
const clickHandler = onClickHandler ?? graphClickHandler;
return (
<div
style={{
@@ -404,7 +366,7 @@ function WidgetGraphComponent({
yAxisUnit={widget.yAxisUnit}
onToggleModelHandler={onToggleModelHandler}
tableProcessedDataRef={tableProcessedDataRef}
onClickHandler={clickHandler}
onClickHandler={onClickHandler ?? graphClickHandler}
customOnDragSelect={customOnDragSelect}
setCurrentGraphRef={setCurrentGraphRef}
enableDrillDown={
@@ -454,7 +416,7 @@ function WidgetGraphComponent({
setRequestData={setRequestData}
setGraphVisibility={setGraphVisibility}
graphVisibility={graphVisibility}
onClickHandler={clickHandler}
onClickHandler={onClickHandler ?? graphClickHandler}
onDragSelect={onDragSelect}
tableProcessedDataRef={tableProcessedDataRef}
customTooltipElement={customTooltipElement}

View File

@@ -5,7 +5,7 @@ import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo } from 'react';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -46,11 +46,6 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
[selectedDashboard],
);
// Use ref to access latest mutateAsync without recreating the callback
// queryRangeMutation object recreates on every render, but mutateAsync is stable
const mutateAsyncRef = useRef(queryRangeMutation.mutateAsync);
mutateAsyncRef.current = queryRangeMutation.mutateAsync;
const getUpdatedQuery = useCallback(
async ({
widgetConfig,
@@ -68,12 +63,12 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
});
// Execute query and process results
const queryResult = await mutateAsyncRef.current(queryPayload);
const queryResult = await queryRangeMutation.mutateAsync(queryPayload);
// Map query data from API response
return mapQueryDataFromApi(queryResult.data.compositeQuery);
},
[dynamicVariables, globalSelectedInterval],
[dynamicVariables, globalSelectedInterval, queryRangeMutation],
);
return {

View File

@@ -1,8 +1,14 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { useTableView } from 'components/Logs/TableView/useTableView';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
ColumnTitleIcon,
ColumnTitleWrapper,
} from 'container/OptionsMenu/styles';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -127,6 +133,12 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
.filter((column) => column.key)
.map((column) => {
const isDragColumn = column.key !== 'expand';
const columnRecord = column as Record<string, unknown>;
const hasUnselectedConflict =
columnRecord._hasUnselectedConflict === true;
const titleText = (column.title as string).replace(/^\w/, (c) =>
c.toUpperCase(),
);
return (
<TableHeaderCellStyled
@@ -139,7 +151,16 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
{...(isDragColumn && { className: `dragHandler ${column.key}` })}
columnKey={column.key as string}
>
{(column.title as string).replace(/^\w/, (c) => c.toUpperCase())}
<ColumnTitleWrapper>
{titleText}
{hasUnselectedConflict && (
<Tooltip title="The same column with a different type or context exists">
<ColumnTitleIcon>
<InfoCircleOutlined />
</ColumnTitleIcon>
</Tooltip>
)}
</ColumnTitleWrapper>
</TableHeaderCellStyled>
);
})}

View File

@@ -60,7 +60,7 @@ function LogsExplorerList({
onSetActiveLog,
} = useActiveLog();
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator:
@@ -147,6 +147,7 @@ function LogsExplorerList({
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
allAvailableKeys: config.addColumn?.allAvailableKeys,
}}
infitiyTableProps={{ onEndReached }}
/>
@@ -195,6 +196,7 @@ function LogsExplorerList({
onEndReached,
getItemContent,
selectedFields,
config.addColumn?.allAvailableKeys,
]);
const isTraceToLogsNavigation = useMemo(() => {

View File

@@ -7,9 +7,11 @@ import { ResizeTable } from 'components/ResizeTable';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Controls from 'container/Controls';
import { extractTelemetryFieldKeys } from 'container/OptionsMenu/utils';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { tableStyles } from 'container/TracesExplorer/ListView/styles';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useLogsData } from 'hooks/useLogsData';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { FlatLogData } from 'lib/logs/flatLogData';
@@ -27,6 +29,7 @@ import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { getLogPanelColumnsList } from './utils';
@@ -59,14 +62,31 @@ function LogsPanelComponent({
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// Fetch available keys to detect variants
const { data: keysData } = useGetQueryKeySuggestions(
{
searchText: '',
signal: DataSource.LOGS,
},
{
queryKey: [DataSource.LOGS, LogsAggregatorOperator.NOOP, ''],
},
);
// Extract all available keys from API response
const allAvailableKeys = useMemo(() => extractTelemetryFieldKeys(keysData), [
keysData,
]);
const columns = useMemo(
() =>
getLogPanelColumnsList(
widget.selectedLogFields,
formatTimezoneAdjustedTimestamp,
allAvailableKeys,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[widget.selectedLogFields],
[widget.selectedLogFields, formatTimezoneAdjustedTimestamp, allAvailableKeys],
);
const dataLength =

View File

@@ -0,0 +1,107 @@
import { mockAllAvailableKeys } from 'container/OptionsMenu/__tests__/mockData';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { renderColumnHeader } from 'tests/columnHeaderHelpers';
import { IField } from 'types/api/logs/fields';
import { getLogPanelColumnsList } from '../utils';
const COLUMN_UNDEFINED_ERROR = 'statusCodeColumn is undefined';
// Mock the timezone formatter
const mockFormatTimezoneAdjustedTimestamp = jest.fn(
(input: TimestampInput): string => {
if (typeof input === 'string') {
return new Date(input).toISOString();
}
if (typeof input === 'number') {
return new Date(input / 1e6).toISOString();
}
return new Date(input).toISOString();
},
);
describe('getLogPanelColumnsList - Column Headers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows tooltip icon when conflicting variant exists in allAvailableKeys', () => {
// Even with single variant selected, tooltip should appear if conflicting variant exists
const selectedLogFields: IField[] = [
{
// eslint-disable-next-line sonarjs/no-duplicate-string
name: 'http.status_code',
dataType: 'string',
type: 'attribute',
} as IField,
];
const columns = getLogPanelColumnsList(
selectedLogFields,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys, // Contains number variant
);
const statusCodeColumn = columns.find(
(col) => 'dataIndex' in col && col.dataIndex === 'http.status_code',
);
expect(statusCodeColumn).toBeDefined();
expect(statusCodeColumn?.title).toBeDefined();
// Verify that _hasUnselectedConflict metadata is set correctly
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBe(true);
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
expect(container.textContent).toContain('http.status_code (string)');
// Tooltip icon should appear
// eslint-disable-next-line sonarjs/no-duplicate-string
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).toBeInTheDocument();
});
it('hides tooltip icon when all conflicting variants are selected', () => {
const selectedLogFields: IField[] = [
{
name: 'http.status_code',
dataType: 'string',
type: 'attribute',
} as IField,
{
name: 'http.status_code',
dataType: 'number',
type: 'attribute',
} as IField,
];
const columns = getLogPanelColumnsList(
selectedLogFields,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
const statusCodeColumn = columns.find(
(col) => 'dataIndex' in col && col.dataIndex === 'http.status_code',
);
expect(statusCodeColumn).toBeDefined();
// Verify that _hasUnselectedConflict metadata is NOT set when all variants are selected
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBeUndefined();
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,12 @@
import { ColumnsType } from 'antd/es/table';
import { Typography } from 'antd/lib';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
getColumnTitleWithTooltip,
getFieldVariantsByName,
getUniqueColumnKey,
hasMultipleVariants,
} from 'container/OptionsMenu/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
// import Typography from 'antd/es/typography/Typography';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
@@ -13,17 +20,35 @@ export const getLogPanelColumnsList = (
input: TimestampInput,
format?: string,
) => string,
allAvailableKeys?: TelemetryFieldKey[],
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [];
// Group fields by name to analyze variants
const fieldVariantsByName = getFieldVariantsByName(selectedLogFields || []);
const columns: ColumnsType<RowData> =
selectedLogFields?.map((field: IField) => {
const { name } = field;
const hasVariants = hasMultipleVariants(
name,
selectedLogFields || [],
allAvailableKeys,
);
const variants = fieldVariantsByName[name] || [];
const { title, hasUnselectedConflict } = getColumnTitleWithTooltip(
field,
hasVariants,
variants,
selectedLogFields || [],
allAvailableKeys,
);
return {
title: name,
title,
dataIndex: name,
key: name,
key: getUniqueColumnKey(field),
...(hasUnselectedConflict && { _hasUnselectedConflict: true }),
width: name === 'body' ? 350 : 100,
render: (value: ReactNode): JSX.Element => {
if (name === 'timestamp') {

View File

@@ -238,86 +238,6 @@ function External(): JSX.Element {
setSelectedData,
);
const onErrorPercentageClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_error_percentage',
data,
);
},
[onGraphClickHandler],
);
const onDurationClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration',
data,
);
},
[onGraphClickHandler],
);
const onRPSByAddressClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_rps_by_address',
data,
);
},
[onGraphClickHandler],
);
const onDurationByAddressClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration_by_address',
data,
);
},
[onGraphClickHandler],
);
return (
<>
<Row gutter={24}>
@@ -346,7 +266,16 @@ function External(): JSX.Element {
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallErrorWidget}
onClickHandler={onErrorPercentageClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_error_percentage',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>
@@ -380,7 +309,16 @@ function External(): JSX.Element {
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallDurationWidget}
onClickHandler={onDurationClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>
@@ -415,7 +353,16 @@ function External(): JSX.Element {
<Graph
widget={externalCallRPSWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={onRPSByAddressClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): Promise<void> =>
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_rps_by_address',
data,
)
}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>
@@ -449,7 +396,16 @@ function External(): JSX.Element {
<Graph
widget={externalCallDurationAddressWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={onDurationByAddressClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration_by_address',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>

View File

@@ -1,16 +1,27 @@
import { Checkbox, Empty } from 'antd';
import { TelemetryFieldKey } from 'api/v5/v5';
import { AxiosResponse } from 'axios';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import Spinner from 'components/Spinner';
import { EXCLUDED_COLUMNS } from 'container/OptionsMenu/constants';
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
import {
getUniqueColumnKey,
getVariantCounts,
} from 'container/OptionsMenu/utils';
import {
QueryKeyDataSuggestionsProps,
QueryKeySuggestionsResponseProps,
} from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
type ExplorerAttributeColumnsProps = {
isLoading: boolean;
data: AxiosResponse<QueryKeySuggestionsResponseProps> | undefined;
searchText: string;
isAttributeKeySelected: (key: string) => boolean;
handleCheckboxChange: (key: string) => void;
isAttributeKeySelected: (
attributeKey: QueryKeyDataSuggestionsProps,
) => boolean;
handleCheckboxChange: (attributeKey: QueryKeyDataSuggestionsProps) => void;
dataSource: DataSource;
};
@@ -38,6 +49,12 @@ function ExplorerAttributeColumns({
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()) &&
!EXCLUDED_COLUMNS[dataSource].includes(attributeKey.name),
) || [];
// Detect which column names have multiple variants
const nameCounts = getVariantCounts(
filteredAttributeKeys as TelemetryFieldKey[],
);
if (filteredAttributeKeys.length === 0) {
return (
<div className="attribute-columns">
@@ -48,16 +65,26 @@ function ExplorerAttributeColumns({
return (
<div className="attribute-columns">
{filteredAttributeKeys.map((attributeKey: any) => (
<Checkbox
checked={isAttributeKeySelected(attributeKey.name)}
onChange={(): void => handleCheckboxChange(attributeKey.name)}
style={{ padding: 0 }}
key={attributeKey.name}
>
{attributeKey.name}
</Checkbox>
))}
{filteredAttributeKeys.map((attributeKey) => {
const hasVariants = nameCounts[attributeKey.name] > 1;
return (
<Checkbox
checked={isAttributeKeySelected(attributeKey)}
onChange={(): void => handleCheckboxChange(attributeKey)}
key={getUniqueColumnKey(attributeKey)}
>
<span className="attribute-column-label-wrapper">
<span>{attributeKey.name}</span>
{hasVariants && (
<FieldVariantBadges
fieldDataType={attributeKey.fieldDataType}
fieldContext={attributeKey.fieldContext}
/>
)}
</span>
</Checkbox>
);
})}
</div>
);
}

View File

@@ -60,6 +60,13 @@
font-family: Inter;
font-size: 12px;
cursor: grab;
.column-name-wrapper,
.badges-container {
display: flex;
align-items: center;
gap: 4px;
}
}
.lucide-trash2 {
@@ -114,6 +121,16 @@
flex-direction: column;
height: 160px;
overflow: scroll;
.ant-checkbox-wrapper {
padding: 0 !important;
.attribute-column-label-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
}
}
.attribute-columns::-webkit-scrollbar {

View File

@@ -6,8 +6,13 @@ import './ExplorerColumnsRenderer.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Dropdown, Input, Tooltip, Typography } from 'antd';
import { MenuProps } from 'antd/lib';
import { FieldDataType } from 'api/v5/v5';
import { FieldDataType, TelemetryFieldKey } from 'api/v5/v5';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import {
getUniqueColumnKey,
getVariantCounts,
} from 'container/OptionsMenu/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -26,6 +31,7 @@ import {
Droppable,
DropResult,
} from 'react-beautiful-dnd';
import { IField } from 'types/api/logs/fields';
import { DataSource } from 'types/common/queryBuilder';
import { WidgetGraphProps } from '../types';
@@ -82,64 +88,87 @@ function ExplorerColumnsRenderer({
},
);
const isAttributeKeySelected = (key: string): boolean => {
const isAttributeKeySelected = (attribute: any): boolean => {
const uniqueKey = getUniqueColumnKey(attribute);
if (initialDataSource === DataSource.LOGS && selectedLogFields) {
return selectedLogFields.some((field) => field.name === key);
return selectedLogFields.some(
(field) => getUniqueColumnKey(field) === uniqueKey,
);
}
if (initialDataSource === DataSource.TRACES && selectedTracesFields) {
return selectedTracesFields.some((field) => field.name === key);
return selectedTracesFields.some(
(field) => getUniqueColumnKey(field) === uniqueKey,
);
}
return false;
};
const handleCheckboxChange = (key: string): void => {
const handleCheckboxChange = (attribute: any): void => {
const uniqueKey = getUniqueColumnKey(attribute);
if (
initialDataSource === DataSource.LOGS &&
setSelectedLogFields !== undefined
) {
if (selectedLogFields) {
if (isAttributeKeySelected(key)) {
if (isAttributeKeySelected(attribute)) {
setSelectedLogFields(
selectedLogFields.filter((field) => field.name !== key),
selectedLogFields.filter(
(field) => getUniqueColumnKey(field) !== uniqueKey,
),
);
} else {
setSelectedLogFields([
...selectedLogFields,
{ dataType: 'string', name: key, type: '' },
{
name: attribute.name,
dataType: attribute.fieldDataType || 'string',
type: attribute.fieldContext || '',
fieldDataType: attribute.fieldDataType || 'string',
fieldContext: attribute.fieldContext || '',
} as IField & { fieldDataType: string; fieldContext: string },
]);
}
} else {
setSelectedLogFields([{ dataType: 'string', name: key, type: '' }]);
setSelectedLogFields([
{
name: attribute.name,
dataType: attribute.fieldDataType || 'string',
type: attribute.fieldContext || '',
fieldDataType: attribute.fieldDataType || 'string',
fieldContext: attribute.fieldContext || '',
} as IField & { fieldDataType: string; fieldContext: string },
]);
}
} else if (
initialDataSource === DataSource.TRACES &&
setSelectedTracesFields !== undefined
) {
const selectedField = Object.values(data?.data?.data?.keys || {})
?.flat()
?.find((attributeKey) => attributeKey.name === key);
if (selectedTracesFields) {
if (isAttributeKeySelected(key)) {
if (isAttributeKeySelected(attribute)) {
setSelectedTracesFields(
selectedTracesFields.filter((field) => field.name !== key),
selectedTracesFields.filter(
(field) => getUniqueColumnKey(field) !== uniqueKey,
),
);
} else if (selectedField) {
} else {
setSelectedTracesFields([
...selectedTracesFields,
{
...selectedField,
fieldDataType: selectedField.fieldDataType as FieldDataType,
...attribute,
fieldDataType: attribute.fieldDataType as FieldDataType,
},
]);
}
} else if (selectedField)
} else {
setSelectedTracesFields([
{
...selectedField,
fieldDataType: selectedField.fieldDataType as FieldDataType,
...attribute,
fieldDataType: attribute.fieldDataType as FieldDataType,
},
]);
}
}
setOpen(false);
};
@@ -189,14 +218,18 @@ function ExplorerColumnsRenderer({
},
];
const removeSelectedLogField = (name: string): void => {
const removeSelectedLogField = (field: any): void => {
const uniqueKey = getUniqueColumnKey(field);
if (
initialDataSource === DataSource.LOGS &&
setSelectedLogFields &&
selectedLogFields
) {
setSelectedLogFields(
selectedLogFields.filter((field) => field.name !== name),
selectedLogFields.filter(
(field) => getUniqueColumnKey(field) !== uniqueKey,
),
);
}
if (
@@ -205,7 +238,9 @@ function ExplorerColumnsRenderer({
selectedTracesFields
) {
setSelectedTracesFields(
selectedTracesFields.filter((field) => field.name !== name),
selectedTracesFields.filter(
(field) => getUniqueColumnKey(field) !== uniqueKey,
),
);
}
};
@@ -248,6 +283,11 @@ function ExplorerColumnsRenderer({
const isDarkMode = useIsDarkMode();
// Detect which column names have multiple variants from API data
const allAttributeKeys =
Object.values(data?.data?.data?.keys || {})?.flat() || [];
const nameCounts = getVariantCounts(allAttributeKeys as TelemetryFieldKey[]);
return (
<div className="explorer-columns-renderer">
<div className="title">
@@ -271,7 +311,7 @@ function ExplorerColumnsRenderer({
>
{initialDataSource === DataSource.LOGS &&
selectedLogFields &&
selectedLogFields.map((field, index) => (
selectedLogFields.map((field: TelemetryFieldKey, index) => (
// eslint-disable-next-line react/no-array-index-key
<Draggable key={index} draggableId={index.toString()} index={index}>
{(dragProvided): JSX.Element => (
@@ -283,12 +323,22 @@ function ExplorerColumnsRenderer({
>
<div className="explorer-column-title">
<GripVertical size={12} color="#5A5A5A" />
{field.name}
<span className="column-name-wrapper">
{field.name}
{nameCounts[field.name] > 1 && (
<span className="badges-container">
<FieldVariantBadges
fieldDataType={field.fieldDataType}
fieldContext={field.fieldContext}
/>
</span>
)}
</span>
</div>
<Trash2
size={12}
color="red"
onClick={(): void => removeSelectedLogField(field.name)}
onClick={(): void => removeSelectedLogField(field)}
data-testid="trash-icon"
/>
</div>
@@ -309,14 +359,22 @@ function ExplorerColumnsRenderer({
>
<div className="explorer-column-title">
<GripVertical size={12} color="#5A5A5A" />
{field?.name || (field as any)?.key}
<span className="column-name-wrapper">
{field?.name || field?.key}
{nameCounts[field?.name || ''] > 1 && (
<span className="badges-container">
<FieldVariantBadges
fieldDataType={field.fieldDataType}
fieldContext={field.fieldContext}
/>
</span>
)}
</span>
</div>
<Trash2
size={12}
color="red"
onClick={(): void =>
removeSelectedLogField(field?.name || (field as any)?.key)
}
onClick={(): void => removeSelectedLogField(field)}
data-testid="trash-icon"
/>
</div>

View File

@@ -222,7 +222,13 @@ describe('ExplorerColumnsRenderer', () => {
await userEvent.click(checkbox);
expect(mockSetSelectedLogFields).toHaveBeenCalledWith([
{ dataType: 'string', name: 'attribute1', type: '' },
{
dataType: 'string',
fieldContext: '',
fieldDataType: 'string',
name: 'attribute1',
type: '',
},
]);
});
@@ -326,9 +332,21 @@ describe('ExplorerColumnsRenderer', () => {
data: {
data: {
keys: {
attributeKeys: [
{ name: 'trace_attribute1', dataType: 'string', type: 'tag' },
{ name: 'trace_attribute2', dataType: 'string', type: 'tag' },
trace_attribute1: [
{
name: 'trace_attribute1',
fieldDataType: DataTypes.String,
fieldContext: '',
signal: 'traces',
},
],
trace_attribute2: [
{
name: 'trace_attribute2',
fieldDataType: DataTypes.String,
fieldContext: '',
signal: 'traces',
},
],
},
},
@@ -356,7 +374,12 @@ describe('ExplorerColumnsRenderer', () => {
await userEvent.click(checkbox);
expect(mockSetSelectedTracesFields).toHaveBeenCalledWith([
{ name: 'trace_attribute1', dataType: 'string', type: 'tag' },
{
name: 'trace_attribute1',
fieldDataType: DataTypes.String,
fieldContext: '',
signal: 'traces',
},
]);
});

View File

@@ -5541,4 +5541,4 @@
],
"link": "https://signoz.io/docs/userguide/envoy-metrics/"
}
]
]

View File

@@ -0,0 +1,139 @@
import { render, screen } from '@testing-library/react';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
mockAllAvailableKeys,
mockConflictingFieldsByContext,
mockConflictingFieldsByDatatype,
mockNonConflictingField,
} from '../../__tests__/mockData';
import AddColumnField from '../index';
describe('AddColumnField - Badge Display', () => {
const defaultConfig = {
isFetching: false,
options: [],
value: [],
onSelect: jest.fn(),
onFocus: jest.fn(),
onBlur: jest.fn(),
onSearch: jest.fn(),
onRemove: jest.fn(),
allAvailableKeys: mockAllAvailableKeys,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('shows badge for single selected conflicting field (different datatype)', () => {
const selectedColumns: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // Only string variant selected
];
render(
<AddColumnField
config={{
...defaultConfig,
value: selectedColumns,
}}
/>,
);
// Badge should appear even though only one variant is selected
// because allAvailableKeys contains the conflicting variant
const badgeContainer = screen.queryByText('http.status_code')?.closest('div');
expect(badgeContainer).toBeInTheDocument();
// Check for datatype badge
const datatypeBadge = screen.queryByText('string');
expect(datatypeBadge).toBeInTheDocument();
});
it('shows badges for multiple conflicting fields selected', () => {
const selectedColumns: TelemetryFieldKey[] = [
...mockConflictingFieldsByDatatype, // Both string and number variants
];
render(
<AddColumnField
config={{
...defaultConfig,
value: selectedColumns,
}}
/>,
);
// Both variants should show badges
const stringBadge = screen.getByText('string');
const numberBadge = screen.getByText('number');
expect(stringBadge).toBeInTheDocument();
expect(numberBadge).toBeInTheDocument();
});
it('shows badges when all conflicting variants are selected', () => {
const selectedColumns: TelemetryFieldKey[] = [
...mockConflictingFieldsByDatatype, // All variants selected
];
render(
<AddColumnField
config={{
...defaultConfig,
value: selectedColumns,
}}
/>,
);
// Both variants should appear as separate items in the list
const fieldNames = screen.getAllByText('http.status_code');
expect(fieldNames).toHaveLength(2); // One for each variant
// Badges should still be visible when all variants are selected
const stringBadge = screen.getByText('string');
const numberBadge = screen.getByText('number');
expect(stringBadge).toBeInTheDocument();
expect(numberBadge).toBeInTheDocument();
});
it('does not show badge for non-conflicting field', () => {
const selectedColumns: TelemetryFieldKey[] = [...mockNonConflictingField];
render(
<AddColumnField
config={{
...defaultConfig,
value: selectedColumns,
}}
/>,
);
// Field name should be visible
expect(screen.getByText('trace_id')).toBeInTheDocument();
// But no badge should appear (no conflicting variants)
const badgeContainer = document.querySelector(
'.field-variant-badges-container',
);
expect(badgeContainer).not.toBeInTheDocument();
});
it('shows context badge for attribute/resource conflicting fields', () => {
const selectedColumns: TelemetryFieldKey[] = [
mockConflictingFieldsByContext[0], // resource variant
];
render(
<AddColumnField
config={{
...defaultConfig,
value: selectedColumns,
}}
/>,
);
// Context badge should appear for resource
const contextBadge = screen.queryByText('resource');
expect(contextBadge).toBeInTheDocument();
});
});

View File

@@ -1,18 +1,39 @@
import { SearchOutlined } from '@ant-design/icons';
import { Input, Spin, Typography } from 'antd';
import { Input, Spin } from 'antd';
import { BaseOptionType } from 'antd/es/select';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTranslation } from 'react-i18next';
import { FieldTitle } from '../styles';
import { OptionsMenuConfig } from '../types';
import { getUniqueColumnKey, hasMultipleVariants } from '../utils';
import {
AddColumnItem,
AddColumnSelect,
AddColumnWrapper,
DeleteOutlinedIcon,
Name,
NameWrapper,
OptionContent,
SearchIconWrapper,
} from './styles';
function OptionRenderer(option: BaseOptionType): JSX.Element {
const { label, data } = option;
return (
<OptionContent>
<span className="option-label">{label}</span>
{data?.hasMultipleVariants && (
<FieldVariantBadges
fieldDataType={data?.fieldDataType}
fieldContext={data?.fieldContext}
/>
)}
</OptionContent>
);
}
function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const isDarkMode = useIsDarkMode();
@@ -36,18 +57,35 @@ function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
onFocus={config.onFocus}
onBlur={config.onBlur}
notFoundContent={config.isFetching ? <Spin size="small" /> : null}
optionRender={OptionRenderer}
/>
<SearchIconWrapper $isDarkMode={isDarkMode}>
<SearchOutlined />
</SearchIconWrapper>
</Input.Group>
{config.value?.map(({ name }) => (
<AddColumnItem direction="horizontal" key={name}>
<Typography>{name}</Typography>
<DeleteOutlinedIcon onClick={(): void => config.onRemove(name)} />
</AddColumnItem>
))}
{config.value?.map((column) => {
const uniqueKey = getUniqueColumnKey(column);
const showBadge = hasMultipleVariants(
column.name || '',
config.value || [],
config.allAvailableKeys,
);
return (
<AddColumnItem key={uniqueKey}>
<NameWrapper>
<Name>{column.name}</Name>
{showBadge && (
<FieldVariantBadges
fieldDataType={column.fieldDataType}
fieldContext={column.fieldContext}
/>
)}
</NameWrapper>
<DeleteOutlinedIcon onClick={(): void => config.onRemove(uniqueKey)} />
</AddColumnItem>
);
})}
</AddColumnWrapper>
);
}

View File

@@ -28,7 +28,7 @@ export const AddColumnWrapper = styled(Space)`
width: 100%;
`;
export const AddColumnItem = styled(Space)`
export const AddColumnItem = styled.div`
width: 100%;
display: flex;
justify-content: space-between;
@@ -37,3 +37,35 @@ export const AddColumnItem = styled(Space)`
export const DeleteOutlinedIcon = styled(DeleteOutlined)`
color: red;
`;
export const OptionContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 8px;
min-width: 0;
.option-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
export const NameWrapper = styled.span`
display: flex;
justify-content: space-between;
align-items: center;
width: calc(100% - 26px);
gap: 8px;
min-width: 0;
`;
export const Name = styled.span`
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;

View File

@@ -0,0 +1,111 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
const HTTP_STATUS_CODE = 'http.status_code';
const SERVICE_NAME = 'service.name';
// Conflicting fields: same name, different datatype
export const mockConflictingFieldsByDatatype: TelemetryFieldKey[] = [
{
name: HTTP_STATUS_CODE,
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
fieldContext: 'attribute',
signal: 'traces',
},
{
name: HTTP_STATUS_CODE,
fieldDataType: QUERY_BUILDER_KEY_TYPES.NUMBER,
fieldContext: 'attribute',
signal: 'traces',
},
];
// Conflicting fields: same name, different context
export const mockConflictingFieldsByContext: TelemetryFieldKey[] = [
{
name: SERVICE_NAME,
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
fieldContext: 'resource',
signal: 'traces',
},
{
name: SERVICE_NAME,
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
fieldContext: 'attribute',
signal: 'traces',
},
];
// Non-conflicting field (single variant)
export const mockNonConflictingField: TelemetryFieldKey[] = [
{
name: 'trace_id',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
fieldContext: 'attribute',
signal: 'traces',
},
];
// Mock API response structure for conflicting fields by datatype
export const mockQueryKeySuggestionsResponseByDatatype: QueryKeySuggestionsResponseProps = {
status: 'success',
data: {
complete: true,
keys: {
[HTTP_STATUS_CODE]: [
{
name: HTTP_STATUS_CODE,
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
fieldContext: 'attribute',
signal: 'traces',
label: HTTP_STATUS_CODE,
type: 'attribute',
},
{
name: HTTP_STATUS_CODE,
fieldDataType: QUERY_BUILDER_KEY_TYPES.NUMBER,
fieldContext: 'attribute',
signal: 'traces',
label: HTTP_STATUS_CODE,
type: 'attribute',
},
],
},
},
};
// Mock API response structure for conflicting fields by context
export const mockQueryKeySuggestionsResponseByContext: QueryKeySuggestionsResponseProps = {
status: 'success',
data: {
complete: true,
keys: {
[SERVICE_NAME]: [
{
name: SERVICE_NAME,
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
fieldContext: 'resource',
signal: 'traces',
label: SERVICE_NAME,
type: 'resource',
},
{
name: SERVICE_NAME,
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
fieldContext: 'attribute',
signal: 'traces',
label: SERVICE_NAME,
type: 'attribute',
},
],
},
},
};
// All available keys (for allAvailableKeys prop)
export const mockAllAvailableKeys: TelemetryFieldKey[] = [
...mockConflictingFieldsByDatatype,
...mockConflictingFieldsByContext,
...mockNonConflictingField,
];

View File

@@ -10,10 +10,22 @@ export const OptionsContainer = styled(Card)`
`;
export const OptionsContentWrapper = styled(Space)`
min-width: 11rem;
width: 21rem;
padding: 0.25rem 0.5rem;
`;
export const FieldTitle = styled(Typography.Text)`
font-size: 0.75rem;
`;
export const ColumnTitleWrapper = styled.span`
display: inline-flex;
align-items: center;
gap: 4px;
word-break: break-word;
`;
export const ColumnTitleIcon = styled.span`
font-size: 12px;
color: var(--bg-vanilla-400);
`;

View File

@@ -38,5 +38,6 @@ export type OptionsMenuConfig = {
isFetching: boolean;
value: TelemetryFieldKey[];
onRemove: (key: string) => void;
allAvailableKeys?: TelemetryFieldKey[];
};
};

View File

@@ -36,7 +36,7 @@ import {
OptionsMenuConfig,
OptionsQuery,
} from './types';
import { getOptionsFromKeys } from './utils';
import { getOptionsFromKeys, getUniqueColumnKey } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
@@ -170,7 +170,7 @@ const useOptionsMenu = ({
...initialQueryParamsV5,
searchText: debouncedSearchText,
},
{ queryKey: [debouncedSearchText, isFocused], enabled: isFocused },
{ queryKey: [debouncedSearchText, isFocused] },
);
// const {
@@ -186,7 +186,7 @@ const useOptionsMenu = ({
const searchedAttributeKeys: TelemetryFieldKey[] = useMemo(() => {
const searchedAttributesDataList = Object.values(
searchedAttributesDataV5?.data.data.keys || {},
searchedAttributesDataV5?.data?.data?.keys || {},
).flat();
if (searchedAttributesDataList.length) {
if (dataSource === DataSource.LOGS) {
@@ -230,7 +230,7 @@ const useOptionsMenu = ({
}
return [];
}, [dataSource, searchedAttributesDataV5?.data.data.keys]);
}, [dataSource, searchedAttributesDataV5?.data?.data?.keys]);
const initialOptionsQuery: OptionsQuery = useMemo(() => {
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
@@ -262,7 +262,7 @@ const useOptionsMenu = ({
}, [dataSource, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],
() => preferences?.columns?.map((col) => getUniqueColumnKey(col)) || [],
[preferences?.columns],
);
@@ -287,16 +287,14 @@ const useOptionsMenu = ({
const handleSelectColumns = useCallback(
(value: string) => {
const newSelectedColumnKeys = [...new Set([...selectedColumnKeys, value])];
const newSelectedColumns = newSelectedColumnKeys.reduce((acc, key) => {
const column = [
...searchedAttributeKeys,
...(preferences?.columns || []),
].find(({ name }) => name === key);
// value is now the unique key (name::dataType::context)
const column = searchedAttributeKeys.find(
(key) => getUniqueColumnKey(key) === value,
);
if (!column) return acc;
return [...acc, column];
}, [] as TelemetryFieldKey[]);
if (!column) return;
const newSelectedColumns = [...(preferences?.columns || []), column];
const optionsData: OptionsQuery = {
...defaultOptionsQuery,
@@ -311,7 +309,6 @@ const useOptionsMenu = ({
},
[
searchedAttributeKeys,
selectedColumnKeys,
preferences,
handleRedirectWithOptionsData,
updateColumns,
@@ -320,8 +317,9 @@ const useOptionsMenu = ({
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
// columnKey is now the unique key (name::dataType::context)
const newSelectedColumns = preferences?.columns?.filter(
({ name }) => name !== columnKey,
(col) => getUniqueColumnKey(col) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
@@ -432,6 +430,7 @@ const useOptionsMenu = ({
preferences?.columns.filter((item) => has(item, 'name')) ||
defaultOptionsQuery.selectColumns.filter((item) => has(item, 'name')),
options: optionsFromAttributeKeys || [],
allAvailableKeys: searchedAttributeKeys,
onFocus: handleFocus,
onBlur: handleBlur,
onSelect: handleSelectColumns,
@@ -455,6 +454,7 @@ const useOptionsMenu = ({
isSearchedAttributesFetchingV5,
preferences,
optionsFromAttributeKeys,
searchedAttributeKeys,
handleSelectColumns,
handleRemoveSelectedColumn,
handleSearchAttribute,

View File

@@ -1,16 +0,0 @@
import { SelectProps } from 'antd';
import { TelemetryFieldKey } from 'api/v5/v5';
export const getOptionsFromKeys = (
keys: TelemetryFieldKey[],
selectedKeys: (string | undefined)[],
): SelectProps['options'] => {
const options = keys.map(({ name }) => ({
label: name,
value: name,
}));
return options.filter(
({ value }) => !selectedKeys.find((key) => key === value),
);
};

View File

@@ -0,0 +1,294 @@
import { SelectProps } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import { TelemetryFieldKey } from 'api/v5/v5';
import { AxiosResponse } from 'axios';
import {
QueryKeyDataSuggestionsProps,
QueryKeySuggestionsResponseProps,
} from 'types/api/querySuggestions/types';
/**
* Extracts all available keys from API response and transforms them into TelemetryFieldKey format
* @param keysData - The response data from useGetQueryKeySuggestions hook
* @returns Array of TelemetryFieldKey objects
*/
export const extractTelemetryFieldKeys = (
keysData?: AxiosResponse<QueryKeySuggestionsResponseProps>,
): TelemetryFieldKey[] => {
const keysList = Object.values(keysData?.data?.data?.keys || {})?.flat() || [];
return keysList.map((key) => ({
name: key.name,
fieldDataType: key.fieldDataType,
fieldContext: key.fieldContext,
signal: key.signal,
})) as TelemetryFieldKey[];
};
/**
* Creates a unique key for a column by combining context, name, and dataType
* Format: fieldContext::name::fieldDataType
* Example: "attribute::http.status_code::number"
*/
export const getUniqueColumnKey = (
column: TelemetryFieldKey | QueryKeyDataSuggestionsProps,
): string => {
const name = column.name || '';
const dataType =
('fieldDataType' in column && column.fieldDataType) ||
('dataType' in column && column.dataType) ||
'string';
const context =
column.fieldContext || ('type' in column && column.type) || 'attribute';
return `${context}::${name}::${dataType}`;
};
/**
* Parses a unique column key back into its components
* Format: fieldContext::name::fieldDataType
*/
export const parseColumnKey = (
key: string,
): { name: string; fieldDataType: string; fieldContext: string } => {
const parts = key.split('::');
const fieldContext = parts[0] || 'attribute';
const name = parts[1] || '';
const fieldDataType = parts[2] || 'string';
return { name, fieldDataType, fieldContext };
};
/**
* Creates a count map of how many variants each attribute name has
* Used to determine which columns should display badges
*/
export const getVariantCounts = <T extends { name?: string }>(
items: T[],
): Record<string, number> => {
if (!items || !items.length) return {};
return items.reduce((acc: Record<string, number>, item: T) => {
const name = item?.name || '';
if (name) {
acc[name] = (acc[name] || 0) + 1;
}
return acc;
}, {} as Record<string, number>);
};
/**
* Extracts a Set of column names that have multiple variants from options
* Useful when options already have hasMultipleVariants flag
*/
export const getNamesWithVariants = (
options: SelectProps['options'],
): Set<string> => {
if (!options || !Array.isArray(options)) return new Set();
const names = options
.filter((opt) => {
if (!opt) return false;
const option = opt as DefaultOptionType & {
hasMultipleVariants?: boolean;
};
return option?.hasMultipleVariants;
})
.map((opt) => {
if (!opt) return '';
const value = String(opt.value || '');
return parseColumnKey(value).name;
});
return new Set(names);
};
/**
* Groups fields by their name to analyze variants
* Returns a map of field name to array of fields with that name
*/
export const getFieldVariantsByName = <T extends { name?: string }>(
fields: T[],
): Record<string, T[]> =>
fields.reduce((acc, field) => {
const name = field.name || '';
if (!acc[name]) {
acc[name] = [];
}
acc[name].push(field);
return acc;
}, {} as Record<string, T[]>);
/**
* Determines the column title based on variant analysis
* Shows context if dataTypes are same but contexts differ
* Shows dataType if dataTypes differ
*/
export const getColumnTitle = <
T extends Partial<QueryKeyDataSuggestionsProps> | Partial<TelemetryFieldKey>
>(
field: T,
hasVariants: boolean,
variants: T[],
// eslint-disable-next-line sonarjs/cognitive-complexity
): string => {
const name = field.name || '';
if (!hasVariants) return name;
// Extract data types from variants (support both fieldDataType and dataType)
const uniqueDataTypes = new Set(
variants
.map(
(v) =>
('fieldDataType' in v && v.fieldDataType) ||
('dataType' in v && v.dataType),
)
.filter(Boolean),
);
// Extract contexts from variants (support both fieldContext and type)
const uniqueContexts = new Set(
variants
.map(
(v) => ('fieldContext' in v && v.fieldContext) || ('type' in v && v.type),
)
.filter(Boolean),
);
// Same dataType but different contexts - show context
if (
uniqueDataTypes.size === 1 &&
uniqueContexts.size > 1 &&
(field.fieldContext || ('type' in field && field.type))
) {
return `${name} (${field.fieldContext || ('type' in field && field.type)})`;
}
// Different dataTypes - show dataType
const dataType =
('fieldDataType' in field && field.fieldDataType) ||
('dataType' in field && field.dataType);
if (dataType) {
return `${name} (${dataType})`;
}
return name;
};
/**
* Checks if another field with the same name but different unique key exists in availableKeys
* and if any of those conflicting fields are NOT already selected
* This indicates a conflicted column scenario where user might not be aware of other variants
*/
const hasUnselectedConflictingField = <
T extends Partial<QueryKeyDataSuggestionsProps> | Partial<TelemetryFieldKey>
>(
field: T,
availableKeys?: TelemetryFieldKey[],
selectedColumns?: TelemetryFieldKey[],
): boolean => {
if (!availableKeys || availableKeys.length === 0) return false;
const fieldName = field.name || '';
const fieldUniqueKey = getUniqueColumnKey(field as TelemetryFieldKey);
// Find all conflicting fields (same name, different unique key)
const conflictingFields = availableKeys.filter(
(key) => key.name === fieldName && getUniqueColumnKey(key) !== fieldUniqueKey,
);
// If no conflicting fields exist, no conflict
if (conflictingFields.length === 0) return false;
// If no selected columns provided, assume conflict exists
if (!selectedColumns || selectedColumns.length === 0) return true;
// Check if all conflicting fields are already selected
const selectedUniqueKeys = new Set(
selectedColumns.map((col) => getUniqueColumnKey(col)),
);
// Return true if any conflicting field is NOT selected
return conflictingFields.some(
(conflictingField) =>
!selectedUniqueKeys.has(getUniqueColumnKey(conflictingField)),
);
};
/**
* Returns column title as string and metadata for tooltip icon
* Shows tooltip only when another field with the same name but different type/context exists
* and is NOT already selected (better UX - no need to show tooltip if all variants are visible)
*
* Returns an object with:
* - title: string
* - hasUnselectedConflict: boolean
*/
export const getColumnTitleWithTooltip = <
T extends Partial<QueryKeyDataSuggestionsProps> | Partial<TelemetryFieldKey>
>(
field: T,
hasVariants: boolean,
variants: T[],
selectedColumns: TelemetryFieldKey[],
availableKeys?: TelemetryFieldKey[],
): { title: string; hasUnselectedConflict: boolean } => {
const title = getColumnTitle(field, hasVariants, variants);
const hasUnselectedConflict = hasUnselectedConflictingField(
field,
availableKeys,
selectedColumns,
);
return { title, hasUnselectedConflict };
};
export const getOptionsFromKeys = (
keys: TelemetryFieldKey[],
selectedKeys: (string | undefined)[],
): SelectProps['options'] => {
// Detect which attribute names have multiple variants
const nameCounts = keys.reduce((acc, key) => {
const name = key.name || '';
acc[name] = (acc[name] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const options = keys.map((key) => ({
label: key.name,
value: getUniqueColumnKey(key),
// Store additional data for rendering
fieldDataType: key.fieldDataType,
fieldContext: key.fieldContext,
signal: key.signal,
hasMultipleVariants: nameCounts[key.name || ''] > 1,
}));
return options.filter(
({ value }) => !selectedKeys.find((selectedKey) => selectedKey === value),
);
};
/**
* Determines if a column name has multiple variants
* Checks both selected columns and available keys (from search) to detect conflicts
* Reuses getVariantCounts for consistency
*/
export const hasMultipleVariants = (
columnName: string,
selectedColumns: TelemetryFieldKey[],
availableKeys?: TelemetryFieldKey[],
): boolean => {
// Combine selected columns with available keys (if provided)
const allKeys = availableKeys
? [...selectedColumns, ...availableKeys]
: selectedColumns;
// Deduplicate by unique key to avoid counting same variant twice
const uniqueKeysMap = new Map<string, TelemetryFieldKey>();
allKeys.forEach((key) => {
const uniqueKey = getUniqueColumnKey(key);
if (!uniqueKeysMap.has(uniqueKey)) {
uniqueKeysMap.set(uniqueKey, key);
}
});
const deduplicatedKeys = Array.from(uniqueKeysMap.values());
const variantCounts = getVariantCounts(deduplicatedKeys);
return variantCounts[columnName] > 1;
};

View File

@@ -1,5 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { FC, memo } from 'react';
import { FC } from 'react';
import { PanelTypeVsPanelWrapper } from './constants';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -56,36 +55,4 @@ function PanelWrapper({
);
}
function arePropsEqual(
prevProps: PanelWrapperProps,
nextProps: PanelWrapperProps,
): boolean {
// Destructure to separate props that need deep comparison from the rest
const {
widget: prevWidget,
queryResponse: prevQueryResponse,
...prevRest
} = prevProps;
const {
widget: nextWidget,
queryResponse: nextQueryResponse,
...nextRest
} = nextProps;
// Shallow equality check for all other props (primitives, functions, refs, arrays)
const restKeys = Object.keys(prevRest) as Array<
keyof Omit<PanelWrapperProps, 'widget' | 'queryResponse'>
>;
if (restKeys.some((key) => prevRest[key] !== nextRest[key])) {
return false;
}
// Deep equality only for widget config and query response data payload
return (
isEqual(prevWidget, nextWidget) &&
isEqual(prevQueryResponse.data?.payload, nextQueryResponse.data?.payload)
);
}
export default memo(PanelWrapper, arePropsEqual);
export default PanelWrapper;

View File

@@ -132,21 +132,11 @@ function UplotPanelWrapper({
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
);
// Memoize chartData to prevent unnecessary recalculations
const chartData = useMemo(
() =>
getUPlotChartData(
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
),
[
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
],
const chartData = getUPlotChartData(
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
);
useEffect(() => {

View File

@@ -68,7 +68,6 @@ import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { showErrorNotification } from 'utils/error';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { getQueryString } from './helper';
import {
@@ -121,7 +120,6 @@ function SortableFilter({ item }: { item: SidebarItem }): JSX.Element {
// eslint-disable-next-line sonarjs/cognitive-complexity
function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const { openCmdK } = useCmdK();
const { pathname, search } = useLocation();
const { currentVersion, latestVersion, isCurrentVersionError } = useSelector<
AppState,
@@ -639,8 +637,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
} else {
history.push(settingsRoute);
}
} else if (item.key === 'quick-search') {
openCmdK();
} else if (item) {
onClickHandler(item?.key as string, event);
}

View File

@@ -26,7 +26,6 @@ import {
Receipt,
Route,
ScrollText,
Search,
Settings,
Slack,
Unplug,
@@ -189,12 +188,6 @@ export const primaryMenuItems: SidebarItem[] = [
icon: <Home size={16} />,
itemKey: 'home',
},
{
key: 'quick-search',
label: 'Search',
icon: <Search size={16} />,
itemKey: 'quick-search',
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',

View File

@@ -0,0 +1,161 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import {
mockAllAvailableKeys,
mockConflictingFieldsByContext,
mockConflictingFieldsByDatatype,
} from 'container/OptionsMenu/__tests__/mockData';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { renderColumnHeader } from 'tests/columnHeaderHelpers';
import { getListColumns } from '../utils';
const COLUMN_UNDEFINED_ERROR = 'statusCodeColumn is undefined';
const SERVICE_NAME_COLUMN_UNDEFINED_ERROR = 'serviceNameColumn is undefined';
// Mock the timezone formatter
const mockFormatTimezoneAdjustedTimestamp = jest.fn(
(input: TimestampInput): string => {
if (typeof input === 'string') {
return new Date(input).toISOString();
}
if (typeof input === 'number') {
return new Date(input / 1e6).toISOString();
}
return new Date(input).toISOString();
},
);
describe('getListColumns - Column Headers and Tooltips', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows datatype in column header for conflicting fields', () => {
const selectedColumns: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // string variant
];
const columns = getListColumns(
selectedColumns,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
const statusCodeColumn = columns.find(
// eslint-disable-next-line sonarjs/no-duplicate-string
(col) => 'dataIndex' in col && col.dataIndex === 'http.status_code',
);
expect(statusCodeColumn).toBeDefined();
expect(statusCodeColumn?.title).toBeDefined();
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
expect(container.textContent).toContain('http.status_code (string)');
expect(container.textContent).toContain('string');
});
it('shows tooltip icon when unselected conflicting variant exists', () => {
const selectedColumns: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // Only string variant selected
];
const columns = getListColumns(
selectedColumns,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys, // Contains number variant
);
const statusCodeColumn = columns.find(
(col) => 'dataIndex' in col && col.dataIndex === 'http.status_code',
);
expect(statusCodeColumn).toBeDefined();
// Verify that _hasUnselectedConflict metadata is set correctly
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBe(true);
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).toBeInTheDocument();
});
it('hides tooltip icon when all conflicting variants are selected', () => {
const selectedColumns: TelemetryFieldKey[] = [
...mockConflictingFieldsByDatatype, // Both variants selected
];
const columns = getListColumns(
selectedColumns,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
const statusCodeColumn = columns.find(
(col) => 'dataIndex' in col && col.dataIndex === 'http.status_code',
);
expect(statusCodeColumn).toBeDefined();
// Verify that _hasUnselectedConflict metadata is NOT set when all variants are selected
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBeUndefined();
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).not.toBeInTheDocument();
});
it('shows context in header for attribute/resource conflicting fields', () => {
// When same datatype but different contexts, it shows context
const selectedColumns: TelemetryFieldKey[] = [
...mockConflictingFieldsByContext, // Both resource and attribute variants
];
const columns = getListColumns(
selectedColumns,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
const serviceNameColumn = columns.find(
(col) => 'dataIndex' in col && col.dataIndex === 'service.name',
);
expect(serviceNameColumn).toBeDefined();
if (!serviceNameColumn) {
throw new Error(SERVICE_NAME_COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(serviceNameColumn);
expect(container.textContent).toContain('service.name (resource)');
expect(container.textContent).toContain('resource');
});
it('includes timestamp column in initial columns', () => {
const columns = getListColumns(
[],
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
const timestampColumn = columns.find(
(col) => 'dataIndex' in col && col.dataIndex === 'date',
);
expect(timestampColumn).toBeDefined();
expect(timestampColumn?.title).toBe('Timestamp');
});
});

View File

@@ -186,9 +186,15 @@ function ListView({
const updatedColumns = getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
config.addColumn?.allAvailableKeys,
);
return getDraggedColumns(updatedColumns, draggedColumns);
}, [options?.selectColumns, formatTimezoneAdjustedTimestamp, draggedColumns]);
}, [
options?.selectColumns,
formatTimezoneAdjustedTimestamp,
draggedColumns,
config.addColumn?.allAvailableKeys,
]);
const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [],

View File

@@ -3,6 +3,12 @@ import { ColumnsType } from 'antd/es/table';
import { TelemetryFieldKey } from 'api/v5/v5';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import {
getColumnTitleWithTooltip,
getFieldVariantsByName,
getUniqueColumnKey,
hasMultipleVariants,
} from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
@@ -52,6 +58,7 @@ export const getListColumns = (
input: TimestampInput,
format?: string,
) => string | number,
allAvailableKeys?: TelemetryFieldKey[],
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
@@ -79,15 +86,31 @@ export const getListColumns = (
},
];
// Group fields by name to analyze variants
const fieldVariantsByName = getFieldVariantsByName(selectedColumns);
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldDataType = props?.fieldDataType || (props as any)?.dataType;
const fieldContext = props?.fieldContext || (props as any)?.type;
const hasVariants = hasMultipleVariants(
name,
selectedColumns,
allAvailableKeys,
);
const variants = fieldVariantsByName[name] || [];
const { title, hasUnselectedConflict } = getColumnTitleWithTooltip(
props,
hasVariants,
variants,
selectedColumns,
allAvailableKeys,
);
return {
title: name,
title,
dataIndex: name,
key: `${name}-${fieldDataType}-${fieldContext}`,
key: getUniqueColumnKey(props),
...(hasUnselectedConflict && { _hasUnselectedConflict: true }),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {

View File

@@ -4,6 +4,7 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { ResizeTable } from 'components/ResizeTable';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import Controls from 'container/Controls';
import { extractTelemetryFieldKeys } from 'container/OptionsMenu/utils';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { tableStyles } from 'container/TracesExplorer/ListView/styles';
import {
@@ -12,6 +13,7 @@ import {
transformDataWithDate,
} from 'container/TracesExplorer/ListView/utils';
import { Pagination } from 'hooks/queryPagination';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import history from 'lib/history';
@@ -30,6 +32,10 @@ import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import {
DataSource,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
function TracesTableComponent({
widget,
@@ -54,14 +60,35 @@ function TracesTableComponent({
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// Fetch available keys to detect variants
const { data: keysData } = useGetQueryKeySuggestions(
{
searchText: '',
signal: DataSource.TRACES,
},
{
queryKey: [DataSource.TRACES, TracesAggregatorOperator.NOOP, ''],
},
);
// Extract all available keys from API response
const allAvailableKeys = useMemo(() => extractTelemetryFieldKeys(keysData), [
keysData,
]);
const columns = useMemo(
() =>
getListColumns(
widget.selectedTracesFields || [],
formatTimezoneAdjustedTimestamp,
allAvailableKeys,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[widget.selectedTracesFields],
[
widget.selectedTracesFields,
formatTimezoneAdjustedTimestamp,
allAvailableKeys,
],
);
const dataLength =

View File

@@ -0,0 +1,159 @@
import { ColumnType } from 'antd/es/table';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
mockAllAvailableKeys,
mockConflictingFieldsByContext,
mockConflictingFieldsByDatatype,
} from 'container/OptionsMenu/__tests__/mockData';
import { getListColumns } from 'container/TracesExplorer/ListView/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { renderColumnHeader } from 'tests/columnHeaderHelpers';
const HTTP_STATUS_CODE = 'http.status_code';
const SERVICE_NAME = 'service.name';
const COLUMN_UNDEFINED_ERROR = 'statusCodeColumn is undefined';
const SERVICE_NAME_COLUMN_UNDEFINED_ERROR = 'serviceNameColumn is undefined';
// Mock the timezone formatter
const mockFormatTimezoneAdjustedTimestamp = jest.fn(
(input: TimestampInput): string => {
if (typeof input === 'string') {
return new Date(input).toISOString();
}
if (typeof input === 'number') {
return new Date(input / 1e6).toISOString();
}
return new Date(input).toISOString();
},
);
describe('TracesTableComponent - Column Headers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows datatype in column header for conflicting columns', () => {
const selectedTracesFields: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // string variant
];
const columns = getListColumns(
selectedTracesFields,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
// Find the http.status_code column
const statusCodeColumn = columns.find(
(col): col is ColumnType<RowData> =>
'dataIndex' in col && (col.dataIndex as string) === HTTP_STATUS_CODE,
);
expect(statusCodeColumn).toBeDefined();
expect(statusCodeColumn?.title).toBeDefined();
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
expect(container.textContent).toContain('http.status_code (string)');
expect(container.textContent).toContain('string');
});
it('shows tooltip icon when unselected conflicting variant exists', () => {
const selectedTracesFields: TelemetryFieldKey[] = [
mockConflictingFieldsByDatatype[0], // Only string variant selected
];
const columns = getListColumns(
selectedTracesFields,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys, // Contains number variant
);
const statusCodeColumn = columns.find(
(col): col is ColumnType<RowData> =>
'dataIndex' in col && (col.dataIndex as string) === HTTP_STATUS_CODE,
);
expect(statusCodeColumn).toBeDefined();
// Verify that _hasUnselectedConflict metadata is set correctly
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBe(true);
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
// Check for tooltip icon (InfoCircleOutlined)
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).toBeInTheDocument();
});
it('hides tooltip icon when all conflicting variants are selected', () => {
const selectedTracesFields: TelemetryFieldKey[] = [
...mockConflictingFieldsByDatatype, // Both variants selected
];
const columns = getListColumns(
selectedTracesFields,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
const statusCodeColumn = columns.find(
(col): col is ColumnType<RowData> =>
'dataIndex' in col && (col.dataIndex as string) === HTTP_STATUS_CODE,
);
expect(statusCodeColumn).toBeDefined();
// Verify that _hasUnselectedConflict metadata is NOT set when all variants are selected
const columnRecord = statusCodeColumn as Record<string, unknown>;
expect(columnRecord._hasUnselectedConflict).toBeUndefined();
if (!statusCodeColumn) {
throw new Error(COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(statusCodeColumn);
// Tooltip icon should NOT be present when all variants are selected
const tooltipIcon = container.querySelector('.anticon-info-circle');
expect(tooltipIcon).not.toBeInTheDocument();
});
it('shows context in header for attribute/resource conflicting fields', () => {
// When same datatype but different contexts, it shows context
const selectedTracesFields: TelemetryFieldKey[] = [
...mockConflictingFieldsByContext, // Both resource and attribute variants
];
const columns = getListColumns(
selectedTracesFields,
mockFormatTimezoneAdjustedTimestamp,
mockAllAvailableKeys,
);
const serviceNameColumn = columns.find(
(col): col is ColumnType<RowData> =>
'dataIndex' in col && (col.dataIndex as string) === SERVICE_NAME,
);
expect(serviceNameColumn).toBeDefined();
if (!serviceNameColumn) {
throw new Error(SERVICE_NAME_COLUMN_UNDEFINED_ERROR);
}
const { container } = renderColumnHeader(serviceNameColumn);
expect(container.textContent).toContain('service.name (resource)');
expect(container.textContent).toContain('resource');
});
});

View File

@@ -77,20 +77,6 @@ function MessagingQueuesGraph(): JSX.Element {
});
}
};
const onClickHandler = useCallback(
(
xValue: number,
_yValue: number,
_mouseX: number,
_mouseY: number,
data?: any,
): void => {
setSelectedTimelineQuery(urlQuery, xValue, location, history, data);
},
[urlQuery, location, history],
);
return (
<Card
isDarkMode={isDarkMode}
@@ -100,7 +86,9 @@ function MessagingQueuesGraph(): JSX.Element {
<GridCard
widget={widgetData}
headerMenuList={[...ViewMenuAction]}
onClickHandler={onClickHandler}
onClickHandler={(xValue, _yValue, _mouseX, _mouseY, data): void => {
setSelectedTimelineQuery(urlQuery, xValue, location, history, data);
}}
onDragSelect={onDragSelect}
customTooltipElement={messagingQueueCustomTooltipText()}
dataAvailable={checkIfDataExists}

View File

@@ -0,0 +1,229 @@
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import { KBarProvider } from 'kbar';
import history from 'lib/history';
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from './App/App';
export function KBarCommandPaletteProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme } = useThemeMode();
const handleThemeChange = (value: string): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
};
const onClickHandler = useCallback((key: string): void => {
history.push(key);
}, []);
const { updateUserPreferenceInContext } = useAppContext();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const handleOpenSidebar = useCallback((): void => {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
// Update the context immediately
const save = {
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
};
updateUserPreferenceInContext(save as UserPreference);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
const handleCloseSidebar = useCallback((): void => {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
// Update the context immediately
const save = {
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
};
updateUserPreferenceInContext(save as UserPreference);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
const kbarActions = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.HOME);
},
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.ALL_DASHBOARD);
},
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.APPLICATION);
},
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.TRACES_EXPLORER);
},
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.LOGS);
},
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.LIST_ALL_ALERT);
},
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.ALL_ERROR);
},
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW);
},
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.MY_SETTINGS);
},
},
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
perform: (): void => {
handleOpenSidebar();
},
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
perform: (): void => {
handleCloseSidebar();
},
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.DARK);
},
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.LIGHT);
},
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.SYSTEM);
},
},
];
return <KBarProvider actions={kbarActions}>{children}</KBarProvider>;
}

View File

@@ -1,50 +0,0 @@
import React, {
createContext,
ReactNode,
useContext,
useMemo,
useState,
} from 'react';
type CmdKContextType = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
openCmdK: () => void;
closeCmdK: () => void;
};
const CmdKContext = createContext<CmdKContextType | null>(null);
export function CmdKProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [open, setOpen] = useState<boolean>(false);
function openCmdK(): void {
setOpen(true);
}
function closeCmdK(): void {
setOpen(false);
}
const value = useMemo<CmdKContextType>(
() => ({
open,
setOpen,
openCmdK,
closeCmdK,
}),
[open],
);
return <CmdKContext.Provider value={value}>{children}</CmdKContext.Provider>;
}
export function useCmdK(): CmdKContextType {
const ctx = useContext(CmdKContext);
if (!ctx) throw new Error('useCmdK must be used inside CmdKProvider');
return ctx;
}

View File

@@ -0,0 +1,37 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { render } from '@testing-library/react';
import { Tooltip } from 'antd';
import { ColumnsType, ColumnType } from 'antd/es/table';
import {
ColumnTitleIcon,
ColumnTitleWrapper,
} from 'container/OptionsMenu/styles';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
/**
* Helper function that mimics ResizeTable's column title transformation logic.
* This renders the column header the way it appears in the actual table when
* onDragColumn is provided (which adds the tooltip icon for conflicting variants).
*
* Works with both ColumnType and ColumnsType column definitions.
*/
export const renderColumnHeader = <T extends RowData | Record<string, unknown>>(
column: ColumnType<T> | ColumnsType<T>[number],
): ReturnType<typeof render> => {
const columnRecord = column as Record<string, unknown>;
const hasUnselectedConflict = columnRecord._hasUnselectedConflict === true;
const titleText = column?.title?.toString() || '';
return render(
<ColumnTitleWrapper>
{titleText}
{hasUnselectedConflict && (
<Tooltip title="The same column with a different type or context exists">
<ColumnTitleIcon>
<InfoCircleOutlined />
</ColumnTitleIcon>
</Tooltip>
)}
</ColumnTitleWrapper>,
);
};

View File

@@ -3583,7 +3583,7 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1":
"@radix-ui/react-compose-refs@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
@@ -3600,26 +3600,6 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==
"@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-direction@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"
@@ -3677,7 +3657,7 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0":
"@radix-ui/react-id@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7"
integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==
@@ -3746,7 +3726,7 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-portal@1.1.9":
"@radix-ui/react-portal@1.1.9", "@radix-ui/react-portal@^1.0.1":
version "1.1.9"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472"
integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==
@@ -3786,13 +3766,6 @@
dependencies:
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-primitive@^2.0.2":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz#2626ea309ebd63bf5767d3e7fc4081f81b993df0"
integrity sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==
dependencies:
"@radix-ui/react-slot" "1.2.4"
"@radix-ui/react-roving-focus@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
@@ -3824,13 +3797,6 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-slot@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-tabs@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -4082,6 +4048,11 @@
rc-resize-observer "^1.3.1"
rc-util "^5.38.0"
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
"@react-dnd/asap@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
@@ -4335,21 +4306,6 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/command@0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@signozhq/command/-/command-0.0.0.tgz#bd1e1cac7346e862dd61df64b756302e89e1a322"
integrity sha512-AwRYxZTi4o8SBOL4hmgcgbhCKXl2Qb/TUSLbSYEMFdiQSl5VYA8XZJv5fSYVMJkAIlOaHzFzR04XNEU7lZcBpw==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
cmdk "^1.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/design-tokens@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-1.1.4.tgz#5d5de5bd9d19b6a3631383db015cc4b70c3f7661"
@@ -7441,16 +7397,6 @@ clsx@^2.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
cmdk@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-1.1.1.tgz#b8524272699ccaa37aaf07f36850b376bf3d58e5"
integrity sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==
dependencies:
"@radix-ui/react-compose-refs" "^1.1.1"
"@radix-ui/react-dialog" "^1.1.6"
"@radix-ui/react-id" "^1.1.0"
"@radix-ui/react-primitive" "^2.0.2"
co@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"
@@ -9493,6 +9439,11 @@ fast-diff@^1.1.2:
resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-equals@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927"
integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==
fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.3.0:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
@@ -9846,6 +9797,11 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3:
resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
fuse.js@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
@@ -12062,6 +12018,17 @@ kapsule@1, kapsule@^1.14:
dependencies:
lodash-es "4"
kbar@0.1.0-beta.48:
version "0.1.0-beta.48"
resolved "https://registry.yarnpkg.com/kbar/-/kbar-0.1.0-beta.48.tgz#2db254cb2943f14200c5a5f47064135737527983"
integrity sha512-HD5A1dqfK6XGeoH4fRWTmRt4y76sDbtGxY4Dh2xNa5MYtvtKsqfz+nRZ0tKgcrjjGYN4rf5TLXMJuiE7Pb8rXg==
dependencies:
"@radix-ui/react-portal" "^1.0.1"
fast-equals "^2.0.3"
fuse.js "^6.6.2"
react-virtual "^2.8.2"
tiny-invariant "^1.2.0"
keyv@^4.0.0:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -15629,6 +15596,13 @@ react-use@^17.3.2:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-virtual@^2.8.2:
version "2.10.4"
resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704"
integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==
dependencies:
"@reach/observe-rect" "^1.1.0"
react-virtuoso@4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.0.3.tgz#0dc8b10978095852d985b064157639b9fb9d9b1e"
@@ -17343,6 +17317,11 @@ tiny-invariant@^1.0.2, tiny-invariant@^1.0.6:
resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz"
integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==
tiny-invariant@^1.2.0:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
tiny-warning@^1.0.0:
version "1.0.3"
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"

14
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/ClickHouse/clickhouse-go/v2 v2.40.1
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.129.10-rc.9
github.com/SigNoz/signoz-otel-collector v0.129.4
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0
@@ -55,8 +55,6 @@ require (
github.com/spf13/cobra v1.10.1
github.com/srikanthccv/ClickHouse-go-mock v0.12.0
github.com/stretchr/testify v1.11.1
github.com/swaggest/jsonschema-go v0.3.78
github.com/swaggest/rest v0.2.75
github.com/tidwall/gjson v1.18.0
github.com/uptrace/bun v1.2.9
github.com/uptrace/bun/dialect/pgdialect v1.2.9
@@ -88,21 +86,12 @@ require (
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/swaggest/refl v1.4.0 // indirect
github.com/swaggest/usecase v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
go.opentelemetry.io/collector/config/configretry v1.34.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
@@ -260,7 +249,6 @@ require (
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggest/openapi-go v0.2.60
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect

44
go.sum
View File

@@ -106,8 +106,8 @@ github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72g
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=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.129.10-rc.9 h1:WmYDSSwzyW2yiJ3tPq5AFdjsrz3NBdtPkygtFKOsACw=
github.com/SigNoz/signoz-otel-collector v0.129.10-rc.9/go.mod h1:4eJCRUd/P4OiCHXvGYZK8q6oyBVGJFVj/G6qKSoN/TQ=
github.com/SigNoz/signoz-otel-collector v0.129.4 h1:DGDu9y1I1FU+HX4eECPGmfhnXE4ys4yr7LL6znbf6to=
github.com/SigNoz/signoz-otel-collector v0.129.4/go.mod h1:xyR+coBzzO04p6Eu+ql2RVYUl/jFD+8hD9lArcc9U7g=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
@@ -158,20 +158,10 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bool64/dev v0.2.40 h1:LUSD+Aq+WB3KwVntqXstevJ0wB12ig1bEgoG8ZafsZU=
github.com/bool64/dev v0.2.40/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
@@ -188,8 +178,6 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -582,8 +570,6 @@ github.com/huandu/go-sqlbuilder v1.35.0 h1:ESvxFHN8vxCTudY1Vq63zYpU5yJBESn19sf6k
github.com/huandu/go-sqlbuilder v1.35.0/go.mod h1:mS0GAtrtW+XL6nM2/gXHRJax2RwSW1TraavWDFAc1JA=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -904,8 +890,6 @@ github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFT
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw=
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33 h1:KhF0WejiUTDbL5X55nXowP7zNopwpowa6qaMAWyIE+0=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33/go.mod h1:792k1RTU+5JeMXm35/e2Wgp71qPH/DmDoZrRc+EFZDk=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
@@ -918,8 +902,8 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4=
github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI=
github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
@@ -991,18 +975,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw=
github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g=
github.com/swaggest/openapi-go v0.2.60 h1:kglHH/WIfqAglfuWL4tu0LPakqNYySzklUWx06SjSKo=
github.com/swaggest/openapi-go v0.2.60/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk=
github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k=
github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA=
github.com/swaggest/rest v0.2.75 h1:MW9zZ3d0kduJ2KdWnSYZIIrZJ1v3Kg+S7QZrDCZcXws=
github.com/swaggest/rest v0.2.75/go.mod h1:yw+PNgpNSdD6W46r60keVXdsBB+7SKt64i2qpeuBsq4=
github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w=
github.com/swaggest/usecase v1.3.1/go.mod h1:cae3lDd5VDmM36OQcOOOdAlEDg40TiQYIp99S9ejWqA=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -1019,8 +991,6 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GH
github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM=
github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/uptrace/bun v1.2.9 h1:OOt2DlIcRUMSZPr6iXDFg/LaQd59kOxbAjpIVHddKRs=
github.com/uptrace/bun v1.2.9/go.mod h1:r2ZaaGs9Ru5bpGTr8GQfp8jp+TlCav9grYCPOu2CJSg=
github.com/uptrace/bun/dialect/pgdialect v1.2.9 h1:caf5uFbOGiXvadV6pA5gn87k0awFFxL1kuuY3SpxnWk=
@@ -1049,10 +1019,6 @@ github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgk
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -1269,8 +1235,6 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@@ -1,13 +0,0 @@
package apiserver
import (
"github.com/gorilla/mux"
)
type APIServer interface {
// Returns the mux router for the API server. Primarily used for collecting OpenAPI operations.
Router() *mux.Router
// Adds the API server routes to an existing router. This is a backwards compatible method for adding routes to the input router.
AddToRouter(router *mux.Router) error
}

View File

@@ -1,82 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/domains", handler.New(provider.authZ.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{
ID: "ListAuthDomains",
Tags: []string{"authdomains"},
Summary: "List all auth domains",
Description: "This endpoint lists all auth domains",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.GettableAuthDomain, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/domains", handler.New(provider.authZ.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{
ID: "CreateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Create auth domain",
Description: "This endpoint creates an auth domain",
Request: new(authtypes.PostableAuthDomain),
RequestContentType: "application/json",
Response: new(authtypes.GettableAuthDomain),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authZ.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
ID: "UpdateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Update auth domain",
Description: "This endpoint updates an auth domain",
Request: new(authtypes.UpdateableAuthDomain),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authZ.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
ID: "DeleteAuthDomain",
Tags: []string{"authdomains"},
Summary: "Delete auth domain",
Description: "This endpoint deletes an auth domain",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -1,47 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/gorilla/mux"
)
func (provider *provider) addOrgRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/orgs/me", handler.New(provider.authZ.AdminAccess(provider.orgHandler.Get), handler.OpenAPIDef{
ID: "GetMyOrganization",
Tags: []string{"orgs"},
Summary: "Get my organization",
Description: "This endpoint returns the organization I belong to",
Request: nil,
RequestContentType: "",
Response: new(types.Organization),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/orgs/me", handler.New(provider.authZ.AdminAccess(provider.orgHandler.Update), handler.OpenAPIDef{
ID: "UpdateMyOrganization",
Tags: []string{"orgs"},
Summary: "Update my organization",
Description: "This endpoint updates the organization I belong to",
Request: new(types.Organization),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusConflict, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -1,116 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
"github.com/gorilla/mux"
)
func (provider *provider) addPreferenceRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/user/preferences", handler.New(provider.authZ.ViewAccess(provider.preferenceHandler.ListByUser), handler.OpenAPIDef{
ID: "ListUserPreferences",
Tags: []string{"preferences"},
Summary: "List user preferences",
Description: "This endpoint lists all user preferences",
Request: nil,
RequestContentType: "",
Response: make([]*preferencetypes.Preference, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user/preferences/{name}", handler.New(provider.authZ.ViewAccess(provider.preferenceHandler.GetByUser), handler.OpenAPIDef{
ID: "GetUserPreference",
Tags: []string{"preferences"},
Summary: "Get user preference",
Description: "This endpoint returns the user preference by name",
Request: nil,
RequestContentType: "",
Response: new(preferencetypes.Preference),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user/preferences/{name}", handler.New(provider.authZ.ViewAccess(provider.preferenceHandler.UpdateByUser), handler.OpenAPIDef{
ID: "UpdateUserPreference",
Tags: []string{"preferences"},
Summary: "Update user preference",
Description: "This endpoint updates the user preference by name",
Request: new(preferencetypes.UpdatablePreference),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/org/preferences", handler.New(provider.authZ.AdminAccess(provider.preferenceHandler.ListByOrg), handler.OpenAPIDef{
ID: "ListOrgPreferences",
Tags: []string{"preferences"},
Summary: "List org preferences",
Description: "This endpoint lists all org preferences",
Request: nil,
RequestContentType: "",
Response: make([]*preferencetypes.Preference, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/org/preferences/{name}", handler.New(provider.authZ.AdminAccess(provider.preferenceHandler.GetByOrg), handler.OpenAPIDef{
ID: "GetOrgPreference",
Tags: []string{"preferences"},
Summary: "Get org preference",
Description: "This endpoint returns the org preference by name",
Request: nil,
RequestContentType: "",
Response: new(preferencetypes.Preference),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/org/preferences/{name}", handler.New(provider.authZ.AdminAccess(provider.preferenceHandler.UpdateByOrg), handler.OpenAPIDef{
ID: "UpdateOrgPreference",
Tags: []string{"preferences"},
Summary: "Update org preference",
Description: "This endpoint updates the org preference by name",
Request: new(preferencetypes.UpdatablePreference),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -1,115 +0,0 @@
package signozapiserver
import (
"context"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
)
type provider struct {
config apiserver.Config
settings factory.ScopedProviderSettings
router *mux.Router
authZ *middleware.AuthZ
orgHandler organization.Handler
userHandler user.Handler
sessionHandler session.Handler
authDomainHandler authdomain.Handler
preferenceHandler preference.Handler
}
func NewFactory(
orgGetter organization.Getter,
authz authz.AuthZ,
orgHandler organization.Handler,
userHandler user.Handler,
sessionHandler session.Handler,
authDomainHandler authdomain.Handler,
preferenceHandler preference.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(ctx, providerSettings, config, orgGetter, authz, orgHandler, userHandler, sessionHandler, authDomainHandler, preferenceHandler)
})
}
func newProvider(
_ context.Context,
providerSettings factory.ProviderSettings,
config apiserver.Config,
orgGetter organization.Getter,
authz authz.AuthZ,
orgHandler organization.Handler,
userHandler user.Handler,
sessionHandler session.Handler,
authDomainHandler authdomain.Handler,
preferenceHandler preference.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
provider := &provider{
config: config,
settings: settings,
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
preferenceHandler: preferenceHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
if err := provider.AddToRouter(router); err != nil {
return nil, err
}
return provider, nil
}
func (provider *provider) Router() *mux.Router {
return provider.router
}
func (provider *provider) AddToRouter(router *mux.Router) error {
if err := provider.addOrgRoutes(router); err != nil {
return err
}
if err := provider.addSessionRoutes(router); err != nil {
return err
}
if err := provider.addAuthDomainRoutes(router); err != nil {
return err
}
if err := provider.addUserRoutes(router); err != nil {
return err
}
if err := provider.addPreferenceRoutes(router); err != nil {
return err
}
return nil
}
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
return []handler.OpenAPISecurityScheme{
{Name: ctxtypes.AuthTypeAPIKey.StringValue(), Scopes: []string{role.String()}},
{Name: ctxtypes.AuthTypeTokenizer.StringValue(), Scopes: []string{role.String()}},
}
}

View File

@@ -1,153 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addSessionRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/login", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.DeprecatedCreateSessionByEmailPassword), handler.OpenAPIDef{
ID: "DeprecatedCreateSessionByEmailPassword",
Tags: []string{"sessions"},
Summary: "Deprecated create session by email password",
Description: "This endpoint is deprecated and will be removed in the future",
Request: new(authtypes.DeprecatedPostableLogin),
RequestContentType: "application/json",
Response: new(authtypes.DeprecatedGettableLogin),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: true,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions/email_password", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.CreateSessionByEmailPassword), handler.OpenAPIDef{
ID: "CreateSessionByEmailPassword",
Tags: []string{"sessions"},
Summary: "Create session by email and password",
Description: "This endpoint creates a session for a user using email and password.",
Request: new(authtypes.PostableEmailPasswordSession),
RequestContentType: "application/json",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions/context", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.GetSessionContext), handler.OpenAPIDef{
ID: "GetSessionContext",
Tags: []string{"sessions"},
Summary: "Get session context",
Description: "This endpoint returns the context for the session",
Request: nil,
RequestContentType: "",
Response: new(authtypes.SessionContext),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions/rotate", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.RotateSession), handler.OpenAPIDef{
ID: "RotateSession",
Tags: []string{"sessions"},
Summary: "Rotate session",
Description: "This endpoint rotates the session",
Request: new(authtypes.PostableRotateToken),
RequestContentType: "application/json",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.DeleteSession), handler.OpenAPIDef{
ID: "DeleteSession",
Tags: []string{"sessions"},
Summary: "Delete session",
Description: "This endpoint deletes the session",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: ctxtypes.AuthTypeTokenizer.StringValue()}},
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/complete/google", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.CreateSessionByGoogleCallback), handler.OpenAPIDef{
ID: "CreateSessionByGoogleCallback",
Tags: []string{"sessions"},
Summary: "Create session by google callback",
Description: "This endpoint creates a session for a user using google callback",
Request: nil,
RequestContentType: "",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusSeeOther,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/complete/saml", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.CreateSessionBySAMLCallback), handler.OpenAPIDef{
ID: "CreateSessionBySAMLCallback",
Tags: []string{"sessions"},
Summary: "Create session by saml callback",
Description: "This endpoint creates a session for a user using saml callback",
Request: struct {
RelayState string `form:"RelayState"`
SAMLResponse string `form:"SAMLResponse"`
}{},
RequestContentType: "application/x-www-form-urlencoded",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusSeeOther,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/complete/oidc", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.CreateSessionByOIDCCallback), handler.OpenAPIDef{
ID: "CreateSessionByOIDCCallback",
Tags: []string{"sessions"},
Summary: "Create session by oidc callback",
Description: "This endpoint creates a session for a user using oidc callback",
Request: nil,
RequestContentType: "",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusSeeOther,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -1,319 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addUserRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/invite", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateInvite), handler.OpenAPIDef{
ID: "CreateInvite",
Tags: []string{"users"},
Summary: "Create invite",
Description: "This endpoint creates an invite for a user",
Request: new(types.PostableInvite),
RequestContentType: "application/json",
Response: new(types.Invite),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/invite/bulk", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateBulkInvite), handler.OpenAPIDef{
ID: "CreateBulkInvite",
Tags: []string{"users"},
Summary: "Create bulk invite",
Description: "This endpoint creates a bulk invite for a user",
Request: make([]*types.PostableInvite, 0),
RequestContentType: "application/json",
Response: nil,
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/invite/{token}", handler.New(provider.authZ.OpenAccess(provider.userHandler.GetInvite), handler.OpenAPIDef{
ID: "GetInvite",
Tags: []string{"users"},
Summary: "Get invite",
Description: "This endpoint gets an invite by token",
Request: nil,
RequestContentType: "",
Response: new(types.Invite),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/invite/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.DeleteInvite), handler.OpenAPIDef{
ID: "DeleteInvite",
Tags: []string{"users"},
Summary: "Delete invite",
Description: "This endpoint deletes an invite by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/invite", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListInvite), handler.OpenAPIDef{
ID: "ListInvite",
Tags: []string{"users"},
Summary: "List invites",
Description: "This endpoint lists all invites",
Request: nil,
RequestContentType: "",
Response: make([]*types.Invite, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/invite/accept", handler.New(provider.authZ.OpenAccess(provider.userHandler.AcceptInvite), handler.OpenAPIDef{
ID: "AcceptInvite",
Tags: []string{"users"},
Summary: "Accept invite",
Description: "This endpoint accepts an invite by token",
Request: new(types.PostableAcceptInvite),
RequestContentType: "application/json",
Response: new(types.User),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
ID: "CreateAPIKey",
Tags: []string{"users"},
Summary: "Create api key",
Description: "This endpoint creates an api key",
Request: new(types.PostableAPIKey),
RequestContentType: "application/json",
Response: new(types.GettableAPIKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListAPIKeys), handler.OpenAPIDef{
ID: "ListAPIKeys",
Tags: []string{"users"},
Summary: "List api keys",
Description: "This endpoint lists all api keys",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.UpdateAPIKey), handler.OpenAPIDef{
ID: "UpdateAPIKey",
Tags: []string{"users"},
Summary: "Update api key",
Description: "This endpoint updates an api key",
Request: new(types.StorableAPIKey),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.RevokeAPIKey), handler.OpenAPIDef{
ID: "RevokeAPIKey",
Tags: []string{"users"},
Summary: "Revoke api key",
Description: "This endpoint revokes an api key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users",
Description: "This endpoint lists all users",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableUser, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user/me", handler.New(provider.authZ.OpenAccess(provider.userHandler.GetMyUser), handler.OpenAPIDef{
ID: "GetMyUser",
Tags: []string{"users"},
Summary: "Get my user",
Description: "This endpoint returns the user I belong to",
Request: nil,
RequestContentType: "",
Response: new(types.GettableUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: ctxtypes.AuthTypeTokenizer.StringValue()}},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user/{id}", handler.New(provider.authZ.SelfAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(types.GettableUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user/{id}", handler.New(provider.authZ.SelfAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user",
Description: "This endpoint updates the user by id",
Request: new(types.User),
RequestContentType: "application/json",
Response: new(types.GettableUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/getResetPasswordToken/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token",
Description: "This endpoint returns the reset password token by id",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/resetPassword", handler.New(provider.authZ.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
ID: "ResetPassword",
Tags: []string{"users"},
Summary: "Reset password",
Description: "This endpoint resets the password by token",
Request: new(types.PostableResetPassword),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/changePassword/{id}", handler.New(provider.authZ.SelfAccess(provider.userHandler.ChangePassword), handler.OpenAPIDef{
ID: "ChangePassword",
Tags: []string{"users"},
Summary: "Change password",
Description: "This endpoint changes the password by id",
Request: new(types.ChangePasswordRequest),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -208,13 +208,3 @@ func WrapUnexpectedf(cause error, code Code, format string, args ...any) *base {
func NewUnexpectedf(code Code, format string, args ...any) *base {
return Newf(TypeInvalidInput, code, format, args...)
}
// WrapTimeoutf is a wrapper around Wrapf with TypeTimeout.
func WrapTimeoutf(cause error, code Code, format string, args ...any) *base {
return Wrapf(cause, TypeTimeout, code, format, args...)
}
// NewTimeoutf is a wrapper around Newf with TypeTimeout.
func NewTimeoutf(code Code, format string, args ...any) *base {
return Newf(TypeTimeout, code, format, args...)
}

View File

@@ -1,88 +0,0 @@
package handler
import (
"net/http"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/swaggest/openapi-go"
)
type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
// Remove duplicate error status codes
openAPIDef.ErrorStatusCodes = slices.DeleteFunc(openAPIDef.ErrorStatusCodes, func(statusCode int) bool {
return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusInternalServerError
})
// Add internal server error
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusInternalServerError)
// Add unauthorized and forbidden status codes
if len(openAPIDef.SecuritySchemes) > 0 {
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
return &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
}
}
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
handler.handlerFunc.ServeHTTP(rw, req)
}
func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
// Add meta information
opCtx.SetID(handler.openAPIDef.ID)
opCtx.SetTags(handler.openAPIDef.Tags...)
opCtx.SetSummary(handler.openAPIDef.Summary)
opCtx.SetDescription(handler.openAPIDef.Description)
opCtx.SetIsDeprecated(handler.openAPIDef.Deprecated)
// Add security schemes
for _, securityScheme := range handler.openAPIDef.SecuritySchemes {
opCtx.AddSecurity(securityScheme.Name, securityScheme.Scopes...)
}
// Add request structure
opCtx.AddReqStructure(handler.openAPIDef.Request, openapi.WithContentType(handler.openAPIDef.RequestContentType))
// Add success response
if handler.openAPIDef.Response != nil {
opCtx.AddRespStructure(
render.SuccessResponse{Status: render.StatusSuccess.String(), Data: handler.openAPIDef.Response},
openapi.WithContentType(handler.openAPIDef.ResponseContentType),
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
)
} else {
opCtx.AddRespStructure(
nil,
openapi.WithContentType(handler.openAPIDef.ResponseContentType),
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
)
}
// Add error responses
for _, statusCode := range handler.openAPIDef.ErrorStatusCodes {
opCtx.AddRespStructure(
render.ErrorResponse{Status: render.StatusError.String(), Error: &errors.JSON{}},
openapi.WithContentType("application/json"),
openapi.WithHTTPStatus(statusCode),
)
}
}

View File

@@ -1,109 +0,0 @@
package handler
import (
"reflect"
"github.com/gorilla/mux"
"github.com/swaggest/jsonschema-go"
openapigo "github.com/swaggest/openapi-go"
"github.com/swaggest/rest/openapi"
)
// Def is the definition of an OpenAPI operation
type OpenAPIDef struct {
ID string
Tags []string
Summary string
Description string
Request any
RequestContentType string
Response any
ResponseContentType string
SuccessStatusCode int
ErrorStatusCodes []int
Deprecated bool
SecuritySchemes []OpenAPISecurityScheme
}
type OpenAPISecurityScheme struct {
Name string
Scopes []string
}
// Collector is a collector for OpenAPI operations
type OpenAPICollector struct {
collector *openapi.Collector
}
func NewOpenAPICollector(reflector openapigo.Reflector) *OpenAPICollector {
c := openapi.NewCollector(reflector)
return &OpenAPICollector{
collector: c,
}
}
func (c *OpenAPICollector) Walker(route *mux.Route, _ *mux.Router, _ []*mux.Route) error {
httpHandler := route.GetHandler()
if httpHandler == nil {
return nil
}
path, err := route.GetPathTemplate()
if err != nil && path == "" {
// If there is no path, skip the route
return nil
}
methods, err := route.GetMethods()
if err != nil {
// If there is no methods, skip the route
return nil
}
if handler, ok := httpHandler.(Handler); ok {
for _, method := range methods {
if err := c.collector.CollectOperation(method, path, c.collect(method, path, handler.ServeOpenAPI)); err != nil {
return err
}
}
return nil
}
return nil
}
func (c *OpenAPICollector) collect(method string, path string, serveOpenAPIFunc ServeOpenAPIFunc) func(oc openapigo.OperationContext) error {
return func(oc openapigo.OperationContext) error {
// Serve the OpenAPI documentation for the handler
serveOpenAPIFunc(oc)
// If the handler has annotations, skip the collection
if c.collector.HasAnnotation(method, path) {
return nil
}
// Automatically sanitize the method and path
_, _, pathItems, err := openapigo.SanitizeMethodPath(method, path)
if err != nil {
return err
}
// If there are path items, add them to the request structure
if len(pathItems) > 0 {
req := jsonschema.Struct{}
for _, p := range pathItems {
req.Fields = append(req.Fields, jsonschema.Field{
Name: "F" + p,
Tag: reflect.StructTag(`path:"` + p + `"`),
Value: "",
})
}
oc.AddReqStructure(req)
}
return nil
}
}

View File

@@ -15,18 +15,14 @@ const (
var json = jsoniter.ConfigCompatibleWithStandardLibrary
type SuccessResponse struct {
Status string `json:"status"`
Data interface{} `json:"data,omitempty"`
}
type ErrorResponse struct {
type response struct {
Status string `json:"status"`
Error *errors.JSON `json:"error"`
Data interface{} `json:"data,omitempty"`
Error *errors.JSON `json:"error,omitempty"`
}
func Success(rw http.ResponseWriter, httpCode int, data interface{}) {
body, err := json.Marshal(&SuccessResponse{Status: StatusSuccess.s, Data: data})
body, err := json.Marshal(&response{Status: StatusSuccess.s, Data: data})
if err != nil {
Error(rw, err)
return
@@ -68,7 +64,7 @@ func Error(rw http.ResponseWriter, cause error) {
httpCode = http.StatusUnavailableForLegalReasons
}
body, err := json.Marshal(&ErrorResponse{Status: StatusError.s, Error: errors.AsJSON(cause)})
body, err := json.Marshal(&response{Status: StatusError.s, Error: errors.AsJSON(cause)})
if err != nil {
// this should never be the case
http.Error(rw, err.Error(), http.StatusInternalServerError)

View File

@@ -7,7 +7,3 @@ var (
// Defines custom error types
type status struct{ s string }
func (s status) String() string {
return s.s
}

View File

@@ -1,35 +0,0 @@
package metricsexplorer
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
type Config struct {
// TelemetryStore is the telemetrystore configuration
TelemetryStore TelemetryStoreConfig `mapstructure:"telemetrystore"`
}
type TelemetryStoreConfig struct {
// Threads is the number of threads to use for ClickHouse queries
Threads int `mapstructure:"threads"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("metricsexplorer"), newConfig)
}
func newConfig() factory.Config {
return Config{
TelemetryStore: TelemetryStoreConfig{
Threads: 8, // Default value
},
}
}
func (c Config) Validate() error {
if c.TelemetryStore.Threads <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricsexplorer.telemetrystore.threads must be positive, got %d", c.TelemetryStore.Threads)
}
return nil
}

View File

@@ -96,6 +96,7 @@ func (h *handler) UpdateMetricMetadata(rw http.ResponseWriter, req *http.Request
// Set metric name from URL path
in.MetricName = metricName
orgID := valuer.MustNewUUID(claims.OrgID)
err = h.module.UpdateMetricMetadata(req.Context(), orgID, &in)
@@ -137,28 +138,6 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, metadata)
}
func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
metricName := strings.TrimSpace(req.URL.Query().Get("metricName"))
if metricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, highlights)
}
func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -173,10 +152,12 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.GetMetricAttributes(req.Context(), orgID, &in)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}

View File

@@ -21,7 +21,7 @@ func generateMetricMetadataCacheKey(metricName string) string {
func getStatsOrderByColumn(order *qbtypes.OrderBy) (string, string, error) {
if order == nil {
return sqlColumnSamples, qbtypes.OrderDirectionDesc.StringValue(), nil
return sqlColumnTimeSeries, qbtypes.OrderDirectionDesc.StringValue(), nil
}
var columnName string

View File

@@ -2,7 +2,6 @@ package implmetricsexplorer
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
@@ -15,14 +14,12 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"golang.org/x/sync/errgroup"
)
type module struct {
@@ -32,11 +29,10 @@ type module struct {
condBuilder qbtypes.ConditionBuilder
logger *slog.Logger
cache cache.Cache
config metricsexplorer.Config
}
// NewModule constructs the metrics module with the provided dependencies.
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, providerSettings factory.ProviderSettings) metricsexplorer.Module {
fieldMapper := telemetrymetrics.NewFieldMapper()
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
return &module{
@@ -46,7 +42,6 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
logger: providerSettings.Logger,
telemetryMetadataStore: telemetryMetadataStore,
cache: cache,
config: cfg,
}
}
@@ -99,6 +94,7 @@ func (m *module) GetStats(ctx context.Context, orgID valuer.UUID, req *metricsex
}, nil
}
// GetTreemap will return metrics treemap information once implemented.
func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.TreemapRequest) (*metricsexplorertypes.TreemapResponse, error) {
if err := req.Validate(); err != nil {
return nil, err
@@ -110,7 +106,7 @@ func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metrics
}
resp := &metricsexplorertypes.TreemapResponse{}
switch req.Mode {
switch req.Treemap {
case metricsexplorertypes.TreemapModeSamples:
entries, err := m.computeSamplesTreemap(ctx, req, filterWhereClause)
if err != nil {
@@ -194,63 +190,6 @@ func (m *module) UpdateMetricMetadata(ctx context.Context, orgID valuer.UUID, re
return nil
}
// GetMetricHighlights returns highlights for a metric including data points, last received, total time series, and active time series.
func (m *module) GetMetricHighlights(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricHighlightsResponse, error) {
if metricName == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metric name is required")
}
var response metricsexplorertypes.MetricHighlightsResponse
g, gCtx := errgroup.WithContext(ctx)
// Fetch data points
g.Go(func() error {
dataPoints, err := m.getMetricDataPoints(gCtx, metricName)
if err != nil {
return err
}
response.DataPoints = dataPoints
return nil
})
// Fetch last received
g.Go(func() error {
lastReceived, err := m.getMetricLastReceived(gCtx, metricName)
if err != nil {
return err
}
response.LastReceived = lastReceived
return nil
})
// Fetch total time series
g.Go(func() error {
totalTimeSeries, err := m.getTotalTimeSeriesForMetricName(gCtx, metricName)
if err != nil {
return err
}
response.TotalTimeSeries = totalTimeSeries
return nil
})
// Fetch active time series (using 120 minutes as default duration)
g.Go(func() error {
activeTimeSeries, err := m.getActiveTimeSeriesForMetricName(gCtx, metricName, 120*time.Minute)
if err != nil {
return err
}
response.ActiveTimeSeries = activeTimeSeries
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
return &response, nil
}
func (m *module) GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.MetricAttributesRequest) (*metricsexplorertypes.MetricAttributesResponse, error) {
if err := req.Validate(); err != nil {
return nil, err
@@ -308,9 +247,8 @@ func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, me
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
rows, err := db.Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch updated metrics metadata")
}
@@ -354,11 +292,11 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"metric_name",
"anyLast(description) AS description",
"anyLast(type) AS metric_type",
"anyLast(unit) AS metric_unit",
"anyLast(temporality) AS temporality",
"anyLast(is_monotonic) AS is_monotonic",
"ANY_VALUE(description) AS description",
"ANY_VALUE(type) AS metric_type",
"ANY_VALUE(unit) AS metric_unit",
"ANY_VALUE(temporality) AS temporality",
"ANY_VALUE(is_monotonic) AS is_monotonic",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
sb.Where(sb.In("metric_name", args...))
@@ -366,9 +304,8 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
rows, err := db.Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metrics metadata from timeseries table")
}
@@ -452,7 +389,7 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
return err
}
if !hasLabel {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as histogram type: histogram metrics require the 'le' (less than or equal) label for bucket boundaries", req.MetricName)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as histogram type", req.MetricName)
}
}
@@ -462,7 +399,7 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
return err
}
if !hasLabel {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as summary type: summary metrics require the 'quantile' label for quantile values", req.MetricName)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as summary type", req.MetricName)
}
}
@@ -479,10 +416,9 @@ func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, l
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
var hasLabel bool
db := m.telemetryStore.ClickhouseDB()
err := db.QueryRow(valueCtx, query, args...).Scan(&hasLabel)
err := db.QueryRow(ctx, query, args...).Scan(&hasLabel)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "error checking metric label %q", label)
}
@@ -508,9 +444,8 @@ func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, r
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
if err := db.Exec(valueCtx, query, args...); err != nil {
if err := db.Exec(ctx, query, args...); err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to insert metrics metadata")
}
@@ -539,6 +474,7 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
return sqlbuilder.NewWhereClause(), nil
}
// TODO(nikhilmantri0902, srikanthccv): if this is the right way of dealing with whereClauseSelectors
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
for idx := range whereClauseSelectors {
whereClauseSelectors[idx].Signal = telemetrytypes.SignalMetrics
@@ -563,8 +499,8 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
FieldKeys: keys,
}
startNs := querybuilder.ToNanoSecs(uint64(startMillis))
endNs := querybuilder.ToNanoSecs(uint64(endMillis))
startNs := uint64(startMillis * 1_000_000)
endNs := uint64(endMillis * 1_000_000)
whereClause, err := querybuilder.PrepareWhereClause(expression, opts, startNs, endNs)
if err != nil {
@@ -661,9 +597,8 @@ func (m *module) fetchMetricsStatsWithSamples(
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
rows, err := db.Query(ctx, query, args...)
if err != nil {
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute metrics stats with samples query")
}
@@ -731,9 +666,8 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
rows, err := db.Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute timeseries treemap query")
}
@@ -791,7 +725,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
)
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
sampleCountsSB.Where(sampleCountsSB.Between("unix_milli", req.Start, req.End))
sampleCountsSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
sampleCountsSB.Where("metric_name IN (SELECT metric_name FROM __metric_candidates)")
if filterWhereClause != nil {
fingerprintSB := sqlbuilder.NewSelectBuilder()
@@ -801,7 +735,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
fingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
fingerprintSB.Where(fingerprintSB.E("__normalized", false))
fingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
fingerprintSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
fingerprintSB.Where("metric_name IN (SELECT metric_name FROM __metric_candidates)")
fingerprintSB.GroupBy("fingerprint")
sampleCountsSB.Where("fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints)")
@@ -831,9 +765,8 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
rows, err := db.Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute samples treemap query")
}
@@ -855,91 +788,6 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
return entries, nil
}
// getMetricDataPoints returns the total number of data points (samples) for a metric.
func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("sum(count) AS data_points")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4Agg30mTableName))
sb.Where(sb.E("metric_name", metricName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
var dataPoints uint64
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&dataPoints)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get metrics data points")
}
return dataPoints, nil
}
// getMetricLastReceived returns the last received timestamp for a metric.
func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("MAX(last_reported_unix_milli) AS last_received_time")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
sb.Where(sb.E("metric_name", metricName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
var lastReceived sql.NullInt64
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&lastReceived)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get last received timestamp")
}
if !lastReceived.Valid {
return 0, nil
}
return uint64(lastReceived.Int64), nil
}
// getTotalTimeSeriesForMetricName returns the total number of unique time series for a metric.
func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName string) (uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint) AS time_series_count")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
sb.Where(sb.E("metric_name", metricName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
var timeSeriesCount uint64
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&timeSeriesCount)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get total time series count")
}
return timeSeriesCount, nil
}
// getActiveTimeSeriesForMetricName returns the number of active time series for a metric within the given duration.
func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricName string, duration time.Duration) (uint64, error) {
milli := time.Now().Add(-duration).UnixMilli()
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint) AS active_time_series")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
sb.Where(sb.E("metric_name", metricName))
sb.Where(sb.GTE("unix_milli", milli))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
var activeTimeSeries uint64
err := db.QueryRow(valueCtx, query, args...).Scan(&activeTimeSeries)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get active time series count")
}
return activeTimeSeries, nil
}
func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, start, end *int64) ([]metricsexplorertypes.MetricAttribute, error) {
// Build query using sqlbuilder
sb := sqlbuilder.NewSelectBuilder()
@@ -964,11 +812,11 @@ func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, s
sb.GroupBy("attr_name")
sb.OrderBy("valueCount DESC")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
rows, err := db.Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attributes")
}

View File

@@ -15,7 +15,6 @@ type Handler interface {
GetMetricMetadata(http.ResponseWriter, *http.Request)
GetMetricAttributes(http.ResponseWriter, *http.Request)
UpdateMetricMetadata(http.ResponseWriter, *http.Request)
GetMetricHighlights(http.ResponseWriter, *http.Request)
}
// Module represents the metrics module interface.
@@ -24,6 +23,5 @@ type Module interface {
GetTreemap(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.TreemapRequest) (*metricsexplorertypes.TreemapResponse, error)
GetMetricMetadataMulti(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error)
UpdateMetricMetadata(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.UpdateMetricMetadataRequest) error
GetMetricHighlights(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricHighlightsResponse, error)
GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.MetricAttributesRequest) (*metricsexplorertypes.MetricAttributesResponse, error)
}

View File

@@ -6252,6 +6252,17 @@ LIMIT 40`, // added rand to get diff value every time we run this query
return fingerprints, nil
}
func (r *ClickHouseReader) DeleteMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricName string) *model.ApiError {
delQuery := fmt.Sprintf(`ALTER TABLE %s.%s DELETE WHERE metric_name = ?;`, signozMetricDBName, signozUpdatedMetricsMetadataLocalTable)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
err := r.db.Exec(valueCtx, delQuery, metricName)
if err != nil {
return &model.ApiError{Typ: "ClickHouseError", Err: err}
}
r.cache.Delete(ctx, orgID, constants.UpdatedMetricsMetadataCachePrefix+metricName)
return nil
}
func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError {
if req.MetricType == v3.MetricTypeHistogram {
labels := []string{"le"}
@@ -6281,7 +6292,10 @@ func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valu
}
}
// Insert new metadata (keeping history of all updates)
apiErr := r.DeleteMetricsMetadata(ctx, orgID, req.MetricName)
if apiErr != nil {
return apiErr
}
insertQuery := fmt.Sprintf(`INSERT INTO %s.%s (metric_name, temporality, is_monotonic, type, description, unit, created_at)
VALUES ( ?, ?, ?, ?, ?, ?, ?);`, signozMetricDBName, signozUpdatedMetricsMetadataTable)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
@@ -6350,19 +6364,9 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
var stillMissing []string
if len(missingMetrics) > 0 {
metricList := "'" + strings.Join(missingMetrics, "', '") + "'"
query := fmt.Sprintf(`SELECT
metric_name,
argMax(type, created_at) AS type,
argMax(description, created_at) AS description,
argMax(temporality, created_at) AS temporality,
argMax(is_monotonic, created_at) AS is_monotonic,
argMax(unit, created_at) AS unit
FROM %s.%s
WHERE metric_name IN (%s)
GROUP BY metric_name;`,
signozMetricDBName,
signozUpdatedMetricsMetadataTable,
metricList)
query := fmt.Sprintf(`SELECT metric_name, type, description, temporality, is_monotonic, unit
FROM %s.%s
WHERE metric_name IN (%s);`, signozMetricDBName, signozUpdatedMetricsMetadataTable, metricList)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
rows, err := r.db.Query(valueCtx, query)

View File

@@ -94,21 +94,9 @@
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_sum",
"unit": "Count",
"type": "Gauge"
},
{ "name": "aws_ApiGateway_Count_max", "unit": "Count", "type": "Gauge" },
{ "name": "aws_ApiGateway_Count_min", "unit": "Count", "type": "Gauge" },
{ "name": "aws_ApiGateway_Count_sum", "unit": "Count", "type": "Gauge" },
{
"name": "aws_ApiGateway_IntegrationLatency_count",
"unit": "Milliseconds",

View File

@@ -575,12 +575,56 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/user/preferences", am.ViewAccess(aH.Signoz.Handlers.Preference.ListByUser)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/user/preferences/{name}", am.ViewAccess(aH.Signoz.Handlers.Preference.GetByUser)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/user/preferences/{name}", am.ViewAccess(aH.Signoz.Handlers.Preference.UpdateByUser)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/org/preferences", am.AdminAccess(aH.Signoz.Handlers.Preference.ListByOrg)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/org/preferences/{name}", am.AdminAccess(aH.Signoz.Handlers.Preference.GetByOrg)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/org/preferences/{name}", am.AdminAccess(aH.Signoz.Handlers.Preference.UpdateByOrg)).Methods(http.MethodPut)
// Quick Filters
router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetQuickFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSignalFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut)
// === Authentication APIs ===
router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.Signoz.Handlers.User.CreateInvite)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/invite/bulk", am.AdminAccess(aH.Signoz.Handlers.User.CreateBulkInvite)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(aH.Signoz.Handlers.User.GetInvite)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/invite/{id}", am.AdminAccess(aH.Signoz.Handlers.User.DeleteInvite)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.Signoz.Handlers.User.ListInvite)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/invite/accept", am.OpenAccess(aH.Signoz.Handlers.User.AcceptInvite)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/login", am.OpenAccess(aH.Signoz.Handlers.Session.DeprecatedCreateSessionByEmailPassword)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/sessions/email_password", am.OpenAccess(aH.Signoz.Handlers.Session.CreateSessionByEmailPassword)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/sessions/context", am.OpenAccess(aH.Signoz.Handlers.Session.GetSessionContext)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/sessions/rotate", am.OpenAccess(aH.Signoz.Handlers.Session.RotateSession)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/sessions", am.OpenAccess(aH.Signoz.Handlers.Session.DeleteSession)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/complete/google", am.OpenAccess(aH.Signoz.Handlers.Session.CreateSessionByGoogleCallback)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/domains", am.AdminAccess(aH.Signoz.Handlers.AuthDomain.List)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/domains", am.AdminAccess(aH.Signoz.Handlers.AuthDomain.Create)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(aH.Signoz.Handlers.AuthDomain.Update)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(aH.Signoz.Handlers.AuthDomain.Delete)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/pats", am.AdminAccess(aH.Signoz.Handlers.User.CreateAPIKey)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/pats", am.AdminAccess(aH.Signoz.Handlers.User.ListAPIKeys)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(aH.Signoz.Handlers.User.UpdateAPIKey)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(aH.Signoz.Handlers.User.RevokeAPIKey)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/user", am.AdminAccess(aH.Signoz.Handlers.User.ListUsers)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/user/me", am.OpenAccess(aH.Signoz.Handlers.User.GetMyUser)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/user/{id}", am.SelfAccess(aH.Signoz.Handlers.User.GetUser)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/user/{id}", am.SelfAccess(aH.Signoz.Handlers.User.UpdateUser)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/user/{id}", am.AdminAccess(aH.Signoz.Handlers.User.DeleteUser)).Methods(http.MethodDelete)
router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.Signoz.Handlers.Organization.Get)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.Signoz.Handlers.Organization.Update)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/getResetPasswordToken/{id}", am.AdminAccess(aH.Signoz.Handlers.User.GetResetPasswordToken)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/resetPassword", am.OpenAccess(aH.Signoz.Handlers.User.ResetPassword)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/changePassword/{id}", am.SelfAccess(aH.Signoz.Handlers.User.ChangePassword)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, []any{})
@@ -628,8 +672,7 @@ func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.Au
router.HandleFunc("/api/v2/metrics/treemap", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetTreemap)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/attributes", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricAttributes)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/metadata", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricMetadata)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/metrics/{metric_name}/metadata", am.EditAccess(ah.Signoz.Handlers.MetricsExplorer.UpdateMetricMetadata)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metric/highlights", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricHighlights)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/metrics/{metric_name}/metadata", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.UpdateMetricMetadata)).Methods(http.MethodPost)
}
func Intersection(a, b []int) (c []int) {

View File

@@ -223,11 +223,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
api.MetricExplorerRoutes(r, am)
api.RegisterTraceFunnelsRoutes(r, am)
err := s.signoz.APIServer.AddToRouter(r)
if err != nil {
return nil, err
}
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
@@ -238,7 +233,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
handler = handlers.CompressHandler(handler)
err = web.AddToRouter(r)
err := web.AddToRouter(r)
if err != nil {
return nil, err
}

View File

@@ -127,6 +127,7 @@ type Reader interface {
GetInspectMetricsFingerprints(ctx context.Context, attributes []string, req *metrics_explorer.InspectMetricsRequest) ([]string, *model.ApiError)
GetInspectMetrics(ctx context.Context, req *metrics_explorer.InspectMetricsRequest, fingerprints []string) (*metrics_explorer.InspectMetricsResponse, *model.ApiError)
DeleteMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricName string) *model.ApiError
UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError
GetUpdatedMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricNames ...string) (map[string]*model.UpdateMetricsMetadata, *model.ApiError)

View File

@@ -198,6 +198,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
FieldMapper: v.fieldMapper,
ConditionBuilder: v.conditionBuilder,
FullTextColumn: v.fullTextColumn,
JsonBodyPrefix: v.jsonBodyPrefix,
JsonKeyToKey: v.jsonKeyToKey,
}, 0, 0,
)

View File

@@ -1,17 +0,0 @@
package querybuilder
import (
"os"
)
var (
BodyJSONQueryEnabled = GetOrDefaultEnv("BODY_JSON_QUERY_ENABLED", "false") == "true"
)
func GetOrDefaultEnv(key string, fallback string) string {
v := os.Getenv(key)
if len(v) == 0 {
return fallback
}
return v
}

View File

@@ -7,6 +7,7 @@ import (
)
func TestQueryToKeys(t *testing.T) {
testCases := []struct {
query string
expectedKeys []telemetrytypes.FieldKeySelector
@@ -65,9 +66,9 @@ func TestQueryToKeys(t *testing.T) {
query: `body.user_ids[*] = 123`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "user_ids[*]",
Name: "body.user_ids[*]",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextBody,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},

View File

@@ -162,6 +162,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
ConditionBuilder: b.conditionBuilder,
FieldKeys: keys,
FullTextColumn: b.fullTextColumn,
JsonBodyPrefix: b.jsonBodyPrefix,
JsonKeyToKey: b.jsonKeyToKey,
SkipFullTextFilter: true,
SkipFunctionCalls: true,

View File

@@ -33,6 +33,7 @@ type filterExpressionVisitor struct {
mainErrorURL string
builder *sqlbuilder.SelectBuilder
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonBodyPrefix string
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
skipResourceFilter bool
skipFullTextFilter bool
@@ -52,6 +53,7 @@ type FilterExprVisitorOpts struct {
FieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
Builder *sqlbuilder.SelectBuilder
FullTextColumn *telemetrytypes.TelemetryFieldKey
JsonBodyPrefix string
JsonKeyToKey qbtypes.JsonKeyToFieldFunc
SkipResourceFilter bool
SkipFullTextFilter bool
@@ -71,6 +73,7 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
fieldKeys: opts.FieldKeys,
builder: opts.Builder,
fullTextColumn: opts.FullTextColumn,
jsonBodyPrefix: opts.JsonBodyPrefix,
jsonKeyToKey: opts.JsonKeyToKey,
skipResourceFilter: opts.SkipResourceFilter,
skipFullTextFilter: opts.SkipFullTextFilter,
@@ -170,7 +173,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts, startNs uint64
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
return &PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
return &PreparedWhereClause{whereClause, visitor.warnings, visitor.mainWarnURL}, nil
}
// Visit dispatches to the specific visit method based on node type
@@ -715,7 +718,7 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
conds = append(conds, fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", key.Name, v.builder.Var(value[0])))
} else {
// this is that all other functions only support array fields
if key.FieldContext == telemetrytypes.FieldContextBody {
if strings.HasPrefix(key.Name, v.jsonBodyPrefix) {
fieldName, _ = v.jsonKeyToKey(context.Background(), key, qbtypes.FilterOperatorUnknown, value)
} else {
// TODO(add docs for json body search)
@@ -806,8 +809,10 @@ func (v *filterExpressionVisitor) VisitValue(ctx *grammar.ValueContext) any {
// VisitKey handles field/column references
func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(ctx.GetText())
keyName := fieldKey.Name
keyName := strings.TrimPrefix(fieldKey.Name, v.jsonBodyPrefix)
fieldKeysForName := v.fieldKeys[keyName]
@@ -841,11 +846,10 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
// if there is a field with the same name as attribute/resource attribute
// Since it will ORed with the fieldKeysForName, it will not result empty
// when either of them have values
// Note: Skip this logic if body json query is enabled so we can look up the key inside fields
//
// TODO(Piyush): After entire migration this is supposed to be removed.
if !BodyJSONQueryEnabled && fieldKey.FieldContext == telemetrytypes.FieldContextBody {
fieldKeysForName = append(fieldKeysForName, &fieldKey)
if strings.HasPrefix(fieldKey.Name, v.jsonBodyPrefix) && v.jsonBodyPrefix != "" {
if keyName != "" {
fieldKeysForName = append(fieldKeysForName, &fieldKey)
}
}
if len(fieldKeysForName) == 0 {
@@ -856,7 +860,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
return v.fieldKeys[keyWithContext]
}
if fieldKey.FieldContext == telemetrytypes.FieldContextBody && keyName == "" {
if strings.HasPrefix(fieldKey.Name, v.jsonBodyPrefix) && v.jsonBodyPrefix != "" && keyName == "" {
v.errors = append(v.errors, "missing key for body json search - expected key of the form `body.key` (ex: `body.status`)")
} else if !v.ignoreNotFoundKeys {
// TODO(srikanthccv): do we want to return an error here?

View File

@@ -19,7 +19,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
@@ -98,9 +97,6 @@ type Config struct {
// Tokenizer config
Tokenizer tokenizer.Config `mapstructure:"tokenizer"`
// MetricsExplorer config
MetricsExplorer metricsexplorer.Config `mapstructure:"metricsexplorer"`
}
// DeprecatedFlags are the flags that are deprecated and scheduled for removal.
@@ -160,7 +156,6 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
statsreporter.NewConfigFactory(),
gateway.NewConfigFactory(),
tokenizer.NewConfigFactory(),
metricsexplorer.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)
@@ -341,12 +336,12 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge
}
}
func (config Config) Collect(_ context.Context, _ valuer.UUID) (map[string]any, error) {
func (config Config)Collect(_ context.Context, _ valuer.UUID) (map[string]any, error){
stats := make(map[string]any)
// SQL Store Config Stats
stats["config.sqlstore.provider"] = config.SQLStore.Provider
// Tokenizer Config Stats
stats["config.tokenizer.provider"] = config.Tokenizer.Provider

View File

@@ -5,10 +5,16 @@ import (
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/apdex"
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
@@ -17,20 +23,29 @@ import (
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
"github.com/SigNoz/signoz/pkg/modules/services"
"github.com/SigNoz/signoz/pkg/modules/services/implservices"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
)
type Handlers struct {
Organization organization.Handler
Preference preference.Handler
User user.Handler
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
AuthDomain authdomain.Handler
Session session.Handler
SpanPercentile spanpercentile.Handler
Services services.Handler
MetricsExplorer metricsexplorer.Handler
@@ -38,12 +53,17 @@ type Handlers struct {
func NewHandlers(modules Modules, providerSettings factory.ProviderSettings, querier querier.Querier, licensing licensing.Licensing) Handlers {
return Handlers{
Organization: implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
Preference: implpreference.NewHandler(modules.Preference),
User: impluser.NewHandler(modules.User, modules.UserGetter),
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings, querier, licensing),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
AuthDomain: implauthdomain.NewHandler(modules.AuthDomain),
Session: implsession.NewHandler(modules.Session),
Services: implservices.NewHandler(modules.Services),
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),

View File

@@ -36,7 +36,7 @@ func TestNewHandlers(t *testing.T) {
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, nil)

View File

@@ -79,7 +79,6 @@ func NewModules(
authNs map[authtypes.AuthNProvider]authn.AuthN,
authz authz.AuthZ,
cache cache.Cache,
config Config,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -102,6 +101,6 @@ func NewModules(
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, providerSettings, config.MetricsExplorer),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, providerSettings),
}
}

View File

@@ -36,7 +36,7 @@ func TestNewModules(t *testing.T) {
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -1,83 +0,0 @@
package signoz
import (
"context"
"os"
"reflect"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/apiserver/signozapiserver"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/swaggest/jsonschema-go"
"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
)
type OpenAPI struct {
apiserver apiserver.APIServer
reflector *openapi3.Reflector
collector *handler.OpenAPICollector
}
func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumentation) (*OpenAPI, error) {
apiserver, err := signozapiserver.NewFactory(
struct{ organization.Getter }{},
struct{ authz.AuthZ }{},
struct{ organization.Handler }{},
struct{ user.Handler }{},
struct{ session.Handler }{},
struct{ authdomain.Handler }{},
struct{ preference.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err
}
reflector := openapi3.NewReflector()
reflector.JSONSchemaReflector().DefaultOptions = append(reflector.JSONSchemaReflector().DefaultOptions, jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
if defaultDefName == "RenderSuccessResponse" {
field, ok := t.FieldByName("Data")
if !ok {
return defaultDefName
}
return field.Type.Name()
}
return defaultDefName
}))
reflector.SpecSchema().SetTitle("SigNoz")
reflector.SpecSchema().SetDescription("OpenTelemetry-Native Logs, Metrics and Traces in a single pane")
reflector.SpecSchema().SetAPIKeySecurity(ctxtypes.AuthTypeAPIKey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys")
reflector.SpecSchema().SetHTTPBearerTokenSecurity(ctxtypes.AuthTypeTokenizer.StringValue(), "Tokenizer", "Tokens generated by the tokenizer")
collector := handler.NewOpenAPICollector(reflector)
return &OpenAPI{
apiserver: apiserver,
reflector: reflector,
collector: collector,
}, nil
}
func (openapi *OpenAPI) CreateAndWrite(path string) error {
if err := openapi.apiserver.Router().Walk(openapi.collector.Walker); err != nil {
return err
}
spec, err := openapi.reflector.Spec.MarshalYAML()
if err != nil {
return err
}
return os.WriteFile(path, spec, 0o600)
}

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