Compare commits

..

7 Commits

Author SHA1 Message Date
nikhilmantri0902
7107143fc3 fix(alertmanager): remove threading from google chat notifier 2026-08-18 21:55:09 +05:30
Gaurav Tewari
7bcfaab35e feat: add override can edit dashboard (#12580)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- `DashboardContainer` now takes an optional `overrideCanEditDashboard`
prop that forces a dashboard into read-only mode, independent of the
viewer's role o
- It is typed as `false` on purpose: the prop can only take edit rights
away, never grant them, so it can't be used to slip past the existing
permission checks. When it isn't passed, behaviour is unchanged
(`overrideCanEditDashboard ?? canEditDashboard`), so no other dashboard
is affected.
- LLM Observability's Overview passes `overrideCanEditDashboard={false}`
so its built-in dashboard stays view-only, and the dashboard JSON's
`locked` flag goes back to `false` since the lock is no longer what
makes it read-only.
- `DashboardActions` also gates the Lock / Unlock menu item behind
`canEditDashboard`, so a read-only dashboard no longer offers an action
that would let the viewer flip its lock state.
- The prop is marked `@deprecated` with a TODO 

#### Issues closed by this PR

Covers the read-only dashboard requirement discussed in
SigNoz/engineering-pod#5920.

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/e49bf5f7-ca36-4353-be47-f6ca80a2f0d2


#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-18 14:58:51 +00:00
Vinicius Lourenço
5b62b31d34 fix(infrastructure-monitoring): preserving staled params after navigation (#12585)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Similar to bug fixed at
83a6ed46e9,
this caused an incident at
https://github.com/SigNoz/platform-pod/issues/3011 that causes the
navigation to be back to pods after going to nodes category.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Related to https://github.com/SigNoz/platform-pod/issues/3011

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before:


https://github.com/user-attachments/assets/ad9b70fb-350a-48de-8846-37a75457766e

After:


https://github.com/user-attachments/assets/5c6095b5-a387-4c28-adff-be0b45c993ef

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

Because this issue is caused by de-sync between nuqs/react-router, this
is just a temporary fix, the best fix is to migrate to react-router to
v6.
2026-08-18 14:30:33 +00:00
Manika Malhotra
f6a9b4b1f6 feat(changelog): make links open in new tab (#12557)
#### Description

Currently, any external links such as link to the SigNoz docs open in
the same tab when linked in the changelog, taking the user away from the
changelog and the in-app context.

Opening the links in new tabs allows users to check out external sources
without losing the current context.

#### Issues closed by this PR

Closes https://github.com/SigNoz/growth-pod/issues/1267 


#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/3f3b19b2-c914-450e-b5a2-ae30ed78cb30


#### Additional Information

I checked if changing how we create changelog entries only can work, but
failed, markdown method to open in new tab didn't work
https://stackoverflow.com/questions/75633163/how-can-i-use-target-blank-in-an-external-link-on-mdx
2026-08-18 13:32:49 +00:00
Aditya Singh
b46f099966 fix(log-details): prevent scrollbar click closing log details drawer (#12603)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Prevent log details drawer closing on clicking on scrollbar

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5934
<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
Before


https://github.com/user-attachments/assets/b59e2a64-951e-49eb-9b05-ae39f00991ae



After



https://github.com/user-attachments/assets/5eb89c95-fb76-49b7-9928-1bd13df16002
2026-08-18 12:24:31 +00:00
Aditya Singh
fcfc1923c3 feat(log-details): support json view for nested attributes + enable new log details (#12597)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
This PR adds functionality on top of the new log details drawer changes.
- Show JSON view for nested attributes instead of stringified json.
These dont support filter/group-by right now
- Change `resources.*` to `resource.*` to match traces and otel
convention
- remove groupBy for fields nested or not matching the following names:
'trace_id' and 'body'
- enables new log details experience for all users


<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5921
<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/876fb331-015d-42bb-83c7-5ff543ddadec
2026-08-18 11:26:43 +00:00
Gaurav Tewari
dc836bb67c chore: update dependencies resolution for e2e (#12594)
#### Description

- `tests/e2e` is a standalone pnpm project with no overrides of its own,
so `eslint-plugin-playwright > eslint > minimatch` resolved a vulnerable
`brace-expansion@5.0.5` (4 advisories, incl. CVE-2026-13149).
- Adds `tests/e2e/pnpm-workspace.yaml` flooring it to `>=5.0.9 <6`,
which stays inside `minimatch@10.2.5`'s `^5.0.5` range — no breaking
bump, and bumping minimatch instead wouldn't help (10.2.6 only widens to
`^5.0.8`).
- `pnpm audit` in `tests/e2e` now reports no known vulnerabilities.
- This also resolves the vulnerabilities reported by vanta


#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=230120135&issue=SigNoz%7Cengineering-pod%7C5925


#### Screenshots / Screen Recordings

#### Additional Information

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-18 10:29:46 +00:00
41 changed files with 358 additions and 229 deletions

View File

@@ -1,30 +0,0 @@
#!/bin/sh
# Installs dispatcher stubs into the repository's hooks directory. Safe to run
# repeatedly; invoked automatically from the Makefile and from the frontend
# postinstall so no manual setup is required.
set -e
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# core.hooksPath shadows the hooks directory git would otherwise use, and an
# absolute value leaks one worktree's path into every other worktree.
if [ -n "$(git config --get core.hooksPath 2>/dev/null || true)" ]; then
git config --unset-all core.hooksPath 2>/dev/null || true
fi
# Resolves to the common directory for linked worktrees, which is where git
# looks for hooks regardless of which worktree the commit happens in.
hooks_dir="$(git rev-parse --git-path hooks)"
mkdir -p "$hooks_dir"
for hook in pre-commit commit-msg; do
cat >"$hooks_dir/$hook" <<EOF
#!/bin/sh
# Generated by .githooks/install.
root="\$(git rev-parse --show-toplevel)"
[ -x "\$root/.githooks/$hook" ] || exit 0
exec "\$root/.githooks/$hook" "\$@"
EOF
chmod +x "$hooks_dir/$hook"
done

View File

@@ -1,22 +0,0 @@
#!/bin/sh
set -e
staged_go_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.go')
if [ -n "$staged_go_files" ]; then
if command -v gofmt >/dev/null 2>&1; then
echo "$staged_go_files" | xargs gofmt -l -w
echo "$staged_go_files" | xargs git add
else
echo "pre-commit: gofmt not found, skipping go formatting" >&2
fi
fi
staged_web_files=$(git diff --cached --name-only --diff-filter=ACM -- 'frontend/*')
if [ -n "$staged_web_files" ]; then
if [ -d frontend/node_modules ] && command -v pnpm >/dev/null 2>&1; then
cd frontend && pnpm lint-staged
else
echo "pre-commit: frontend dependencies not installed, skipping lint-staged" >&2
fi
fi

View File

@@ -35,13 +35,6 @@ DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
JS_BUILD_CONTEXT = $(SRC)/frontend
##############################################################
# git hooks
##############################################################
# Hooks live outside the working tree, so they cannot be checked in. Expanded
# while this file is parsed, which wires them up on any make invocation.
GITHOOKS_INSTALL := $(shell sh $(SRC)/.githooks/install >/dev/null 2>&1 || true)
##############################################################
# directories
##############################################################

View File

@@ -1,12 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
set -e
if [ -d frontend/node_modules ] && command -v pnpm >/dev/null 2>&1; then
(cd frontend && pnpm run commitlint --edit "$1")
else
echo "commit-msg: frontend dependencies not installed, skipping commitlint" >&2
fi
cd frontend && pnpm run commitlint --edit $1
branch="$(git rev-parse --abbrev-ref HEAD)"

4
frontend/.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && pnpm lint-staged

View File

@@ -20,7 +20,8 @@
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
"postinstall": "pnpm i18n:generate-hash && (is-ci || sh ../.githooks/install) && node scripts/update-registry.cjs",
"postinstall": "pnpm i18n:generate-hash && (is-ci || pnpm husky:configure) && node scripts/update-registry.cjs",
"husky:configure": "cd .. && husky install frontend/.husky && cd frontend && chmod ug+x .husky/*",
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
@@ -185,6 +186,7 @@
"@typescript/native-preview": "7.0.0-dev.20260430.1",
"eslint-plugin-sonarjs": "4.0.2",
"glob": "^13.0.6",
"husky": "^7.0.4",
"is-ci": "^3.0.1",
"jest-environment-jsdom": "29.7.0",
"jest-styled-components": "^7.2.0",

View File

@@ -440,6 +440,9 @@ importers:
glob:
specifier: ^13.0.6
version: 13.0.6
husky:
specifier: ^7.0.4
version: 7.0.4
is-ci:
specifier: ^3.0.1
version: 3.0.1
@@ -5526,6 +5529,11 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
husky@7.0.4:
resolution: {integrity: sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==}
engines: {node: '>=12'}
hasBin: true
hyphenate-style-name@1.0.4:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
@@ -14628,6 +14636,8 @@ snapshots:
human-signals@8.0.1: {}
husky@7.0.4: {}
hyphenate-style-name@1.0.4: {}
i18next-browser-languagedetector@6.1.8:

View File

@@ -8,12 +8,19 @@ import {
import ChangelogRenderer from '../components/ChangelogRenderer';
// Mock react-markdown to just render children as plain text
// Mock react-markdown to render children as plain text and a sample
// anchor through the `components.a` override
jest.mock(
'react-markdown',
() =>
function ReactMarkdown({ children }: any) {
return <div>{children}</div>;
function ReactMarkdown({ children, components }: any) {
const Anchor = components?.a;
return (
<div>
{children}
{Anchor && <Anchor href="https://signoz.io/docs">docs</Anchor>}
</div>
);
},
);
@@ -62,4 +69,14 @@ describe('ChangelogRenderer', () => {
expect(screen.getByAltText('Media')).toBeInTheDocument();
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
});
it('renders markdown links that open in a new tab', () => {
render(<ChangelogRenderer changelog={mockChangelog} />);
const links = screen.getAllByRole('link', { name: 'docs' });
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
});

View File

@@ -13,6 +13,19 @@ interface Props {
changelog: ChangelogSchema;
}
interface LinkProps {
href?: string;
children?: React.ReactNode;
}
function Link({ href, children }: LinkProps): JSX.Element {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
function renderMedia(media: Media): JSX.Element | null {
if (SupportedImageTypes.includes(media.ext)) {
return (
@@ -62,7 +75,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div key={feature.id}>
<div className="changelog-renderer-section-title">{feature.title}</div>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown>{feature.description}</ReactMarkdown>
<ReactMarkdown components={{ a: Link }}>
{feature.description}
</ReactMarkdown>
</div>
))}
</div>
@@ -71,7 +86,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-bug-fixes">
<div className="changelog-renderer-section-title">Bug Fixes</div>
{changelog.bug_fixes && (
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
<ReactMarkdown components={{ a: Link }}>
{changelog.bug_fixes}
</ReactMarkdown>
)}
</div>
)}
@@ -79,7 +96,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-maintenance">
<div className="changelog-renderer-section-title">Maintenance</div>
{changelog.maintenance && (
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
<ReactMarkdown components={{ a: Link }}>
{changelog.maintenance}
</ReactMarkdown>
)}
</div>
)}

View File

@@ -1,9 +1,5 @@
import getLocalStorage from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
// Temp feature flag before actual roll-out
export const isLogDetailsV2 =
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
// temporary flag to be removed with old log details code.
export const isLogDetailsV2 = true;
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',

View File

@@ -100,6 +100,7 @@ function LogDetailInner({
// Don't close if clicking on drawer content, overlays, or portal elements
if (
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.log-detail-drawer') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover') ||

View File

@@ -13,7 +13,6 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -37,7 +37,7 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { QueryParams } from 'constants/query';
const EXPANDED_ROW_LIMIT = 10;

View File

@@ -9,7 +9,11 @@ function Overview(): JSX.Element {
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<DashboardContainer dashboard={dashboard} refetch={refetch} />
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
{
"id": "llm-observability-overview",
"orgId": "",
"locked": true,
"locked": false,
"name": "AI Observability Overview",
"schemaVersion": "v6",
"source": "system",
@@ -1146,4 +1146,4 @@
}
]
}
}
}

View File

@@ -23,9 +23,9 @@ import { useLogAttributeActions } from './hooks/useLogAttributeActions';
import TableView from './TableView';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
getBodyDisplayString,
getSanitizedLogBody,
parseJsonStringBody,
removeEscapeCharacters,
} from './utils';
@@ -71,11 +71,7 @@ function Overview({
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = Object.fromEntries(
Object.entries({ ...raw, body: parseJsonStringBody(raw.body) }).filter(
([, value]) => value !== undefined,
),
);
const prettyData = buildPrettyViewData(raw);
return (
<div className="overview-container">
<DataViewer

View File

@@ -1,6 +1,6 @@
export enum LogAttributeBucket {
ATTRIBUTES = 'attributes',
RESOURCES = 'resources',
RESOURCES = 'resource',
SCOPE = 'scope',
}

View File

@@ -33,7 +33,7 @@ describe('buildLogFilterTarget', () => {
it('maps `resources` with Resource type', () => {
expect(
buildLogFilterTarget(['resources', 'service.name'], 'api', true),
buildLogFilterTarget(['resource', 'service.name'], 'api', true),
).toMatchObject({
fieldKey: 'service.name',
metricsType: MetricsType.Resource,
@@ -53,6 +53,30 @@ describe('buildLogFilterTarget', () => {
});
});
describe('nested attribute values (parsed JSON)', () => {
it('marks a sub-field of a parsed attribute copy-only (restricted, no group-by)', () => {
const t = buildLogFilterTarget(['attributes', 'payload', 'x'], 1, true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it('leaves a top-level attribute (depth 2) filterable', () => {
const t = buildLogFilterTarget(['attributes', 'payload'], 'v', true);
expect(t.isRestricted).toBe(false);
expect(t.groupBySupported).toBe(true);
});
it('does not restrict nested resource/scope values', () => {
expect(
buildLogFilterTarget(['resource', 'k8s', 'pod'], 'p', true).isRestricted,
).toBe(false);
expect(
buildLogFilterTarget(['scope', 'a', 'b'], 'v', true).isRestricted,
).toBe(false);
});
});
describe('restricted fields (timestamp / id)', () => {
it.each(['timestamp', 'id'])(
'marks %s restricted with no group-by',
@@ -65,6 +89,30 @@ describe('buildLogFilterTarget', () => {
);
});
describe('group-by-restricted fields (trace_id)', () => {
it('allows filtering but not group-by on top-level trace_id', () => {
const t = buildLogFilterTarget(['trace_id'], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it.each([
['resource', ['resource', 'trace_id']],
['attributes', ['attributes', 'trace_id']],
])(
'blocks group-by on a %s field named trace_id, keeping filter',
(_bucket, path) => {
const t = buildLogFilterTarget(path as string[], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
},
);
});
describe('body scalars', () => {
it('maps a top-level body scalar to body.<key> with =/!=, groupable when json body on', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', true);

View File

@@ -5,7 +5,10 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import {
RESTRICTED_GROUP_BY_FIELDS,
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
@@ -83,15 +86,24 @@ export const buildLogFilterTarget = (
if (root !== 'body') {
const fieldKey =
fieldKeyPath.length > 1 ? fieldKeyPath.slice(1).join('.') : String(root);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(fieldKey);
// Temporarily removing filter/group-by support for nested attributes.
// This will be removed once backend starts to support these actions.
const isNestedAttributeValue =
root === LogAttributeBucket.ATTRIBUTES && fieldKeyPath.length > 2;
const isRestricted =
RESTRICTED_SELECTED_FIELDS.includes(fieldKey) || isNestedAttributeValue;
const groupBySupported =
!isRestricted && !RESTRICTED_GROUP_BY_FIELDS.includes(fieldKey);
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
metricsType: metricsTypeForRoot(root),
groupBySupported: !isRestricted,
groupByKey: isRestricted ? undefined : fieldKey,
groupBySupported,
groupByKey: groupBySupported ? fieldKey : undefined,
isRestricted,
};
}

View File

@@ -3,45 +3,79 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
flattenObject,
getDataTypes,
getSanitizedLogBody,
parseJsonStringBody,
parseJsonStringValue,
recursiveParseJSON,
} from './utils';
describe('parseJsonStringBody', () => {
describe('parseJsonStringValue', () => {
it('parses a JSON-object string into an object', () => {
expect(parseJsonStringBody('{"a":1,"b":{"c":2}}')).toStrictEqual({
expect(parseJsonStringValue('{"a":1,"b":{"c":2}}')).toStrictEqual({
a: 1,
b: { c: 2 },
});
});
it('parses a JSON-array string into an array', () => {
expect(parseJsonStringBody('[1,2,3]')).toStrictEqual([1, 2, 3]);
expect(parseJsonStringValue('[1,2,3]')).toStrictEqual([1, 2, 3]);
});
it('returns a plain (non-JSON) string unchanged', () => {
expect(parseJsonStringBody('plain log line')).toBe('plain log line');
expect(parseJsonStringValue('plain log line')).toBe('plain log line');
});
it('returns a string that is not object/array-looking unchanged', () => {
expect(parseJsonStringBody('42')).toBe('42');
expect(parseJsonStringValue('42')).toBe('42');
});
it('returns an invalid JSON string unchanged', () => {
expect(parseJsonStringBody('{not valid}')).toBe('{not valid}');
expect(parseJsonStringValue('{not valid}')).toBe('{not valid}');
});
it('returns an already-object body unchanged (same reference)', () => {
const body = { message: 'hi', a: 1 };
expect(parseJsonStringBody(body)).toBe(body);
it('returns an already-object value unchanged (same reference)', () => {
const value = { message: 'hi', a: 1 };
expect(parseJsonStringValue(value)).toBe(value);
});
it('leaves a body larger than the 128KB parse guard as a string', () => {
it('leaves a value larger than the 128KB parse guard as a string', () => {
const huge = `{"x":"${'a'.repeat(130 * 1024)}"}`;
expect(parseJsonStringBody(huge)).toBe(huge);
expect(parseJsonStringValue(huge)).toBe(huge);
});
});
describe('buildPrettyViewData', () => {
const baseRaw = {
id: 'log-1',
timestamp: 1234,
body: 'hello',
attributes: {},
resource: {},
scope: {},
} as any;
it('parses a JSON-string body into a tree', () => {
const result = buildPrettyViewData({ ...baseRaw, body: '{"a":1}' });
expect(result.body).toStrictEqual({ a: 1 });
});
it('parses attribute values that are JSON strings, leaves others as-is', () => {
const result = buildPrettyViewData({
...baseRaw,
attributes: { payload: '{"x":1}', name: 'cart', count: 3 },
});
expect(result.attributes).toStrictEqual({
payload: { x: 1 },
name: 'cart',
count: 3,
});
});
it('drops undefined fields so they do not render as empty rows', () => {
const result = buildPrettyViewData({ ...baseRaw, trace_id: undefined });
expect('trace_id' in result).toBe(false);
});
});
@@ -65,7 +99,7 @@ describe('aggregateAttributesResourcesToObject', () => {
'http.method': 'GET',
retries: 3,
});
expect(result.resources).toStrictEqual({ 'service.name': 'cart' });
expect(result.resource).toStrictEqual({ 'service.name': 'cart' });
expect(result.scope).toStrictEqual({ lib: 'otel' });
expect(result.body).toBe('hello');
expect(result.id).toBe('log-1');

View File

@@ -276,7 +276,7 @@ export const aggregateAttributesResourcesToObject = (
traceFlags: logData.traceFlags,
traceId: logData.traceId,
attributes: {},
resources: {},
resource: {},
scope: {},
severity_text: logData.severity_text,
severity_number: logData.severity_number,
@@ -290,8 +290,8 @@ export const aggregateAttributesResourcesToObject = (
outputJson.attributes = outputJson.attributes || {};
Object.assign(outputJson.attributes, logData[key as keyof ILog]);
} else if (key.startsWith('resources_')) {
outputJson.resources = outputJson.resources || {};
Object.assign(outputJson.resources, logData[key as keyof ILog]);
outputJson.resource = outputJson.resource || {};
Object.assign(outputJson.resource, logData[key as keyof ILog]);
} else if (key.startsWith('scope_string')) {
outputJson.scope = outputJson.scope || {};
Object.assign(outputJson.scope, logData[key as keyof ILog]);
@@ -315,30 +315,57 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
}
};
const MAX_JSON_BODY_PARSE_BYTES = 128 * 1024;
const MAX_JSON_PARSE_BYTES = 128 * 1024;
// A JSON-encoded object/array `body` is parsed so DataViewer renders it as a
// tree instead of one escaped string; plain-text bodies are returned unchanged.
// A JSON-encoded object/array string is parsed so DataViewer renders it as a tree
// instead of one escaped string; non-JSON / plain-text values are returned unchanged.
// Guarded against very large payloads.
export const parseJsonStringBody = (body: ILog['body']): ILog['body'] => {
if (typeof body !== 'string') {
return body;
export const parseJsonStringValue = (value: unknown): unknown => {
if (typeof value !== 'string') {
return value;
}
const trimmed = body.trim();
const trimmed = value.trim();
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[');
if (!looksLikeJson || trimmed.length > MAX_JSON_BODY_PARSE_BYTES) {
return body;
if (!looksLikeJson || trimmed.length > MAX_JSON_PARSE_BYTES) {
return value;
}
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === 'object'
? (parsed as ILogBody)
: body;
return parsed !== null && typeof parsed === 'object' ? parsed : value;
} catch {
return body;
return value;
}
};
// Parse each attribute value that's a stringified JSON string into an object
// Non-JSON values are left unchanged.
const parseAttributeJsonValues = (
attributes: Record<string, unknown>,
): Record<string, unknown> => {
const parsed: Record<string, unknown> = {};
Object.keys(attributes).forEach((key) => {
parsed[key] = parseJsonStringValue(attributes[key]);
});
return parsed;
};
export const buildPrettyViewData = (
raw: ILogAggregateAttributesResources,
): Record<string, unknown> => {
const prettyData: Record<string, unknown> = { ...raw };
prettyData.body = parseJsonStringValue(raw.body);
prettyData.attributes = parseAttributeJsonValues(raw.attributes);
// drop undefined fields so they don't render as empty rows
Object.keys(prettyData).forEach((key) => {
if (prettyData[key] === undefined) {
delete prettyData[key];
}
});
return prettyData;
};
const isFloat = (num: number): boolean => num % 1 !== 0;
const isBooleanString = (str: string): boolean =>

View File

@@ -2,6 +2,9 @@ import { blue, red } from '@ant-design/colors';
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
// Fields that can be filtered on but not grouped by in the log details view.
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
export const ICON_STYLE = {
PLUS: { color: blue[5] },
CLOSE: { color: red[5] },

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from '../utils/getUnstableCurrentSearchParams';
} from 'utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from '../utils/getUnstableCurrentSearchParams';
} from 'utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -24,7 +24,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from '../utils/getUnstableCurrentSearchParams';
} from 'utils/getUnstableCurrentSearchParams';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -5,7 +5,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from '../utils/getUnstableCurrentSearchParams';
} from 'utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from '../utils/getUnstableCurrentSearchParams';
} from 'utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper, createMockMoment } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -54,7 +54,7 @@ import {
Time,
TimeRange,
} from './types';
import { getUnstableCurrentSearchParams } from './utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import './DateTimeSelectionV2.styles.scss';

View File

@@ -189,7 +189,8 @@ function DashboardActions({
onClick: (): void => void handleClone(),
});
}
if (isAuthor || user.role === USER_ROLES.ADMIN) {
if (canEditDashboard && (isAuthor || user.role === USER_ROLES.ADMIN)) {
dashboardGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',

View File

@@ -46,23 +46,11 @@ beforeAll(() => {
});
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest
@@ -204,9 +192,12 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>

View File

@@ -16,23 +16,11 @@ import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
@@ -150,9 +138,12 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>

View File

@@ -14,23 +14,11 @@ import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
@@ -184,9 +172,12 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<CompatRouter>
<QueryBuilderProvider>
<Harness />

View File

@@ -19,11 +19,20 @@ import { resolveDashboardImage } from 'pages/DashboardPageV2/DashboardContainer/
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
/**
* @deprecated
* `canEditDashboardOverride` is a temporary solution to allow the dashboard to be view only.
* This is only used for LLM Observability.
* It will be removed in the future.
* TODO: @Ashwin / @Abhi — remove when the final solution is implemented.
*/
canEditDashboardOverride?: boolean;
}
function DashboardContainer({
dashboard,
refetch,
canEditDashboardOverride,
}: DashboardContainerProps): JSX.Element {
const spec = dashboard.spec;
const image = resolveDashboardImage(dashboard.image);
@@ -45,10 +54,11 @@ function DashboardContainer({
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard,
canEditDashboard: canEditDashboardOverride ?? canEditDashboard,
refetch,
});

View File

@@ -37,7 +37,6 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -66,6 +65,7 @@ import {
} from 'types/common/queryBuilder';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
import { v4 as uuid } from 'uuid';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
currentQuery: initialQueriesMap.metrics,
@@ -105,7 +105,6 @@ export const QueryBuilderContext = createContext<QueryBuilderContextType>({
export function QueryBuilderProvider({
children,
}: PropsWithChildren): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const currentPathnameRef = useRef<string | null>(location.pathname);
@@ -122,7 +121,7 @@ export function QueryBuilderProvider({
null,
);
const panelTypeQueryParams = urlQuery.get(
const panelTypeQueryParams = getUnstableCurrentSearchParams().get(
QueryParams.panelTypes,
) as PANEL_TYPES | null;
@@ -976,6 +975,7 @@ export function QueryBuilderProvider({
unit: query.unit || initialQueryState.unit,
};
const urlQuery = getUnstableCurrentSearchParams();
const pagination = urlQuery.get(QueryParams.pagination);
if (pagination) {
@@ -1014,7 +1014,7 @@ export function QueryBuilderProvider({
safeNavigate(generatedUrl, { newTab });
},
[location.pathname, safeNavigate, urlQuery],
[location.pathname, safeNavigate],
);
const handleSetConfig = useCallback(

View File

@@ -0,0 +1,54 @@
// Mock factory for suites that need `useSafeNavigate` to navigate for real.
//
// `jest.config.ts` maps every `hooks/useSafeNavigate` import to the no-op
// `__tests__/safeNavigateMock.ts`, so a suite that drives navigation has to opt
// out with its own `jest.mock`.
//
// In production `safeNavigate` goes through `createBrowserHistory`, which writes
// `window.location` as well as notifying the router. `MemoryRouter` never touches
// `window`, so anything reading `getUnstableCurrentSearchParams()` sees an empty
// search and drops the params the test just navigated with. This mock writes both.
//
// The `jest.mock` factory is hoisted above imports, so require it inside:
//
// jest.mock('hooks/useSafeNavigate', () =>
// jest
// .requireActual('tests/browser-history-safe-navigate')
// .createBrowserHistorySafeNavigateMock(),
// );
import type { History } from 'history';
interface SafeNavigateOptions {
replace?: boolean;
}
interface UseSafeNavigateModule {
useSafeNavigate: () => {
safeNavigate: (to: string, options?: SafeNavigateOptions) => void;
};
}
export function createBrowserHistorySafeNavigateMock(): UseSafeNavigateModule {
const { useHistory } = jest.requireActual<{ useHistory: () => History }>(
'react-router-dom',
);
return {
useSafeNavigate: () => {
const history = useHistory();
return {
safeNavigate: (to: string, options?: SafeNavigateOptions): void => {
if (options?.replace) {
window.history.replaceState(null, '', to);
history.replace(to);
} else {
window.history.pushState(null, '', to);
history.push(to);
}
},
};
},
};
}

View File

@@ -41,6 +41,6 @@ type OmitAttributesResources = Pick<
export type ILogAggregateAttributesResources = OmitAttributesResources & {
attributes: Record<string, never>;
resources: Record<string, never>;
resource: Record<string, never>;
scope: Record<string, never>;
};

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
@@ -186,18 +185,7 @@ func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, er
}
}
// Thread same-rule alerts together: threadKey is a stable hash of the
// alert group key. Changing a rule's grouping starts a new thread.
u, err := url.Parse(n.conf.WebhookURL.String())
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse google chat webhook url")
}
q := u.Query()
q.Set("threadKey", key.Hash())
q.Set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
u.RawQuery = q.Encode()
resp, err := notify.PostJSON(ctx, n.client, u.String(), buf) //nolint:bodyclose
resp, err := notify.PostJSON(ctx, n.client, n.conf.WebhookURL.String(), buf) //nolint:bodyclose
if err != nil {
return true, notify.RedactURL(err)
}

View File

@@ -245,7 +245,7 @@ func TestGoogleChatMessageSizeLimit(t *testing.T) {
assert.LessOrEqual(t, bodyLen, maxMessageBytes, "posted body must be within the size limit")
}
func TestGoogleChatThreading(t *testing.T) {
func TestGoogleChatWebhookURLVerbatim(t *testing.T) {
var query url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.Query()
@@ -253,25 +253,11 @@ func TestGoogleChatThreading(t *testing.T) {
}))
defer server.Close()
cases := []struct{ name, groupKey string }{
{"rule a", "{ruleId=\"aaa\"}"},
{"rule b", "{ruleId=\"bbb\"}"},
}
seen := map[string]string{}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n := newTestNotifier(t, server.URL, "T", "")
ctx := notify.WithGroupKey(context.Background(), c.groupKey)
_, err := n.Notify(ctx, newTestAlerts("X")...)
require.NoError(t, err)
n := newTestNotifier(t, server.URL+"?key=abc&token=xyz", "T", "")
_, err := n.Notify(newTestContext(), newTestAlerts("X")...)
require.NoError(t, err)
assert.Equal(t, "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", query.Get("messageReplyOption"))
threadKey := query.Get("threadKey")
assert.Equal(t, notify.Key(c.groupKey).Hash(), threadKey, "threadKey must be the group key hash")
seen[c.name] = threadKey
})
}
assert.NotEqual(t, seen["rule a"], seen["rule b"], "distinct group keys must yield distinct threadKeys")
assert.Equal(t, url.Values{"key": {"abc"}, "token": {"xyz"}}, query, "configured webhook URL must be posted verbatim, with no params added")
}
func TestGoogleChatCustomTemplateMarkdown(t *testing.T) {

View File

@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
brace-expansion@>=5.0.0 <5.0.9: '>=5.0.9 <6'
importers:
.:
@@ -377,9 +380,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -845,7 +848,7 @@ snapshots:
balanced-match@4.0.4: {}
brace-expansion@5.0.5:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -998,7 +1001,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
brace-expansion: 5.0.9
ms@2.1.3: {}

View File

@@ -0,0 +1,6 @@
# Security floors for vulnerable transitive deps. Where possible, targets are
# capped to avoid crossing breaking versions (major; and minor for 0.x).
overrides:
# via: eslint-plugin-playwright > eslint@10 > minimatch@10.2.5 (brace-expansion ^5.0.5)
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'