mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-13 20:01:05 +00:00
Compare commits
8 Commits
feat/input
...
tvats-patt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6dffa3a9 | ||
|
|
198230f064 | ||
|
|
0791306e52 | ||
|
|
ed87ed1b24 | ||
|
|
47c273e428 | ||
|
|
792de1376d | ||
|
|
20e6d44289 | ||
|
|
9fef8eea69 |
@@ -11,7 +11,7 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
|
||||
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
|
||||
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
|
||||
- **Plays live with the mocks.** A `play` is a named function exported from `stories/__story_mockdata__/<page>.play.ts`, which is collapsed for review like the rest of the AI-owned mocks. The story file only names it: `play: openEditDrawer`. A story file that still holds play bodies moves them there the first time you edit it.
|
||||
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, row count, hidden header). A value the page supports must never fail the play.
|
||||
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, access, check state and revoked permissions, license, held-open tooltips, a count at 0, a view or tab that drops the target, a write state). A value the page supports must never fail the play. The `signoz-page-story` skill's **Plays** section lists the cases plays have missed.
|
||||
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
|
||||
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
|
||||
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.
|
||||
|
||||
@@ -106,6 +106,27 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
|
||||
**Delete a story once its difference disappears.** Say a story checks that a button reads "View" rather than "Edit" when a permission is missing. If the label later becomes the same either way, that story passes no matter which permissions are held, including all of them, and it is testing nothing. When a behaviour stops depending on a permission, delete the story or rewrite it to lock in the new behaviour, and say which one you did in the PR.
|
||||
|
||||
## Plays
|
||||
|
||||
CI runs each play once, with its story's own args. Anyone can flip a control in the panel, and nothing runs the play against that value, so a play that breaks on it goes unnoticed. Write each play so it cannot.
|
||||
|
||||
Before writing the clicks, list what has to be on screen for the play's target to exist, and return early when the args take it away. These are the cases plays have missed:
|
||||
|
||||
- The controls every story has. `State` on loading or error renders no rows and no filter suggestions. `Access`, `Permissions` and `Revoked` remove rows, row actions and gated buttons, and the `editor` and `viewer` presets grant telemetry reads only, so a page gated on its own resource shows them nothing. `Check state` on loading disables every gated control, and on error an `AuthZButton` stays enabled while an `AuthZGuardContent` set to fail closed hides what it wraps. `License` and `Banner` decide the plan, so a cloud-only form is gone on the others. `Hold tooltips open` shows a label a second time, inside its tooltip.
|
||||
- A count at 0. The page can swap its whole list for an empty state, and the header, filters and column picker go with it.
|
||||
- A view, filter or invalid query that leaves no rows, even when the count is above 0.
|
||||
- A tab without the widget. A filter box that one tab has is not on the next.
|
||||
- A second list beside the main one, such as saved views. At 0, loading or failed, its actions are gone.
|
||||
- Text that depends on an arg. A disabled rule offers Enable, not Disable. A write that fails raises a different toast from one that lands, and one left loading raises none. Pick the expected text from the args.
|
||||
- Rows that move. A view, a sort or pinning changes which row is locked, pinned or legacy, so find the row by what it is, not by its index.
|
||||
|
||||
Make those checks with the page's own code instead of restating a condition. Build the rows with the builder the handler uses and the view's own filter, and check a permission with the page's own check through `accessFor(...).allows(...)`, or the role against the constant the page gates on, such as `componentPermission.action`. Export the checks from the mocks module, next to the handlers, so a handler change moves them too. The alert rules page has `listedRules`, `hasRowMenu`, `showsNoRulesPage` and `listsSavedViews`; the dashboards list has `listedDashboards`, `canList`, `canCreate` and `canUpdate`.
|
||||
|
||||
Two habits cover the rest:
|
||||
|
||||
- Use `findAllBy*` and take the first whenever the text can appear twice, such as in a tooltip or a popover title.
|
||||
- Wrap `toBeVisible` in `waitFor` for anything that fades in. A tooltip or toast found the moment it mounts is not visible yet.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args, every widget carrying data. Empty, loading and failed are variants or control values, never the default.
|
||||
@@ -116,7 +137,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
|
||||
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
|
||||
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`, play functions in `stories/__story_mockdata__/<page>.play.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
|
||||
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. See **Plays**. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
|
||||
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file under a `__story_mockdata__/` open with this banner, above the imports:
|
||||
|
||||
```ts
|
||||
@@ -143,7 +164,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it reads, each reached by `revoked`, none of them a role preset, and each one checked by the `disabled` state of what the permission gates
|
||||
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
|
||||
- [ ] every control flipped once, its effect seen on screen
|
||||
- [ ] every play passes with each control value its story exposes, and the story file holds no play body
|
||||
- [ ] every play passes with each control value its story exposes, checked against the cases in **Plays**, and the story file holds no play body
|
||||
- [ ] every request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
|
||||
50
.github/workflows/build-staging.yaml
vendored
50
.github/workflows/build-staging.yaml
vendored
@@ -18,25 +18,25 @@ env:
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
|
||||
if: ${{ github.event_name == 'push' || startsWith(github.event.label.name, 'staging:') }}
|
||||
outputs:
|
||||
version: ${{ steps.build-info.outputs.version }}
|
||||
hash: ${{ steps.build-info.outputs.hash }}
|
||||
time: ${{ steps.build-info.outputs.time }}
|
||||
branch: ${{ steps.build-info.outputs.branch }}
|
||||
deployment: ${{ steps.build-info.outputs.deployment }}
|
||||
path: ${{ steps.build-info.outputs.path }}
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: token
|
||||
name: github-token-gen
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
app-id: ${{ secrets.PRIMUS_APP_ID }}
|
||||
private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
- name: primus-checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: signoz/primus
|
||||
ref: main
|
||||
@@ -44,22 +44,29 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: build-info
|
||||
id: build-info
|
||||
env:
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
run: |
|
||||
echo "version=$($MAKE info-version)" >> $GITHUB_OUTPUT
|
||||
echo "hash=$($MAKE info-commit-short)" >> $GITHUB_OUTPUT
|
||||
echo "time=$($MAKE info-timestamp)" >> $GITHUB_OUTPUT
|
||||
echo "branch=$($MAKE info-branch)" >> $GITHUB_OUTPUT
|
||||
|
||||
staging_label="${{ github.event.label.name }}"
|
||||
if [[ "${staging_label}" == "staging:"* ]]; then
|
||||
deployment=${staging_label#"staging:"}
|
||||
elif [[ "${{ github.event.ref }}" == "refs/heads/main" ]]; then
|
||||
deployment="staging"
|
||||
# merges to main release the base every overlay inherits, a staging:<deployment> label releases that overlay
|
||||
if [[ "${GITHUB_EVENT_NAME}" == push ]]; then
|
||||
path="overlays/staging/*/signoz/base"
|
||||
else
|
||||
echo "error: not able to determine deployment - please verify the PR label or the branch"
|
||||
# the deployment is spliced into the release path glob and the JSON payload
|
||||
deployment="${LABEL#staging:}"
|
||||
if [[ ! "${deployment}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
|
||||
echo "::error::label ${LABEL} must be staging:<deployment>, where the deployment is lowercase letters, digits and dashes"
|
||||
exit 1
|
||||
fi
|
||||
path="overlays/staging/*/signoz/overlays/${deployment}"
|
||||
fi
|
||||
echo "deployment=${deployment}" >> $GITHUB_OUTPUT
|
||||
|
||||
{
|
||||
echo "version=$($MAKE info-version)"
|
||||
echo "hash=$($MAKE info-commit-short)"
|
||||
echo "time=$($MAKE info-timestamp)"
|
||||
echo "branch=$($MAKE info-branch)"
|
||||
echo "path=${path}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
- name: create-dotenv
|
||||
run: |
|
||||
mkdir -p frontend
|
||||
@@ -71,7 +78,7 @@ jobs:
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: frontend/.env
|
||||
key: staging-dotenv-${{ github.sha }}
|
||||
@@ -115,7 +122,6 @@ jobs:
|
||||
DOCKER_MANIFEST: true
|
||||
DOCKER_PROVIDERS: gcp
|
||||
staging:
|
||||
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
|
||||
uses: signoz/primus.workflows/.github/workflows/github-trigger.yaml@main
|
||||
secrets: inherit
|
||||
needs: [prepare, go-build]
|
||||
@@ -123,6 +129,6 @@ jobs:
|
||||
PRIMUS_REF: main
|
||||
GITHUB_ENVIRONMENT: staging
|
||||
GITHUB_SILENT: true
|
||||
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
|
||||
GITHUB_EVENT_NAME: releaser
|
||||
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'
|
||||
GITHUB_REPOSITORY_NAME: signoz.staging.cl.gcp.deployments
|
||||
GITHUB_EVENT_NAME: release
|
||||
GITHUB_EVENT_PAYLOAD: '{"path": "${{ needs.prepare.outputs.path }}", "image": "us-central1-docker.pkg.dev/signoz-registry/signoz/signoz", "tag": "${{ needs.prepare.outputs.version }}"}'
|
||||
|
||||
@@ -25613,7 +25613,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -26569,7 +26568,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -27426,7 +27424,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -29045,7 +29042,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
|
||||
@@ -11,6 +11,8 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
// generated API Instance
|
||||
const generatedAPIAxiosInstance = axios.create({
|
||||
baseURL: ENVIRONMENT.baseURL,
|
||||
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
|
||||
|
||||
@@ -630,7 +630,6 @@ describe('Footer utils', () => {
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
expect(props.schemaVersion).toBe('v2alpha1');
|
||||
expect(props.condition.algorithm).toBe('standard');
|
||||
expect(props.condition.seasonality).toBe('daily');
|
||||
expect(props.condition.thresholds?.spec[0]).toMatchObject({
|
||||
target: 3,
|
||||
|
||||
@@ -324,7 +324,6 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
condition: {
|
||||
...payload.condition,
|
||||
thresholds: { kind: 'basic', spec: thresholds },
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -323,7 +323,6 @@ describe('CreateAlert utils', () => {
|
||||
ruleType: 'anomaly_rule',
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
@@ -349,7 +348,6 @@ describe('CreateAlert utils', () => {
|
||||
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [{ thresholdValue: 3 }],
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,8 +274,6 @@ export function getThresholdStateFromAlertDef(
|
||||
matchType:
|
||||
alertDef.condition.thresholds?.spec[0].matchType ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
algorithm:
|
||||
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
|
||||
seasonality:
|
||||
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import {
|
||||
type ListRulesV3200,
|
||||
type ListRulesV3Params,
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
|
||||
const HOME_ALERT_RULES_LIMIT = 5;
|
||||
|
||||
@@ -23,18 +21,7 @@ interface HomeAlertRules {
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params: FIRING_RULES_PARAMS,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
|
||||
@@ -104,5 +104,5 @@ describe('AI observability Explorer', () => {
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: [],
|
||||
});
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
|
||||
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
|
||||
}));
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
|
||||
const VIEW_1_QUERY = "severity = 'critical'";
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@ import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import type {
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesLabelPairDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
@@ -35,18 +33,7 @@ export function useAlertRulesData(
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRulesV3(params, {
|
||||
query: {
|
||||
keepPreviousData: true,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
query: { keepPreviousData: true },
|
||||
});
|
||||
|
||||
const rules = useMemo(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest, type ResponseComposition, type RestContext } from 'msw';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
@@ -12,7 +13,10 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
|
||||
import { componentPermission } from 'utils/permission';
|
||||
import { DEFAULT_RULE_LIST_FILTER } from 'container/ListAlertRules/utils';
|
||||
import {
|
||||
DEFAULT_RULE_VIEW,
|
||||
@@ -281,3 +285,37 @@ export const runRowAction = async (
|
||||
await userEvent.click(actions);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name }));
|
||||
};
|
||||
|
||||
export type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
export const listedRules = (
|
||||
args: AlertRulesArgs,
|
||||
): RuletypesListableRuleDTO[] => {
|
||||
const params = new URL(listRoute(args.view), 'http://localhost').searchParams;
|
||||
if (
|
||||
args.dataState !== 'loaded' ||
|
||||
(args.invalidQuery !== 'off' && params.get('query'))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return alertRulesPage(
|
||||
args.rules,
|
||||
{ severity: args.ruleSeverity, state: args.ruleState },
|
||||
params,
|
||||
).data.rules;
|
||||
};
|
||||
|
||||
export const hasRowMenu = (args: AlertRulesArgs): boolean =>
|
||||
listedRules(args).length > 0 &&
|
||||
componentPermission.action.includes(
|
||||
accessFor(args.access, args.permissions, args.revoked).legacyRole,
|
||||
);
|
||||
|
||||
export const showsNoRulesPage = (args: AlertRulesArgs): boolean =>
|
||||
args.view === DEFAULT_RULE_VIEW &&
|
||||
args.dataState === 'loaded' &&
|
||||
args.rules === 0;
|
||||
|
||||
export const listsSavedViews = (args: AlertRulesArgs): boolean =>
|
||||
args.ruleViews > 0 && args.ruleViewsState === 'success';
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertRulesMocks } from './AlertRules.stories.mocks';
|
||||
import {
|
||||
alertRulesMocks,
|
||||
type AlertRulesArgs,
|
||||
} from './AlertRules.stories.mocks';
|
||||
import {
|
||||
cloneAlert,
|
||||
copyLabel,
|
||||
@@ -22,8 +24,6 @@ import {
|
||||
import AlertList from '../../index';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { DEFAULT_RULE_VIEW } from 'container/ListAlertRules/list.config';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
alertRulesMocks,
|
||||
type AlertRulesArgs,
|
||||
hasRowMenu,
|
||||
listedRules,
|
||||
listsSavedViews,
|
||||
runRowAction,
|
||||
showsNoRulesPage,
|
||||
untilLoaded,
|
||||
} from '../AlertRules.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof alertRulesMocks>;
|
||||
args: AlertRulesArgs;
|
||||
}
|
||||
|
||||
// A row's rename and delete only take the pointer while the row is hovered,
|
||||
@@ -25,10 +27,15 @@ const hoverActions = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
export const openQuerySuggestions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (listedRules(args).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
// The labels come with the rule list, so the box is opened once rows are in.
|
||||
await canvas.findAllByTestId('alert-actions', undefined, untilLoaded);
|
||||
await canvas.findAllByTestId(/^alert-row-.+-name$/, undefined, untilLoaded);
|
||||
const search = canvas.getByTestId('alert-rules-list-search');
|
||||
await userEvent.click(search.querySelector('.cm-content') ?? search);
|
||||
await screen.findByText('labels.team');
|
||||
@@ -49,7 +56,12 @@ export const openSaveViewPopover = async ({
|
||||
|
||||
export const openRenameViewPopover = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await hoverActions.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-rules-view-rename-view-1',
|
||||
@@ -57,12 +69,17 @@ export const openRenameViewPopover = async ({
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Rename view');
|
||||
await screen.findAllByText('Rename view');
|
||||
};
|
||||
|
||||
export const openDeleteViewConfirm = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await hoverActions.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-rules-view-delete-view-1',
|
||||
@@ -75,7 +92,12 @@ export const openDeleteViewConfirm = async ({
|
||||
|
||||
export const openRowActions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
@@ -88,7 +110,12 @@ export const openRowActions = async ({
|
||||
|
||||
export const openDisabledRuleActions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
@@ -96,12 +123,21 @@ export const openDisabledRuleActions = async ({
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/^enable$/i);
|
||||
await screen.findByText(
|
||||
listedRules(args)[0].state === RuletypesAlertStateDTO.disabled
|
||||
? /^enable$/i
|
||||
: /^disable$/i,
|
||||
);
|
||||
};
|
||||
|
||||
export const openColumnPicker = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (showsNoRulesPage(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-columns-button',
|
||||
@@ -116,11 +152,7 @@ export const openStatesFilter = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.rules === 0 &&
|
||||
args.view === DEFAULT_RULE_VIEW &&
|
||||
args.dataState === 'loaded'
|
||||
) {
|
||||
if (showsNoRulesPage(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,6 +176,10 @@ export const toggleAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^(enable|disable)$/i);
|
||||
if (args.toggleState === 'success') {
|
||||
await expect(
|
||||
@@ -156,6 +192,10 @@ export const cloneAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^clone$/i);
|
||||
if (args.cloneState === 'success') {
|
||||
await expect(
|
||||
@@ -168,6 +208,10 @@ export const deleteAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^delete$/i);
|
||||
if (args.deleteState === 'success') {
|
||||
await expect(
|
||||
@@ -176,7 +220,11 @@ export const deleteAlert = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLabel = async (): Promise<void> => {
|
||||
export const copyLabel = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.tooltipsOpen || listedRules(args).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [copy] = await screen.findAllByRole(
|
||||
'button',
|
||||
{ name: 'Copy to clipboard' },
|
||||
@@ -184,5 +232,7 @@ export const copyLabel = async (): Promise<void> => {
|
||||
);
|
||||
|
||||
await userEvent.click(copy);
|
||||
await expect(await screen.findByText(/copied! use in search/i)).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
DashboardCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import {
|
||||
expect,
|
||||
mocked,
|
||||
@@ -24,7 +30,9 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'storybook/test';
|
||||
import { LicensePlatform } from 'types/api/licensesV3/getActive';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
@@ -32,6 +40,8 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { appShellMocks } from '@/storybook/globals/appShellMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
orgUsersResponse,
|
||||
@@ -67,6 +77,8 @@ import {
|
||||
type DashboardViewId,
|
||||
findBuiltinView,
|
||||
} from '../list.config';
|
||||
import { LIST_CHECKS } from '../constants/permissions';
|
||||
import type { DashboardListItem } from '../utils/helpers';
|
||||
|
||||
const LIST = 'Dashboards · list';
|
||||
const VIEWS = 'Dashboards · views';
|
||||
@@ -528,6 +540,81 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
},
|
||||
});
|
||||
|
||||
export type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export const canList = (args: DashboardsListArgs): boolean =>
|
||||
grantsAll(args, ...LIST_CHECKS);
|
||||
|
||||
/** `AuthZButton` leaves New dashboard enabled when the check itself fails. */
|
||||
export const canCreate = (args: DashboardsListArgs): boolean =>
|
||||
args.authzState === 'error' || grantsAll(args, DashboardCreatePermission);
|
||||
|
||||
export const canUpdate = (
|
||||
args: DashboardsListArgs,
|
||||
row: DashboardListItem,
|
||||
): boolean =>
|
||||
grantsAll(
|
||||
args,
|
||||
buildDashboardReadPermission(row.id),
|
||||
buildDashboardUpdatePermission(row.id),
|
||||
);
|
||||
|
||||
export const canDuplicate = (
|
||||
args: DashboardsListArgs,
|
||||
row: DashboardListItem,
|
||||
): boolean =>
|
||||
grantsAll(
|
||||
args,
|
||||
buildDashboardReadPermission(row.id),
|
||||
DashboardCreatePermission,
|
||||
);
|
||||
|
||||
export const canDelete = (
|
||||
args: DashboardsListArgs,
|
||||
row: DashboardListItem,
|
||||
): boolean =>
|
||||
!row.locked && grantsAll(args, buildDashboardDeletePermission(row.id));
|
||||
|
||||
export const listedDashboards = (
|
||||
args: DashboardsListArgs,
|
||||
): DashboardListItem[] => {
|
||||
if (args.dataState !== 'loaded' || args.invalidQuery || !canList(args)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = findBuiltinView(args.view);
|
||||
const rows = dashboardsListResponse({
|
||||
count: args.dashboards,
|
||||
offset: 0,
|
||||
limit: view?.clientFilter ? args.dashboards : PAGE_SIZE,
|
||||
markers: args.markers,
|
||||
query:
|
||||
new URL(listRoute(args.view), 'http://localhost').searchParams.get(
|
||||
'query',
|
||||
) ?? '',
|
||||
}).data.dashboards;
|
||||
|
||||
return view?.clientFilter
|
||||
? view.clientFilter(rows, {
|
||||
userEmail: STORY_USER_EMAIL,
|
||||
recent: recentDashboardIds(RECENT_COUNT),
|
||||
})
|
||||
: rows;
|
||||
};
|
||||
|
||||
export const showsWorkspaceEmpty = (args: DashboardsListArgs): boolean =>
|
||||
args.view === DEFAULT_DASHBOARD_VIEW &&
|
||||
args.dataState === 'loaded' &&
|
||||
!args.invalidQuery &&
|
||||
canList(args) &&
|
||||
args.dashboards === 0;
|
||||
|
||||
export const isCloudTenant = (args: DashboardsListArgs): boolean =>
|
||||
appShellMocks.config?.(appShellMocks.read(args)).appContext?.activeLicense
|
||||
?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
/** Opens the actions menu of the row at `index`. */
|
||||
export const openRowActions = async (
|
||||
canvasElement: HTMLElement,
|
||||
@@ -541,7 +628,7 @@ export const openRowActions = async (
|
||||
);
|
||||
|
||||
await userEvent.click(triggers[index]);
|
||||
await screen.findByText('Rename');
|
||||
await screen.findByTestId('dashboard-action-delete');
|
||||
};
|
||||
|
||||
/** Picks a row action, retrying while its permission check still disables it. */
|
||||
@@ -574,10 +661,11 @@ export const TOAST_TIMEOUT = 10000;
|
||||
|
||||
export const openLegacyDialog = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-title-4',
|
||||
`dashboard-title-${index}`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
@@ -587,8 +675,9 @@ export const openLegacyDialog = async (
|
||||
|
||||
export const retryMigration = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
await openLegacyDialog(canvasElement);
|
||||
await openLegacyDialog(canvasElement, index);
|
||||
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
|
||||
|
||||
await waitFor(
|
||||
|
||||
@@ -1,47 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardsListMocks,
|
||||
type DashboardsListArgs,
|
||||
longQueryRoute,
|
||||
overflowingRows,
|
||||
} from './DashboardsListPage.stories.mocks';
|
||||
import {
|
||||
copyLegacyDashboardId,
|
||||
createDashboardFails,
|
||||
createDashboard,
|
||||
deleteDashboard,
|
||||
deleteViewFails,
|
||||
deleteView,
|
||||
duplicateDashboard,
|
||||
importCreateFails,
|
||||
importDashboard,
|
||||
importInvalidImage,
|
||||
importInvalidJson,
|
||||
lockDashboard,
|
||||
migrateDashboard,
|
||||
migrationFails,
|
||||
openCreatedByFilter,
|
||||
openEditTagsDialog,
|
||||
openFirstRowActions,
|
||||
openRenameDialog,
|
||||
openSaveViewPopover,
|
||||
openTemplateTab,
|
||||
pinFails,
|
||||
pinPastLimit,
|
||||
pinDashboard,
|
||||
renameDashboard,
|
||||
renameViewFails,
|
||||
saveViewFails,
|
||||
renameView,
|
||||
saveView,
|
||||
submitTemplateRequest,
|
||||
templateRequestFails,
|
||||
templateRequestRejected,
|
||||
unlockDashboard,
|
||||
unpinFails,
|
||||
unpinDashboard,
|
||||
updateTags,
|
||||
} from './__story_mockdata__/dashboardsList.play';
|
||||
|
||||
import DashboardsListPage from '../DashboardsListPage';
|
||||
|
||||
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardsListMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -182,21 +176,21 @@ export const DashboardDeletedToast: Story = {
|
||||
/** Pinning past the limit: the backend refuses with a 409. */
|
||||
export const PinLimitToast: Story = {
|
||||
args: { pinWrite: 'limit' },
|
||||
play: pinPastLimit,
|
||||
play: pinDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** A pin the backend failed to write. */
|
||||
export const PinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: pinFails,
|
||||
play: pinDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** An unpin the backend failed to write, from the first (pinned) row. */
|
||||
export const UnpinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: unpinFails,
|
||||
play: unpinDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -213,14 +207,14 @@ export const DashboardMigratedToast: Story = {
|
||||
/** Retry migration of the legacy dashboard refused by the backend. */
|
||||
export const DashboardMigrationFailedToast: Story = {
|
||||
args: { migrationWrite: 'error' },
|
||||
play: migrationFails,
|
||||
play: migrateDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The Blank tab's create failing: the toast raised beside the error modal. */
|
||||
export const CreateDashboardFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: createDashboardFails,
|
||||
play: createDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -232,7 +226,7 @@ export const ImportJsonInvalidImageToast: Story = {
|
||||
/** A valid dashboard file the create endpoint refuses. */
|
||||
export const ImportJsonCreateFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: importCreateFails,
|
||||
play: importDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -244,33 +238,33 @@ export const TemplateRequestSubmittedToast: Story = {
|
||||
/** A template request the analytics endpoint answered with a failure. */
|
||||
export const TemplateRequestFailedToast: Story = {
|
||||
args: { templateRequest: 'error' },
|
||||
play: templateRequestFails,
|
||||
play: submitTemplateRequest,
|
||||
};
|
||||
|
||||
/** A template request that threw before an answer came back. */
|
||||
export const TemplateRequestRejectedToast: Story = {
|
||||
args: { templateRequest: 'rejected' },
|
||||
play: templateRequestRejected,
|
||||
play: submitTemplateRequest,
|
||||
};
|
||||
|
||||
/** Saving the current filters as a view, refused by the backend. */
|
||||
export const SaveViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: saveViewFails,
|
||||
play: saveView,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Renaming a saved view, refused by the backend. */
|
||||
export const RenameViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: renameViewFails,
|
||||
play: renameView,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Deleting a saved view, refused by the backend. */
|
||||
export const DeleteViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: deleteViewFails,
|
||||
play: deleteView,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,30 +5,68 @@
|
||||
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
canCreate,
|
||||
canDelete,
|
||||
canDuplicate,
|
||||
canList,
|
||||
canUpdate,
|
||||
clickRowAction,
|
||||
dashboardsListMocks,
|
||||
type DashboardsListArgs,
|
||||
isCloudTenant,
|
||||
listedDashboards,
|
||||
openLegacyDialog,
|
||||
openRowActions,
|
||||
pickRowAction,
|
||||
requestTemplate,
|
||||
retryMigration,
|
||||
showsWorkspaceEmpty,
|
||||
TOAST_TIMEOUT,
|
||||
uploadDashboardJson,
|
||||
} from '../DashboardsListPage.stories.mocks';
|
||||
import type { TemplateRequest } from './dashboardsList';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof dashboardsListMocks>;
|
||||
args: DashboardsListArgs;
|
||||
}
|
||||
|
||||
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
|
||||
|
||||
const TEMPLATE_REQUEST_TOASTS = {
|
||||
success: 'Dashboard request submitted',
|
||||
error: 'Event rejected',
|
||||
rejected: 'Something went wrong',
|
||||
} satisfies Record<TemplateRequest, string>;
|
||||
|
||||
const openNewDashboard = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const canEditRow = (args: DashboardsListArgs, index: number): boolean => {
|
||||
const row = listedDashboards(args)[index];
|
||||
|
||||
return !!row && !row.legacy && !row.locked && canUpdate(args, row);
|
||||
};
|
||||
|
||||
export const openCreatedByFilter = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
!canList(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
showsWorkspaceEmpty(args)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = await within(canvasElement).findByTestId(
|
||||
'dashboards-filter-created-by',
|
||||
{},
|
||||
@@ -37,18 +75,18 @@ export const openCreatedByFilter = async ({
|
||||
|
||||
await userEvent.click(filter.querySelector('.ant-select-selector') ?? filter);
|
||||
await userEvent.click(await screen.findByText('Ada (ada@signoz.io)'));
|
||||
await screen.findByRole('checkbox', { checked: true });
|
||||
await screen.findAllByRole('checkbox', { checked: true });
|
||||
};
|
||||
|
||||
export const openTemplateTab = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
|
||||
);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await expect(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
).toHaveTextContent('New dashboard');
|
||||
@@ -58,12 +96,13 @@ export const openTemplateTab = async ({
|
||||
|
||||
export const importInvalidJson = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
|
||||
);
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(await screen.findByText('Import JSON'));
|
||||
await userEvent.click(await screen.findByTestId('import-json-submit'));
|
||||
await screen.findByText(/error loading json/i);
|
||||
@@ -73,13 +112,23 @@ export const importInvalidJson = async ({
|
||||
|
||||
export const openFirstRowActions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (listedDashboards(args).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 0);
|
||||
};
|
||||
|
||||
export const openRenameDialog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
await screen.findByRole('dialog', { name: 'Rename dashboard' });
|
||||
@@ -87,7 +136,12 @@ export const openRenameDialog = async ({
|
||||
|
||||
export const openEditTagsDialog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
|
||||
@@ -95,7 +149,12 @@ export const openEditTagsDialog = async ({
|
||||
|
||||
export const openSaveViewPopover = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canList(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
@@ -110,6 +169,10 @@ export const renameDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
const input = await screen.findByTestId('rename-dashboard-input');
|
||||
@@ -126,6 +189,10 @@ export const updateTags = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
const input = await screen.findByPlaceholderText('key:value (press Enter)');
|
||||
@@ -141,11 +208,37 @@ export const duplicateDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const row = listedDashboards(args)[1];
|
||||
if (!row || row.legacy || !canDuplicate(args, row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-duplicate');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Duplicated "API latency and errors"',
|
||||
`Duplicated "${row.name}"`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLockAt = async (
|
||||
canvasElement: HTMLElement,
|
||||
args: DashboardsListArgs,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
const row = listedDashboards(args)[index];
|
||||
if (!row || row.legacy || !canUpdate(args, row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, index);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText(
|
||||
row.locked ? 'Dashboard unlocked' : 'Dashboard locked',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
@@ -155,29 +248,22 @@ export const duplicateDashboard = async ({
|
||||
export const lockDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
};
|
||||
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 1);
|
||||
|
||||
export const unlockDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard unlocked', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
};
|
||||
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 0);
|
||||
|
||||
export const deleteDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const row = listedDashboards(args)[1];
|
||||
if (!row || !canDelete(args, row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-delete');
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
@@ -194,61 +280,73 @@ export const deleteDashboard = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const pinPastLimit = async ({
|
||||
export const pinDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const index = listedDashboards(args).findIndex((row) => !row.pinned);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-3',
|
||||
`dashboard-pin-${index}`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
/You can pin up to 10 dashboards/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.pinWrite === 'limit') {
|
||||
await screen.findByText(
|
||||
/You can pin up to 10 dashboards/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
if (args.pinWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to pin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const pinFails = async ({
|
||||
export const unpinDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-3',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to pin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
const index = listedDashboards(args).findIndex((row) => row.pinned);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const unpinFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-0',
|
||||
`dashboard-pin-${index}`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to unpin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.pinWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to unpin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLegacyDashboardId = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openLegacyDialog(canvasElement);
|
||||
const index = listedDashboards(args).findIndex((row) => row.legacy);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openLegacyDialog(canvasElement, index);
|
||||
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
|
||||
await screen.findByText('Dashboard ID copied', {}, { timeout: TOAST_TIMEOUT });
|
||||
};
|
||||
@@ -257,7 +355,13 @@ export const migrateDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await retryMigration(canvasElement);
|
||||
const rows = listedDashboards(args);
|
||||
const index = rows.findIndex((row) => row.legacy);
|
||||
if (index < 0 || !canUpdate(args, rows[index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
await retryMigration(canvasElement, index);
|
||||
if (args.migrationWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Dashboard migrated to the new experience',
|
||||
@@ -265,47 +369,43 @@ export const migrateDashboard = async ({
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const migrationFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await retryMigration(canvasElement);
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const createDashboardFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
if (args.migrationWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const createDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.createWrite === 'error') {
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const importInvalidImage = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
image: 'https://example.com/logo.png',
|
||||
@@ -318,59 +418,52 @@ export const importInvalidImage = async ({
|
||||
);
|
||||
};
|
||||
|
||||
export const importCreateFails = async ({
|
||||
export const importDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
|
||||
});
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.createWrite === 'error') {
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const submitTemplateRequest = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args) || !isCloudTenant(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText(
|
||||
'Dashboard request submitted',
|
||||
TEMPLATE_REQUEST_TOASTS[args.templateRequest],
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const templateRequestFails = async ({
|
||||
export const saveView = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
|
||||
};
|
||||
if (!canList(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const templateRequestRejected = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const saveViewFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
@@ -380,16 +473,26 @@ export const saveViewFails = async ({
|
||||
);
|
||||
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
|
||||
await userEvent.click(await screen.findByTestId('save-view-confirm'));
|
||||
await screen.findByText(
|
||||
'Failed to save view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to save view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const renameViewFails = async ({
|
||||
const listsSavedViews = (args: DashboardsListArgs): boolean =>
|
||||
canList(args) && args.dataState === 'loaded' && args.savedViews > 0;
|
||||
|
||||
export const renameView = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rename] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Rename view' },
|
||||
@@ -403,16 +506,23 @@ export const renameViewFails = async ({
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Renamed view');
|
||||
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
|
||||
await screen.findByText(
|
||||
'Failed to update view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to update view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteViewFails = async ({
|
||||
export const deleteView = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [remove] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Delete view' },
|
||||
@@ -425,9 +535,11 @@ export const deleteViewFails = async ({
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: 'Delete' }),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to delete view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to delete view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
InfraMonitoringEntity,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { infraStoryMocks } from './InfrastructureMonitoring.stories.mocks';
|
||||
import { infraListResponse } from './__story_mockdata__/infraMonitoring';
|
||||
|
||||
@@ -56,6 +58,7 @@ export const podLongNodeName = rest.post(
|
||||
* what puts `?category=` on the route the story starts on.
|
||||
*/
|
||||
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
|
||||
export type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
|
||||
export const namespacesMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { podLongNodeName, podsMocks } from './Kubernetes.stories.mocks';
|
||||
import {
|
||||
podLongNodeName,
|
||||
podsMocks,
|
||||
type PodsArgs,
|
||||
} from './Kubernetes.stories.mocks';
|
||||
import {
|
||||
copyPodMetadata,
|
||||
copyPodName,
|
||||
openDrawerLogsTab,
|
||||
openTableOptions,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
} from './__story_mockdata__/pods.play';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
|
||||
const pageStory = storyMocks(podsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -57,38 +60,19 @@ export const PodDetailsEventsJsonBody: StoryObj<PodsArgs> = {
|
||||
/** The selected pod's details drawer, switched to its logs tab. */
|
||||
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
await drawer.findByText('Logs', {}, { timeout: 10000 }),
|
||||
);
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
},
|
||||
play: openDrawerLogsTab,
|
||||
};
|
||||
|
||||
/** The copy button beside the pod name in the drawer, which toasts the copy. */
|
||||
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Pod name copied to clipboard');
|
||||
},
|
||||
play: copyPodName,
|
||||
};
|
||||
|
||||
/** A metadata value's copy button in the drawer, which toasts the label it copied. */
|
||||
export const PodMetadataCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('NAMESPACE copied to clipboard');
|
||||
},
|
||||
play: copyPodMetadata,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -122,19 +106,7 @@ export const TooltipsInDetailsDrawer: StoryObj<PodsArgs> = {
|
||||
*/
|
||||
export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId(
|
||||
'k8s-table-options-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByText('Columns');
|
||||
},
|
||||
play: openTableOptions,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -147,16 +119,11 @@ const heldViewport = { sbshot: { viewport: { height: 1200 } } };
|
||||
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
|
||||
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** The pod filter on a namespace: the namespaces the pods run in. */
|
||||
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
97
frontend/src/pages/InfrastructureMonitoring/stories/__story_mockdata__/pods.play.ts
generated
Normal file
97
frontend/src/pages/InfrastructureMonitoring/stories/__story_mockdata__/pods.play.ts
generated
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PodsArgs } from '../Kubernetes.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PodsArgs;
|
||||
}
|
||||
|
||||
export const openDrawerLogsTab = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!args.drawer || args.dataState !== 'loaded' || args.telemetryRows === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(await drawer.findByText('Logs', {}, { timeout: 10000 }));
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
};
|
||||
|
||||
export const copyPodName = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.drawer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Pod name copied to clipboard');
|
||||
};
|
||||
|
||||
export const copyPodMetadata = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.drawer || args.dataState !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('NAMESPACE copied to clipboard');
|
||||
};
|
||||
|
||||
export const openTableOptions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
// The drawer covers the list, so its options are out of reach.
|
||||
if (args.drawer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'k8s-table-options-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('Columns');
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.dataState !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.dataState !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
@@ -312,3 +313,5 @@ export const logsMocks = defineStoryMocks({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export type LogsArgs = PageStoryArgs<typeof logsMocks>;
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
blurFilter,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
typeFilterWithCaretBack,
|
||||
findSuggestion,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { logsMocks } from './LogsModulePage.stories.mocks';
|
||||
import { type LogsArgs, logsMocks } from './LogsModulePage.stories.mocks';
|
||||
import {
|
||||
completeFilter,
|
||||
copyLog,
|
||||
copyLogDetailsLink,
|
||||
copyLogLinkFromRow,
|
||||
dirtyQuickFiltersSettings,
|
||||
openLogContext,
|
||||
openLogDetails,
|
||||
openLogDetailsMenu,
|
||||
openQuickFiltersSettings,
|
||||
showFilterSyntaxError,
|
||||
stepToNextLog,
|
||||
suggestConjunctions,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
suggestInListValues,
|
||||
suggestInNestedGroup,
|
||||
suggestNegatedOperators,
|
||||
suggestNoValues,
|
||||
suggestNumberOperators,
|
||||
suggestOperators,
|
||||
suggestPartialKey,
|
||||
suggestRecentSearches,
|
||||
} from './__story_mockdata__/logs.play';
|
||||
|
||||
import LogsModulePage from '../LogsModulePage';
|
||||
|
||||
type LogsArgs = PageStoryArgs<typeof logsMocks>;
|
||||
|
||||
const pageStory = storyMocks(logsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -38,42 +50,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<LogsArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
/** Opening a log is what the drawer is for, and the first row will do. */
|
||||
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
// The list hangs its click handler off the cells rather than the row, so a
|
||||
// click on the row itself opens nothing.
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.click(timestamp);
|
||||
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorer on its list view: quick filters down the left, the frequency
|
||||
* chart over a page of log lines across every severity, and the saved views
|
||||
@@ -148,30 +124,17 @@ export const JsonBodyFieldsRaw: Story = {
|
||||
|
||||
/** One log opened up: its own fields, then the resource that sent it. */
|
||||
export const LogDetails: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
},
|
||||
play: openLogDetails,
|
||||
};
|
||||
|
||||
/** The logs either side of the one that was opened, on the same filters. */
|
||||
export const LogDetailsContext: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByText('Context', undefined, untilLoaded),
|
||||
);
|
||||
// The tab queries the logs either side before it can render them.
|
||||
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
|
||||
},
|
||||
play: openLogContext,
|
||||
};
|
||||
|
||||
/** The log detail drawer with its header actions menu open. */
|
||||
export const LogDetailsMenuOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await screen.findByText('Copy link to log');
|
||||
},
|
||||
play: openLogDetailsMenu,
|
||||
};
|
||||
|
||||
/** A response warning shown beside the query actions. */
|
||||
@@ -196,17 +159,6 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
@@ -232,76 +184,48 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
|
||||
);
|
||||
},
|
||||
play: stepToNextLog,
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: every key the logs carry. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'severity_text');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** A partial key, with the keys that still match and the typed part marked. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
},
|
||||
play: suggestPartialKey,
|
||||
};
|
||||
|
||||
/** A string key followed by a space: the operators a string compares with. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
},
|
||||
play: suggestOperators,
|
||||
};
|
||||
|
||||
/** A number key puts the range comparisons first. */
|
||||
export const FilterNumberOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
},
|
||||
play: suggestNumberOperators,
|
||||
};
|
||||
|
||||
/** `NOT` after a key narrows the list to the operators it can negate. */
|
||||
export const FilterNegatedOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
},
|
||||
play: suggestNegatedOperators,
|
||||
};
|
||||
|
||||
/** A key and an operator: the values the key holds, fetched for it. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** Values still being fetched for the key. */
|
||||
export const FilterValuesLoading: Story = {
|
||||
args: { filterValues: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Loading suggestions');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** A key the backend holds no values for, such as the free-text body. */
|
||||
export const FilterNoValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
},
|
||||
play: suggestNoValues,
|
||||
};
|
||||
|
||||
/** The values request failed. */
|
||||
@@ -309,120 +233,55 @@ export const FilterValuesError: Story = {
|
||||
args: { filterValues: 'error' },
|
||||
// The values request deliberately fails.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Error loading suggestions');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** Inside an `IN` list, after the first value: the rest of the values. */
|
||||
export const FilterInList: Story = {
|
||||
// The editor logs a TypeError while the list is open, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
},
|
||||
play: suggestInListValues,
|
||||
};
|
||||
|
||||
/** A complete condition: the conjunctions that start the next one. */
|
||||
export const FilterConjunctionSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
},
|
||||
play: suggestConjunctions,
|
||||
};
|
||||
|
||||
/** Inside an opened group: keys, another group and `NOT`. */
|
||||
export const FilterNestedGroup: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
|
||||
},
|
||||
play: suggestInNestedGroup,
|
||||
};
|
||||
|
||||
/** A long valid expression mixing operators, left for the next run. */
|
||||
export const FilterComplete: Story = {
|
||||
// The editor logs a TypeError while the `IN` list is typed, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(
|
||||
canvasElement,
|
||||
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
|
||||
);
|
||||
await blurFilter(canvasElement);
|
||||
},
|
||||
play: completeFilter,
|
||||
};
|
||||
|
||||
/** An incomplete expression after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'service.name = ');
|
||||
},
|
||||
play: showFilterSyntaxError,
|
||||
};
|
||||
|
||||
/** Filters run before, offered above the key suggestions. */
|
||||
export const FilterRecentSearches: Story = {
|
||||
args: { recentFilters: 3 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
},
|
||||
play: suggestRecentSearches,
|
||||
};
|
||||
|
||||
/** Row actions render on hover, so the first row is hovered before the copy link is pressed. */
|
||||
export const LogLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.hover(timestamp);
|
||||
await userEvent.click(
|
||||
await waitFor(() => {
|
||||
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
|
||||
|
||||
if (!button) {
|
||||
throw new Error('Row actions did not render');
|
||||
}
|
||||
|
||||
return button;
|
||||
}, untilLoaded),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
play: copyLogLinkFromRow,
|
||||
};
|
||||
|
||||
/** "Copy log" in the log detail drawer's actions menu. */
|
||||
export const LogCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await userEvent.click(await screen.findByText('Copy log'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
play: copyLog,
|
||||
};
|
||||
|
||||
/** "Copy link to log" in the log detail drawer's actions menu. */
|
||||
export const LogDetailsLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await userEvent.click(await screen.findByText('Copy link to log'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
play: copyLogDetailsLink,
|
||||
};
|
||||
|
||||
319
frontend/src/pages/LogsModulePage/stories/__story_mockdata__/logs.play.ts
generated
Normal file
319
frontend/src/pages/LogsModulePage/stories/__story_mockdata__/logs.play.ts
generated
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
blurFilter,
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
typeFilterWithCaretBack,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
|
||||
import type { LogsArgs } from '../LogsModulePage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: LogsArgs;
|
||||
}
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const VALUE_SUGGESTIONS = {
|
||||
loaded: 'checkout',
|
||||
loading: 'Loading suggestions',
|
||||
error: 'Error loading suggestions',
|
||||
} satisfies Record<LogsArgs['filterValues'], string>;
|
||||
|
||||
/** The rows a play opens exist only in the list view's table format. */
|
||||
const listsLogRows = (args: LogsArgs, count = 1): boolean =>
|
||||
args.view === 'list' &&
|
||||
args.format === 'table' &&
|
||||
args.dataState === 'loaded' &&
|
||||
args.logs >= count;
|
||||
|
||||
/** The settings control stays enabled when the check itself fails. */
|
||||
const canManageQuickFilters = (args: LogsArgs): boolean =>
|
||||
args.filtersPanel &&
|
||||
(args.authzState === 'error' ||
|
||||
grantsAll(args, ...QuickFilterManagePermissions));
|
||||
|
||||
/** Runs `drive` once the filter has its keys, which come with the page's data. */
|
||||
const onFilter =
|
||||
(drive: (canvasElement: HTMLElement, args: LogsArgs) => Promise<void>) =>
|
||||
async ({ canvasElement, args }: PlayContext): Promise<void> => {
|
||||
if (args.dataState === 'loaded') {
|
||||
await drive(canvasElement, args);
|
||||
}
|
||||
};
|
||||
|
||||
/** Opening a log is what the drawer is for, and the first row will do. */
|
||||
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
// The list hangs its click handler off the cells rather than the row, so a
|
||||
// click on the row itself opens nothing.
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.click(timestamp);
|
||||
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
const openLogMenuItem = async (
|
||||
canvasElement: HTMLElement,
|
||||
item: string,
|
||||
): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await userEvent.click(await screen.findByText(item));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
export const openLogDetails = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
};
|
||||
|
||||
export const openLogContext = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByText('Context', undefined, untilLoaded),
|
||||
);
|
||||
// The tab queries the logs either side before it can render them.
|
||||
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openLogDetailsMenu = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await screen.findByText('Copy link to log');
|
||||
};
|
||||
|
||||
export const openQuickFiltersSettings = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canManageQuickFilters(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(async () => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
export const dirtyQuickFiltersSettings = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { args } = context;
|
||||
if (
|
||||
!canManageQuickFilters(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.quickFilters === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openQuickFiltersSettings(context);
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
export const stepToNextLog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args, 2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
|
||||
);
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = onFilter((canvasElement) =>
|
||||
openKeySuggestions(canvasElement, 'severity_text'),
|
||||
);
|
||||
|
||||
export const suggestPartialKey = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
});
|
||||
|
||||
export const suggestOperators = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
});
|
||||
|
||||
export const suggestNumberOperators = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
});
|
||||
|
||||
export const suggestNegatedOperators = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
});
|
||||
|
||||
export const suggestFilterValues = onFilter(async (canvasElement, args) => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, VALUE_SUGGESTIONS[args.filterValues]);
|
||||
});
|
||||
|
||||
export const suggestNoValues = onFilter(async (canvasElement, args) => {
|
||||
if (args.filterValues !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
});
|
||||
|
||||
export const suggestInListValues = onFilter(async (canvasElement, args) => {
|
||||
if (args.filterValues !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
});
|
||||
|
||||
export const suggestConjunctions = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
});
|
||||
|
||||
export const suggestInNestedGroup = onFilter((canvasElement) =>
|
||||
typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT'),
|
||||
);
|
||||
|
||||
export const completeFilter = onFilter(async (canvasElement) => {
|
||||
await typeFilter(
|
||||
canvasElement,
|
||||
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
|
||||
);
|
||||
await blurFilter(canvasElement);
|
||||
});
|
||||
|
||||
export const showFilterSyntaxError = onFilter((canvasElement) =>
|
||||
showFilterErrors(canvasElement, 'service.name = '),
|
||||
);
|
||||
|
||||
export const suggestRecentSearches = onFilter(async (canvasElement, args) => {
|
||||
if (args.recentFilters === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
});
|
||||
|
||||
export const copyLogLinkFromRow = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.hover(timestamp);
|
||||
await userEvent.click(
|
||||
await waitFor(() => {
|
||||
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
|
||||
|
||||
if (!button) {
|
||||
throw new Error('Row actions did not render');
|
||||
}
|
||||
|
||||
return button;
|
||||
}, untilLoaded),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
export const copyLog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openLogMenuItem(canvasElement, 'Copy log');
|
||||
};
|
||||
|
||||
export const copyLogDetailsLink = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openLogMenuItem(canvasElement, 'Copy link to log');
|
||||
};
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
@@ -553,6 +554,8 @@ export const metricsMocks = defineStoryMocks({
|
||||
},
|
||||
});
|
||||
|
||||
export type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
|
||||
export const saveUnit = async (
|
||||
canvasElement: HTMLElement,
|
||||
expected: string | false,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { metricsMocks, saveUnit } from './MetricsExplorerPage.stories.mocks';
|
||||
import {
|
||||
type MetricsArgs,
|
||||
metricsMocks,
|
||||
} from './MetricsExplorerPage.stories.mocks';
|
||||
import {
|
||||
openDashboardsMenu,
|
||||
saveMetricUnit,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
} from './__story_mockdata__/metricsExplorer.play';
|
||||
import MetricsExplorerPage from '../MetricsExplorerPage';
|
||||
|
||||
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
|
||||
const pageStory = storyMocks(metricsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -103,31 +103,17 @@ export const Tooltips: Story = {
|
||||
*/
|
||||
export const MetricDetailsDashboardsMenu: Story = {
|
||||
args: { drawer: 'details' },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'metric-dashboards-popover',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
play: openDashboardsMenu,
|
||||
};
|
||||
|
||||
/** The summary's metric search focused: the attributes metrics can be found by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** The metric search on an attribute and an operator: the values it holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -136,14 +122,7 @@ export const FilterValueSuggestions: Story = {
|
||||
*/
|
||||
export const UnitSavedToast: Story = {
|
||||
args: { tab: 'explorer', metricUnit: 'unset' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await saveUnit(
|
||||
canvasElement,
|
||||
args.unitSave === 'success'
|
||||
? 'Unit saved successfully'
|
||||
: 'Failed to save unit',
|
||||
);
|
||||
},
|
||||
play: saveMetricUnit,
|
||||
};
|
||||
|
||||
/** The same save with the metadata write failing. */
|
||||
@@ -151,7 +130,5 @@ export const UnitSaveErrorToast: Story = {
|
||||
args: { tab: 'explorer', metricUnit: 'unset', unitSave: 'error' },
|
||||
// The deliberate 500 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await saveUnit(canvasElement, 'Failed to save unit');
|
||||
},
|
||||
play: saveMetricUnit,
|
||||
};
|
||||
|
||||
90
frontend/src/pages/MetricsExplorer/stories/__story_mockdata__/metricsExplorer.play.ts
generated
Normal file
90
frontend/src/pages/MetricsExplorer/stories/__story_mockdata__/metricsExplorer.play.ts
generated
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import {
|
||||
type MetricsArgs,
|
||||
saveUnit,
|
||||
} from '../MetricsExplorerPage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: MetricsArgs;
|
||||
}
|
||||
|
||||
const showsMetricSearch = (args: MetricsArgs): boolean =>
|
||||
args.dataState === 'loaded' && args.tab !== 'volume-control';
|
||||
|
||||
export const openDashboardsMenu = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.tab !== 'summary' ||
|
||||
args.drawer !== 'details' ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.relatedAssets === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'metric-dashboards-popover',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsMetricSearch(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsMetricSearch(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
};
|
||||
|
||||
export const saveMetricUnit = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.tab !== 'explorer' ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.metricUnit !== 'unset' ||
|
||||
args.explorerMetrics !== 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await saveUnit(
|
||||
canvasElement,
|
||||
args.unitSave === 'success'
|
||||
? 'Unit saved successfully'
|
||||
: 'Failed to save unit',
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
queryRangeV5RawResponse,
|
||||
@@ -354,3 +355,5 @@ export const tracesMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: tracesRoute(values) }),
|
||||
});
|
||||
|
||||
export type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { tracesMocks } from './TracesModulePage.stories.mocks';
|
||||
import { type TracesArgs, tracesMocks } from './TracesModulePage.stories.mocks';
|
||||
import {
|
||||
dirtyQuickFiltersSettings,
|
||||
loadNextListPage,
|
||||
openQuickFiltersSettings,
|
||||
showRootSpansNextPage,
|
||||
suggestBooleanValues,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
suggestResourceKeys,
|
||||
showFilterSyntaxError,
|
||||
} from './__story_mockdata__/traces.play';
|
||||
import TracesModulePage from '../TracesModulePage';
|
||||
|
||||
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
|
||||
const pageStory = storyMocks(tracesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -34,28 +35,8 @@ const meta = {
|
||||
|
||||
export default meta;
|
||||
|
||||
/** The page fetches before it renders its filters, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
type Story = StoryObj<TracesArgs>;
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* The Explorer tab on a full window of spans: the org's quick filters down the
|
||||
* left, the query builder and the view switcher above, and the span list with
|
||||
@@ -77,35 +58,13 @@ export const RootSpans: Story = {
|
||||
*/
|
||||
export const RootSpansNextPage: Story = {
|
||||
args: { view: ExplorerViews.TRACE, spans: 50 },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
|
||||
);
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
|
||||
await expect(
|
||||
await within(pager).findByText('3', undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showRootSpansNextPage,
|
||||
};
|
||||
|
||||
/** The span list scrolled to its end, with the next 100 spans loaded under the first. */
|
||||
export const ListNextPage: Story = {
|
||||
args: { spans: 150 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
// Scrolls again on every attempt, since each loaded page moves the end.
|
||||
await waitFor(async (): Promise<void> => {
|
||||
const scroller = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-virtuoso-scroller]',
|
||||
);
|
||||
|
||||
await expect(scroller).not.toBeNull();
|
||||
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
|
||||
await expect(
|
||||
canvasElement.querySelector('tr[data-index="120"]'),
|
||||
).not.toBeNull();
|
||||
}, untilLoaded);
|
||||
},
|
||||
play: loadNextListPage,
|
||||
};
|
||||
|
||||
/** The charted view of the same trace query. */
|
||||
@@ -154,17 +113,6 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
@@ -183,38 +131,25 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
|
||||
/** The filter focused before anything is typed: span and resource keys together. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** A context prefix: only the keys that live on the resource. */
|
||||
export const FilterResourceKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
},
|
||||
play: suggestResourceKeys,
|
||||
};
|
||||
|
||||
/** A key and an operator: the services the spans came from. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** A boolean key offers its two values. */
|
||||
export const FilterBooleanValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
},
|
||||
play: suggestBooleanValues,
|
||||
};
|
||||
|
||||
/** A dangling conjunction after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
},
|
||||
play: showFilterSyntaxError,
|
||||
};
|
||||
|
||||
191
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/traces.play.ts
generated
Normal file
191
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/traces.play.ts
generated
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
|
||||
import type { TracesArgs } from '../TracesModulePage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: TracesArgs;
|
||||
}
|
||||
|
||||
/** The page fetches before it renders its filters, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LIST_PAGE_SIZE = 100;
|
||||
|
||||
const TRACE_PAGE_SIZE = 20;
|
||||
|
||||
const showsFilter = (args: TracesArgs): boolean =>
|
||||
args.dataState === 'loaded' && args.tab !== 'funnels';
|
||||
|
||||
const showsView = (args: TracesArgs, view: ExplorerViews): boolean =>
|
||||
args.tab === 'explorer' && args.view === view && args.dataState === 'loaded';
|
||||
|
||||
/** The settings control stays enabled when the check itself fails. */
|
||||
const canManageQuickFilters = (args: TracesArgs): boolean =>
|
||||
args.tab === 'explorer' &&
|
||||
(args.authzState === 'error' ||
|
||||
grantsAll(args, ...QuickFilterManagePermissions));
|
||||
|
||||
export const showRootSpansNextPage = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
// The pager offers page 3 once page 2 comes back full.
|
||||
if (
|
||||
!showsView(args, ExplorerViews.TRACE) ||
|
||||
args.spans < 2 * TRACE_PAGE_SIZE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
|
||||
);
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
|
||||
await expect(
|
||||
await within(pager).findByText('3', undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const loadNextListPage = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsView(args, ExplorerViews.LIST) || args.spans <= LIST_PAGE_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scrolls again on every attempt, since each loaded page moves the end.
|
||||
await waitFor(async (): Promise<void> => {
|
||||
const scroller = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-virtuoso-scroller]',
|
||||
);
|
||||
|
||||
await expect(scroller).not.toBeNull();
|
||||
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
|
||||
const indexes = Array.from(
|
||||
canvasElement.querySelectorAll<HTMLElement>('tr[data-index]'),
|
||||
(row) => Number(row.dataset.index),
|
||||
);
|
||||
|
||||
await expect(Math.max(...indexes)).toBeGreaterThanOrEqual(LIST_PAGE_SIZE);
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const openQuickFiltersSettings = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canManageQuickFilters(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(async () => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
export const dirtyQuickFiltersSettings = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { args } = context;
|
||||
if (
|
||||
!canManageQuickFilters(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.quickFilters === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openQuickFiltersSettings(context);
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
};
|
||||
|
||||
export const suggestResourceKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
};
|
||||
|
||||
export const suggestBooleanValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
};
|
||||
|
||||
export const showFilterSyntaxError = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
};
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
IsViewerPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
formatPermission,
|
||||
gettableTransactionToPermission,
|
||||
permissionToTransactionDto,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import type { ResponseState } from '../runtime/responseState';
|
||||
|
||||
/**
|
||||
* `relation:kind` for every verb the backend allows on a resource, from the
|
||||
* generated permission catalogue, so a resource added there shows up here
|
||||
@@ -159,3 +163,23 @@ export const accessFor = (
|
||||
legacyRole: deriveLegacyRole(permissions),
|
||||
};
|
||||
};
|
||||
|
||||
export interface AccessArgs {
|
||||
access: AccessPreset;
|
||||
permissions: readonly string[];
|
||||
revoked: readonly string[];
|
||||
authzState: ResponseState;
|
||||
}
|
||||
|
||||
/** Whether `authz/check`, answered from a story's access controls, passes every one of `checks`. */
|
||||
export const grantsAll = (
|
||||
args: AccessArgs,
|
||||
...checks: BrandedPermission[]
|
||||
): boolean => {
|
||||
const grant = accessFor(args.access, args.permissions, args.revoked);
|
||||
|
||||
return (
|
||||
args.authzState === 'loaded' &&
|
||||
checks.every((check) => grant.allows(permissionToTransactionDto(check)))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface PostableAlertRuleV2 {
|
||||
absentFor?: number;
|
||||
requireMinPoints?: boolean;
|
||||
requiredNumPoints?: number;
|
||||
algorithm?: string;
|
||||
seasonality?: string;
|
||||
};
|
||||
evaluation?: {
|
||||
|
||||
@@ -38,6 +38,7 @@ primary
|
||||
| comparison
|
||||
| functionCall
|
||||
| searchCall
|
||||
| patternCall
|
||||
| fullText
|
||||
| key
|
||||
| value
|
||||
@@ -121,6 +122,16 @@ searchCall
|
||||
;
|
||||
|
||||
// Function parameters can be keys, single scalar values, or arrays
|
||||
/*
|
||||
* Log pattern filter: pattern('User <*> logged in from <IP>'[, '...']) keeps the
|
||||
* logs whose pattern is one of the given pattern texts. Handled in the visitor.
|
||||
* PATTERN carries its opening parenthesis, so a bare `pattern` stays a key
|
||||
* or a full-text word.
|
||||
*/
|
||||
patternCall
|
||||
: PATTERN valueList RPAREN
|
||||
;
|
||||
|
||||
functionParamList
|
||||
: functionParam ( COMMA functionParam )*
|
||||
;
|
||||
@@ -195,6 +206,7 @@ HAS : [Hh][Aa][Ss] ;
|
||||
HASANY : [Hh][Aa][Ss][Aa][Nn][Yy] ;
|
||||
HASALL : [Hh][Aa][Ss][Aa][Ll][Ll] ;
|
||||
SEARCH : [Ss][Ee][Aa][Rr][Cc][Hh] ;
|
||||
PATTERN : [Pp][Aa][Tt][Tt][Ee][Rr][Nn] [ \t\r\n]* '(' ;
|
||||
|
||||
// Potential boolean constants
|
||||
BOOL
|
||||
|
||||
@@ -251,7 +251,6 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
},
|
||||
},
|
||||
"algorithm": "standard",
|
||||
"seasonality": "daily",
|
||||
"selectedQueryName": "A",
|
||||
"requireMinPoints": true,
|
||||
|
||||
@@ -5,28 +5,23 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPostableRuleExamplesValidate verifies every example payload returned by
|
||||
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
|
||||
// postableRuleExamples() round-trips through PostableRule.DecodeStrict and
|
||||
// passes Validate(). If an example drifts from the runtime contract this
|
||||
// breaks loudly so the spec doesn't ship invalid payloads to users.
|
||||
func TestPostableRuleExamplesValidate(t *testing.T) {
|
||||
for _, example := range postableRuleExamples() {
|
||||
t.Run(example.Name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(example.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal example: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
var rule ruletypes.PostableRule
|
||||
if err := json.Unmarshal(raw, &rule); err != nil {
|
||||
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
|
||||
}
|
||||
|
||||
if err := rule.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v\npayload: %s", err, raw)
|
||||
}
|
||||
require.NoError(t, rule.DecodeStrict(string(raw)), "payload: %s", raw)
|
||||
assert.NoError(t, rule.Validate(), "payload: %s", raw)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,8 @@ func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) any {
|
||||
ctx.FunctionCall().Accept(r)
|
||||
} else if ctx.SearchCall() != nil {
|
||||
ctx.SearchCall().Accept(r)
|
||||
} else if ctx.PatternCall() != nil {
|
||||
ctx.PatternCall().Accept(r)
|
||||
} else if ctx.FullText() != nil {
|
||||
ctx.FullText().Accept(r)
|
||||
} else if ctx.Key() != nil {
|
||||
@@ -374,6 +376,13 @@ func (r *WhereClauseRewriter) VisitSearchCall(ctx *parser.SearchCallContext) any
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitPatternCall visits pattern() calls. It has no keys to rewrite, so it
|
||||
// is preserved verbatim.
|
||||
func (r *WhereClauseRewriter) VisitPatternCall(ctx *parser.PatternCallContext) any {
|
||||
r.rewritten.WriteString(ctx.GetText())
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitFunctionCall visits function calls.
|
||||
func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext) any {
|
||||
// Write function name
|
||||
|
||||
@@ -25,6 +25,16 @@ func TestPrepareFiltersV5(t *testing.T) {
|
||||
expected: "",
|
||||
description: "Should return empty string for empty inputs",
|
||||
},
|
||||
{
|
||||
name: "pattern_call_kept",
|
||||
labels: map[string]string{},
|
||||
whereClause: "service.name = 'serviceB' AND pattern('User <*> logged in')",
|
||||
groupByItems: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}},
|
||||
},
|
||||
expected: "service.name='serviceB' AND pattern('User <*> logged in')",
|
||||
description: "pattern() has no key to rewrite, so it is kept",
|
||||
},
|
||||
{
|
||||
name: "no_label_replacement",
|
||||
labels: map[string]string{},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,12 +25,13 @@ HAS=24
|
||||
HASANY=25
|
||||
HASALL=26
|
||||
SEARCH=27
|
||||
BOOL=28
|
||||
NUMBER=29
|
||||
QUOTED_TEXT=30
|
||||
KEY=31
|
||||
WS=32
|
||||
FREETEXT=33
|
||||
PATTERN=28
|
||||
BOOL=29
|
||||
NUMBER=30
|
||||
QUOTED_TEXT=31
|
||||
KEY=32
|
||||
WS=33
|
||||
FREETEXT=34
|
||||
'('=1
|
||||
')'=2
|
||||
'['=3
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,12 +25,13 @@ HAS=24
|
||||
HASANY=25
|
||||
HASALL=26
|
||||
SEARCH=27
|
||||
BOOL=28
|
||||
NUMBER=29
|
||||
QUOTED_TEXT=30
|
||||
KEY=31
|
||||
WS=32
|
||||
FREETEXT=33
|
||||
PATTERN=28
|
||||
BOOL=29
|
||||
NUMBER=30
|
||||
QUOTED_TEXT=31
|
||||
KEY=32
|
||||
WS=33
|
||||
FREETEXT=34
|
||||
'('=1
|
||||
')'=2
|
||||
'['=3
|
||||
|
||||
@@ -99,6 +99,12 @@ func (s *BaseFilterQueryListener) EnterSearchCall(ctx *SearchCallContext) {}
|
||||
// ExitSearchCall is called when production searchCall is exited.
|
||||
func (s *BaseFilterQueryListener) ExitSearchCall(ctx *SearchCallContext) {}
|
||||
|
||||
// EnterPatternCall is called when production patternCall is entered.
|
||||
func (s *BaseFilterQueryListener) EnterPatternCall(ctx *PatternCallContext) {}
|
||||
|
||||
// ExitPatternCall is called when production patternCall is exited.
|
||||
func (s *BaseFilterQueryListener) ExitPatternCall(ctx *PatternCallContext) {}
|
||||
|
||||
// EnterFunctionParamList is called when production functionParamList is entered.
|
||||
func (s *BaseFilterQueryListener) EnterFunctionParamList(ctx *FunctionParamListContext) {}
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ func (v *BaseFilterQueryVisitor) VisitSearchCall(ctx *SearchCallContext) interfa
|
||||
return v.VisitChildren(ctx)
|
||||
}
|
||||
|
||||
func (v *BaseFilterQueryVisitor) VisitPatternCall(ctx *PatternCallContext) interface{} {
|
||||
return v.VisitChildren(ctx)
|
||||
}
|
||||
|
||||
func (v *BaseFilterQueryVisitor) VisitFunctionParamList(ctx *FunctionParamListContext) interface{} {
|
||||
return v.VisitChildren(ctx)
|
||||
}
|
||||
|
||||
@@ -50,174 +50,184 @@ func filterquerylexerLexerInit() {
|
||||
"", "LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
|
||||
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS",
|
||||
"REGEXP", "CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY",
|
||||
"HASALL", "SEARCH", "BOOL", "NUMBER", "QUOTED_TEXT", "KEY", "WS", "FREETEXT",
|
||||
"HASALL", "SEARCH", "PATTERN", "BOOL", "NUMBER", "QUOTED_TEXT", "KEY",
|
||||
"WS", "FREETEXT",
|
||||
}
|
||||
staticData.RuleNames = []string{
|
||||
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
|
||||
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS",
|
||||
"REGEXP", "CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY",
|
||||
"HASALL", "SEARCH", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT",
|
||||
"EMPTY_BRACKS", "OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
|
||||
"HASALL", "SEARCH", "PATTERN", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT",
|
||||
"SEGMENT", "EMPTY_BRACKS", "OLD_JSON_BRACKS", "KEY", "WS", "DIGIT",
|
||||
"FREETEXT",
|
||||
}
|
||||
staticData.PredictionContextCache = antlr.NewPredictionContextCache()
|
||||
staticData.serializedATN = []int32{
|
||||
4, 0, 33, 329, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
|
||||
4, 0, 34, 346, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
|
||||
4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2,
|
||||
10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15,
|
||||
7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7,
|
||||
20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25,
|
||||
2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2,
|
||||
31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36,
|
||||
7, 36, 2, 37, 7, 37, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1,
|
||||
4, 1, 4, 1, 5, 1, 5, 1, 5, 3, 5, 91, 8, 5, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7,
|
||||
1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11,
|
||||
1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1,
|
||||
13, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15,
|
||||
1, 15, 1, 15, 1, 15, 1, 15, 3, 15, 134, 8, 15, 1, 16, 1, 16, 1, 16, 1,
|
||||
16, 1, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17,
|
||||
1, 17, 3, 17, 151, 8, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1,
|
||||
19, 1, 20, 1, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 22, 1, 22, 1, 22,
|
||||
1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1,
|
||||
24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 25, 1, 25,
|
||||
1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1,
|
||||
27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 3, 27, 210,
|
||||
8, 27, 1, 28, 1, 28, 1, 29, 3, 29, 215, 8, 29, 1, 29, 4, 29, 218, 8, 29,
|
||||
11, 29, 12, 29, 219, 1, 29, 1, 29, 5, 29, 224, 8, 29, 10, 29, 12, 29, 227,
|
||||
9, 29, 3, 29, 229, 8, 29, 1, 29, 1, 29, 3, 29, 233, 8, 29, 1, 29, 4, 29,
|
||||
236, 8, 29, 11, 29, 12, 29, 237, 3, 29, 240, 8, 29, 1, 29, 3, 29, 243,
|
||||
8, 29, 1, 29, 1, 29, 4, 29, 247, 8, 29, 11, 29, 12, 29, 248, 1, 29, 1,
|
||||
29, 3, 29, 253, 8, 29, 1, 29, 4, 29, 256, 8, 29, 11, 29, 12, 29, 257, 3,
|
||||
29, 260, 8, 29, 3, 29, 262, 8, 29, 1, 30, 1, 30, 1, 30, 1, 30, 5, 30, 268,
|
||||
8, 30, 10, 30, 12, 30, 271, 9, 30, 1, 30, 1, 30, 1, 30, 1, 30, 1, 30, 5,
|
||||
30, 278, 8, 30, 10, 30, 12, 30, 281, 9, 30, 1, 30, 3, 30, 284, 8, 30, 1,
|
||||
31, 1, 31, 5, 31, 288, 8, 31, 10, 31, 12, 31, 291, 9, 31, 1, 32, 1, 32,
|
||||
1, 32, 1, 33, 1, 33, 1, 33, 1, 33, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1,
|
||||
34, 1, 34, 4, 34, 307, 8, 34, 11, 34, 12, 34, 308, 5, 34, 311, 8, 34, 10,
|
||||
34, 12, 34, 314, 9, 34, 1, 35, 4, 35, 317, 8, 35, 11, 35, 12, 35, 318,
|
||||
1, 35, 1, 35, 1, 36, 1, 36, 1, 37, 4, 37, 326, 8, 37, 11, 37, 12, 37, 327,
|
||||
0, 0, 38, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19,
|
||||
10, 21, 11, 23, 12, 25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37,
|
||||
19, 39, 20, 41, 21, 43, 22, 45, 23, 47, 24, 49, 25, 51, 26, 53, 27, 55,
|
||||
28, 57, 0, 59, 29, 61, 30, 63, 0, 65, 0, 67, 0, 69, 31, 71, 32, 73, 0,
|
||||
75, 33, 1, 0, 29, 2, 0, 76, 76, 108, 108, 2, 0, 73, 73, 105, 105, 2, 0,
|
||||
75, 75, 107, 107, 2, 0, 69, 69, 101, 101, 2, 0, 66, 66, 98, 98, 2, 0, 84,
|
||||
84, 116, 116, 2, 0, 87, 87, 119, 119, 2, 0, 78, 78, 110, 110, 2, 0, 88,
|
||||
88, 120, 120, 2, 0, 83, 83, 115, 115, 2, 0, 82, 82, 114, 114, 2, 0, 71,
|
||||
71, 103, 103, 2, 0, 80, 80, 112, 112, 2, 0, 67, 67, 99, 99, 2, 0, 79, 79,
|
||||
111, 111, 2, 0, 65, 65, 97, 97, 2, 0, 68, 68, 100, 100, 2, 0, 72, 72, 104,
|
||||
104, 2, 0, 89, 89, 121, 121, 2, 0, 85, 85, 117, 117, 2, 0, 70, 70, 102,
|
||||
102, 2, 0, 43, 43, 45, 45, 2, 0, 34, 34, 92, 92, 2, 0, 39, 39, 92, 92,
|
||||
4, 0, 35, 36, 64, 90, 95, 95, 97, 123, 7, 0, 35, 36, 45, 45, 47, 58, 64,
|
||||
90, 95, 95, 97, 123, 125, 125, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57,
|
||||
8, 0, 9, 10, 13, 13, 32, 34, 39, 41, 44, 44, 60, 62, 91, 91, 93, 93, 353,
|
||||
0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0,
|
||||
0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0,
|
||||
0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0,
|
||||
0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1,
|
||||
0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39,
|
||||
1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0,
|
||||
47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0,
|
||||
0, 55, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 61, 1, 0, 0, 0, 0, 69, 1, 0, 0,
|
||||
0, 0, 71, 1, 0, 0, 0, 0, 75, 1, 0, 0, 0, 1, 77, 1, 0, 0, 0, 3, 79, 1, 0,
|
||||
0, 0, 5, 81, 1, 0, 0, 0, 7, 83, 1, 0, 0, 0, 9, 85, 1, 0, 0, 0, 11, 90,
|
||||
1, 0, 0, 0, 13, 92, 1, 0, 0, 0, 15, 95, 1, 0, 0, 0, 17, 98, 1, 0, 0, 0,
|
||||
19, 100, 1, 0, 0, 0, 21, 103, 1, 0, 0, 0, 23, 105, 1, 0, 0, 0, 25, 108,
|
||||
1, 0, 0, 0, 27, 113, 1, 0, 0, 0, 29, 119, 1, 0, 0, 0, 31, 127, 1, 0, 0,
|
||||
0, 33, 135, 1, 0, 0, 0, 35, 142, 1, 0, 0, 0, 37, 152, 1, 0, 0, 0, 39, 155,
|
||||
1, 0, 0, 0, 41, 159, 1, 0, 0, 0, 43, 163, 1, 0, 0, 0, 45, 166, 1, 0, 0,
|
||||
0, 47, 175, 1, 0, 0, 0, 49, 179, 1, 0, 0, 0, 51, 186, 1, 0, 0, 0, 53, 193,
|
||||
1, 0, 0, 0, 55, 209, 1, 0, 0, 0, 57, 211, 1, 0, 0, 0, 59, 261, 1, 0, 0,
|
||||
0, 61, 283, 1, 0, 0, 0, 63, 285, 1, 0, 0, 0, 65, 292, 1, 0, 0, 0, 67, 295,
|
||||
1, 0, 0, 0, 69, 299, 1, 0, 0, 0, 71, 316, 1, 0, 0, 0, 73, 322, 1, 0, 0,
|
||||
0, 75, 325, 1, 0, 0, 0, 77, 78, 5, 40, 0, 0, 78, 2, 1, 0, 0, 0, 79, 80,
|
||||
5, 41, 0, 0, 80, 4, 1, 0, 0, 0, 81, 82, 5, 91, 0, 0, 82, 6, 1, 0, 0, 0,
|
||||
83, 84, 5, 93, 0, 0, 84, 8, 1, 0, 0, 0, 85, 86, 5, 44, 0, 0, 86, 10, 1,
|
||||
0, 0, 0, 87, 91, 5, 61, 0, 0, 88, 89, 5, 61, 0, 0, 89, 91, 5, 61, 0, 0,
|
||||
90, 87, 1, 0, 0, 0, 90, 88, 1, 0, 0, 0, 91, 12, 1, 0, 0, 0, 92, 93, 5,
|
||||
33, 0, 0, 93, 94, 5, 61, 0, 0, 94, 14, 1, 0, 0, 0, 95, 96, 5, 60, 0, 0,
|
||||
96, 97, 5, 62, 0, 0, 97, 16, 1, 0, 0, 0, 98, 99, 5, 60, 0, 0, 99, 18, 1,
|
||||
0, 0, 0, 100, 101, 5, 60, 0, 0, 101, 102, 5, 61, 0, 0, 102, 20, 1, 0, 0,
|
||||
0, 103, 104, 5, 62, 0, 0, 104, 22, 1, 0, 0, 0, 105, 106, 5, 62, 0, 0, 106,
|
||||
107, 5, 61, 0, 0, 107, 24, 1, 0, 0, 0, 108, 109, 7, 0, 0, 0, 109, 110,
|
||||
7, 1, 0, 0, 110, 111, 7, 2, 0, 0, 111, 112, 7, 3, 0, 0, 112, 26, 1, 0,
|
||||
0, 0, 113, 114, 7, 1, 0, 0, 114, 115, 7, 0, 0, 0, 115, 116, 7, 1, 0, 0,
|
||||
116, 117, 7, 2, 0, 0, 117, 118, 7, 3, 0, 0, 118, 28, 1, 0, 0, 0, 119, 120,
|
||||
7, 4, 0, 0, 120, 121, 7, 3, 0, 0, 121, 122, 7, 5, 0, 0, 122, 123, 7, 6,
|
||||
0, 0, 123, 124, 7, 3, 0, 0, 124, 125, 7, 3, 0, 0, 125, 126, 7, 7, 0, 0,
|
||||
126, 30, 1, 0, 0, 0, 127, 128, 7, 3, 0, 0, 128, 129, 7, 8, 0, 0, 129, 130,
|
||||
7, 1, 0, 0, 130, 131, 7, 9, 0, 0, 131, 133, 7, 5, 0, 0, 132, 134, 7, 9,
|
||||
0, 0, 133, 132, 1, 0, 0, 0, 133, 134, 1, 0, 0, 0, 134, 32, 1, 0, 0, 0,
|
||||
135, 136, 7, 10, 0, 0, 136, 137, 7, 3, 0, 0, 137, 138, 7, 11, 0, 0, 138,
|
||||
139, 7, 3, 0, 0, 139, 140, 7, 8, 0, 0, 140, 141, 7, 12, 0, 0, 141, 34,
|
||||
1, 0, 0, 0, 142, 143, 7, 13, 0, 0, 143, 144, 7, 14, 0, 0, 144, 145, 7,
|
||||
7, 0, 0, 145, 146, 7, 5, 0, 0, 146, 147, 7, 15, 0, 0, 147, 148, 7, 1, 0,
|
||||
0, 148, 150, 7, 7, 0, 0, 149, 151, 7, 9, 0, 0, 150, 149, 1, 0, 0, 0, 150,
|
||||
151, 1, 0, 0, 0, 151, 36, 1, 0, 0, 0, 152, 153, 7, 1, 0, 0, 153, 154, 7,
|
||||
7, 0, 0, 154, 38, 1, 0, 0, 0, 155, 156, 7, 7, 0, 0, 156, 157, 7, 14, 0,
|
||||
0, 157, 158, 7, 5, 0, 0, 158, 40, 1, 0, 0, 0, 159, 160, 7, 15, 0, 0, 160,
|
||||
161, 7, 7, 0, 0, 161, 162, 7, 16, 0, 0, 162, 42, 1, 0, 0, 0, 163, 164,
|
||||
7, 14, 0, 0, 164, 165, 7, 10, 0, 0, 165, 44, 1, 0, 0, 0, 166, 167, 7, 17,
|
||||
0, 0, 167, 168, 7, 15, 0, 0, 168, 169, 7, 9, 0, 0, 169, 170, 7, 5, 0, 0,
|
||||
170, 171, 7, 14, 0, 0, 171, 172, 7, 2, 0, 0, 172, 173, 7, 3, 0, 0, 173,
|
||||
174, 7, 7, 0, 0, 174, 46, 1, 0, 0, 0, 175, 176, 7, 17, 0, 0, 176, 177,
|
||||
7, 15, 0, 0, 177, 178, 7, 9, 0, 0, 178, 48, 1, 0, 0, 0, 179, 180, 7, 17,
|
||||
0, 0, 180, 181, 7, 15, 0, 0, 181, 182, 7, 9, 0, 0, 182, 183, 7, 15, 0,
|
||||
0, 183, 184, 7, 7, 0, 0, 184, 185, 7, 18, 0, 0, 185, 50, 1, 0, 0, 0, 186,
|
||||
187, 7, 17, 0, 0, 187, 188, 7, 15, 0, 0, 188, 189, 7, 9, 0, 0, 189, 190,
|
||||
7, 15, 0, 0, 190, 191, 7, 0, 0, 0, 191, 192, 7, 0, 0, 0, 192, 52, 1, 0,
|
||||
0, 0, 193, 194, 7, 9, 0, 0, 194, 195, 7, 3, 0, 0, 195, 196, 7, 15, 0, 0,
|
||||
196, 197, 7, 10, 0, 0, 197, 198, 7, 13, 0, 0, 198, 199, 7, 17, 0, 0, 199,
|
||||
54, 1, 0, 0, 0, 200, 201, 7, 5, 0, 0, 201, 202, 7, 10, 0, 0, 202, 203,
|
||||
7, 19, 0, 0, 203, 210, 7, 3, 0, 0, 204, 205, 7, 20, 0, 0, 205, 206, 7,
|
||||
15, 0, 0, 206, 207, 7, 0, 0, 0, 207, 208, 7, 9, 0, 0, 208, 210, 7, 3, 0,
|
||||
0, 209, 200, 1, 0, 0, 0, 209, 204, 1, 0, 0, 0, 210, 56, 1, 0, 0, 0, 211,
|
||||
212, 7, 21, 0, 0, 212, 58, 1, 0, 0, 0, 213, 215, 3, 57, 28, 0, 214, 213,
|
||||
1, 0, 0, 0, 214, 215, 1, 0, 0, 0, 215, 217, 1, 0, 0, 0, 216, 218, 3, 73,
|
||||
36, 0, 217, 216, 1, 0, 0, 0, 218, 219, 1, 0, 0, 0, 219, 217, 1, 0, 0, 0,
|
||||
219, 220, 1, 0, 0, 0, 220, 228, 1, 0, 0, 0, 221, 225, 5, 46, 0, 0, 222,
|
||||
224, 3, 73, 36, 0, 223, 222, 1, 0, 0, 0, 224, 227, 1, 0, 0, 0, 225, 223,
|
||||
1, 0, 0, 0, 225, 226, 1, 0, 0, 0, 226, 229, 1, 0, 0, 0, 227, 225, 1, 0,
|
||||
0, 0, 228, 221, 1, 0, 0, 0, 228, 229, 1, 0, 0, 0, 229, 239, 1, 0, 0, 0,
|
||||
230, 232, 7, 3, 0, 0, 231, 233, 3, 57, 28, 0, 232, 231, 1, 0, 0, 0, 232,
|
||||
233, 1, 0, 0, 0, 233, 235, 1, 0, 0, 0, 234, 236, 3, 73, 36, 0, 235, 234,
|
||||
1, 0, 0, 0, 236, 237, 1, 0, 0, 0, 237, 235, 1, 0, 0, 0, 237, 238, 1, 0,
|
||||
0, 0, 238, 240, 1, 0, 0, 0, 239, 230, 1, 0, 0, 0, 239, 240, 1, 0, 0, 0,
|
||||
240, 262, 1, 0, 0, 0, 241, 243, 3, 57, 28, 0, 242, 241, 1, 0, 0, 0, 242,
|
||||
243, 1, 0, 0, 0, 243, 244, 1, 0, 0, 0, 244, 246, 5, 46, 0, 0, 245, 247,
|
||||
3, 73, 36, 0, 246, 245, 1, 0, 0, 0, 247, 248, 1, 0, 0, 0, 248, 246, 1,
|
||||
0, 0, 0, 248, 249, 1, 0, 0, 0, 249, 259, 1, 0, 0, 0, 250, 252, 7, 3, 0,
|
||||
0, 251, 253, 3, 57, 28, 0, 252, 251, 1, 0, 0, 0, 252, 253, 1, 0, 0, 0,
|
||||
253, 255, 1, 0, 0, 0, 254, 256, 3, 73, 36, 0, 255, 254, 1, 0, 0, 0, 256,
|
||||
257, 1, 0, 0, 0, 257, 255, 1, 0, 0, 0, 257, 258, 1, 0, 0, 0, 258, 260,
|
||||
1, 0, 0, 0, 259, 250, 1, 0, 0, 0, 259, 260, 1, 0, 0, 0, 260, 262, 1, 0,
|
||||
0, 0, 261, 214, 1, 0, 0, 0, 261, 242, 1, 0, 0, 0, 262, 60, 1, 0, 0, 0,
|
||||
263, 269, 5, 34, 0, 0, 264, 268, 8, 22, 0, 0, 265, 266, 5, 92, 0, 0, 266,
|
||||
268, 9, 0, 0, 0, 267, 264, 1, 0, 0, 0, 267, 265, 1, 0, 0, 0, 268, 271,
|
||||
1, 0, 0, 0, 269, 267, 1, 0, 0, 0, 269, 270, 1, 0, 0, 0, 270, 272, 1, 0,
|
||||
0, 0, 271, 269, 1, 0, 0, 0, 272, 284, 5, 34, 0, 0, 273, 279, 5, 39, 0,
|
||||
0, 274, 278, 8, 23, 0, 0, 275, 276, 5, 92, 0, 0, 276, 278, 9, 0, 0, 0,
|
||||
277, 274, 1, 0, 0, 0, 277, 275, 1, 0, 0, 0, 278, 281, 1, 0, 0, 0, 279,
|
||||
277, 1, 0, 0, 0, 279, 280, 1, 0, 0, 0, 280, 282, 1, 0, 0, 0, 281, 279,
|
||||
1, 0, 0, 0, 282, 284, 5, 39, 0, 0, 283, 263, 1, 0, 0, 0, 283, 273, 1, 0,
|
||||
0, 0, 284, 62, 1, 0, 0, 0, 285, 289, 7, 24, 0, 0, 286, 288, 7, 25, 0, 0,
|
||||
287, 286, 1, 0, 0, 0, 288, 291, 1, 0, 0, 0, 289, 287, 1, 0, 0, 0, 289,
|
||||
290, 1, 0, 0, 0, 290, 64, 1, 0, 0, 0, 291, 289, 1, 0, 0, 0, 292, 293, 5,
|
||||
91, 0, 0, 293, 294, 5, 93, 0, 0, 294, 66, 1, 0, 0, 0, 295, 296, 5, 91,
|
||||
0, 0, 296, 297, 5, 42, 0, 0, 297, 298, 5, 93, 0, 0, 298, 68, 1, 0, 0, 0,
|
||||
299, 312, 3, 63, 31, 0, 300, 301, 5, 46, 0, 0, 301, 311, 3, 63, 31, 0,
|
||||
302, 311, 3, 65, 32, 0, 303, 311, 3, 67, 33, 0, 304, 306, 5, 46, 0, 0,
|
||||
305, 307, 3, 73, 36, 0, 306, 305, 1, 0, 0, 0, 307, 308, 1, 0, 0, 0, 308,
|
||||
306, 1, 0, 0, 0, 308, 309, 1, 0, 0, 0, 309, 311, 1, 0, 0, 0, 310, 300,
|
||||
1, 0, 0, 0, 310, 302, 1, 0, 0, 0, 310, 303, 1, 0, 0, 0, 310, 304, 1, 0,
|
||||
0, 0, 311, 314, 1, 0, 0, 0, 312, 310, 1, 0, 0, 0, 312, 313, 1, 0, 0, 0,
|
||||
313, 70, 1, 0, 0, 0, 314, 312, 1, 0, 0, 0, 315, 317, 7, 26, 0, 0, 316,
|
||||
315, 1, 0, 0, 0, 317, 318, 1, 0, 0, 0, 318, 316, 1, 0, 0, 0, 318, 319,
|
||||
1, 0, 0, 0, 319, 320, 1, 0, 0, 0, 320, 321, 6, 35, 0, 0, 321, 72, 1, 0,
|
||||
0, 0, 322, 323, 7, 27, 0, 0, 323, 74, 1, 0, 0, 0, 324, 326, 8, 28, 0, 0,
|
||||
325, 324, 1, 0, 0, 0, 326, 327, 1, 0, 0, 0, 327, 325, 1, 0, 0, 0, 327,
|
||||
328, 1, 0, 0, 0, 328, 76, 1, 0, 0, 0, 29, 0, 90, 133, 150, 209, 214, 219,
|
||||
225, 228, 232, 237, 239, 242, 248, 252, 257, 259, 261, 267, 269, 277, 279,
|
||||
283, 289, 308, 310, 312, 318, 327, 1, 6, 0, 0,
|
||||
7, 36, 2, 37, 7, 37, 2, 38, 7, 38, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2,
|
||||
1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 3, 5, 93, 8, 5, 1, 6, 1, 6, 1,
|
||||
6, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11,
|
||||
1, 11, 1, 11, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1,
|
||||
13, 1, 13, 1, 13, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14,
|
||||
1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 3, 15, 136, 8, 15, 1, 16, 1,
|
||||
16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17,
|
||||
1, 17, 1, 17, 1, 17, 3, 17, 153, 8, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1,
|
||||
19, 1, 19, 1, 19, 1, 20, 1, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 22,
|
||||
1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1,
|
||||
23, 1, 23, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25,
|
||||
1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1,
|
||||
26, 1, 26, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 5, 27,
|
||||
211, 8, 27, 10, 27, 12, 27, 214, 9, 27, 1, 27, 1, 27, 1, 28, 1, 28, 1,
|
||||
28, 1, 28, 1, 28, 1, 28, 1, 28, 1, 28, 1, 28, 3, 28, 227, 8, 28, 1, 29,
|
||||
1, 29, 1, 30, 3, 30, 232, 8, 30, 1, 30, 4, 30, 235, 8, 30, 11, 30, 12,
|
||||
30, 236, 1, 30, 1, 30, 5, 30, 241, 8, 30, 10, 30, 12, 30, 244, 9, 30, 3,
|
||||
30, 246, 8, 30, 1, 30, 1, 30, 3, 30, 250, 8, 30, 1, 30, 4, 30, 253, 8,
|
||||
30, 11, 30, 12, 30, 254, 3, 30, 257, 8, 30, 1, 30, 3, 30, 260, 8, 30, 1,
|
||||
30, 1, 30, 4, 30, 264, 8, 30, 11, 30, 12, 30, 265, 1, 30, 1, 30, 3, 30,
|
||||
270, 8, 30, 1, 30, 4, 30, 273, 8, 30, 11, 30, 12, 30, 274, 3, 30, 277,
|
||||
8, 30, 3, 30, 279, 8, 30, 1, 31, 1, 31, 1, 31, 1, 31, 5, 31, 285, 8, 31,
|
||||
10, 31, 12, 31, 288, 9, 31, 1, 31, 1, 31, 1, 31, 1, 31, 1, 31, 5, 31, 295,
|
||||
8, 31, 10, 31, 12, 31, 298, 9, 31, 1, 31, 3, 31, 301, 8, 31, 1, 32, 1,
|
||||
32, 5, 32, 305, 8, 32, 10, 32, 12, 32, 308, 9, 32, 1, 33, 1, 33, 1, 33,
|
||||
1, 34, 1, 34, 1, 34, 1, 34, 1, 35, 1, 35, 1, 35, 1, 35, 1, 35, 1, 35, 1,
|
||||
35, 4, 35, 324, 8, 35, 11, 35, 12, 35, 325, 5, 35, 328, 8, 35, 10, 35,
|
||||
12, 35, 331, 9, 35, 1, 36, 4, 36, 334, 8, 36, 11, 36, 12, 36, 335, 1, 36,
|
||||
1, 36, 1, 37, 1, 37, 1, 38, 4, 38, 343, 8, 38, 11, 38, 12, 38, 344, 0,
|
||||
0, 39, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10,
|
||||
21, 11, 23, 12, 25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37, 19,
|
||||
39, 20, 41, 21, 43, 22, 45, 23, 47, 24, 49, 25, 51, 26, 53, 27, 55, 28,
|
||||
57, 29, 59, 0, 61, 30, 63, 31, 65, 0, 67, 0, 69, 0, 71, 32, 73, 33, 75,
|
||||
0, 77, 34, 1, 0, 29, 2, 0, 76, 76, 108, 108, 2, 0, 73, 73, 105, 105, 2,
|
||||
0, 75, 75, 107, 107, 2, 0, 69, 69, 101, 101, 2, 0, 66, 66, 98, 98, 2, 0,
|
||||
84, 84, 116, 116, 2, 0, 87, 87, 119, 119, 2, 0, 78, 78, 110, 110, 2, 0,
|
||||
88, 88, 120, 120, 2, 0, 83, 83, 115, 115, 2, 0, 82, 82, 114, 114, 2, 0,
|
||||
71, 71, 103, 103, 2, 0, 80, 80, 112, 112, 2, 0, 67, 67, 99, 99, 2, 0, 79,
|
||||
79, 111, 111, 2, 0, 65, 65, 97, 97, 2, 0, 68, 68, 100, 100, 2, 0, 72, 72,
|
||||
104, 104, 2, 0, 89, 89, 121, 121, 3, 0, 9, 10, 13, 13, 32, 32, 2, 0, 85,
|
||||
85, 117, 117, 2, 0, 70, 70, 102, 102, 2, 0, 43, 43, 45, 45, 2, 0, 34, 34,
|
||||
92, 92, 2, 0, 39, 39, 92, 92, 4, 0, 35, 36, 64, 90, 95, 95, 97, 123, 7,
|
||||
0, 35, 36, 45, 45, 47, 58, 64, 90, 95, 95, 97, 123, 125, 125, 1, 0, 48,
|
||||
57, 8, 0, 9, 10, 13, 13, 32, 34, 39, 41, 44, 44, 60, 62, 91, 91, 93, 93,
|
||||
371, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0,
|
||||
0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1,
|
||||
0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23,
|
||||
1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0,
|
||||
31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0,
|
||||
0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0,
|
||||
0, 0, 47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0,
|
||||
0, 0, 0, 55, 1, 0, 0, 0, 0, 57, 1, 0, 0, 0, 0, 61, 1, 0, 0, 0, 0, 63, 1,
|
||||
0, 0, 0, 0, 71, 1, 0, 0, 0, 0, 73, 1, 0, 0, 0, 0, 77, 1, 0, 0, 0, 1, 79,
|
||||
1, 0, 0, 0, 3, 81, 1, 0, 0, 0, 5, 83, 1, 0, 0, 0, 7, 85, 1, 0, 0, 0, 9,
|
||||
87, 1, 0, 0, 0, 11, 92, 1, 0, 0, 0, 13, 94, 1, 0, 0, 0, 15, 97, 1, 0, 0,
|
||||
0, 17, 100, 1, 0, 0, 0, 19, 102, 1, 0, 0, 0, 21, 105, 1, 0, 0, 0, 23, 107,
|
||||
1, 0, 0, 0, 25, 110, 1, 0, 0, 0, 27, 115, 1, 0, 0, 0, 29, 121, 1, 0, 0,
|
||||
0, 31, 129, 1, 0, 0, 0, 33, 137, 1, 0, 0, 0, 35, 144, 1, 0, 0, 0, 37, 154,
|
||||
1, 0, 0, 0, 39, 157, 1, 0, 0, 0, 41, 161, 1, 0, 0, 0, 43, 165, 1, 0, 0,
|
||||
0, 45, 168, 1, 0, 0, 0, 47, 177, 1, 0, 0, 0, 49, 181, 1, 0, 0, 0, 51, 188,
|
||||
1, 0, 0, 0, 53, 195, 1, 0, 0, 0, 55, 202, 1, 0, 0, 0, 57, 226, 1, 0, 0,
|
||||
0, 59, 228, 1, 0, 0, 0, 61, 278, 1, 0, 0, 0, 63, 300, 1, 0, 0, 0, 65, 302,
|
||||
1, 0, 0, 0, 67, 309, 1, 0, 0, 0, 69, 312, 1, 0, 0, 0, 71, 316, 1, 0, 0,
|
||||
0, 73, 333, 1, 0, 0, 0, 75, 339, 1, 0, 0, 0, 77, 342, 1, 0, 0, 0, 79, 80,
|
||||
5, 40, 0, 0, 80, 2, 1, 0, 0, 0, 81, 82, 5, 41, 0, 0, 82, 4, 1, 0, 0, 0,
|
||||
83, 84, 5, 91, 0, 0, 84, 6, 1, 0, 0, 0, 85, 86, 5, 93, 0, 0, 86, 8, 1,
|
||||
0, 0, 0, 87, 88, 5, 44, 0, 0, 88, 10, 1, 0, 0, 0, 89, 93, 5, 61, 0, 0,
|
||||
90, 91, 5, 61, 0, 0, 91, 93, 5, 61, 0, 0, 92, 89, 1, 0, 0, 0, 92, 90, 1,
|
||||
0, 0, 0, 93, 12, 1, 0, 0, 0, 94, 95, 5, 33, 0, 0, 95, 96, 5, 61, 0, 0,
|
||||
96, 14, 1, 0, 0, 0, 97, 98, 5, 60, 0, 0, 98, 99, 5, 62, 0, 0, 99, 16, 1,
|
||||
0, 0, 0, 100, 101, 5, 60, 0, 0, 101, 18, 1, 0, 0, 0, 102, 103, 5, 60, 0,
|
||||
0, 103, 104, 5, 61, 0, 0, 104, 20, 1, 0, 0, 0, 105, 106, 5, 62, 0, 0, 106,
|
||||
22, 1, 0, 0, 0, 107, 108, 5, 62, 0, 0, 108, 109, 5, 61, 0, 0, 109, 24,
|
||||
1, 0, 0, 0, 110, 111, 7, 0, 0, 0, 111, 112, 7, 1, 0, 0, 112, 113, 7, 2,
|
||||
0, 0, 113, 114, 7, 3, 0, 0, 114, 26, 1, 0, 0, 0, 115, 116, 7, 1, 0, 0,
|
||||
116, 117, 7, 0, 0, 0, 117, 118, 7, 1, 0, 0, 118, 119, 7, 2, 0, 0, 119,
|
||||
120, 7, 3, 0, 0, 120, 28, 1, 0, 0, 0, 121, 122, 7, 4, 0, 0, 122, 123, 7,
|
||||
3, 0, 0, 123, 124, 7, 5, 0, 0, 124, 125, 7, 6, 0, 0, 125, 126, 7, 3, 0,
|
||||
0, 126, 127, 7, 3, 0, 0, 127, 128, 7, 7, 0, 0, 128, 30, 1, 0, 0, 0, 129,
|
||||
130, 7, 3, 0, 0, 130, 131, 7, 8, 0, 0, 131, 132, 7, 1, 0, 0, 132, 133,
|
||||
7, 9, 0, 0, 133, 135, 7, 5, 0, 0, 134, 136, 7, 9, 0, 0, 135, 134, 1, 0,
|
||||
0, 0, 135, 136, 1, 0, 0, 0, 136, 32, 1, 0, 0, 0, 137, 138, 7, 10, 0, 0,
|
||||
138, 139, 7, 3, 0, 0, 139, 140, 7, 11, 0, 0, 140, 141, 7, 3, 0, 0, 141,
|
||||
142, 7, 8, 0, 0, 142, 143, 7, 12, 0, 0, 143, 34, 1, 0, 0, 0, 144, 145,
|
||||
7, 13, 0, 0, 145, 146, 7, 14, 0, 0, 146, 147, 7, 7, 0, 0, 147, 148, 7,
|
||||
5, 0, 0, 148, 149, 7, 15, 0, 0, 149, 150, 7, 1, 0, 0, 150, 152, 7, 7, 0,
|
||||
0, 151, 153, 7, 9, 0, 0, 152, 151, 1, 0, 0, 0, 152, 153, 1, 0, 0, 0, 153,
|
||||
36, 1, 0, 0, 0, 154, 155, 7, 1, 0, 0, 155, 156, 7, 7, 0, 0, 156, 38, 1,
|
||||
0, 0, 0, 157, 158, 7, 7, 0, 0, 158, 159, 7, 14, 0, 0, 159, 160, 7, 5, 0,
|
||||
0, 160, 40, 1, 0, 0, 0, 161, 162, 7, 15, 0, 0, 162, 163, 7, 7, 0, 0, 163,
|
||||
164, 7, 16, 0, 0, 164, 42, 1, 0, 0, 0, 165, 166, 7, 14, 0, 0, 166, 167,
|
||||
7, 10, 0, 0, 167, 44, 1, 0, 0, 0, 168, 169, 7, 17, 0, 0, 169, 170, 7, 15,
|
||||
0, 0, 170, 171, 7, 9, 0, 0, 171, 172, 7, 5, 0, 0, 172, 173, 7, 14, 0, 0,
|
||||
173, 174, 7, 2, 0, 0, 174, 175, 7, 3, 0, 0, 175, 176, 7, 7, 0, 0, 176,
|
||||
46, 1, 0, 0, 0, 177, 178, 7, 17, 0, 0, 178, 179, 7, 15, 0, 0, 179, 180,
|
||||
7, 9, 0, 0, 180, 48, 1, 0, 0, 0, 181, 182, 7, 17, 0, 0, 182, 183, 7, 15,
|
||||
0, 0, 183, 184, 7, 9, 0, 0, 184, 185, 7, 15, 0, 0, 185, 186, 7, 7, 0, 0,
|
||||
186, 187, 7, 18, 0, 0, 187, 50, 1, 0, 0, 0, 188, 189, 7, 17, 0, 0, 189,
|
||||
190, 7, 15, 0, 0, 190, 191, 7, 9, 0, 0, 191, 192, 7, 15, 0, 0, 192, 193,
|
||||
7, 0, 0, 0, 193, 194, 7, 0, 0, 0, 194, 52, 1, 0, 0, 0, 195, 196, 7, 9,
|
||||
0, 0, 196, 197, 7, 3, 0, 0, 197, 198, 7, 15, 0, 0, 198, 199, 7, 10, 0,
|
||||
0, 199, 200, 7, 13, 0, 0, 200, 201, 7, 17, 0, 0, 201, 54, 1, 0, 0, 0, 202,
|
||||
203, 7, 12, 0, 0, 203, 204, 7, 15, 0, 0, 204, 205, 7, 5, 0, 0, 205, 206,
|
||||
7, 5, 0, 0, 206, 207, 7, 3, 0, 0, 207, 208, 7, 10, 0, 0, 208, 212, 7, 7,
|
||||
0, 0, 209, 211, 7, 19, 0, 0, 210, 209, 1, 0, 0, 0, 211, 214, 1, 0, 0, 0,
|
||||
212, 210, 1, 0, 0, 0, 212, 213, 1, 0, 0, 0, 213, 215, 1, 0, 0, 0, 214,
|
||||
212, 1, 0, 0, 0, 215, 216, 5, 40, 0, 0, 216, 56, 1, 0, 0, 0, 217, 218,
|
||||
7, 5, 0, 0, 218, 219, 7, 10, 0, 0, 219, 220, 7, 20, 0, 0, 220, 227, 7,
|
||||
3, 0, 0, 221, 222, 7, 21, 0, 0, 222, 223, 7, 15, 0, 0, 223, 224, 7, 0,
|
||||
0, 0, 224, 225, 7, 9, 0, 0, 225, 227, 7, 3, 0, 0, 226, 217, 1, 0, 0, 0,
|
||||
226, 221, 1, 0, 0, 0, 227, 58, 1, 0, 0, 0, 228, 229, 7, 22, 0, 0, 229,
|
||||
60, 1, 0, 0, 0, 230, 232, 3, 59, 29, 0, 231, 230, 1, 0, 0, 0, 231, 232,
|
||||
1, 0, 0, 0, 232, 234, 1, 0, 0, 0, 233, 235, 3, 75, 37, 0, 234, 233, 1,
|
||||
0, 0, 0, 235, 236, 1, 0, 0, 0, 236, 234, 1, 0, 0, 0, 236, 237, 1, 0, 0,
|
||||
0, 237, 245, 1, 0, 0, 0, 238, 242, 5, 46, 0, 0, 239, 241, 3, 75, 37, 0,
|
||||
240, 239, 1, 0, 0, 0, 241, 244, 1, 0, 0, 0, 242, 240, 1, 0, 0, 0, 242,
|
||||
243, 1, 0, 0, 0, 243, 246, 1, 0, 0, 0, 244, 242, 1, 0, 0, 0, 245, 238,
|
||||
1, 0, 0, 0, 245, 246, 1, 0, 0, 0, 246, 256, 1, 0, 0, 0, 247, 249, 7, 3,
|
||||
0, 0, 248, 250, 3, 59, 29, 0, 249, 248, 1, 0, 0, 0, 249, 250, 1, 0, 0,
|
||||
0, 250, 252, 1, 0, 0, 0, 251, 253, 3, 75, 37, 0, 252, 251, 1, 0, 0, 0,
|
||||
253, 254, 1, 0, 0, 0, 254, 252, 1, 0, 0, 0, 254, 255, 1, 0, 0, 0, 255,
|
||||
257, 1, 0, 0, 0, 256, 247, 1, 0, 0, 0, 256, 257, 1, 0, 0, 0, 257, 279,
|
||||
1, 0, 0, 0, 258, 260, 3, 59, 29, 0, 259, 258, 1, 0, 0, 0, 259, 260, 1,
|
||||
0, 0, 0, 260, 261, 1, 0, 0, 0, 261, 263, 5, 46, 0, 0, 262, 264, 3, 75,
|
||||
37, 0, 263, 262, 1, 0, 0, 0, 264, 265, 1, 0, 0, 0, 265, 263, 1, 0, 0, 0,
|
||||
265, 266, 1, 0, 0, 0, 266, 276, 1, 0, 0, 0, 267, 269, 7, 3, 0, 0, 268,
|
||||
270, 3, 59, 29, 0, 269, 268, 1, 0, 0, 0, 269, 270, 1, 0, 0, 0, 270, 272,
|
||||
1, 0, 0, 0, 271, 273, 3, 75, 37, 0, 272, 271, 1, 0, 0, 0, 273, 274, 1,
|
||||
0, 0, 0, 274, 272, 1, 0, 0, 0, 274, 275, 1, 0, 0, 0, 275, 277, 1, 0, 0,
|
||||
0, 276, 267, 1, 0, 0, 0, 276, 277, 1, 0, 0, 0, 277, 279, 1, 0, 0, 0, 278,
|
||||
231, 1, 0, 0, 0, 278, 259, 1, 0, 0, 0, 279, 62, 1, 0, 0, 0, 280, 286, 5,
|
||||
34, 0, 0, 281, 285, 8, 23, 0, 0, 282, 283, 5, 92, 0, 0, 283, 285, 9, 0,
|
||||
0, 0, 284, 281, 1, 0, 0, 0, 284, 282, 1, 0, 0, 0, 285, 288, 1, 0, 0, 0,
|
||||
286, 284, 1, 0, 0, 0, 286, 287, 1, 0, 0, 0, 287, 289, 1, 0, 0, 0, 288,
|
||||
286, 1, 0, 0, 0, 289, 301, 5, 34, 0, 0, 290, 296, 5, 39, 0, 0, 291, 295,
|
||||
8, 24, 0, 0, 292, 293, 5, 92, 0, 0, 293, 295, 9, 0, 0, 0, 294, 291, 1,
|
||||
0, 0, 0, 294, 292, 1, 0, 0, 0, 295, 298, 1, 0, 0, 0, 296, 294, 1, 0, 0,
|
||||
0, 296, 297, 1, 0, 0, 0, 297, 299, 1, 0, 0, 0, 298, 296, 1, 0, 0, 0, 299,
|
||||
301, 5, 39, 0, 0, 300, 280, 1, 0, 0, 0, 300, 290, 1, 0, 0, 0, 301, 64,
|
||||
1, 0, 0, 0, 302, 306, 7, 25, 0, 0, 303, 305, 7, 26, 0, 0, 304, 303, 1,
|
||||
0, 0, 0, 305, 308, 1, 0, 0, 0, 306, 304, 1, 0, 0, 0, 306, 307, 1, 0, 0,
|
||||
0, 307, 66, 1, 0, 0, 0, 308, 306, 1, 0, 0, 0, 309, 310, 5, 91, 0, 0, 310,
|
||||
311, 5, 93, 0, 0, 311, 68, 1, 0, 0, 0, 312, 313, 5, 91, 0, 0, 313, 314,
|
||||
5, 42, 0, 0, 314, 315, 5, 93, 0, 0, 315, 70, 1, 0, 0, 0, 316, 329, 3, 65,
|
||||
32, 0, 317, 318, 5, 46, 0, 0, 318, 328, 3, 65, 32, 0, 319, 328, 3, 67,
|
||||
33, 0, 320, 328, 3, 69, 34, 0, 321, 323, 5, 46, 0, 0, 322, 324, 3, 75,
|
||||
37, 0, 323, 322, 1, 0, 0, 0, 324, 325, 1, 0, 0, 0, 325, 323, 1, 0, 0, 0,
|
||||
325, 326, 1, 0, 0, 0, 326, 328, 1, 0, 0, 0, 327, 317, 1, 0, 0, 0, 327,
|
||||
319, 1, 0, 0, 0, 327, 320, 1, 0, 0, 0, 327, 321, 1, 0, 0, 0, 328, 331,
|
||||
1, 0, 0, 0, 329, 327, 1, 0, 0, 0, 329, 330, 1, 0, 0, 0, 330, 72, 1, 0,
|
||||
0, 0, 331, 329, 1, 0, 0, 0, 332, 334, 7, 19, 0, 0, 333, 332, 1, 0, 0, 0,
|
||||
334, 335, 1, 0, 0, 0, 335, 333, 1, 0, 0, 0, 335, 336, 1, 0, 0, 0, 336,
|
||||
337, 1, 0, 0, 0, 337, 338, 6, 36, 0, 0, 338, 74, 1, 0, 0, 0, 339, 340,
|
||||
7, 27, 0, 0, 340, 76, 1, 0, 0, 0, 341, 343, 8, 28, 0, 0, 342, 341, 1, 0,
|
||||
0, 0, 343, 344, 1, 0, 0, 0, 344, 342, 1, 0, 0, 0, 344, 345, 1, 0, 0, 0,
|
||||
345, 78, 1, 0, 0, 0, 30, 0, 92, 135, 152, 212, 226, 231, 236, 242, 245,
|
||||
249, 254, 256, 259, 265, 269, 274, 276, 278, 284, 286, 294, 296, 300, 306,
|
||||
325, 327, 329, 335, 344, 1, 6, 0, 0,
|
||||
}
|
||||
deserializer := antlr.NewATNDeserializer(nil)
|
||||
staticData.atn = deserializer.Deserialize(staticData.serializedATN)
|
||||
@@ -285,10 +295,11 @@ const (
|
||||
FilterQueryLexerHASANY = 25
|
||||
FilterQueryLexerHASALL = 26
|
||||
FilterQueryLexerSEARCH = 27
|
||||
FilterQueryLexerBOOL = 28
|
||||
FilterQueryLexerNUMBER = 29
|
||||
FilterQueryLexerQUOTED_TEXT = 30
|
||||
FilterQueryLexerKEY = 31
|
||||
FilterQueryLexerWS = 32
|
||||
FilterQueryLexerFREETEXT = 33
|
||||
FilterQueryLexerPATTERN = 28
|
||||
FilterQueryLexerBOOL = 29
|
||||
FilterQueryLexerNUMBER = 30
|
||||
FilterQueryLexerQUOTED_TEXT = 31
|
||||
FilterQueryLexerKEY = 32
|
||||
FilterQueryLexerWS = 33
|
||||
FilterQueryLexerFREETEXT = 34
|
||||
)
|
||||
|
||||
@@ -47,6 +47,9 @@ type FilterQueryListener interface {
|
||||
// EnterSearchCall is called when entering the searchCall production.
|
||||
EnterSearchCall(c *SearchCallContext)
|
||||
|
||||
// EnterPatternCall is called when entering the patternCall production.
|
||||
EnterPatternCall(c *PatternCallContext)
|
||||
|
||||
// EnterFunctionParamList is called when entering the functionParamList production.
|
||||
EnterFunctionParamList(c *FunctionParamListContext)
|
||||
|
||||
@@ -101,6 +104,9 @@ type FilterQueryListener interface {
|
||||
// ExitSearchCall is called when exiting the searchCall production.
|
||||
ExitSearchCall(c *SearchCallContext)
|
||||
|
||||
// ExitPatternCall is called when exiting the patternCall production.
|
||||
ExitPatternCall(c *PatternCallContext)
|
||||
|
||||
// ExitFunctionParamList is called when exiting the functionParamList production.
|
||||
ExitFunctionParamList(c *FunctionParamListContext)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,9 @@ type FilterQueryVisitor interface {
|
||||
// Visit a parse tree produced by FilterQueryParser#searchCall.
|
||||
VisitSearchCall(ctx *SearchCallContext) interface{}
|
||||
|
||||
// Visit a parse tree produced by FilterQueryParser#patternCall.
|
||||
VisitPatternCall(ctx *PatternCallContext) interface{}
|
||||
|
||||
// Visit a parse tree produced by FilterQueryParser#functionParamList.
|
||||
VisitFunctionParamList(ctx *FunctionParamListContext) interface{}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ var friendly = map[string]string{
|
||||
"BETWEEN": "BETWEEN", "IN": "IN", "EXISTS": "EXISTS",
|
||||
"REGEXP": "REGEXP", "CONTAINS": "CONTAINS",
|
||||
"HAS": "has()", "HASANY": "hasAny()", "HASALL": "hasAll()",
|
||||
"HASTOKEN": "hasToken()", "SEARCH": "search()",
|
||||
"HASTOKEN": "hasToken()", "SEARCH": "search()", "PATTERN": "pattern()",
|
||||
|
||||
// literals / identifiers
|
||||
"NUMBER": "number",
|
||||
|
||||
@@ -194,6 +194,9 @@ func (d *LogicalContradictionDetector) VisitPrimary(ctx *grammar.PrimaryContext)
|
||||
} else if ctx.SearchCall() != nil {
|
||||
// search() spans all fields; it can never be a logical contradiction
|
||||
return nil
|
||||
} else if ctx.PatternCall() != nil {
|
||||
// pattern() reads no key, so it can't contradict a comparison
|
||||
return nil
|
||||
} else if ctx.FullText() != nil {
|
||||
// Handle full text search if needed
|
||||
return nil
|
||||
|
||||
@@ -204,6 +204,8 @@ func (v *filterExpressionVisitor) Visit(tree antlr.ParseTree) any {
|
||||
return v.VisitFullText(t)
|
||||
case *grammar.SearchCallContext:
|
||||
return v.VisitSearchCall(t)
|
||||
case *grammar.PatternCallContext:
|
||||
return v.VisitPatternCall(t)
|
||||
case *grammar.FunctionCallContext:
|
||||
return v.VisitFunctionCall(t)
|
||||
case *grammar.FunctionParamListContext:
|
||||
@@ -320,6 +322,8 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
return v.Visit(ctx.FunctionCall())
|
||||
} else if ctx.SearchCall() != nil {
|
||||
return v.Visit(ctx.SearchCall())
|
||||
} else if ctx.PatternCall() != nil {
|
||||
return v.Visit(ctx.PatternCall())
|
||||
} else if ctx.FullText() != nil {
|
||||
return v.Visit(ctx.FullText())
|
||||
}
|
||||
@@ -841,6 +845,13 @@ func (v *filterExpressionVisitor) VisitSearchCall(ctx *grammar.SearchCallContext
|
||||
return v.builder.Or(conds...)
|
||||
}
|
||||
|
||||
// VisitPatternCall handles pattern('<pattern text>'[, ...]), the log pattern
|
||||
// filter. The grammar accepts it, but no storage evaluates it yet.
|
||||
func (v *filterExpressionVisitor) VisitPatternCall(_ *grammar.PatternCallContext) any {
|
||||
v.errors = append(v.errors, "function `pattern` is not supported yet")
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
|
||||
// searchParamText returns an argument's raw token text (quoted or bare) rather than its
|
||||
// visited value, so a bare word stays literal and search(1000000) isn't "1e+06".
|
||||
func searchParamText(val grammar.IValueContext) (string, bool) {
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
category: "Single word",
|
||||
query: "<script>alert('xss')</script>",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '<'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got '<'",
|
||||
},
|
||||
|
||||
// Single word searches with spaces
|
||||
@@ -178,7 +178,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
category: "Special characters",
|
||||
query: "[tracing]",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '['",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got '['",
|
||||
},
|
||||
{
|
||||
category: "Special characters",
|
||||
@@ -208,7 +208,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
category: "Special characters",
|
||||
query: "ERROR: cannot execute update() in a read-only context",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "expecting one of {(, AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got ')'",
|
||||
expectedErrorContains: "expecting one of {(, AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got ')'",
|
||||
},
|
||||
{
|
||||
category: "Special characters",
|
||||
@@ -630,7 +630,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'and'",
|
||||
expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'and'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -638,7 +638,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'or'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'or'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -646,7 +646,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got EOF",
|
||||
expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got EOF",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -654,7 +654,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'like'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'like'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -662,7 +662,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'between'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'between'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -670,7 +670,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'in'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'in'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -678,7 +678,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'exists'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'exists'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -686,7 +686,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'regexp'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'regexp'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -694,7 +694,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'contains'",
|
||||
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'contains'",
|
||||
},
|
||||
{
|
||||
category: "Keyword conflict",
|
||||
@@ -2062,9 +2062,9 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
|
||||
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'AND'"},
|
||||
{category: "Only keywords", query: "OR", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'OR'"},
|
||||
{category: "Only keywords", query: "NOT", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got EOF"},
|
||||
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'AND'"},
|
||||
{category: "Only keywords", query: "OR", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'OR'"},
|
||||
{category: "Only keywords", query: "NOT", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got EOF"},
|
||||
|
||||
{category: "Only functions", query: "has", shouldPass: false, expectedErrorContains: "expecting one of {(, )} but got EOF"},
|
||||
{category: "Only functions", query: "hasAny", shouldPass: false, expectedErrorContains: "expecting one of {(, )} but got EOF"},
|
||||
@@ -2206,7 +2206,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'and'",
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'and'",
|
||||
},
|
||||
{
|
||||
category: "Operator keywords as keys",
|
||||
@@ -2214,7 +2214,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'or'",
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'or'",
|
||||
},
|
||||
{
|
||||
category: "Operator keywords as keys",
|
||||
@@ -2222,7 +2222,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "line 1:3 expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '='",
|
||||
expectedErrorContains: "line 1:3 expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got '='",
|
||||
},
|
||||
{
|
||||
category: "Operator keywords as keys",
|
||||
@@ -2230,7 +2230,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'between'",
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'between'",
|
||||
},
|
||||
{
|
||||
category: "Operator keywords as keys",
|
||||
@@ -2238,7 +2238,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
shouldPass: false,
|
||||
expectedQuery: "",
|
||||
expectedArgs: nil,
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'in'",
|
||||
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, pattern(), quoted text, search()} but got 'in'",
|
||||
},
|
||||
|
||||
// Using function keywords as keys
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package logstelemetryschema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func TestFilterExprPattern(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
opts := querybuilder.FilterExprVisitorOpts{
|
||||
Context: context.Background(),
|
||||
Logger: instrumentationtest.New().Logger(),
|
||||
Storage: NewStorage(),
|
||||
Query: querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, flaggertest.New(t), telemetrytypes.SignalLogs, nil, uint64(releaseTime.Add(-5*time.Minute).UnixNano()), uint64(releaseTime.Add(5*time.Minute).UnixNano())),
|
||||
FieldKeys: BuildCompleteFieldKeyMap(releaseTime),
|
||||
FullTextColumn: DefaultFullTextColumn,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
expectedQuery string
|
||||
expectedArgs []any
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "OnePattern_NotSupportedYet",
|
||||
query: "pattern('User <*> logged in from <IP>')",
|
||||
expectedError: "function `pattern` is not supported yet",
|
||||
},
|
||||
{
|
||||
name: "SeveralPatterns_NotSupportedYet",
|
||||
query: `pattern('<IP>', "can't connect to <IP>")`,
|
||||
expectedError: "function `pattern` is not supported yet",
|
||||
},
|
||||
{
|
||||
name: "Not_NotSupportedYet",
|
||||
query: "service.name = 'api' AND NOT pattern('<IP>')",
|
||||
expectedError: "function `pattern` is not supported yet",
|
||||
},
|
||||
{
|
||||
name: "SpaceBeforeParenthesis_NotSupportedYet",
|
||||
query: "PATTERN ('<IP>')",
|
||||
expectedError: "function `pattern` is not supported yet",
|
||||
},
|
||||
{
|
||||
name: "NewlineBeforeParenthesis_NotSupportedYet",
|
||||
query: "pattern\r\n('<IP>')",
|
||||
expectedError: "function `pattern` is not supported yet",
|
||||
},
|
||||
{
|
||||
name: "NoPattern_SyntaxError",
|
||||
query: "pattern()",
|
||||
expectedError: "syntax error",
|
||||
},
|
||||
{
|
||||
name: "PatternKey_StaysAKey",
|
||||
query: "pattern = 'x'",
|
||||
expectedQuery: "WHERE ((attributes_string['pattern'] = ? AND mapContains(attributes_string, 'pattern')) OR (JSON_VALUE(body, '$.\"pattern\"') = ? AND JSON_EXISTS(body, '$.\"pattern\"')))",
|
||||
expectedArgs: []any{"x", "x"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
clause, err := querybuilder.PrepareWhereClause(testCase.query, opts)
|
||||
if testCase.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), testCase.expectedError) || detailContains(err, testCase.expectedError), err.Error())
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
sql, args := clause.WhereClause.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Equal(t, testCase.expectedQuery, sql)
|
||||
assert.Equal(t, testCase.expectedArgs, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ func (v *variableReplacementVisitor) Visit(tree antlr.ParseTree) any {
|
||||
return v.VisitFullText(t)
|
||||
case *grammar.SearchCallContext:
|
||||
return v.VisitSearchCall(t)
|
||||
case *grammar.PatternCallContext:
|
||||
return v.VisitPatternCall(t)
|
||||
case *grammar.FunctionCallContext:
|
||||
return v.VisitFunctionCall(t)
|
||||
case *grammar.FunctionParamListContext:
|
||||
@@ -207,6 +209,8 @@ func (v *variableReplacementVisitor) VisitPrimary(ctx *grammar.PrimaryContext) a
|
||||
return v.Visit(ctx.FunctionCall())
|
||||
} else if ctx.SearchCall() != nil {
|
||||
return v.Visit(ctx.SearchCall())
|
||||
} else if ctx.PatternCall() != nil {
|
||||
return v.Visit(ctx.PatternCall())
|
||||
} else if ctx.FullText() != nil {
|
||||
return v.Visit(ctx.FullText())
|
||||
}
|
||||
@@ -423,6 +427,18 @@ func (v *variableReplacementVisitor) VisitSearchCall(ctx *grammar.SearchCallCont
|
||||
return "search" + result
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitPatternCall(ctx *grammar.PatternCallContext) any {
|
||||
if ctx.ValueList() == nil {
|
||||
return "pattern()"
|
||||
}
|
||||
// VisitValueList already parenthesizes the args and propagates the __all__ marker.
|
||||
result := v.Visit(ctx.ValueList()).(string)
|
||||
if result == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
return "pattern" + result
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
|
||||
params := ctx.AllFunctionParam()
|
||||
parts := make([]string, 0, len(params))
|
||||
|
||||
@@ -279,6 +279,17 @@ func TestReplaceVariablesInExpression(t *testing.T) {
|
||||
},
|
||||
expected: "hasToken(tags, 'test')",
|
||||
},
|
||||
{
|
||||
name: "pattern function kept as is",
|
||||
expression: "service.name = $service AND pattern('User <*> logged in from <IP>', \"can't connect\")",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "auth",
|
||||
},
|
||||
},
|
||||
expected: "service.name = 'auth' AND pattern('User <*> logged in from <IP>', \"can't connect\")",
|
||||
},
|
||||
{
|
||||
name: "empty array variable",
|
||||
expression: "service.name IN $services",
|
||||
|
||||
Reference in New Issue
Block a user