mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-30 01:30:39 +01:00
Compare commits
10 Commits
v0.135.0-c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d5d3ea35 | ||
|
|
7eb3f7df32 | ||
|
|
5eb3b5e3e0 | ||
|
|
b88ee12cd5 | ||
|
|
6f3dd0b7ad | ||
|
|
d32d93cd39 | ||
|
|
e9a931788c | ||
|
|
e51c464417 | ||
|
|
04637bd960 | ||
|
|
0703fbf961 |
@@ -1496,6 +1496,7 @@ components:
|
||||
- redis
|
||||
- cloudsql_postgres
|
||||
- memorystore_redis
|
||||
- computeengine
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
|
||||
2
frontend/docs/assets/drawer-example.svg
Normal file
2
frontend/docs/assets/drawer-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 139 KiB |
2
frontend/docs/assets/edit-example.svg
Normal file
2
frontend/docs/assets/edit-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 49 KiB |
2
frontend/docs/assets/list-example.svg
Normal file
2
frontend/docs/assets/list-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 86 KiB |
2
frontend/docs/assets/quick-filters-example.svg
Normal file
2
frontend/docs/assets/quick-filters-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 64 KiB |
136
frontend/docs/authz-guide.md
Normal file
136
frontend/docs/authz-guide.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# AuthZ Guide
|
||||
|
||||
How to structure a page so it works with the permission system.
|
||||
|
||||
We are migrating from the `ADMIN | EDITOR | VIEWER` roles to per-action checks. Instead of granting `VIEWER` and
|
||||
exposing everything, a user can now be granted access to a single resource, eg: `Logs` only.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Core rules](#core-rules)
|
||||
- [Page patterns](#page-patterns)
|
||||
- [What "blocked" means](#what-blocked-means)
|
||||
- [Migration checklist](#migration-checklist)
|
||||
- [Testing](#testing)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Check whether the resource your page represents is supported: see
|
||||
[permissions.type.ts](../src/lib/authz/hooks/useAuthZ/permissions.type.ts) for the current resources and their allowed
|
||||
verbs.
|
||||
|
||||
**If the resource is not listed there, skip authz for now**. The backend does not enforce it yet, so any frontend
|
||||
check would be decorative. Revisit once the resource is generated into that file (it is auto-generated from the
|
||||
backend).
|
||||
|
||||
## Core rules
|
||||
|
||||
These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||
1. **A page is always reachable, regardless of permission.** The intended action must be known before permission can
|
||||
be checked, so the route renders first and individual pieces are gated.
|
||||
2. **`update` requires both `read` and `update`.** A user who can write but cannot read the current value must not get
|
||||
an edit affordance.
|
||||
3. **`delete` is independent of `read`.** Delete controls stay visible for a user who holds `delete` but not `read`.
|
||||
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
|
||||
detail route).
|
||||
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
|
||||
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
|
||||
## Page patterns
|
||||
|
||||
### List page
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
|
||||
- Title, description, search filters and action buttons stay visible.
|
||||
- Filters and any control that drives the table are non-interactive.
|
||||
- The create button stays enabled if the user holds `create`.
|
||||
|
||||
### Edit page
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the content with `withAuthZContent`, not the whole route.
|
||||
- Keep delete visible (rule 3).
|
||||
- Keep update blocked (rule 2).
|
||||
|
||||
### Drawer
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the drawer body, not the drawer itself.
|
||||
- Prefer routing that carries the resource ID in the URL, so delete stays reachable without `read`.
|
||||
- Keep update blocked (rule 2).
|
||||
- Watch for sub-resources the user may still be allowed to act on (rule 6).
|
||||
|
||||
### Create
|
||||
|
||||
Without `create`:
|
||||
|
||||
| Entry point | Gate with |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| Button | `AuthZButton` |
|
||||
| Dedicated create page | `withAuthZPage` |
|
||||
| Create drawer opened directly (deep link) | `withAuthZContent` on the body + `AuthZButton` on footer actions |
|
||||
|
||||
### Delete
|
||||
|
||||
Gate the control with `AuthZButton`, or `AuthZTooltip` for a non-button trigger such as an icon or menu item.
|
||||
|
||||
### Quick filters
|
||||
|
||||

|
||||
|
||||
## What "blocked" means
|
||||
|
||||
Blocking is always a visible denial, never a silent removal. Use the components in
|
||||
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
|
||||
denial message and the loading state.
|
||||
|
||||
| Scope | Component | Denied state |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------- |
|
||||
| Button | `AuthZButton` | Disabled + tooltip |
|
||||
| Any element | `AuthZTooltip` | Disabled child + tooltip |
|
||||
| Section | `withAuthZContent` / `AuthZGuardContent` | Inline `PermissionDeniedCallout` |
|
||||
| Page / route | `withAuthZPage` / `AuthZGuardPage` | `PermissionDeniedFullPage` |
|
||||
| Custom fallback | `withAuthZ` / `AuthZGuard` | Whatever you pass as `fallback` |
|
||||
|
||||
Prefer the HOC (`withAuthZ*`); reach for the JSX guard (`AuthZGuard*`) when the gate depends on conditional rendering
|
||||
and an HOC cannot express it. The components README has the full decision tree and how to build the `checks` array.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Use the existing components. Create a new one only if none fit.
|
||||
|
||||
- [ ] Can I apply a `withAuthZ*` variant directly, or must I extract the content into a component first?
|
||||
- If extraction is needed, declare the new content component in the same file to keep the diff small, then move it
|
||||
to its own file in a follow-up commit or PR.
|
||||
- [ ] Is the layout structured to respect the [core rules](#core-rules)?
|
||||
- If not, raise it in the frontend Slack channel before reshaping the page.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
|
||||
## Testing
|
||||
|
||||
### Devtool
|
||||
|
||||
Press `Cmd + K` (`Ctrl + K` on Windows/Linux) to open the shortcuts, search for `AuthZ`, and pick the first
|
||||
result. The devtool simulates granted and denied permissions across the UI.
|
||||
|
||||
Available only in local development, it is stripped from production builds.
|
||||
|
||||
### Unit tests
|
||||
|
||||
[`lib/authz/utils/README.md`](../src/lib/authz/utils/README.md) covers the `*.authz.test.tsx` naming convention, the
|
||||
MSW handlers (`setupAuthzAdmin`, `setupAuthzDenyAll`, `setupAuthzDeny`, `setupAuthzAllow`,
|
||||
`setupAuthzGrantByPrefix`), and how to test the loading state.
|
||||
@@ -2815,6 +2815,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
redis = 'redis',
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
memorystore_redis = 'memorystore_redis',
|
||||
computeengine = 'computeengine',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
|
||||
@@ -438,7 +438,11 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
|
||||
@@ -49,7 +49,11 @@ import { unquote } from 'utils/stringUtils';
|
||||
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
|
||||
import {
|
||||
queryExamples,
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -353,7 +357,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchKeySuggestions = useMemo(
|
||||
() => debounce(fetchKeySuggestions, 300),
|
||||
() => debounce(fetchKeySuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchKeySuggestions],
|
||||
);
|
||||
|
||||
@@ -584,7 +588,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchValueSuggestions = useMemo(
|
||||
() => debounce(fetchValueSuggestions, 300),
|
||||
() => debounce(fetchValueSuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchValueSuggestions],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
|
||||
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
|
||||
|
||||
export const SUGGESTION_FETCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// TODO: move to using TelemetrytypesFieldContextDTO when we migrate getKeySuggestions API (Part of https://github.com/SigNoz/engineering-pod/issues/5289)
|
||||
export const FIELD_CONTEXTS = [
|
||||
'attribute',
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
|
||||
// tests aren't paced by it; coalescing semantics stay intact.
|
||||
jest.mock('../QuerySearch/constants', () => ({
|
||||
...jest.requireActual('../QuerySearch/constants'),
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS: 30,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
return {
|
||||
|
||||
@@ -88,6 +88,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
{...props}
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
data-testid={context?.getRowTestId?.(rowData)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={clearHovered}
|
||||
>
|
||||
@@ -154,6 +155,12 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevTestId = prev.context?.getRowTestId?.(prevData) ?? '';
|
||||
const nextTestId = next.context?.getRowTestId?.(nextData) ?? '';
|
||||
if (prevTestId !== nextTestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
||||
const nextStyle = next.context?.getRowStyle?.(nextData);
|
||||
if (prevStyle !== nextStyle) {
|
||||
|
||||
@@ -33,7 +33,6 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
// Stable references via destructuring, keep them as is
|
||||
const onRowClick = context?.onRowClick;
|
||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||
const onRowDeactivate = context?.onRowDeactivate;
|
||||
const isRowActive = context?.isRowActive;
|
||||
const getRowKeyData = context?.getRowKeyData;
|
||||
const rowIndex = row.index;
|
||||
@@ -52,21 +51,9 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
}
|
||||
|
||||
const isActive = isRowActive?.(rowData) ?? false;
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
onRowClick?.(rowData, itemKey, { isActive });
|
||||
},
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
@@ -120,7 +107,6 @@ function areRowCellsPropsEqual<TData>(
|
||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
||||
|
||||
@@ -92,11 +92,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
getGroupKey,
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
@@ -378,11 +378,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
@@ -396,11 +396,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
|
||||
@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||
it('calls onRowClick with isActive: true when the row is active', async () => {
|
||||
// The table no longer owns open/close — it reports the active state and the
|
||||
// consumer routes the click. An active row must still fire onRowClick.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowClick with isActive: false when the row is not active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
isRowActive: () => false,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render renderRowActions before hover', () => {
|
||||
|
||||
@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
it('calls onRowClick with isActive: true when clicking the active row', async () => {
|
||||
// The consumer owns open/close routing — the table just reports the
|
||||
// active state via the click context.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.anything(),
|
||||
{ isActive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
|
||||
@@ -116,9 +116,11 @@ export * from './useTableParams';
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* // The table reports the click + the row's active state; the consumer owns open/close.
|
||||
* onRowClick={(row, itemKey, { isActive }) =>
|
||||
* setSelectedId(isActive ? undefined : itemKey)
|
||||
* }
|
||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||
* onRowDeactivate={() => setSelectedId(undefined)}
|
||||
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
||||
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
||||
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
||||
|
||||
@@ -81,15 +81,19 @@ export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type RowClickContext = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type TableRowContext<TData, TItemKey = string> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
@@ -178,12 +182,12 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
getGroupKey?: (row: TData) => Record<string, string>;
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
|
||||
@@ -60,3 +60,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ function LiveLogsList({
|
||||
data={formattedLogs}
|
||||
isLoading={false}
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -216,10 +219,13 @@ function LiveLogsList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
|
||||
@@ -323,3 +323,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -211,9 +211,11 @@ function LogsExplorerList({
|
||||
data={logs}
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={onEndReached}
|
||||
isRowActive={(log): boolean =>
|
||||
log.id === activeLog?.id || log.id === activeLogId
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowTestId={(log): string => `logs-table-row-${log.id}`}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -226,10 +228,13 @@ function LogsExplorerList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
@@ -282,6 +287,7 @@ function LogsExplorerList({
|
||||
options.maxLines,
|
||||
options.fontSize,
|
||||
activeLogIndex,
|
||||
activeLogId,
|
||||
logs,
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_COPIED_RESET_MS = 2000;
|
||||
|
||||
export interface UseCopyToClipboardOptions {
|
||||
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
|
||||
copiedResetMs?: number;
|
||||
}
|
||||
|
||||
export type ID = number | string | null;
|
||||
|
||||
export interface UseCopyToClipboardReturn {
|
||||
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
|
||||
copyToClipboard: (text: string, id?: ID) => void;
|
||||
/** True when something was just copied and still within the reset threshold. */
|
||||
isCopied: boolean;
|
||||
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
|
||||
id: ID;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(
|
||||
options: UseCopyToClipboardOptions = {},
|
||||
): UseCopyToClipboardReturn {
|
||||
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
|
||||
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
|
||||
isCopied: false,
|
||||
id: null,
|
||||
});
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, id?: ID): void => {
|
||||
// oxlint-disable-next-line signoz/no-navigator-clipboard
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setState({ isCopied: true, id: id ?? null });
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setState({ isCopied: false, id: null });
|
||||
timeoutRef.current = null;
|
||||
}, copiedResetMs);
|
||||
});
|
||||
},
|
||||
[copiedResetMs],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied: state.isCopied,
|
||||
id: state.id,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
|
||||
@@ -33,7 +31,6 @@ export default function Legend({
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
@@ -57,17 +54,8 @@ export default function Legend({
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const handleCopyLegendItem = useCallback(
|
||||
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(label, seriesIndex);
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
const isCopied = copiedId === item.seriesIndex;
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
@@ -91,36 +79,18 @@ export default function Legend({
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<TooltipSimple
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
arrow
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="legend-copy-button"
|
||||
onClick={(e): void =>
|
||||
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
|
||||
}
|
||||
aria-label={`Copy ${item.label}`}
|
||||
// data-testid (not testId): TooltipSimple's trigger injects
|
||||
// data-testid:undefined via Radix Slot, and Button spreads
|
||||
// incoming props after its own testId — so set it as a prop
|
||||
// that wins the Slot merge and survives the spread.
|
||||
data-testid="legend-copy"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
@@ -15,9 +9,6 @@ import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
@@ -49,15 +40,6 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
>;
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
beforeAll(() => {
|
||||
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
@@ -89,11 +71,6 @@ describe('UPlotLegend', () => {
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
mockWriteText.mockClear();
|
||||
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
@@ -110,7 +87,6 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -237,47 +213,4 @@ describe('UPlotLegend', () => {
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy action', () => {
|
||||
it('copies the legend label to clipboard when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('A');
|
||||
});
|
||||
|
||||
it('copies the correct label when copy is clicked on a different legend item', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const thirdLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="2"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('does not call onLegendClick when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(onLegendClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { CSSProperties, type MouseEvent, useCallback } from 'react';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
|
||||
import styles from './CopyButton.module.scss';
|
||||
import { useCopyButton } from './useCopyButton';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text written to the clipboard on click. */
|
||||
@@ -30,11 +30,15 @@ function CopyButton({
|
||||
className,
|
||||
testId,
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard();
|
||||
const { copyToClipboard, isCopied } = useCopyButton();
|
||||
|
||||
const handleClick = useCallback((): void => {
|
||||
copyToClipboard(value);
|
||||
}, [copyToClipboard, value]);
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(value);
|
||||
},
|
||||
[copyToClipboard, value],
|
||||
);
|
||||
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CopyButton from '../CopyButton';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// Exercise the real useCopyButton — stub only react-use's underlying copy so the
|
||||
// click doesn't hit copy-to-clipboard's jsdom fallback (window.prompt).
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
describe('CopyButton', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
mockCopy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('copies its value on click', async () => {
|
||||
render(<CopyButton value="hello" testId="copy" />);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('does not trigger parent click handlers (stops propagation)', async () => {
|
||||
const onParentClick = jest.fn();
|
||||
render(
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div onClick={onParentClick}>
|
||||
<CopyButton value="x" testId="copy" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the copied state after clicking and reverts after the reset window', async () => {
|
||||
const { container } = render(<CopyButton value="hello" testId="copy" />);
|
||||
const iconStack = container.querySelector('[data-copied]') as HTMLElement;
|
||||
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'true');
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** How long (ms) CopyButton stays in the "copied" state before reverting. */
|
||||
export const COPIED_RESET_MS = 1000;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { COPIED_RESET_MS } from './constants';
|
||||
|
||||
export interface UseCopyButtonReturn {
|
||||
copyToClipboard: (text: string) => void;
|
||||
isCopied: boolean;
|
||||
}
|
||||
|
||||
export function useCopyButton(): UseCopyButtonReturn {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string): void => {
|
||||
copy(text);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsCopied(true);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, COPIED_RESET_MS);
|
||||
},
|
||||
[copy],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied,
|
||||
};
|
||||
}
|
||||
@@ -143,6 +143,12 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
plugins,
|
||||
resolve: {
|
||||
alias: {
|
||||
// @grafana/data imports bare CJS `lodash`, whose UMD footer checks for an
|
||||
// AMD loader before assigning module.exports. Any third-party script that
|
||||
// defines window.define.amd first leaves the bundled namespace empty, so
|
||||
// every lodash method reached through it is undefined at runtime. lodash-es
|
||||
// is the same version, real ESM, and tree-shakes.
|
||||
lodash: 'lodash-es',
|
||||
'@': resolve(__dirname, './src'),
|
||||
utils: resolve(__dirname, './src/utils'),
|
||||
types: resolve(__dirname, './src/types'),
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2 h1:KCxdmvuVawVF4yl0ZS33q7olgmLZwvZG5BjWHp6o414=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3 h1:6iap8XGjuSjD3w7r1UNrg66ljBugcv2P39s4eo/ZLRw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_ComputeEngine_Color</title><g data-name="Product Icons"><rect class="cls-1" x="9" y="9" width="6" height="6"/><rect class="cls-2" x="11" y="2" width="2" height="4"/><rect class="cls-2" x="7" y="2" width="2" height="4"/><rect class="cls-2" x="15" y="2" width="2" height="4"/><rect class="cls-3" x="11" y="18" width="2" height="4"/><rect class="cls-3" x="7" y="18" width="2" height="4"/><rect class="cls-3" x="15" y="18" width="2" height="4"/><rect class="cls-3" x="19" y="10" width="2" height="4" transform="translate(8 32) rotate(-90)"/><rect class="cls-3" x="19" y="14" width="2" height="4" transform="translate(4 36) rotate(-90)"/><rect class="cls-3" x="19" y="6" width="2" height="4" transform="translate(12 28) rotate(-90)"/><rect class="cls-2" x="3" y="10" width="2" height="4" transform="translate(-8 16) rotate(-90)"/><rect class="cls-2" x="3" y="14" width="2" height="4" transform="translate(-12 20) rotate(-90)"/><rect class="cls-2" x="3" y="6" width="2" height="4" transform="translate(-4 12) rotate(-90)"/><path class="cls-1" d="M5,5V19H19V5ZM17,17H7V7H17Z"/><polygon class="cls-2" points="9 15 15 15 12 12 9 15"/><polygon class="cls-3" points="12 12 15 15 15 9 12 12"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"id": "computeengine",
|
||||
"title": "GCP Compute Engine",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/uptime_total",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/cpu/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/guest/memory/bytes_used",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/guest/memory/percent_used",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "agent.googleapis.com/memory/percent_used",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/average_io_latency",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/read_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/write_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/read_ops_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/write_ops_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/performance_status",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/received_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/sent_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/received_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/sent_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/firewall/dropped_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Compute Engine Overview",
|
||||
"description": "Overview of GCP Compute Engine metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Compute Engine with SigNoz
|
||||
|
||||
Collect key GCP Compute Engine metrics and view them with an out of the box dashboard.
|
||||
@@ -9,6 +9,16 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
CodeClickHouseSQLParserPanic = errors.MustNewCode("clickhouse_sql_parser_panic")
|
||||
CodeClickHouseSQLUnparseable = errors.MustNewCode("clickhouse_sql_unparseable")
|
||||
CodeClickHouseSQLNotSingleStatement = errors.MustNewCode("clickhouse_sql_not_single_statement")
|
||||
CodeClickHouseSQLNotSelect = errors.MustNewCode("clickhouse_sql_not_select")
|
||||
CodeClickHouseSQLTableFunction = errors.MustNewCode("clickhouse_sql_table_function")
|
||||
CodeClickHouseSQLInternalDatabase = errors.MustNewCode("clickhouse_sql_internal_database")
|
||||
CodeClickHouseSQLReadonlyOverride = errors.MustNewCode("clickhouse_sql_readonly_override")
|
||||
)
|
||||
|
||||
// internalDatabases hold server metadata, credentials and grants rather than telemetry.
|
||||
var internalDatabases = map[string]struct{}{
|
||||
"system": {},
|
||||
@@ -20,30 +30,30 @@ func ErrIfStatementIsNotValid(query string) (err error) {
|
||||
defer func() {
|
||||
// The parser has a history of panicking on malformed input rather than returning an error.
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL (recovered): %v", recovered)
|
||||
err = errors.NewInvalidInputf(CodeClickHouseSQLParserPanic, "invalid ClickHouse SQL (recovered): %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
stmts, parseErr := chparser.NewParser(query).ParseStmts()
|
||||
if parseErr != nil {
|
||||
// Wrapped rather than formatted in, so that callers can recover the parser's *ParseError and read the position off it.
|
||||
return errors.WrapInvalidInputf(parseErr, errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", parseErr.Error())
|
||||
return errors.WrapInvalidInputf(parseErr, CodeClickHouseSQLUnparseable, "invalid ClickHouse SQL: %s", parseErr.Error())
|
||||
}
|
||||
|
||||
if len(stmts) != 1 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse SQL must contain exactly one statement, found %d statements", len(stmts))
|
||||
return errors.NewInvalidInputf(CodeClickHouseSQLNotSingleStatement, "ClickHouse SQL must contain exactly one statement, found %d statements", len(stmts))
|
||||
}
|
||||
|
||||
selectQuery, ok := stmts[0].(*chparser.SelectQuery)
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "only SELECT statements are allowed in ClickHouse SQL queries")
|
||||
return errors.NewInvalidInputf(CodeClickHouseSQLNotSelect, "only SELECT statements are allowed in ClickHouse SQL queries")
|
||||
}
|
||||
|
||||
visitor := &chparser.DefaultASTVisitor{Visit: func(node chparser.Expr) error {
|
||||
switch expr := node.(type) {
|
||||
case *chparser.TableFunctionExpr:
|
||||
// Source table functions remain usable in ClickHouse read-only mode.
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse table functions are not allowed in SQL queries: %s", chparser.Format(expr.Name))
|
||||
return errors.NewInvalidInputf(CodeClickHouseSQLTableFunction, "ClickHouse table functions are not allowed in SQL queries: %s", chparser.Format(expr.Name))
|
||||
|
||||
case *chparser.TableIdentifier:
|
||||
// Reading these is unaffected by ClickHouse read-only mode.
|
||||
@@ -52,13 +62,13 @@ func ErrIfStatementIsNotValid(query string) (err error) {
|
||||
}
|
||||
|
||||
if _, ok := internalDatabases[strings.ToLower(expr.Database.Name)]; ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse %s database is not allowed in SQL queries", expr.Database.Name)
|
||||
return errors.NewInvalidInputf(CodeClickHouseSQLInternalDatabase, "the ClickHouse %s database is not allowed in SQL queries", expr.Database.Name)
|
||||
}
|
||||
|
||||
case *chparser.SettingExpr:
|
||||
// A query-level setting takes precedence over the context setting.
|
||||
if strings.EqualFold(expr.Name.Name, "readonly") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse readonly setting cannot be overridden")
|
||||
return errors.NewInvalidInputf(CodeClickHouseSQLReadonlyOverride, "the ClickHouse readonly setting cannot be overridden")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package querybuilder
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -30,10 +32,15 @@ func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
|
||||
{"TrailingUnterminatedBlockComment", "SELECT count() FROM t /* unterminated"},
|
||||
// The rule keys on the database, not on the table name.
|
||||
{"TableNamedSystemInTelemetryDatabase", "SELECT * FROM signoz_logs.system"},
|
||||
{"SignedLiteralAfterClosingParen", "SELECT (toUnixTimestamp(now()) - 3600)*1000000000"},
|
||||
{"SignedLiteralAfterClosingParenSpaced", "SELECT (toUnixTimestamp(now()) - 3600)*1000000000"},
|
||||
// order by interval
|
||||
{"OrderByInterval", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval ORDER BY interval"},
|
||||
{"OrderByIntervalAndDirection", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS `interval` ORDER BY `interval` ASC"},
|
||||
// Unspaced, so rejected until the parser stopped lexing a signed literal after a
|
||||
// closing bracket. The spaced form above no longer needs to be spaced.
|
||||
// https://github.com/AfterShip/clickhouse-sql-parser/issues/286
|
||||
{"SignedLiteralAfterClosingParenUnspaced", "SELECT now() AS ts, toFloat64(count()) AS value FROM ( SELECT attributes_string['TableName'] AS T, attributes_string['MissingId'] AS M, max(fromUnixTimestamp64Nano(timestamp)) AS last_seen, dateDiff('minute', min(fromUnixTimestamp64Nano(timestamp)), max(fromUnixTimestamp64Nano(timestamp))) AS age_min FROM signoz_logs.distributed_logs_v2 WHERE body='missing_map_record' AND timestamp >= (toUnixTimestamp(now())-3600)*1000000000 GROUP BY T, M ) WHERE age_min >= 20 AND last_seen >= now() - toIntervalMinute(8)"},
|
||||
{"SignedLiteralAfterClosingParenMinimal", "SELECT (1)-1"},
|
||||
{"TrimFunction", "SELECT trimBoth('/api/endpoint/', '/');"},
|
||||
}
|
||||
|
||||
@@ -47,49 +54,52 @@ func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
|
||||
|
||||
func TestErrIfStatementIsNotValid_Fail(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
name string
|
||||
query string
|
||||
expectedCode errors.Code
|
||||
}{
|
||||
// Not a single statement, or not a statement at all.
|
||||
{"Empty", ""},
|
||||
{"UnterminatedBlockCommentOnly", "/* x"},
|
||||
{"Unparseable", "SELECT FROM WHERE"},
|
||||
{"MultipleStatements", "SELECT 1; DROP TABLE signoz_logs.logs_v2"},
|
||||
{"Empty", "", CodeClickHouseSQLNotSingleStatement},
|
||||
{"UnterminatedBlockCommentOnly", "/* x", CodeClickHouseSQLUnparseable},
|
||||
{"Unparseable", "SELECT FROM WHERE", CodeClickHouseSQLUnparseable},
|
||||
{"MultipleStatements", "SELECT 1; DROP TABLE signoz_logs.logs_v2", CodeClickHouseSQLNotSingleStatement},
|
||||
// Parses, but is not a SELECT.
|
||||
{"Drop", "DROP TABLE signoz_logs.logs_v2"},
|
||||
{"Insert", "INSERT INTO signoz_logs.logs_v2 SELECT * FROM signoz_logs.logs_v2"},
|
||||
{"AlterDelete", "ALTER TABLE signoz_logs.logs_v2 DELETE WHERE 1 = 1"},
|
||||
{"CreateTable", "CREATE TABLE evil (a Int) ENGINE = Memory"},
|
||||
{"Grant", "GRANT ALL ON *.* TO admin"},
|
||||
{"Set", "SET readonly = 0"},
|
||||
{"Drop", "DROP TABLE signoz_logs.logs_v2", CodeClickHouseSQLNotSelect},
|
||||
{"Insert", "INSERT INTO signoz_logs.logs_v2 SELECT * FROM signoz_logs.logs_v2", CodeClickHouseSQLNotSelect},
|
||||
{"AlterDelete", "ALTER TABLE signoz_logs.logs_v2 DELETE WHERE 1 = 1", CodeClickHouseSQLNotSelect},
|
||||
{"CreateTable", "CREATE TABLE evil (a Int) ENGINE = Memory", CodeClickHouseSQLNotSelect},
|
||||
{"Grant", "GRANT ALL ON *.* TO admin", CodeClickHouseSQLNotSelect},
|
||||
{"Set", "SET readonly = 0", CodeClickHouseSQLNotSelect},
|
||||
// These the parser rejects outright rather than classifying.
|
||||
{"ShowGrants", "SHOW GRANTS"},
|
||||
{"IntoOutfile", "SELECT * FROM t INTO OUTFILE '/tmp/x.csv'"},
|
||||
{"ShowGrants", "SHOW GRANTS", CodeClickHouseSQLUnparseable},
|
||||
{"IntoOutfile", "SELECT * FROM t INTO OUTFILE '/tmp/x.csv'", CodeClickHouseSQLUnparseable},
|
||||
// Table functions, which read through something other than a telemetry table.
|
||||
{"UrlTableFunction", "SELECT * FROM url('http://attacker.example/x', CSV, 'a String')"},
|
||||
{"FileTableFunction", "SELECT * FROM file('/etc/passwd', CSV, 'a String')"},
|
||||
{"ExecutableTableFunction", "SELECT * FROM executable('script.sh', CSV, 'a String')"},
|
||||
{"TableFunctionInJoin", "SELECT * FROM t1 JOIN url('http://x', CSV, 'a String') u ON 1 = 1"},
|
||||
{"TableFunctionInCommonTableExpression", "WITH c AS (SELECT * FROM url('http://x', CSV, 'a String')) SELECT * FROM c"},
|
||||
{"TableFunctionInWhereSubquery", "SELECT * FROM t WHERE a IN (SELECT * FROM file('/etc/passwd', CSV, 'a String'))"},
|
||||
{"TableFunctionInUnion", "SELECT * FROM t UNION ALL SELECT * FROM url('http://x', CSV, 'a String')"},
|
||||
{"UrlTableFunction", "SELECT * FROM url('http://attacker.example/x', CSV, 'a String')", CodeClickHouseSQLTableFunction},
|
||||
{"FileTableFunction", "SELECT * FROM file('/etc/passwd', CSV, 'a String')", CodeClickHouseSQLTableFunction},
|
||||
{"ExecutableTableFunction", "SELECT * FROM executable('script.sh', CSV, 'a String')", CodeClickHouseSQLTableFunction},
|
||||
{"TableFunctionInJoin", "SELECT * FROM t1 JOIN url('http://x', CSV, 'a String') u ON 1 = 1", CodeClickHouseSQLTableFunction},
|
||||
{"TableFunctionInCommonTableExpression", "WITH c AS (SELECT * FROM url('http://x', CSV, 'a String')) SELECT * FROM c", CodeClickHouseSQLTableFunction},
|
||||
{"TableFunctionInWhereSubquery", "SELECT * FROM t WHERE a IN (SELECT * FROM file('/etc/passwd', CSV, 'a String'))", CodeClickHouseSQLTableFunction},
|
||||
{"TableFunctionInUnion", "SELECT * FROM t UNION ALL SELECT * FROM url('http://x', CSV, 'a String')", CodeClickHouseSQLTableFunction},
|
||||
// Internal databases, which hold grants and server metadata rather than telemetry.
|
||||
{"SystemUsers", "SELECT * FROM system.users"},
|
||||
{"SystemUppercase", "SELECT * FROM SYSTEM.USERS"},
|
||||
{"SystemQuoted", "SELECT count() FROM `system`.`tables`"},
|
||||
{"SystemInSubquery", "SELECT * FROM (SELECT name FROM system.parts)"},
|
||||
{"SystemInJoin", "SELECT * FROM signoz_logs.distributed_logs_v2 AS l JOIN system.users AS u ON 1 = 1"},
|
||||
{"SystemInIntersect", "SELECT * FROM t INTERSECT SELECT * FROM system.users"},
|
||||
{"InformationSchema", "SELECT * FROM information_schema.tables"},
|
||||
{"SystemUsers", "SELECT * FROM system.users", CodeClickHouseSQLInternalDatabase},
|
||||
{"SystemUppercase", "SELECT * FROM SYSTEM.USERS", CodeClickHouseSQLInternalDatabase},
|
||||
{"SystemQuoted", "SELECT count() FROM `system`.`tables`", CodeClickHouseSQLInternalDatabase},
|
||||
{"SystemInSubquery", "SELECT * FROM (SELECT name FROM system.parts)", CodeClickHouseSQLInternalDatabase},
|
||||
{"SystemInJoin", "SELECT * FROM signoz_logs.distributed_logs_v2 AS l JOIN system.users AS u ON 1 = 1", CodeClickHouseSQLInternalDatabase},
|
||||
{"SystemInIntersect", "SELECT * FROM t INTERSECT SELECT * FROM system.users", CodeClickHouseSQLInternalDatabase},
|
||||
{"InformationSchema", "SELECT * FROM information_schema.tables", CodeClickHouseSQLInternalDatabase},
|
||||
// A query-level setting takes precedence over the one the caller applies.
|
||||
{"ReadonlySettingOverride", "SELECT * FROM t SETTINGS readonly = 0"},
|
||||
{"ReadonlySettingOverrideAmongOthers", "SELECT * FROM t SETTINGS max_threads = 4, readonly = 0"},
|
||||
{"ReadonlySettingOverride", "SELECT * FROM t SETTINGS readonly = 0", CodeClickHouseSQLReadonlyOverride},
|
||||
{"ReadonlySettingOverrideAmongOthers", "SELECT * FROM t SETTINGS max_threads = 4, readonly = 0", CodeClickHouseSQLReadonlyOverride},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := ErrIfStatementIsNotValid(testCase.query)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Asc(err, testCase.expectedCode), "expected code %s, got %v", testCase.expectedCode, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -122,18 +132,6 @@ func TestErrIfStatementIsNotValid_ShouldPassButFails(t *testing.T) {
|
||||
expectedStopsAfter: "trim(BOTH '",
|
||||
fix: "SELECT trimBoth(replaceOne( JSONExtractString(body, 'Action'), 'health_status: ', '' ), ' ')",
|
||||
},
|
||||
{
|
||||
name: "SignedLiteralAfterClosingParen",
|
||||
query: "SELECT now() AS ts, toFloat64(count()) AS value FROM ( SELECT attributes_string['TableName'] AS T, attributes_string['MissingId'] AS M, max(fromUnixTimestamp64Nano(timestamp)) AS last_seen, dateDiff('minute', min(fromUnixTimestamp64Nano(timestamp)), max(fromUnixTimestamp64Nano(timestamp))) AS age_min FROM signoz_logs.distributed_logs_v2 WHERE body='missing_map_record' AND timestamp >= (toUnixTimestamp(now())-3600)*1000000000 GROUP BY T, M ) WHERE age_min >= 20 AND last_seen >= now() - toIntervalMinute(8)",
|
||||
expectedStopsAfter: "(toUnixTimestamp(now())",
|
||||
fix: "SELECT (toUnixTimestamp(now()) - 3600) * 1000000000",
|
||||
},
|
||||
{
|
||||
name: "SignedLiteralAfterClosingParenMinimal",
|
||||
query: "SELECT (1)-1",
|
||||
expectedStopsAfter: "(1)",
|
||||
fix: "SELECT (1) - 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -205,7 +205,7 @@ func (b *auditQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[st
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
return query
|
||||
|
||||
@@ -274,8 +274,7 @@ func (b *logQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[stri
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
return query
|
||||
|
||||
@@ -127,8 +127,7 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
}
|
||||
|
||||
for _, action := range adjustTraceKeys(keys, &query, requestType) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
// Create SQL builder
|
||||
q := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
@@ -223,8 +223,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
// The CTE only selects spans matching the filter. Aggregations, group by
|
||||
// and order by run later in buildFinalQuery, so RequestTypeRaw is fine here.
|
||||
for _, action := range adjustTraceKeys(keys, query, qbtypes.RequestTypeRaw) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
b.stmtBuilder.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
// Build resource filter CTE for this specific query
|
||||
@@ -576,7 +575,7 @@ func (b *traceOperatorCTEBuilder) adjustOperatorKeys(ctx context.Context, keys m
|
||||
b.operator.Order = tmp.Order
|
||||
|
||||
for _, action := range actions {
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
b.stmtBuilder.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ var (
|
||||
// GCP services.
|
||||
GCPServiceCloudSQLPostgres = ServiceID{valuer.NewString("cloudsql_postgres")}
|
||||
GCPServiceMemorystoreRedis = ServiceID{valuer.NewString("memorystore_redis")}
|
||||
GCPServiceComputeEngine = ServiceID{valuer.NewString("computeengine")}
|
||||
)
|
||||
|
||||
func (ServiceID) Enum() []any {
|
||||
@@ -76,6 +77,7 @@ func (ServiceID) Enum() []any {
|
||||
AzureServiceRedis,
|
||||
GCPServiceCloudSQLPostgres,
|
||||
GCPServiceMemorystoreRedis,
|
||||
GCPServiceComputeEngine,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +117,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
|
||||
CloudProviderTypeGCP: {
|
||||
GCPServiceCloudSQLPostgres,
|
||||
GCPServiceMemorystoreRedis,
|
||||
GCPServiceComputeEngine,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
112
tests/e2e/helpers/logs-explorer.ts
Normal file
112
tests/e2e/helpers/logs-explorer.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { dismissQuickFiltersAnnouncement } from './quick-filters';
|
||||
|
||||
const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
|
||||
|
||||
const RELATIVE_TIME = '6h';
|
||||
|
||||
const FORMAT_OPTIONS_TEST_ID = 'periscope-btn-format-options';
|
||||
|
||||
const ROW_TEST_ID_PREFIX = 'logs-table-row-';
|
||||
|
||||
const LOG_DETAIL_DRAWER_TEST_ID = 'log-detail-drawer';
|
||||
export const LINKED_ROW_CLASS = 'logs-linked-row';
|
||||
|
||||
type LogsFormat = 'Raw' | 'Default' | 'Column';
|
||||
|
||||
const FORMAT_MODES: Record<LogsFormat, string> = {
|
||||
Raw: 'raw',
|
||||
Default: 'list',
|
||||
Column: 'table',
|
||||
};
|
||||
|
||||
export async function gotoLogsExplorer(page: Page): Promise<void> {
|
||||
await dismissQuickFiltersAnnouncement(page);
|
||||
await page.goto(`${LOGS_EXPLORER_PATH}?relativeTime=${RELATIVE_TIME}`);
|
||||
await expect(page.getByTestId(FORMAT_OPTIONS_TEST_ID)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function setLogsFormat(
|
||||
page: Page,
|
||||
format: LogsFormat,
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId(FORMAT_OPTIONS_TEST_ID);
|
||||
const popover = page.locator('.format-options-popover');
|
||||
const appliedMode = new RegExp(`%22format%22%3A%22${FORMAT_MODES[format]}%22`);
|
||||
|
||||
await expect(async () => {
|
||||
if (!(await popover.isVisible())) {
|
||||
await trigger.click();
|
||||
await expect(popover).toBeVisible();
|
||||
}
|
||||
await popover
|
||||
.locator('.menu-items .item')
|
||||
.filter({ hasText: format })
|
||||
.click();
|
||||
await expect(page).toHaveURL(appliedMode, { timeout: 3_000 });
|
||||
}).toPass({ timeout: 20_000 });
|
||||
|
||||
await trigger.click();
|
||||
await expect(popover).toBeHidden();
|
||||
}
|
||||
|
||||
export function logsTableRows(page: Page): Locator {
|
||||
return page.locator(`[data-testid^="${ROW_TEST_ID_PREFIX}"]`);
|
||||
}
|
||||
|
||||
export function logsTableRow(page: Page, logId: string): Locator {
|
||||
return page.getByTestId(`${ROW_TEST_ID_PREFIX}${logId}`);
|
||||
}
|
||||
|
||||
export function highlightedLogsTableRows(page: Page): Locator {
|
||||
return logsTableRows(page).and(page.locator(`.${LINKED_ROW_CLASS}`));
|
||||
}
|
||||
|
||||
export function unhighlightedLogsTableRows(page: Page): Locator {
|
||||
return page.locator(
|
||||
`[data-testid^="${ROW_TEST_ID_PREFIX}"]:not(.${LINKED_ROW_CLASS})`,
|
||||
);
|
||||
}
|
||||
|
||||
export function activeLogIdFromUrl(page: Page): string {
|
||||
const value = new URL(page.url()).searchParams.get('activeLogId');
|
||||
if (!value) {
|
||||
throw new Error(`No activeLogId in URL: ${page.url()}`);
|
||||
}
|
||||
return value.replace(/"/g, '');
|
||||
}
|
||||
|
||||
export function logDetailsDrawer(page: Page): Locator {
|
||||
return page.getByTestId(LOG_DETAIL_DRAWER_TEST_ID);
|
||||
}
|
||||
|
||||
export async function clickRowFirstCell(row: Locator): Promise<void> {
|
||||
await row.locator('td').first().click();
|
||||
}
|
||||
|
||||
export async function openLogDetailsFromRow(row: Locator): Promise<void> {
|
||||
await clickRowFirstCell(row);
|
||||
await expect(logDetailsDrawer(row.page())).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openContextView(page: Page): Promise<void> {
|
||||
await page
|
||||
.locator('.views-tabs')
|
||||
.getByText('Context', { exact: true })
|
||||
.click();
|
||||
await expect(contextLogItems(page).first()).toBeVisible();
|
||||
}
|
||||
|
||||
function contextLogItems(page: Page): Locator {
|
||||
return page.locator('.context-log-renderer__item');
|
||||
}
|
||||
|
||||
export async function openFirstContextLogInNewTab(page: Page): Promise<Page> {
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
contextLogItems(page).first().click(),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
return newPage;
|
||||
}
|
||||
49
tests/e2e/tests/logs-explorer/linked-row-click.spec.ts
Normal file
49
tests/e2e/tests/logs-explorer/linked-row-click.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
activeLogIdFromUrl,
|
||||
clickRowFirstCell,
|
||||
gotoLogsExplorer,
|
||||
highlightedLogsTableRows,
|
||||
LINKED_ROW_CLASS,
|
||||
logDetailsDrawer,
|
||||
logsTableRow,
|
||||
logsTableRows,
|
||||
openFirstContextLogInNewTab,
|
||||
openContextView,
|
||||
openLogDetailsFromRow,
|
||||
setLogsFormat,
|
||||
unhighlightedLogsTableRows,
|
||||
} from '../../helpers/logs-explorer';
|
||||
|
||||
test.describe('Logs Explorer — linked row in Column format', () => {
|
||||
test('TC-01 the linked row in a newly opened tab is highlighted and toggles the details drawer on click', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLogsExplorer(page);
|
||||
await setLogsFormat(page, 'Column');
|
||||
|
||||
const rows = logsTableRows(page);
|
||||
await expect(rows.first()).toBeVisible();
|
||||
await expect(highlightedLogsTableRows(page)).toHaveCount(0);
|
||||
|
||||
await openLogDetailsFromRow(rows.first());
|
||||
await openContextView(page);
|
||||
|
||||
const linkedTab = await openFirstContextLogInNewTab(page);
|
||||
await expect(linkedTab).toHaveURL(/activeLogId=/);
|
||||
|
||||
const linkedLogId = activeLogIdFromUrl(linkedTab);
|
||||
const linkedRow = logsTableRow(linkedTab, linkedLogId);
|
||||
await expect(linkedRow).toBeVisible();
|
||||
await expect(linkedRow).toHaveClass(new RegExp(LINKED_ROW_CLASS));
|
||||
|
||||
await expect(highlightedLogsTableRows(linkedTab)).toHaveCount(1);
|
||||
await expect(unhighlightedLogsTableRows(linkedTab).first()).toBeVisible();
|
||||
|
||||
await expect(logDetailsDrawer(linkedTab)).toBeHidden();
|
||||
await openLogDetailsFromRow(linkedRow);
|
||||
|
||||
await clickRowFirstCell(linkedRow);
|
||||
await expect(logDetailsDrawer(linkedTab)).toBeHidden();
|
||||
});
|
||||
});
|
||||
53
tests/fixtures/querier.py
vendored
53
tests/fixtures/querier.py
vendored
@@ -192,6 +192,59 @@ def make_query_request(
|
||||
)
|
||||
|
||||
|
||||
def make_preview_query_request(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
queries: list[dict],
|
||||
*,
|
||||
request_type: str = RequestType.TIME_SERIES,
|
||||
format_options: dict | None = None,
|
||||
variables: dict | None = None,
|
||||
verbose: bool = True,
|
||||
timeout: int = QUERY_TIMEOUT,
|
||||
) -> requests.Response:
|
||||
"""Dry-run the same payload as make_query_request against /query_range/preview.
|
||||
Verbose (the default) renders the underlying ClickHouse statement per query."""
|
||||
if format_options is None:
|
||||
format_options = {"formatTableResultForUI": False, "fillGaps": False}
|
||||
|
||||
payload = {
|
||||
"schemaVersion": "v1",
|
||||
"start": start_ms,
|
||||
"end": end_ms,
|
||||
"requestType": request_type,
|
||||
"compositeQuery": {"queries": queries},
|
||||
"formatOptions": format_options,
|
||||
}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
|
||||
return requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range/preview"),
|
||||
params={"verbose": str(verbose).lower()},
|
||||
timeout=timeout,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
|
||||
def get_preview_statements(response: requests.Response, name: str) -> list[dict[str, Any]]:
|
||||
"""The rendered statements for the named query in a preview response."""
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
preview = response.json()["data"]["compositeQuery"][name]
|
||||
assert preview["valid"], f"preview for query {name} is invalid: {preview['error']}"
|
||||
return preview["statements"]
|
||||
|
||||
|
||||
def get_preview_sql(response: requests.Response, name: str) -> str:
|
||||
"""The single rendered ClickHouse statement for the named query in a preview response."""
|
||||
statements = get_preview_statements(response, name)
|
||||
assert len(statements) == 1, f"expected 1 statement for query {name}, got {len(statements)}"
|
||||
return statements[0]["db.statement.query"]
|
||||
|
||||
|
||||
def aligned_epoch(ago: timedelta, step_seconds: int = DEFAULT_STEP_INTERVAL) -> int:
|
||||
"""Epoch seconds for `now - ago`, floored to a step boundary so seeded
|
||||
points land exactly on the query's toStartOfInterval buckets."""
|
||||
|
||||
@@ -5,7 +5,8 @@ At or above the configured fingerprint threshold the optimization pushes resourc
|
||||
conditions onto the main spans/logs table instead of the fingerprint CTE. That
|
||||
rewrite must change ClickHouse performance, never the rows: each test runs the same
|
||||
query against the primary instance (optimization on, threshold=2) and
|
||||
`signoz_fingerprint` (optimization off) and asserts the responses are identical.
|
||||
`signoz_fingerprint` (optimization off) and asserts the responses are identical, then
|
||||
diffs the rendered SQL to prove the two really did take different paths.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
@@ -17,13 +18,20 @@ from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
assert_identical_query_response,
|
||||
get_preview_sql,
|
||||
get_rows,
|
||||
make_preview_query_request,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import Traces
|
||||
|
||||
# The clause the baseline joins the fingerprint CTE with; the fallback path renders the
|
||||
# resource conditions on the main table and drops it.
|
||||
FINGERPRINT_CTE_FILTER = "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)"
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_traces_fallback_matches_fingerprint(
|
||||
signoz: types.SigNoz,
|
||||
@@ -58,12 +66,21 @@ def test_skip_resource_fingerprint_traces_fallback_matches_fingerprint(
|
||||
|
||||
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
|
||||
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query])
|
||||
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query])
|
||||
|
||||
assert len(get_rows(optimized)) == 3
|
||||
assert_identical_query_response(optimized, fingerprint)
|
||||
|
||||
# Identical rows alone can't tell the two instances apart; the rendered SQL can.
|
||||
# The baseline resolves the env filter through the fingerprint CTE, the optimized
|
||||
# instance pushes it onto the spans table.
|
||||
optimized_sql = get_preview_sql(make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query]), "A")
|
||||
fingerprint_sql = get_preview_sql(make_preview_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query]), "A")
|
||||
|
||||
assert FINGERPRINT_CTE_FILTER in fingerprint_sql, fingerprint_sql
|
||||
assert FINGERPRINT_CTE_FILTER not in optimized_sql, optimized_sql
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_logs_fallback_matches_fingerprint(
|
||||
signoz: types.SigNoz,
|
||||
@@ -98,8 +115,15 @@ def test_skip_resource_fingerprint_logs_fallback_matches_fingerprint(
|
||||
|
||||
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
|
||||
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query])
|
||||
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query])
|
||||
|
||||
assert len(get_rows(optimized)) == 3
|
||||
assert_identical_query_response(optimized, fingerprint)
|
||||
|
||||
# Same transparency check as above, on the logs table.
|
||||
optimized_sql = get_preview_sql(make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query]), "A")
|
||||
fingerprint_sql = get_preview_sql(make_preview_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type=RequestType.RAW, queries=[query]), "A")
|
||||
|
||||
assert FINGERPRINT_CTE_FILTER in fingerprint_sql, fingerprint_sql
|
||||
assert FINGERPRINT_CTE_FILTER not in optimized_sql, optimized_sql
|
||||
|
||||
@@ -31,8 +31,8 @@ def signoz_skip_resource_fingerprint(
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-skip-resource-fingerprint",
|
||||
env_overrides={
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_ENABLED": True,
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_THRESHOLD": 2,
|
||||
"SIGNOZ_STATEMENTBUILDER_SKIP__RESOURCE__FINGERPRINT_ENABLED": True,
|
||||
"SIGNOZ_STATEMENTBUILDER_SKIP__RESOURCE__FINGERPRINT_THRESHOLD": 2,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -66,6 +66,6 @@ def signoz_fingerprint(
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-skip-resource-fingerprint-baseline",
|
||||
env_overrides={
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_ENABLED": False,
|
||||
"SIGNOZ_STATEMENTBUILDER_SKIP__RESOURCE__FINGERPRINT_ENABLED": False,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user