mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-17 03:40:31 +01:00
Compare commits
5 Commits
worktree-h
...
issue_5674
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59e372f07 | ||
|
|
ca368a5b38 | ||
|
|
dab5ceee0d | ||
|
|
8e4d7c68b3 | ||
|
|
2704317c6a |
@@ -1495,6 +1495,7 @@ components:
|
||||
- cassandradb
|
||||
- redis
|
||||
- cloudsql_postgres
|
||||
- memorystore_redis
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
|
||||
@@ -2814,6 +2814,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
memorystore_redis = 'memorystore_redis',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
|
||||
@@ -3,3 +3,13 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tabBadge {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,58 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import { MODEL_COSTS_TAB, TAB_KEY, UNPRICED_MODELS_TAB } from './constants';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import UnpricedModelsTab from './UnpricedModelsTab';
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
TAB_KEY,
|
||||
parseAsStringEnum([MODEL_COSTS_TAB, UNPRICED_MODELS_TAB]).withDefault(
|
||||
MODEL_COSTS_TAB,
|
||||
),
|
||||
);
|
||||
|
||||
// Count powers the tab badge; deduped with the tab's own fetch by react-query.
|
||||
const { data } = useListUnmappedLLMModels();
|
||||
const unpricedCount = data?.data?.items?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
defaultValue="model-costs"
|
||||
value={activeTab}
|
||||
onChange={(key): void => {
|
||||
void setActiveTab(key as typeof activeTab);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: 'model-costs',
|
||||
key: MODEL_COSTS_TAB,
|
||||
label: 'Model costs',
|
||||
children: <ModelCostTabPanel />,
|
||||
},
|
||||
{
|
||||
// Unpriced-models tab lands in a later PR.
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
children: null,
|
||||
key: UNPRICED_MODELS_TAB,
|
||||
label: (
|
||||
<span className={styles.tabLabel}>
|
||||
Unpriced models
|
||||
{unpricedCount > 0 && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className={styles.tabBadge}
|
||||
data-testid="unpriced-models-count"
|
||||
>
|
||||
{unpricedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: <UnpricedModelsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
getListUnmappedLLMModelsQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
@@ -10,9 +11,16 @@ import {
|
||||
EMPTY_DRAFT,
|
||||
TOAST_MODEL_COST_ADDED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type {
|
||||
DrawerDraft,
|
||||
DrawerMode,
|
||||
PricingRule,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import {
|
||||
buildRulePayload,
|
||||
draftFromRule,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
|
||||
interface UseModelCostDrawerResult {
|
||||
isOpen: boolean;
|
||||
@@ -38,17 +46,26 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
|
||||
useCreateOrUpdateLLMPricingRules();
|
||||
|
||||
// Adding pricing can also resolve a model that was showing up as unpriced, so
|
||||
// refresh the unmapped list (and its tab-badge count) alongside the rules list.
|
||||
const invalidateList = useCallback(async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListUnmappedLLMModelsQueryKey(),
|
||||
}),
|
||||
]);
|
||||
}, [queryClient]);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
// prefillModelName seeds the billing model ID when adding from an unpriced
|
||||
// model row, so the user only has to fill in pricing.
|
||||
const openForAdd = useCallback((prefillModelName?: string): void => {
|
||||
setMode('add');
|
||||
setInitialDraft({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: '',
|
||||
modelName: prefillModelName ?? '',
|
||||
patterns: [],
|
||||
});
|
||||
setSelectedRuleId(null);
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
.drawerSurface {
|
||||
padding: var(--spacing-7);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.unpricedModelsTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
|
||||
color: var(--bg-amber-300, var(--bg-amber-400));
|
||||
}
|
||||
|
||||
.bannerIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import styles from './UnpricedModelsTab.module.scss';
|
||||
import ModelCostDrawer, {
|
||||
useModelCostDrawer,
|
||||
} from '../ModelCostTabPanel/components/ModelCostDrawer';
|
||||
import type { PricingRule, UnpricedModel } from '../types';
|
||||
import MapConfirmDialog from './components/MapConfirmDialog';
|
||||
import type { UnpricedColumnsConfig } from './components/UnpricedModelsTable/TableConfig';
|
||||
import UnpricedModelsTable from './components/UnpricedModelsTable';
|
||||
import { useUnpricedModelMapping } from './hooks/useUnpricedModelMapping';
|
||||
import { usePendingMappingStore } from './usePendingMappingStore';
|
||||
|
||||
function UnpricedModelsTab(): JSX.Element {
|
||||
const { data, isLoading, isError } = useListUnmappedLLMModels();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [canManagePricing] = useComponentPermission(
|
||||
['manage_llm_pricing'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const models: UnpricedModel[] = useMemo(() => data?.data?.items || [], [data]);
|
||||
|
||||
// Picking a billing model stages a single mapping for confirmation; the
|
||||
// mapping only commits once the user confirms in the dialog. Kept in a store
|
||||
// (not local state) so the row's memoized select trigger can mirror the pick —
|
||||
// see usePendingMappingStore.
|
||||
const pendingMapping = usePendingMappingStore((state) => state.pending);
|
||||
const setPending = usePendingMappingStore((state) => state.setPending);
|
||||
const clearPending = usePendingMappingStore((state) => state.clearPending);
|
||||
const { mapModel, isSaving } = useUnpricedModelMapping();
|
||||
|
||||
// Reset any staged mapping when leaving the tab so a stale pick doesn't reopen
|
||||
// the dialog on remount (the store outlives this component).
|
||||
useEffect(() => (): void => clearPending(), [clearPending]);
|
||||
|
||||
// Reuses the "Model costs" add/edit drawer to define brand-new pricing for a
|
||||
// model that has no matching billing model to map onto. Saving resolves the
|
||||
// model, so it drops off this tab (the drawer invalidates the unmapped list).
|
||||
const drawer = useModelCostDrawer();
|
||||
|
||||
const onRequestMap = useCallback(
|
||||
(model: UnpricedModel, rule: PricingRule): void => {
|
||||
setPending({ model, rule });
|
||||
},
|
||||
[setPending],
|
||||
);
|
||||
|
||||
const onConfirmMap = useCallback(async (): Promise<void> => {
|
||||
if (!pendingMapping) {
|
||||
return;
|
||||
}
|
||||
const didSave = await mapModel(pendingMapping);
|
||||
if (didSave) {
|
||||
clearPending();
|
||||
}
|
||||
}, [mapModel, pendingMapping, clearPending]);
|
||||
|
||||
// openForAdd is stable (useCallback with no deps); drawer itself is a fresh
|
||||
// object each render, so depend on the method, not the object.
|
||||
const { openForAdd } = drawer;
|
||||
const onCreateNew = useCallback(
|
||||
(modelName: string): void => openForAdd(modelName),
|
||||
[openForAdd],
|
||||
);
|
||||
const columnsConfig = useMemo<UnpricedColumnsConfig>(
|
||||
() => ({
|
||||
canManage: canManagePricing,
|
||||
onRequestMap,
|
||||
onCreateNew,
|
||||
}),
|
||||
[canManagePricing, onRequestMap, onCreateNew],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.unpricedModelsTab}>
|
||||
<div className={styles.banner}>
|
||||
<TriangleAlert size="sm" className={styles.bannerIcon} />
|
||||
<Typography.Text as="span" size="small" color="warning">
|
||||
Models detected in traces without pricing. Map each to a billing model or
|
||||
create pricing so estimated cost can be computed.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className={styles.error}>
|
||||
<Typography.Text as="p" size="small" color="danger" role="alert">
|
||||
Failed to load unpriced models. Please try again.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UnpricedModelsTable
|
||||
models={models}
|
||||
isLoading={isLoading}
|
||||
columnsConfig={columnsConfig}
|
||||
/>
|
||||
|
||||
{pendingMapping && (
|
||||
<MapConfirmDialog
|
||||
open
|
||||
model={pendingMapping.model}
|
||||
rule={pendingMapping.rule}
|
||||
isSaving={isSaving}
|
||||
onConfirm={onConfirmMap}
|
||||
onCancel={clearPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
initialDraft={drawer.initialDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={drawer.save}
|
||||
isSaving={drawer.isSaving}
|
||||
saveError={drawer.saveError}
|
||||
canManage={canManagePricing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnpricedModelsTab;
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { LlmpricingruletypesUpdatableLLMPricingRulesDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
LLM_UNMAPPED_ENDPOINT,
|
||||
makeListResponse,
|
||||
makeUnmappedResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import UnpricedModelsTab from '../UnpricedModelsTab';
|
||||
|
||||
const toastSuccess = jest.fn();
|
||||
const toastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): void => toastSuccess(...args),
|
||||
error: (...args: unknown[]): void => toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const MODEL = 'gpt-4o-mini-2024-07-18';
|
||||
|
||||
function setup(): void {
|
||||
server.use(
|
||||
rest.get(LLM_UNMAPPED_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeUnmappedResponse())),
|
||||
),
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(mockRules))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Picks a billing model in a row's dropdown: open the combobox, then click the
|
||||
// option. The dropdown fetches its options from the rules list (mocked above).
|
||||
async function selectRule(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
modelName: string,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await user.click(screen.getByTestId(`map-to-select-${modelName}`));
|
||||
await user.click(await screen.findByTestId(`map-to-option-${ruleId}`));
|
||||
}
|
||||
|
||||
describe('UnpricedModelsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
toastSuccess.mockClear();
|
||||
toastError.mockClear();
|
||||
});
|
||||
|
||||
it('opens the confirm dialog with the target rule pricing when a model is picked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
|
||||
// Picking a rule stages the mapping in the confirm dialog.
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
const confirmItem = await screen.findByTestId(
|
||||
`unpriced-map-confirm-item-${MODEL}`,
|
||||
);
|
||||
expect(confirmItem).toBeInTheDocument();
|
||||
// Shows the target billing model + its pricing so the user can eyeball it.
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getByText('$3.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('$9.00')).toBeInTheDocument();
|
||||
// While the dialog is open, the row's trigger mirrors the staged pick
|
||||
// instead of the placeholder.
|
||||
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
|
||||
expect(
|
||||
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reverts the row trigger to the placeholder when the mapping is cancelled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
|
||||
expect(
|
||||
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(trigger).queryByText('openai:gpt-4o ($3.00/$9.00)'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
within(trigger).getByText('Select / Create a pricing model'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('commits the mapping in one request when confirmed', async () => {
|
||||
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
sent.push(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
await user.click(await screen.findByTestId('unpriced-map-confirm-btn'));
|
||||
|
||||
await waitFor(() => expect(sent).toHaveLength(1));
|
||||
expect(sent[0].rules).toHaveLength(1);
|
||||
expect(sent[0].rules?.[0].modelPattern).toContain(MODEL);
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Mapped model'),
|
||||
);
|
||||
// Dialog closes on success.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels the mapping without committing', async () => {
|
||||
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
sent.push(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens the add-cost drawer prefilled when creating pricing for a model', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
|
||||
// Open the row's dropdown and take the "Create pricing for …" escape hatch
|
||||
// instead of mapping onto an existing billing model.
|
||||
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
|
||||
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));
|
||||
|
||||
// The shared add-cost drawer opens with the model name prefilled.
|
||||
const drawerTitle = await screen.findByText('Add model cost');
|
||||
expect(drawerTitle).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-model-id-input')).toHaveValue(MODEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.mapping {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.pricingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowRight, Check, Link2, X } from '@signozhq/icons';
|
||||
import { startCase } from 'lodash-es';
|
||||
|
||||
import styles from './MapConfirmDialog.module.scss';
|
||||
import type {
|
||||
PricingRule,
|
||||
UnpricedModel,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
|
||||
interface MapConfirmDialogProps {
|
||||
open: boolean;
|
||||
model: UnpricedModel;
|
||||
rule: PricingRule;
|
||||
isSaving: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Per-row confirm step before mapping an unpriced model onto an existing billing
|
||||
// model. Mapping appends the span's model name as a match pattern on the chosen
|
||||
// rule, so the model inherits that rule's pricing — shown here so the user can
|
||||
// eyeball the rates before committing. Dismissible (outside click / close), since
|
||||
// mapping is reversible (re-map or edit the rule's patterns afterwards).
|
||||
function MapConfirmDialog({
|
||||
open,
|
||||
model,
|
||||
rule,
|
||||
isSaving,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: MapConfirmDialogProps): JSX.Element {
|
||||
const extraBuckets = getExtraBuckets(rule);
|
||||
|
||||
const pricingRows = [
|
||||
{ key: 'input', label: 'Input / 1M', value: rule.pricing?.input },
|
||||
{ key: 'output', label: 'Output / 1M', value: rule.pricing?.output },
|
||||
...extraBuckets.map((bucket) => ({
|
||||
key: bucket.key,
|
||||
label: startCase(bucket.key),
|
||||
value: bucket.pricePerMillion,
|
||||
})),
|
||||
];
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
prefix={<X size={12} />}
|
||||
testId="unpriced-map-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
onClick={onConfirm}
|
||||
prefix={<Check size={12} />}
|
||||
testId="unpriced-map-confirm-btn"
|
||||
>
|
||||
Map model
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="base"
|
||||
title="Map to billing model"
|
||||
titleIcon={<Link2 size={16} />}
|
||||
footer={footer}
|
||||
testId="unpriced-map-confirm-dialog"
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
Spans from this model will be priced using the selected billing model.
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.mapping}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
testId={`unpriced-map-confirm-item-${model.modelName}`}
|
||||
>
|
||||
{model.modelName}
|
||||
</Typography.Text>
|
||||
<ArrowRight size={14} className={styles.arrow} />
|
||||
<Typography.Text weight="semibold">{getCanonicalId(rule)}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.pricing}>
|
||||
{pricingRows.map((row) => (
|
||||
<div className={styles.pricingRow} key={row.key}>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
{row.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" weight="semibold">
|
||||
{formatPricePerMillion(row.value)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapConfirmDialog;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MapConfirmDialog';
|
||||
@@ -0,0 +1,27 @@
|
||||
.mapToCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapToSelect {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.mapToDropdown {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.skeletonList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxCommand,
|
||||
ComboboxContent,
|
||||
ComboboxCreateItem,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import styles from './MapToBillingModelSelect.module.scss';
|
||||
import { RULE_OPTIONS_LIMIT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import { getRuleOptionLabel } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import { usePendingMappingLabel } from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/usePendingMappingStore';
|
||||
import { useMapToBillingModelSearch } from './useMapToBillingModelSearch';
|
||||
|
||||
// One placeholder row per fetched option, so the skeleton height matches the
|
||||
// loaded list. Stable keys derived from the fetch limit.
|
||||
const SKELETON_ROW_KEYS = Array.from(
|
||||
{ length: RULE_OPTIONS_LIMIT },
|
||||
(_, index) => `skeleton-${index}`,
|
||||
);
|
||||
|
||||
interface MapToBillingModelSelectProps {
|
||||
modelName: string;
|
||||
disabled: boolean;
|
||||
onSelect: (rule: PricingRule) => void;
|
||||
onCreateNew: () => void;
|
||||
}
|
||||
|
||||
// Searchable, server-paged dropdown for picking the billing model an unpriced
|
||||
// model maps onto. Only RULE_OPTIONS_LIMIT rules are fetched at a time; typing
|
||||
// narrows the set via the rules API rather than client-side filtering, so cmdk's
|
||||
// own filter is disabled (shouldFilter={false}). The dropdown is a pure picker —
|
||||
// choosing a rule hands it up to the confirm dialog rather than persisting a
|
||||
// selection here. The trigger only mirrors the staged pick (read from the
|
||||
// pending-mapping store) while that dialog is open, reverting on confirm/cancel.
|
||||
function MapToBillingModelSelect({
|
||||
modelName,
|
||||
disabled,
|
||||
onSelect,
|
||||
onCreateNew,
|
||||
}: MapToBillingModelSelectProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { searchText, setSearchText, rules, rulesById, isFetching } =
|
||||
useMapToBillingModelSearch(open);
|
||||
const selectedLabel = usePendingMappingLabel(modelName);
|
||||
|
||||
const handleSelect = (ruleId: string): void => {
|
||||
const rule = rulesById.get(ruleId);
|
||||
if (rule) {
|
||||
onSelect(rule);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateNew = (): void => {
|
||||
setOpen(false);
|
||||
onCreateNew();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.mapToCell}>
|
||||
<Combobox open={open} onOpenChange={setOpen}>
|
||||
<ComboboxTrigger
|
||||
className={styles.mapToSelect}
|
||||
disabled={disabled}
|
||||
placeholder="Select / Create a pricing model"
|
||||
value={selectedLabel}
|
||||
testId={`map-to-select-${modelName}`}
|
||||
/>
|
||||
<ComboboxContent className={styles.mapToDropdown}>
|
||||
<ComboboxCommand shouldFilter={false}>
|
||||
<ComboboxInput
|
||||
value={searchText}
|
||||
onValueChange={setSearchText}
|
||||
placeholder="Search billing models"
|
||||
testId={`map-to-search-${modelName}`}
|
||||
/>
|
||||
<ComboboxList>
|
||||
{rules.map((rule) => (
|
||||
<ComboboxItem
|
||||
key={rule.id}
|
||||
value={rule.id}
|
||||
onSelect={(): void => handleSelect(rule.id)}
|
||||
data-testid={`map-to-option-${rule.id}`}
|
||||
>
|
||||
{getRuleOptionLabel(rule)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{isFetching && (
|
||||
<div
|
||||
className={styles.skeletonList}
|
||||
data-testid={`map-to-loading-${modelName}`}
|
||||
>
|
||||
{SKELETON_ROW_KEYS.map((key) => (
|
||||
<Skeleton.Input
|
||||
key={key}
|
||||
active
|
||||
block
|
||||
size="small"
|
||||
className={styles.skeletonRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && rules.length === 0 && (
|
||||
<ComboboxEmpty>No billing models found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{/* Kept outside ComboboxList so it stays pinned as a footer while the
|
||||
options scroll. Escape hatch when no existing billing model fits:
|
||||
define this model's own pricing rather than mapping onto another. */}
|
||||
<ComboboxSeparator alwaysRender />
|
||||
<ComboboxCreateItem
|
||||
inputValue={modelName}
|
||||
value={`create-pricing-${modelName}`}
|
||||
prefix={<Plus size={14} />}
|
||||
onSelect={handleCreateNew}
|
||||
testId={`map-to-create-${modelName}`}
|
||||
>
|
||||
Create pricing for "{modelName}"
|
||||
</ComboboxCreateItem>
|
||||
</ComboboxCommand>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapToBillingModelSelect;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MapToBillingModelSelect';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import {
|
||||
RULE_OPTIONS_LIMIT,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
|
||||
interface UseMapToBillingModelSearch {
|
||||
searchText: string;
|
||||
setSearchText: (value: string) => void;
|
||||
rules: PricingRule[];
|
||||
// Fetched rules keyed by id, so a pick can resolve to its full rule object.
|
||||
rulesById: Map<string, PricingRule>;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
// Server-side search for the per-row "Map to billing model" dropdown. Only
|
||||
// RULE_OPTIONS_LIMIT rules are fetched at a time; typing narrows the set via the
|
||||
// rules API's `q` param instead of filtering client-side. The fetch is gated on
|
||||
// `enabled` (the dropdown's open state) so closed rows don't fetch, and
|
||||
// react-query dedupes identical query keys so rows sharing a term hit the network
|
||||
// once.
|
||||
export function useMapToBillingModelSearch(
|
||||
enabled: boolean,
|
||||
): UseMapToBillingModelSearch {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const debouncedSearch = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const { data, isFetching } = useListLLMPricingRules(
|
||||
{ offset: 0, limit: RULE_OPTIONS_LIMIT, q: debouncedSearch || undefined },
|
||||
{ query: { enabled } },
|
||||
);
|
||||
|
||||
const rules = useMemo<PricingRule[]>(() => data?.data?.items || [], [data]);
|
||||
const rulesById = useMemo(
|
||||
() => new Map(rules.map((rule) => [rule.id, rule])),
|
||||
[rules],
|
||||
);
|
||||
|
||||
return { searchText, setSearchText, rules, rulesById, isFetching };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { getUnpricedModelsColumns } from './table.config';
|
||||
export type { UnpricedColumnsConfig } from './table.config';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import MapToBillingModelSelect from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/components/MapToBillingModelSelect';
|
||||
import type {
|
||||
PricingRule,
|
||||
UnpricedModel,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import { formatSpanCount } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import styles from './tableConfig.module.scss';
|
||||
|
||||
export interface UnpricedColumnsConfig {
|
||||
canManage: boolean;
|
||||
// Picking a billing model requests a mapping — the tab opens a confirm dialog
|
||||
// and commits that single mapping there (no batch/selection state here).
|
||||
onRequestMap: (model: UnpricedModel, rule: PricingRule) => void;
|
||||
// Opens the add-cost drawer prefilled with the row's model name.
|
||||
onCreateNew: (modelName: string) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the unpriced-models TanStackTable. Sorting is off — the
|
||||
// unmapped-models endpoint returns the full set in one shot with no ordering knob.
|
||||
// Each row's action is self-contained: pick a billing model to map onto (confirmed
|
||||
// in a dialog) or create pricing for the model, so there's no bulk-save column.
|
||||
export function getUnpricedModelsColumns({
|
||||
canManage,
|
||||
onRequestMap,
|
||||
onCreateNew,
|
||||
}: UnpricedColumnsConfig): TableColumnDef<UnpricedModel>[] {
|
||||
return [
|
||||
{
|
||||
id: 'model',
|
||||
header: 'Model (from spans)',
|
||||
accessorFn: (row): string => row.modelName,
|
||||
width: { min: 240, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
testId={`unpriced-model-name-${row.modelName}`}
|
||||
>
|
||||
{row.modelName}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
header: 'Provider',
|
||||
width: { min: 140 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => row.provider || 'Unknown',
|
||||
},
|
||||
{
|
||||
id: 'spans',
|
||||
header: 'Spans',
|
||||
width: { min: 100 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge
|
||||
color="cherry"
|
||||
variant="outline"
|
||||
className={styles.spansBadge}
|
||||
data-testid={`unpriced-spans-${row.modelName}`}
|
||||
>
|
||||
{formatSpanCount(row.spanCount)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mapTo',
|
||||
header: 'Map to billing model',
|
||||
width: { min: 280, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<MapToBillingModelSelect
|
||||
modelName={row.modelName}
|
||||
disabled={!canManage}
|
||||
onSelect={(rule): void => onRequestMap(row, rule)}
|
||||
onCreateNew={(): void => onCreateNew(row.modelName)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.spansBadge {
|
||||
margin: 0;
|
||||
font-family: var(--code-font-family, monospace);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Capped scroll viewport — mirrors ModelCostsTable so the unpriced tab doesn't
|
||||
// shift on load, but uses max-height so a short list stays compact instead of
|
||||
// reserving the full viewport. The extra offset over the model-costs table
|
||||
// accounts for the banner toolbar above.
|
||||
.unpricedModelsTable {
|
||||
--tanstack-table-row-height: 48px;
|
||||
max-height: calc(100vh - 220px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.unpricedModelsEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import { SKELETON_ROW_COUNT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type { UnpricedModel } from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import styles from './UnpricedModelsTable.module.scss';
|
||||
import {
|
||||
getUnpricedModelsColumns,
|
||||
type UnpricedColumnsConfig,
|
||||
} from './TableConfig';
|
||||
|
||||
interface UnpricedModelsTableProps {
|
||||
models: UnpricedModel[];
|
||||
isLoading: boolean;
|
||||
columnsConfig: UnpricedColumnsConfig;
|
||||
}
|
||||
|
||||
// The unmapped-models endpoint returns the full set in one response, so there's
|
||||
// no pagination here — just a content-height list. Virtual scroll is disabled
|
||||
// because the set is small and bounded.
|
||||
function UnpricedModelsTable({
|
||||
models,
|
||||
isLoading,
|
||||
columnsConfig,
|
||||
}: UnpricedModelsTableProps): JSX.Element {
|
||||
const columns = useMemo(
|
||||
() => getUnpricedModelsColumns(columnsConfig),
|
||||
[columnsConfig],
|
||||
);
|
||||
|
||||
if (!isLoading && models.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={styles.unpricedModelsEmpty}
|
||||
data-testid="unpriced-models-empty"
|
||||
>
|
||||
All models in your traces are priced.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable<UnpricedModel>
|
||||
className={styles.unpricedModelsTable}
|
||||
data={models}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.modelName}
|
||||
disableVirtualScroll
|
||||
testId="unpriced-models-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnpricedModelsTable;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './UnpricedModelsTable';
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
getListUnmappedLLMModelsQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import type {
|
||||
PricingRule,
|
||||
UnpricedModel,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import { buildPatternMappingPayload } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
|
||||
// A single row's choice: map this unpriced model onto this billing rule.
|
||||
export interface UnpricedModelMapping {
|
||||
model: UnpricedModel;
|
||||
rule: PricingRule;
|
||||
}
|
||||
|
||||
interface UseUnpricedModelMappingResult {
|
||||
// Commits a single mapping in one request. Resolves true on success so the
|
||||
// caller can close the confirm dialog and clear its staged pick.
|
||||
mapModel: (mapping: UnpricedModelMapping) => Promise<boolean>;
|
||||
// True while the save is in flight, so the confirm button can spin.
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
// Maps an unpriced model onto an existing pricing rule. There's no dedicated
|
||||
// "edit" endpoint — mapping reuses CreateOrUpdate (PUT), appending the model name
|
||||
// as a match pattern on the chosen rule so the model inherits its pricing. Both
|
||||
// the unmapped list and the rules list are invalidated on success so the mapped
|
||||
// model drops out of this tab immediately.
|
||||
export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const { mutateAsync: createOrUpdate } = useCreateOrUpdateLLMPricingRules();
|
||||
|
||||
const mapModel = useCallback(
|
||||
async ({ model, rule }: UnpricedModelMapping): Promise<boolean> => {
|
||||
const payload = buildPatternMappingPayload(rule, model.modelName);
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await createOrUpdate({ data: { rules: [payload] } });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListUnmappedLLMModelsQueryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
}),
|
||||
]);
|
||||
toast.success('Mapped model');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Mapping failed';
|
||||
toast.error(message);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[createOrUpdate, queryClient],
|
||||
);
|
||||
|
||||
return { mapModel, isSaving };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './UnpricedModelsTab';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { PricingRule, UnpricedModel } from '../types';
|
||||
import { getRuleOptionLabel } from '../utils';
|
||||
|
||||
// A model + the billing rule it's about to be mapped onto, held while the confirm
|
||||
// dialog is open.
|
||||
export interface PendingMapping {
|
||||
model: UnpricedModel;
|
||||
rule: PricingRule;
|
||||
}
|
||||
|
||||
interface PendingMappingState {
|
||||
pending: PendingMapping | null;
|
||||
setPending: (mapping: PendingMapping) => void;
|
||||
clearPending: () => void;
|
||||
}
|
||||
|
||||
// The staged mapping lives in a store (not component state/props) because the
|
||||
// row's select trigger, which mirrors the staged pick, sits inside a memoized
|
||||
// TanStackTable cell whose value is cached per row — a prop/context change would
|
||||
// not re-render it, but a store subscription does. Picking a billing model stages
|
||||
// a single mapping for confirmation; it only commits once confirmed in the dialog.
|
||||
export const usePendingMappingStore = create<PendingMappingState>((set) => ({
|
||||
pending: null,
|
||||
setPending: (mapping): void => set({ pending: mapping }),
|
||||
clearPending: (): void => set({ pending: null }),
|
||||
}));
|
||||
|
||||
// Label to show on the given row's select trigger while its mapping is staged, or
|
||||
// undefined when nothing is staged for that row (falls back to the placeholder).
|
||||
export const usePendingMappingLabel = (modelName: string): string | undefined =>
|
||||
usePendingMappingStore((state) => {
|
||||
const { pending } = state;
|
||||
if (!pending || pending.model.modelName !== modelName) {
|
||||
return undefined;
|
||||
}
|
||||
return getRuleOptionLabel(pending.rule);
|
||||
});
|
||||
@@ -2,14 +2,19 @@ import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type ListLLMPricingRules200,
|
||||
type ListUnmappedLLMModels200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from '../types';
|
||||
import type { PricingRule, UnpricedModel } from '../types';
|
||||
|
||||
// Endpoint glob used by MSW handlers. The generated client hits a relative
|
||||
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
|
||||
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
|
||||
// Distinct path (extra segment), so it needs its own handler — the list glob
|
||||
// above does not match it.
|
||||
export const LLM_UNMAPPED_ENDPOINT =
|
||||
'*/api/v1/llm_pricing_rules/unmapped_models';
|
||||
|
||||
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
|
||||
// replaced wholesale when provided so callers can shape cache buckets freely.
|
||||
@@ -60,6 +65,26 @@ export const mockRules: PricingRule[] = [
|
||||
}),
|
||||
];
|
||||
|
||||
// Unpriced models seen in traces with no matching pricing rule.
|
||||
export const mockUnpricedModels: UnpricedModel[] = [
|
||||
{ modelName: 'gpt-4o-mini-2024-07-18', provider: 'openai', spanCount: 18400 },
|
||||
{
|
||||
modelName: 'claude-3-7-sonnet-20250219',
|
||||
provider: 'anthropic',
|
||||
spanCount: 9200,
|
||||
},
|
||||
];
|
||||
|
||||
// Wraps unpriced models in the envelope the unmapped-models query reads.
|
||||
export function makeUnmappedResponse(
|
||||
items: UnpricedModel[] = mockUnpricedModels,
|
||||
): ListUnmappedLLMModels200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: { items },
|
||||
};
|
||||
}
|
||||
|
||||
// Wraps items in the list response envelope the list query reads
|
||||
// (`data.data.items` / `data.data.total`).
|
||||
export function makeListResponse(
|
||||
|
||||
@@ -34,6 +34,13 @@ export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
|
||||
// loaded page renders — otherwise the table height jumps on load.
|
||||
export const SKELETON_ROW_COUNT = PAGE_SIZE;
|
||||
|
||||
export const RULE_OPTIONS_LIMIT = 10;
|
||||
|
||||
// URL-backed key for the active tab on the model-pricing page.
|
||||
export const TAB_KEY = 'tab';
|
||||
export const MODEL_COSTS_TAB = 'model-costs';
|
||||
export const UNPRICED_MODELS_TAB = 'unpriced-models';
|
||||
|
||||
export const PROVIDER_OPTIONS = [
|
||||
{ value: 'OpenAI', label: 'OpenAI' },
|
||||
{ value: 'Anthropic', label: 'Anthropic' },
|
||||
@@ -62,6 +69,7 @@ export const EMPTY_DRAFT: DrawerDraft = {
|
||||
provider: 'OpenAI',
|
||||
patterns: [],
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
pricing: {
|
||||
input: null,
|
||||
output: null,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
type LlmpricingruletypesLLMPricingRuleDTO,
|
||||
type LlmpricingruletypesUnmappedModelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
|
||||
|
||||
// A model seen in trace data (gen_ai.request.model) that no pricing rule matches.
|
||||
export type UnpricedModel = LlmpricingruletypesUnmappedModelDTO;
|
||||
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
@@ -29,6 +33,7 @@ export interface DrawerDraft {
|
||||
provider: string;
|
||||
patterns: string[];
|
||||
isOverride: boolean;
|
||||
enabled: boolean;
|
||||
pricing: {
|
||||
input: number | null;
|
||||
output: number | null;
|
||||
|
||||
@@ -86,6 +86,7 @@ export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
|
||||
provider: rule.provider,
|
||||
patterns: rule.modelPattern || [],
|
||||
isOverride: !!rule.isOverride,
|
||||
enabled: rule.enabled,
|
||||
pricing: {
|
||||
input: rule.pricing?.input ?? 0,
|
||||
output: rule.pricing?.output ?? 0,
|
||||
@@ -129,7 +130,7 @@ export const buildRulePayload = (
|
||||
provider: draft.provider.trim(),
|
||||
modelPattern: draft.patterns,
|
||||
isOverride: draft.isOverride,
|
||||
enabled: true,
|
||||
enabled: draft.enabled,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: buildPricingPayload(draft),
|
||||
});
|
||||
@@ -161,3 +162,38 @@ export const validatePricing = (
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const spanCountFormatter = new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export const formatSpanCount = (count: number): string =>
|
||||
spanCountFormatter.format(count);
|
||||
|
||||
// Label for the "Map to billing model" dropdown, e.g. "openai:gpt-4o ($15.00/$60.00)".
|
||||
export const getRuleOptionLabel = (rule: PricingRule): string =>
|
||||
`${getCanonicalId(rule)} (${formatPricePerMillion(
|
||||
rule.pricing?.input,
|
||||
)}/${formatPricePerMillion(rule.pricing?.output)})`;
|
||||
|
||||
export const buildPatternMappingPayload = (
|
||||
rule: PricingRule,
|
||||
modelName: string,
|
||||
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => {
|
||||
const existing = rule.modelPattern ?? [];
|
||||
const modelPattern = existing.includes(modelName)
|
||||
? existing
|
||||
: [...existing, modelName];
|
||||
return {
|
||||
id: rule.id,
|
||||
sourceId: rule.sourceId,
|
||||
modelName: rule.modelName,
|
||||
provider: rule.provider,
|
||||
modelPattern,
|
||||
isOverride: rule.isOverride,
|
||||
enabled: rule.enabled,
|
||||
unit: rule.unit,
|
||||
pricing: rule.pricing,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
}
|
||||
|
||||
.headerZone {
|
||||
// Sticks to the top of the scroll area so the heading + filters stay put while
|
||||
// the results scroll beneath. Its own stacking context sits above the rows; the
|
||||
// DSL suggestion popup (z-index 1000) still layers above everything.
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
@@ -50,7 +56,6 @@
|
||||
:global(.ant-table-row:last-child)
|
||||
:global(.ant-table-cell)
|
||||
> div {
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
:global(.ant-pagination-item) {
|
||||
|
||||
@@ -10,7 +10,10 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
|
||||
import {
|
||||
type TagPair,
|
||||
useAccumulatedTags,
|
||||
} from '../../hooks/useAccumulatedTags';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
@@ -22,7 +25,7 @@ import {
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
|
||||
import { BuiltinViewId } from '../../types';
|
||||
import type { SelectedTag, UpdatedWindow } from '../../types';
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { applyClientView } from '../../utils/views';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
@@ -50,17 +53,7 @@ function DashboardsList(): JSX.Element {
|
||||
);
|
||||
const canEdit = !!editDashboard;
|
||||
|
||||
const {
|
||||
filters,
|
||||
query,
|
||||
isEmpty: filtersEmpty,
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
setTags,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
} = useDashboardFilters();
|
||||
const { query, isEmpty: filtersEmpty, setQuery } = useDashboardFilters();
|
||||
const [sortColumn, setSortColumn] = useSortColumn();
|
||||
const [sortOrder, setSortOrder] = useSortOrder();
|
||||
const [page, setPage] = usePage();
|
||||
@@ -80,8 +73,8 @@ function DashboardsList(): JSX.Element {
|
||||
removeView,
|
||||
renameView,
|
||||
} = useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
query,
|
||||
setQuery,
|
||||
userEmail: user.email,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
@@ -95,38 +88,13 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
// Any filter change resets to the first page so the user isn't stranded on a
|
||||
// now-out-of-range offset.
|
||||
const handleSearchChange = useCallback(
|
||||
const handleQueryChange = useCallback(
|
||||
(value: string): void => {
|
||||
setSearch(value);
|
||||
setQuery(value);
|
||||
void setPage(1);
|
||||
},
|
||||
[setSearch, setPage],
|
||||
[setQuery, setPage],
|
||||
);
|
||||
const handleCreatedByChange = useCallback(
|
||||
(emails: string[]): void => {
|
||||
setCreatedBy(emails);
|
||||
void setPage(1);
|
||||
},
|
||||
[setCreatedBy, setPage],
|
||||
);
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
setUpdated(window);
|
||||
void setPage(1);
|
||||
},
|
||||
[setUpdated, setPage],
|
||||
);
|
||||
const handleTagsChange = useCallback(
|
||||
(tags: SelectedTag[]): void => {
|
||||
setTags(tags);
|
||||
void setPage(1);
|
||||
},
|
||||
[setTags, setPage],
|
||||
);
|
||||
const handleClearAll = useCallback((): void => {
|
||||
clearAll();
|
||||
void setPage(1);
|
||||
}, [clearAll, setPage]);
|
||||
|
||||
// View actions that change the result set reset pagination too.
|
||||
const handleSelectView = useCallback(
|
||||
@@ -195,6 +163,16 @@ function DashboardsList(): JSX.Element {
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
// Step back a page when a delete empties the current one, instead of showing nothing.
|
||||
useEffect(() => {
|
||||
if (clientView || isFetching) {
|
||||
return;
|
||||
}
|
||||
if (page > 1 && dashboards.length === 0) {
|
||||
void setPage(page - 1);
|
||||
}
|
||||
}, [clientView, isFetching, dashboards.length, page, setPage]);
|
||||
|
||||
// Authors present on the loaded page — a fallback for the creator filter until
|
||||
// the org-wide user list resolves.
|
||||
const pageAuthorEmails = useMemo<string[]>(
|
||||
@@ -210,15 +188,34 @@ function DashboardsList(): JSX.Element {
|
||||
});
|
||||
|
||||
// All key:value tags the API reports for the org's dashboards, powering the
|
||||
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
|
||||
// previously-seen tags stay selectable even when a filtered page omits them.
|
||||
const responseTags = useMemo<SelectedTag[]>(
|
||||
// DSL key/value autocomplete. Accumulated across refetches so previously-seen
|
||||
// tags stay suggestable even when a filtered page omits them.
|
||||
const responseTags = useMemo<TagPair[]>(
|
||||
() =>
|
||||
(response?.data?.tags ?? []).map((t) => ({ key: t.key, value: t.value })),
|
||||
[response],
|
||||
);
|
||||
const availableTags = useAccumulatedTags(responseTags);
|
||||
|
||||
// Autocomplete data source: reserved keys from the response, tag keys/values
|
||||
// accumulated across pages, and creator emails for `created_by` values.
|
||||
const source = useMemo<SuggestionSource>(() => {
|
||||
const tagValuesByKey: Record<string, string[]> = {};
|
||||
const tagKeys = new Set<string>();
|
||||
availableTags.forEach((t) => {
|
||||
tagKeys.add(t.key);
|
||||
const lower = t.key.toLowerCase();
|
||||
(tagValuesByKey[lower] ??= []).push(t.value);
|
||||
});
|
||||
return {
|
||||
reservedKeys: response?.data?.reservedKeywords,
|
||||
tagKeys: Array.from(tagKeys),
|
||||
tagValuesByKey,
|
||||
creatorEmails: creatorOptions.map((o) => o.email),
|
||||
currentUserEmail: user.email,
|
||||
};
|
||||
}, [availableTags, creatorOptions, response, user.email]);
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const visibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
@@ -306,18 +303,10 @@ function DashboardsList(): JSX.Element {
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
<FilterZone
|
||||
search={filters.search}
|
||||
createdBy={filters.createdBy}
|
||||
updated={filters.updated}
|
||||
tags={filters.tags}
|
||||
availableTags={availableTags}
|
||||
query={query}
|
||||
creatorOptions={creatorOptions}
|
||||
isEmpty={filtersEmpty}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onTagsChange={handleTagsChange}
|
||||
onClearAll={handleClearAll}
|
||||
source={source}
|
||||
onQueryChange={handleQueryChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.viewContent}>
|
||||
@@ -332,7 +321,7 @@ function DashboardsList(): JSX.Element {
|
||||
errorMessage={errorMessage}
|
||||
dashboards={dashboards}
|
||||
activeViewId={activeViewId}
|
||||
searchValue={filters.search}
|
||||
searchValue={query}
|
||||
hasFilters={!filtersEmpty}
|
||||
sortColumn={sortColumn}
|
||||
onSortChange={onSortChange}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { CalendarClock, ChevronDown, User } from '@signozhq/icons';
|
||||
import { Checkbox, Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
@@ -21,6 +18,10 @@ const UPDATED_LABELS: Record<UpdatedWindow, string> = {
|
||||
};
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
|
||||
value: w,
|
||||
label: UPDATED_LABELS[w],
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
@@ -28,6 +29,10 @@ interface Props {
|
||||
creatorOptions: CreatorOption[];
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
// Run the staged draft — fired when a dropdown closes.
|
||||
onApply: () => void;
|
||||
// Clear all Created-by selections and run immediately.
|
||||
onClearCreatedBy: () => void;
|
||||
}
|
||||
|
||||
function FilterChips({
|
||||
@@ -36,92 +41,57 @@ function FilterChips({
|
||||
creatorOptions,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
onApply,
|
||||
onClearCreatedBy,
|
||||
}: Props): JSX.Element {
|
||||
const createdByLabel = useMemo((): string => {
|
||||
if (createdBy.length === 0) {
|
||||
return 'Anyone';
|
||||
}
|
||||
if (createdBy.length === 1) {
|
||||
const match = creatorOptions.find((o) => o.email === createdBy[0]);
|
||||
return match?.label ?? createdBy[0];
|
||||
}
|
||||
return `${createdBy.length} people`;
|
||||
}, [createdBy, creatorOptions]);
|
||||
const creatorOptionsData = creatorOptions.map((o) => ({
|
||||
value: o.email,
|
||||
label: o.label,
|
||||
}));
|
||||
|
||||
const createdByItems = useMemo<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = creatorOptions.map((option) => ({
|
||||
type: 'checkbox',
|
||||
key: option.email,
|
||||
label: option.label,
|
||||
checked: createdBy.includes(option.email),
|
||||
onCheckedChange: (checked: boolean): void =>
|
||||
onCreatedByChange(
|
||||
checked
|
||||
? [...createdBy, option.email]
|
||||
: createdBy.filter((e) => e !== option.email),
|
||||
),
|
||||
}));
|
||||
if (createdBy.length > 0) {
|
||||
items.push({ type: 'divider', key: 'sep' });
|
||||
items.push({
|
||||
key: 'clear',
|
||||
label: 'Clear selection',
|
||||
onClick: (): void => onCreatedByChange([]),
|
||||
});
|
||||
const runOnClose = (open: boolean): void => {
|
||||
if (!open) {
|
||||
onApply();
|
||||
}
|
||||
return items;
|
||||
}, [creatorOptions, createdBy, onCreatedByChange]);
|
||||
|
||||
const updatedItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: updated,
|
||||
onChange: (value: string): void => onUpdatedChange(value as UpdatedWindow),
|
||||
children: UPDATED_WINDOWS.map((window) => ({
|
||||
type: 'radio',
|
||||
key: window,
|
||||
value: window,
|
||||
label: UPDATED_LABELS[window],
|
||||
})),
|
||||
},
|
||||
],
|
||||
[updated, onUpdatedChange],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<DropdownMenuSimple menu={{ items: createdByItems }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<User size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, {
|
||||
[styles.chipActive]: createdBy.length > 0,
|
||||
})}
|
||||
testId="dashboards-filter-created-by"
|
||||
>
|
||||
Created by: {createdByLabel}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
|
||||
<DropdownMenuSimple menu={{ items: updatedItems }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<CalendarClock size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, {
|
||||
[styles.chipActive]: updated !== 'any',
|
||||
})}
|
||||
testId="dashboards-filter-updated"
|
||||
>
|
||||
Updated: {UPDATED_LABELS[updated]}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
className={cx(styles.select, styles.selectWide)}
|
||||
placeholder="Created by"
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
optionFilterProp="label"
|
||||
maxTagCount={1}
|
||||
menuItemSelectedIcon={null}
|
||||
data-testid="dashboards-filter-created-by"
|
||||
onClear={onClearCreatedBy}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<div className={styles.creatorOption}>
|
||||
<Checkbox
|
||||
checked={createdBy.includes(option.value as string)}
|
||||
className={styles.creatorCheck}
|
||||
/>
|
||||
<span className={styles.creatorLabel}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={(value): void => onCreatedByChange(value as string[])}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
<Select
|
||||
showSearch
|
||||
className={cx(styles.select, styles.selectNarrow)}
|
||||
placeholder="Updated"
|
||||
value={updated}
|
||||
options={UPDATED_OPTIONS}
|
||||
optionFilterProp="label"
|
||||
data-testid="dashboards-filter-updated"
|
||||
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,14 +37,62 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
// Keep the two dropdowns side by side; each is fixed-width so pills can't
|
||||
// grow the trigger and wrap them onto separate lines.
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: var(--font-size-sm);
|
||||
.select {
|
||||
flex: none;
|
||||
|
||||
// Match the search bar: near-black background, --l2-border border.
|
||||
:global(.ant-select-selector) {
|
||||
background-color: var(--l1-background) !important;
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
border-color: var(--primary-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
// The single-select (Updated) is compact; the multi-select (Created-by) is wider
|
||||
// to fit a truncated pill + "+N" + clear.
|
||||
.selectNarrow {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.selectWide {
|
||||
width: 240px;
|
||||
|
||||
// Keep the pills on one line and let the first pill shrink + ellipsis to fill
|
||||
// the available width (antd sets `flex: none` on tags, so allow the pill to
|
||||
// shrink; keep the "+N" count and the search area intact).
|
||||
:global(.ant-select-selection-overflow) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-overflow-item) {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-overflow-item-rest),
|
||||
:global(.ant-select-selection-overflow-item-suffix) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Created-by option: a presentational checkbox + label (antd handles the toggle).
|
||||
.creatorOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.creatorCheck {
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.creatorLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -9,79 +9,108 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import { buildSuggestionKeys } from '../../utils/dslSuggestions';
|
||||
import type { SelectedTag, UpdatedWindow } from '../../types';
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
import {
|
||||
createdByClause,
|
||||
parseReflectedClauses,
|
||||
spliceClause,
|
||||
updatedClause,
|
||||
} from '../../utils/filterQuery';
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import FilterChips, { type CreatorOption } from './FilterChips';
|
||||
import TagsFilterChip from './TagsFilterChip';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
tags: SelectedTag[];
|
||||
availableTags: SelectedTag[];
|
||||
// The last-run query (source of truth for fetching + the dirty baseline).
|
||||
query: string;
|
||||
creatorOptions: CreatorOption[];
|
||||
isEmpty: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
onTagsChange: (tags: SelectedTag[]) => void;
|
||||
onClearAll: () => void;
|
||||
source: SuggestionSource;
|
||||
// Commit (run) the draft — the only path that triggers a fetch.
|
||||
onQueryChange: (value: string) => void;
|
||||
// Rendered at the end of the search row (e.g. the New Dashboard action).
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
// The filter command zone: name search + structured chips (created-by, updated)
|
||||
// + clear-all. Search is committed on submit/blur (matching the prior bar);
|
||||
// chips apply immediately.
|
||||
// The filter command zone. The query box is a DRAFT: typing and the Created-by /
|
||||
// Updated dropdowns all edit the draft (dropdowns splice their clause in); nothing
|
||||
// fetches until the draft is run (Cmd/Ctrl+Enter or the Run button). A dirty dot
|
||||
// signals unrun changes. The draft re-syncs when the last-run query changes
|
||||
// externally (view select, back/forward).
|
||||
function FilterZone({
|
||||
search,
|
||||
createdBy,
|
||||
updated,
|
||||
tags,
|
||||
availableTags,
|
||||
query,
|
||||
creatorOptions,
|
||||
isEmpty,
|
||||
onSearchChange,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
onTagsChange,
|
||||
onClearAll,
|
||||
source,
|
||||
onQueryChange,
|
||||
rightSlot,
|
||||
}: Props): JSX.Element {
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
const [draft, setDraft] = useState(query);
|
||||
|
||||
const suggestionKeys = useMemo(
|
||||
() => buildSuggestionKeys(availableTags),
|
||||
[availableTags],
|
||||
useEffect(() => {
|
||||
setDraft(query);
|
||||
}, [query]);
|
||||
|
||||
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
|
||||
const dirty = draft.trim() !== query.trim();
|
||||
const isEmpty = !draft.trim();
|
||||
|
||||
const run = useCallback((): void => {
|
||||
const next = draft.trim();
|
||||
if (next !== query) {
|
||||
onQueryChange(next);
|
||||
}
|
||||
}, [draft, query, onQueryChange]);
|
||||
|
||||
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
|
||||
// when the dropdown closes (FilterChips fires onApply), not on each pick.
|
||||
const handleCreatedByChange = useCallback((emails: string[]): void => {
|
||||
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
|
||||
}, []);
|
||||
|
||||
// Updated (single-select) is a definitive single choice, so it splices AND runs
|
||||
// immediately (with the fresh value, avoiding the async draft-state lag).
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
const next = spliceClause(draft, 'updated_at', updatedClause(window));
|
||||
setDraft(next);
|
||||
const trimmed = next.trim();
|
||||
if (trimmed !== query) {
|
||||
onQueryChange(trimmed);
|
||||
}
|
||||
},
|
||||
[draft, query, onQueryChange],
|
||||
);
|
||||
|
||||
// Keep the local input in sync with external search changes (applying a view,
|
||||
// clear-all, back/forward). User typing only mutates the local copy.
|
||||
useEffect(() => {
|
||||
setSearchInput(search);
|
||||
}, [search]);
|
||||
|
||||
const handleSubmit = useCallback((): void => {
|
||||
const next = searchInput.trim();
|
||||
if (next !== search) {
|
||||
onSearchChange(next);
|
||||
// Clear-all on the Created-by select removes its clause and runs immediately.
|
||||
const handleClearCreatedBy = useCallback((): void => {
|
||||
const next = spliceClause(draft, 'created_by', null);
|
||||
setDraft(next);
|
||||
const trimmed = next.trim();
|
||||
if (trimmed !== query) {
|
||||
onQueryChange(trimmed);
|
||||
}
|
||||
}, [searchInput, search, onSearchChange]);
|
||||
}, [draft, query, onQueryChange]);
|
||||
|
||||
// Clear resets the draft and runs immediately (empty query).
|
||||
const handleClear = useCallback((): void => {
|
||||
setDraft('');
|
||||
if (query !== '') {
|
||||
onQueryChange('');
|
||||
}
|
||||
}, [query, onQueryChange]);
|
||||
|
||||
return (
|
||||
<div className={styles.filterZone}>
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.searchInput}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
placeholder={`Search with DSL — e.g. name contains "prod" AND env = "staging"`}
|
||||
suggestionKeys={suggestionKeys}
|
||||
onChange={setSearchInput}
|
||||
onSubmit={handleSubmit}
|
||||
value={draft}
|
||||
placeholder="DSL Filter — e.g. name CONTAINS 'api' AND env IN ['prod','staging']"
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
onChange={setDraft}
|
||||
onSubmit={run}
|
||||
/>
|
||||
</div>
|
||||
{rightSlot}
|
||||
@@ -89,16 +118,13 @@ function FilterZone({
|
||||
<div className={styles.filtersRow}>
|
||||
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
|
||||
<FilterChips
|
||||
createdBy={createdBy}
|
||||
updated={updated}
|
||||
createdBy={reflected.createdBy}
|
||||
updated={reflected.updated}
|
||||
creatorOptions={creatorOptions}
|
||||
onCreatedByChange={onCreatedByChange}
|
||||
onUpdatedChange={onUpdatedChange}
|
||||
/>
|
||||
<TagsFilterChip
|
||||
availableTags={availableTags}
|
||||
tags={tags}
|
||||
onTagsChange={onTagsChange}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onApply={run}
|
||||
onClearCreatedBy={handleClearCreatedBy}
|
||||
/>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
@@ -106,7 +132,7 @@ function FilterZone({
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<X size={12} />}
|
||||
onClick={onClearAll}
|
||||
onClick={handleClear}
|
||||
testId="dashboards-filter-clear"
|
||||
>
|
||||
Clear
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { ChevronDown, Tag } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { SelectedTag } from '../../types';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
// All key:value tags the list API reports across the org's dashboards.
|
||||
availableTags: SelectedTag[];
|
||||
tags: SelectedTag[];
|
||||
onTagsChange: (tags: SelectedTag[]) => void;
|
||||
}
|
||||
|
||||
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
|
||||
|
||||
function TagsFilterChip({
|
||||
availableTags,
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: Props): JSX.Element {
|
||||
const selectedIds = useMemo(() => new Set(tags.map(tagId)), [tags]);
|
||||
|
||||
const label = useMemo((): string => {
|
||||
if (tags.length === 0) {
|
||||
return 'Any';
|
||||
}
|
||||
if (tags.length === 1) {
|
||||
return tagId(tags[0]);
|
||||
}
|
||||
return `${tags.length} tags`;
|
||||
}, [tags]);
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const options: MenuItem[] = availableTags.map((tag) => {
|
||||
const id = tagId(tag);
|
||||
return {
|
||||
type: 'checkbox',
|
||||
key: id,
|
||||
label: id,
|
||||
checked: selectedIds.has(id),
|
||||
onCheckedChange: (checked: boolean): void =>
|
||||
onTagsChange(
|
||||
checked ? [...tags, tag] : tags.filter((t) => tagId(t) !== id),
|
||||
),
|
||||
};
|
||||
});
|
||||
if (tags.length > 0) {
|
||||
options.push({ type: 'divider', key: 'sep' });
|
||||
options.push({
|
||||
key: 'clear',
|
||||
label: 'Clear selection',
|
||||
onClick: (): void => onTagsChange([]),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [availableTags, selectedIds, tags, onTagsChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Tag size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, { [styles.chipActive]: tags.length > 0 })}
|
||||
disabled={availableTags.length === 0}
|
||||
testId="dashboards-filter-tags"
|
||||
>
|
||||
Tags: {label}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagsFilterChip;
|
||||
@@ -5,8 +5,7 @@
|
||||
padding: 16px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px 6px 0px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +1,203 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
// --- CodeMirror internals, scoped to this component ---
|
||||
:global(.cm-editor) {
|
||||
background: transparent;
|
||||
// The completion popup is an absolute child; keep it from being clipped.
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// The query wraps (EditorView.lineWrapping), so no horizontal scrolling.
|
||||
:global(.cm-scroller) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// DSL token colours.
|
||||
:global(.cm-dsl-key) {
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
:global(.cm-dsl-op) {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
:global(.cm-dsl-logic) {
|
||||
color: var(--bg-sakura-400);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
:global(.cm-dsl-string) {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
:global(.cm-dsl-value) {
|
||||
color: var(--bg-amber-400);
|
||||
}
|
||||
|
||||
// Completion popup — pinned to the input box (absolute, left:0/right:0) like the
|
||||
// query builder's where-clause editor, not caret-anchored. A "SUGGESTIONS"
|
||||
// header, a scrolling option list, and a keyboard-hint footer.
|
||||
:global(.cm-tooltip.cm-tooltip-autocomplete) {
|
||||
position: absolute !important;
|
||||
top: calc(100% + 6px) !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l1-background);
|
||||
/* stylelint-disable-next-line local/prefer-css-variables -- dropdown shadow */
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&::before {
|
||||
content: 'SUGGESTIONS';
|
||||
padding: 12px 14px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '↑↓ to navigate · ↵ to apply · esc to dismiss';
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
& > ul {
|
||||
flex: 1 1 auto;
|
||||
// min-height:0 lets the list scroll inside the flex column instead of
|
||||
// overflowing past the footer; width 100% so the scrollbar sits at the
|
||||
// popup's right edge (CM otherwise caps the list width, leaving a gap).
|
||||
min-height: 0;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 46vh;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 20px;
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.cm-tooltip-autocomplete .cm-completionLabel) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Right-aligned "field" / "tag" hint, as a pill (matches the QB type tags).
|
||||
:global(.cm-tooltip-autocomplete .cm-completionDetail) {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
border-radius: 50px;
|
||||
background: color-mix(in srgb, var(--l1-foreground) 10%, transparent);
|
||||
font-style: normal;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:global(.cm-completionIcon) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Flatten the input's bottom corners while the dropdown is attached below it.
|
||||
.inputOpen {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
// Input-like shell. The lead icon and Run button overlay the editor (absolute) so
|
||||
// the editor — and its full-width completion popup — spans the whole box.
|
||||
.field {
|
||||
position: relative;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--l1-background);
|
||||
/* stylelint-disable-next-line local/prefer-css-variables -- matches the V2 dashboard dropdown shadow */
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
.leadIcon {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.suggestionActive {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
.editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
// Bordered light pill so "Run query ⌘↵" reads as an actionable button, not stray
|
||||
// text.
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
// Unrun-changes indicator next to "Run query".
|
||||
.dirtyDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
// Keycap-styled shortcut so ⌘↵ reads as "press this", not decoration.
|
||||
.cmdHint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
gap: 3px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { type MouseEvent, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ChangeEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
completionKeymap,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import CodeMirror, {
|
||||
EditorView,
|
||||
keymap,
|
||||
Prec,
|
||||
type ReactCodeMirrorRef,
|
||||
} from '@uiw/react-codemirror';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import {
|
||||
applyKeySuggestion,
|
||||
getActiveKeyToken,
|
||||
matchKeys,
|
||||
} from '../../utils/dslSuggestions';
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
import { dslCompletionSource } from './dslCompletions';
|
||||
import { dslHighlight } from './dslHighlight';
|
||||
|
||||
import styles from './SearchBar.module.scss';
|
||||
|
||||
@@ -25,139 +27,160 @@ interface Props {
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
// Keys offered as you type (reserved DSL columns + tag keys from the API).
|
||||
suggestionKeys?: string[];
|
||||
source?: SuggestionSource;
|
||||
// The draft differs from the last-run query — shows a "run to apply" hint.
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_SOURCE: SuggestionSource = {
|
||||
tagKeys: [],
|
||||
tagValuesByKey: {},
|
||||
creatorEmails: [],
|
||||
};
|
||||
|
||||
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
|
||||
// handles layout — transparent background, monospace, single line.
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': { fontSize: '13px', backgroundColor: 'transparent' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
// Leave room for the overlaid lead icon (left) and Run button (right).
|
||||
padding: '8px 120px 8px 30px',
|
||||
fontFamily: "'Space Mono', monospace",
|
||||
color: 'var(--l1-foreground)',
|
||||
caretColor: 'var(--l1-foreground)',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
|
||||
'.cm-placeholder': { color: 'var(--l3-foreground)' },
|
||||
});
|
||||
|
||||
const BASIC_SETUP = {
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
|
||||
// styling and doesn't blink by default).
|
||||
drawSelection: false,
|
||||
} as const;
|
||||
|
||||
function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
|
||||
suggestionKeys = [],
|
||||
placeholder = "Filter with DSL (e.g. name CONTAINS 'foo')",
|
||||
source = EMPTY_SOURCE,
|
||||
dirty = false,
|
||||
}: Props): JSX.Element {
|
||||
const [focused, setFocused] = useState(false);
|
||||
// -1 means nothing is highlighted, so Enter submits the typed query rather
|
||||
// than picking a suggestion (arrow keys engage selection).
|
||||
const [highlighted, setHighlighted] = useState(-1);
|
||||
|
||||
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
|
||||
const active = useMemo(() => getActiveKeyToken(value), [value]);
|
||||
const suggestions = useMemo(
|
||||
() => (active ? matchKeys(suggestionKeys, active.token) : []),
|
||||
[active, suggestionKeys],
|
||||
// Refs so the (memoised, stable) extensions always see the latest values.
|
||||
const sourceRef = useRef(source);
|
||||
sourceRef.current = source;
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
dslHighlight,
|
||||
editorTheme,
|
||||
// Wrap a long query onto the next line instead of scrolling horizontally.
|
||||
EditorView.lineWrapping,
|
||||
autocompletion({
|
||||
override: [dslCompletionSource(() => sourceRef.current)],
|
||||
closeOnBlur: true,
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 100,
|
||||
icons: false,
|
||||
}),
|
||||
// Open the suggestions on focus/click (not only while typing), so a click
|
||||
// into the box immediately shows the key list.
|
||||
EditorView.domEventHandlers({
|
||||
focus: (_event, view): boolean => {
|
||||
startCompletion(view);
|
||||
return false;
|
||||
},
|
||||
click: (_event, view): boolean => {
|
||||
startCompletion(view);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...completionKeymap,
|
||||
{ key: 'Escape', run: closeCompletion },
|
||||
// Enter accepts an open suggestion (via completionKeymap above); with
|
||||
// none open it is a no-op — it never runs the query or inserts a newline.
|
||||
{ key: 'Enter', preventDefault: true, run: (): boolean => true },
|
||||
{ key: 'Shift-Enter', preventDefault: true, run: (): boolean => true },
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
preventDefault: true,
|
||||
run: (): boolean => {
|
||||
onSubmitRef.current();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const showSuggestions = focused && suggestions.length > 0;
|
||||
|
||||
const pickSuggestion = (key: string): void => {
|
||||
if (active) {
|
||||
onChange(applyKeySuggestion(value, active, key));
|
||||
}
|
||||
setHighlighted(-1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
return;
|
||||
}
|
||||
if (showSuggestions && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
|
||||
return;
|
||||
}
|
||||
if (showSuggestions && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => Math.max(h - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (showSuggestions && highlighted >= 0) {
|
||||
e.preventDefault();
|
||||
pickSuggestion(suggestions[highlighted]);
|
||||
} else {
|
||||
onSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setFocused(false);
|
||||
setHighlighted(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Input
|
||||
className={cx(styles.input, { [styles.inputOpen]: showSuggestions })}
|
||||
placeholder={placeholder}
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
suffix={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.submit}
|
||||
aria-label="Run search"
|
||||
testId="dashboards-list-search-submit"
|
||||
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
// Prevent the input's blur from firing first and double-submitting.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Run query
|
||||
<span className={styles.cmdHint}>
|
||||
{isMac ? (
|
||||
<Command size={12} color={Color.BG_VANILLA_400} />
|
||||
) : (
|
||||
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
|
||||
)}
|
||||
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
value={value}
|
||||
testId="dashboards-list-search"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
onChange(e.target.value);
|
||||
setHighlighted(-1);
|
||||
}}
|
||||
onFocus={(): void => setFocused(true)}
|
||||
onBlur={(): void => {
|
||||
setFocused(false);
|
||||
setHighlighted(-1);
|
||||
onSubmit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<div
|
||||
className={styles.suggestions}
|
||||
data-testid="dashboards-list-search-suggestions"
|
||||
<div className={styles.field}>
|
||||
<Search
|
||||
size={12}
|
||||
color={Color.BG_VANILLA_400}
|
||||
className={styles.leadIcon}
|
||||
/>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
className={styles.editor}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
extensions={extensions}
|
||||
basicSetup={BASIC_SETUP}
|
||||
indentWithTab={false}
|
||||
data-testid="dashboards-list-search"
|
||||
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.submit}
|
||||
aria-label="Run search"
|
||||
testId="dashboards-list-search-submit"
|
||||
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{suggestions.map((key, index) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={cx(styles.suggestion, {
|
||||
[styles.suggestionActive]: index === highlighted,
|
||||
})}
|
||||
data-testid={`dashboards-list-search-suggestion-${key}`}
|
||||
onMouseEnter={(): void => setHighlighted(index)}
|
||||
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
// Keep focus on the input so blur doesn't submit before we update.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={(): void => pickSuggestion(key)}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{dirty && (
|
||||
<span
|
||||
className={styles.dirtyDot}
|
||||
data-testid="dashboards-list-search-dirty"
|
||||
/>
|
||||
)}
|
||||
Run query
|
||||
<span className={styles.cmdHint}>
|
||||
{isMac ? (
|
||||
<Command size={12} color={Color.BG_VANILLA_400} />
|
||||
) : (
|
||||
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
|
||||
)}
|
||||
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Bridges our stage-aware DSL suggestion engine into CodeMirror's autocompletion,
|
||||
// so the query box gets keyboard navigation + a scrollable popup for free.
|
||||
import {
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
|
||||
import {
|
||||
getSuggestions,
|
||||
type SuggestionSource,
|
||||
} from '../../utils/dslSuggestions';
|
||||
|
||||
// `getSource` is a getter so the freshest tags/users (which load async) are read
|
||||
// on every keystroke.
|
||||
export const dslCompletionSource =
|
||||
(getSource: () => SuggestionSource) =>
|
||||
(context: CompletionContext): CompletionResult | null => {
|
||||
const query = context.state.doc.toString();
|
||||
const { items, ctx } = getSuggestions(query, context.pos, getSource());
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: ctx.replaceStart,
|
||||
to: ctx.replaceEnd,
|
||||
// We already filtered/ranked in getSuggestions; keep CodeMirror from
|
||||
// re-filtering by the typed text.
|
||||
filter: false,
|
||||
options: items.map((item) => ({
|
||||
label: item.label,
|
||||
type: item.kind,
|
||||
detail: item.detail,
|
||||
// Insert the raw text (not the display label) and re-open completion so
|
||||
// key -> operator -> value chains without extra keystrokes.
|
||||
apply: (view: EditorView, _c, from: number, to: number): void => {
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: item.insertText },
|
||||
// caretOffset lands the caret inside a `[...]` list (before its
|
||||
// closing bracket) so multi-select can continue.
|
||||
selection: {
|
||||
anchor: from + item.insertText.length - (item.caretOffset ?? 0),
|
||||
},
|
||||
});
|
||||
startCompletion(view);
|
||||
},
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
|
||||
// tokenizer used for autocomplete (dslTokenizer). Each key / operator / value /
|
||||
// AND-OR span gets a `cm-dsl-*` class coloured in SearchBar.module.scss.
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
type EditorView,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { scanTerm, splitTopLevelTerms } from '../../utils/dslTokenizer';
|
||||
|
||||
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
|
||||
const OP = Decoration.mark({ class: 'cm-dsl-op' });
|
||||
const STRING = Decoration.mark({ class: 'cm-dsl-string' });
|
||||
const VALUE = Decoration.mark({ class: 'cm-dsl-value' });
|
||||
const LOGIC = Decoration.mark({ class: 'cm-dsl-logic' });
|
||||
|
||||
interface Marked {
|
||||
from: number;
|
||||
to: number;
|
||||
deco: Decoration;
|
||||
}
|
||||
|
||||
const collectRanges = (query: string): Marked[] => {
|
||||
const ranges: Marked[] = [];
|
||||
const terms = splitTopLevelTerms(query);
|
||||
terms.forEach((term, i) => {
|
||||
// The AND/OR keyword sits in the gap before this term.
|
||||
if (i > 0 && term.precedingJoiner) {
|
||||
const prevEnd = terms[i - 1].end;
|
||||
const m = /\b(?:AND|OR)\b/i.exec(query.slice(prevEnd, term.start));
|
||||
if (m) {
|
||||
ranges.push({
|
||||
from: prevEnd + m.index,
|
||||
to: prevEnd + m.index + m[0].length,
|
||||
deco: LOGIC,
|
||||
});
|
||||
}
|
||||
}
|
||||
const scan = scanTerm(term.text);
|
||||
const base = term.start;
|
||||
if (scan.key) {
|
||||
ranges.push({
|
||||
from: base + scan.key.start,
|
||||
to: base + scan.key.end,
|
||||
deco: KEY,
|
||||
});
|
||||
}
|
||||
if (scan.operator) {
|
||||
ranges.push({
|
||||
from: base + scan.operator.start,
|
||||
to: base + scan.operator.end,
|
||||
deco: OP,
|
||||
});
|
||||
}
|
||||
if (scan.value) {
|
||||
const isString = /^['"]/.test(scan.value.text.trim());
|
||||
ranges.push({
|
||||
from: base + scan.value.start,
|
||||
to: base + scan.value.end,
|
||||
deco: isString ? STRING : VALUE,
|
||||
});
|
||||
}
|
||||
});
|
||||
return ranges
|
||||
.filter((r) => r.to > r.from)
|
||||
.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
};
|
||||
|
||||
const buildDecorations = (view: EditorView): DecorationSet => {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
collectRanges(view.state.doc.toString()).forEach((r) => {
|
||||
builder.add(r.from, r.to, r.deco);
|
||||
});
|
||||
return builder.finish();
|
||||
};
|
||||
|
||||
export const dslHighlight = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin): DecorationSet => plugin.decorations },
|
||||
);
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { SelectedTag } from '../types';
|
||||
// A key:value tag pair the list API reports across the org's dashboards.
|
||||
export interface TagPair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
|
||||
const tagId = (tag: TagPair): string => `${tag.key}:${tag.value}`;
|
||||
|
||||
// The list response only reports the tags present in the current (filtered) page,
|
||||
// so tags vanish from the filter options as results narrow. Accumulate every tag
|
||||
// we've ever seen so previously-surfaced tags stay selectable across refetches.
|
||||
export function useAccumulatedTags(responseTags: SelectedTag[]): SelectedTag[] {
|
||||
const [tags, setTags] = useState<SelectedTag[]>([]);
|
||||
// so tags vanish from the suggestions as results narrow. Accumulate every tag
|
||||
// we've ever seen so previously-surfaced tags stay suggestable across refetches.
|
||||
export function useAccumulatedTags(responseTags: TagPair[]): TagPair[] {
|
||||
const [tags, setTags] = useState<TagPair[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (responseTags.length === 0) {
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { parseAsString, useQueryState, type Options } from 'nuqs';
|
||||
import { type Options, parseAsString, useQueryState } from 'nuqs';
|
||||
import type {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
areFilterStatesEqual,
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
} from '../utils/filterQuery';
|
||||
import { areQueriesEqual } from '../utils/filterQuery';
|
||||
import { BuiltinViewId } from '../types';
|
||||
import type { DashboardFilterState, SavedView } from '../types';
|
||||
import type { SavedView } from '../types';
|
||||
import {
|
||||
BUILTIN_VIEWS,
|
||||
builtinViewSnapshot,
|
||||
type BuiltinView,
|
||||
builtinViewQuery,
|
||||
isClientView,
|
||||
} from '../utils/views';
|
||||
import { useSavedViews } from './useSavedViews';
|
||||
@@ -23,8 +19,8 @@ import { useSavedViews } from './useSavedViews';
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
interface UseActiveViewArgs {
|
||||
filters: DashboardFilterState;
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
query: string;
|
||||
setQuery: (next: string) => void;
|
||||
userEmail: string;
|
||||
sortColumn: DashboardtypesListSortDTO;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
@@ -38,7 +34,7 @@ export interface UseActiveViewResult {
|
||||
customViews: SavedView[];
|
||||
customViewsLoading: boolean;
|
||||
isCustomActive: boolean;
|
||||
// Current filters diverge from the active view's canonical snapshot.
|
||||
// The current query diverges from the active view's canonical query.
|
||||
isModified: boolean;
|
||||
// Whether the active view constrains the list client-side (pinned/recent).
|
||||
clientView: boolean;
|
||||
@@ -50,19 +46,13 @@ export interface UseActiveViewResult {
|
||||
renameView: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
// The canonical filter snapshot a saved view "is": the backend stores a flat
|
||||
// query, so a view folds entirely into the search box with empty chips.
|
||||
const customSnapshot = (view: SavedView): DashboardFilterState => ({
|
||||
...DEFAULT_FILTER_STATE,
|
||||
search: view.query,
|
||||
});
|
||||
|
||||
// Orchestrates the active view: which view is selected (URL `view` param),
|
||||
// merging built-in + org-shared saved views, applying a view's snapshot on
|
||||
// select, dirty detection, and save/reset/delete via the Views API.
|
||||
// merging built-in + org-shared saved views, applying a view's query on select,
|
||||
// dirty detection, and save/reset/delete via the Views API. A view now simply
|
||||
// "is" a query string, so dirty detection is a trimmed string compare.
|
||||
export function useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
query,
|
||||
setQuery,
|
||||
userEmail,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
@@ -87,35 +77,34 @@ export function useActiveView({
|
||||
[customViews, activeViewId],
|
||||
);
|
||||
|
||||
// The filter state the active view "is" — used to detect divergence.
|
||||
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
|
||||
// The query the active view "is" — used to detect divergence.
|
||||
const canonicalQuery = useMemo<string | null>(
|
||||
() =>
|
||||
activeCustom
|
||||
? customSnapshot(activeCustom)
|
||||
: builtinViewSnapshot(activeViewId, userEmail),
|
||||
? activeCustom.query
|
||||
: builtinViewQuery(activeViewId, userEmail),
|
||||
[activeCustom, activeViewId, userEmail],
|
||||
);
|
||||
|
||||
const isModified = canonicalSnapshot
|
||||
? !areFilterStatesEqual(filters, canonicalSnapshot)
|
||||
: false;
|
||||
const isModified =
|
||||
canonicalQuery !== null && !areQueriesEqual(query, canonicalQuery);
|
||||
|
||||
const selectView = useCallback(
|
||||
(id: string): void => {
|
||||
void setActiveViewId(id);
|
||||
const custom = customViews.find((v) => v.id === id);
|
||||
if (custom) {
|
||||
applyFilters(customSnapshot(custom));
|
||||
setQuery(custom.query);
|
||||
setSortColumn(custom.sort);
|
||||
setSortOrder(custom.order);
|
||||
return;
|
||||
}
|
||||
applyFilters(builtinViewSnapshot(id, userEmail) ?? DEFAULT_FILTER_STATE);
|
||||
setQuery(builtinViewQuery(id, userEmail) ?? '');
|
||||
},
|
||||
[
|
||||
setActiveViewId,
|
||||
customViews,
|
||||
applyFilters,
|
||||
setQuery,
|
||||
userEmail,
|
||||
setSortColumn,
|
||||
setSortOrder,
|
||||
@@ -124,9 +113,6 @@ export function useActiveView({
|
||||
|
||||
const saveView = useCallback(
|
||||
(name: string): void => {
|
||||
// The active view's clause already lives in the filter state (e.g. Locked
|
||||
// seeds `locked = true` into search), so the chips fold into one query.
|
||||
const query = filterStateToQuery(filters);
|
||||
void (async (): Promise<void> => {
|
||||
const created = await createView({
|
||||
name,
|
||||
@@ -136,55 +122,44 @@ export function useActiveView({
|
||||
});
|
||||
if (created) {
|
||||
void setActiveViewId(created.id);
|
||||
// Re-apply the folded representation so the new view isn't
|
||||
// immediately flagged as modified.
|
||||
applyFilters(customSnapshot(created));
|
||||
}
|
||||
})();
|
||||
},
|
||||
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
|
||||
[query, createView, sortColumn, sortOrder, setActiveViewId],
|
||||
);
|
||||
|
||||
const saveActiveView = useCallback((): void => {
|
||||
if (!activeCustom) {
|
||||
return;
|
||||
}
|
||||
const query = filterStateToQuery(filters);
|
||||
updateView(activeCustom.id, {
|
||||
name: activeCustom.name,
|
||||
query,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
});
|
||||
applyFilters({ ...DEFAULT_FILTER_STATE, search: query });
|
||||
}, [activeCustom, filters, updateView, sortColumn, sortOrder, applyFilters]);
|
||||
}, [activeCustom, query, updateView, sortColumn, sortOrder]);
|
||||
|
||||
const resetView = useCallback((): void => {
|
||||
if (!canonicalSnapshot) {
|
||||
if (canonicalQuery === null) {
|
||||
return;
|
||||
}
|
||||
applyFilters(canonicalSnapshot);
|
||||
setQuery(canonicalQuery);
|
||||
if (activeCustom) {
|
||||
setSortColumn(activeCustom.sort);
|
||||
setSortOrder(activeCustom.order);
|
||||
}
|
||||
}, [
|
||||
canonicalSnapshot,
|
||||
applyFilters,
|
||||
activeCustom,
|
||||
setSortColumn,
|
||||
setSortOrder,
|
||||
]);
|
||||
}, [canonicalQuery, setQuery, activeCustom, setSortColumn, setSortOrder]);
|
||||
|
||||
const removeView = useCallback(
|
||||
(id: string): void => {
|
||||
deleteView(id);
|
||||
if (activeViewId === id) {
|
||||
void setActiveViewId(BuiltinViewId.All);
|
||||
applyFilters(DEFAULT_FILTER_STATE);
|
||||
setQuery('');
|
||||
}
|
||||
},
|
||||
[deleteView, activeViewId, setActiveViewId, applyFilters],
|
||||
[deleteView, activeViewId, setActiveViewId, setQuery],
|
||||
);
|
||||
|
||||
// Rename only touches the view's name; its stored query/sort/order are preserved.
|
||||
|
||||
@@ -1,138 +1,33 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
type Options,
|
||||
} from 'nuqs';
|
||||
import { useCallback } from 'react';
|
||||
import { type Options, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import {
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
isFilterStateEmpty,
|
||||
} from '../utils/filterQuery';
|
||||
import type {
|
||||
DashboardFilterState,
|
||||
SelectedTag,
|
||||
UpdatedWindow,
|
||||
} from '../types';
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
import { isQueryEmpty } from '../utils/filterQuery';
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
// Tags are carried in the URL as `key:value` strings; split on the first colon.
|
||||
const parseTag = (raw: string): SelectedTag | null => {
|
||||
const idx = raw.indexOf(':');
|
||||
if (idx <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { key: raw.slice(0, idx), value: raw.slice(idx + 1) };
|
||||
};
|
||||
|
||||
const serializeTag = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
|
||||
|
||||
export interface UseDashboardFiltersResult {
|
||||
filters: DashboardFilterState;
|
||||
// The backend list-filter `query` string derived from the current filters.
|
||||
// The last-run backend list-filter query string — the source of truth for
|
||||
// fetching. The editable draft lives in FilterZone; this only changes when a
|
||||
// query is actually run (or a view is applied).
|
||||
query: string;
|
||||
isEmpty: boolean;
|
||||
setSearch: (value: string) => void;
|
||||
setCreatedBy: (emails: string[]) => void;
|
||||
setUpdated: (window: UpdatedWindow) => void;
|
||||
setTags: (tags: SelectedTag[]) => void;
|
||||
// Replace the whole filter state at once — used when applying a saved view.
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
clearAll: () => void;
|
||||
setQuery: (value: string) => void;
|
||||
}
|
||||
|
||||
// Owns the dashboards-list filter state, synced to the URL (shareable links,
|
||||
// back/forward) and projected into the backend `query` string. Sort/order/page
|
||||
// live in their own query-param hooks; this hook is filters-only.
|
||||
// Owns the run dashboards-list query, synced to the URL (shareable links,
|
||||
// back/forward). Sort/order/page live in their own query-param hooks.
|
||||
export function useDashboardFilters(): UseDashboardFiltersResult {
|
||||
const [search, setSearchState] = useQueryState(
|
||||
'search',
|
||||
const [query, setQueryState] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions(opts),
|
||||
);
|
||||
const [createdBy, setCreatedByState] = useQueryState(
|
||||
'createdBy',
|
||||
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
|
||||
);
|
||||
const [updated, setUpdatedState] = useQueryState(
|
||||
'updated',
|
||||
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
|
||||
);
|
||||
const [tagStrings, setTagStringsState] = useQueryState(
|
||||
'tags',
|
||||
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
|
||||
);
|
||||
|
||||
const tags = useMemo<SelectedTag[]>(
|
||||
() => tagStrings.map(parseTag).filter((t): t is SelectedTag => t !== null),
|
||||
[tagStrings],
|
||||
);
|
||||
|
||||
const filters = useMemo<DashboardFilterState>(
|
||||
() => ({ search, createdBy, updated, tags }),
|
||||
[search, createdBy, updated, tags],
|
||||
);
|
||||
|
||||
const query = useMemo(() => filterStateToQuery(filters), [filters]);
|
||||
|
||||
const setSearch = useCallback(
|
||||
const setQuery = useCallback(
|
||||
(value: string): void => {
|
||||
void setSearchState(value);
|
||||
void setQueryState(value.trim() ? value : null);
|
||||
},
|
||||
[setSearchState],
|
||||
[setQueryState],
|
||||
);
|
||||
|
||||
const setCreatedBy = useCallback(
|
||||
(emails: string[]): void => {
|
||||
void setCreatedByState(emails.length ? emails : null);
|
||||
},
|
||||
[setCreatedByState],
|
||||
);
|
||||
|
||||
const setUpdated = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
void setUpdatedState(window);
|
||||
},
|
||||
[setUpdatedState],
|
||||
);
|
||||
|
||||
const setTags = useCallback(
|
||||
(next: SelectedTag[]): void => {
|
||||
void setTagStringsState(next.length ? next.map(serializeTag) : null);
|
||||
},
|
||||
[setTagStringsState],
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: DashboardFilterState): void => {
|
||||
void setSearchState(next.search || null);
|
||||
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
|
||||
void setUpdatedState(next.updated);
|
||||
void setTagStringsState(
|
||||
next.tags.length ? next.tags.map(serializeTag) : null,
|
||||
);
|
||||
},
|
||||
[setSearchState, setCreatedByState, setUpdatedState, setTagStringsState],
|
||||
);
|
||||
|
||||
const clearAll = useCallback((): void => {
|
||||
applyFilters(DEFAULT_FILTER_STATE);
|
||||
}, [applyFilters]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
query,
|
||||
isEmpty: isFilterStateEmpty(filters),
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
setTags,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
};
|
||||
return { query, isEmpty: isQueryEmpty(query), setQuery };
|
||||
}
|
||||
|
||||
@@ -3,27 +3,9 @@ import type {
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Relative "updated within" windows offered by the Updated filter chip.
|
||||
// Relative "updated within" windows offered by the Updated filter dropdown.
|
||||
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
|
||||
|
||||
// A tag selected in the Tags filter chip — a concrete key:value pair drawn from
|
||||
// the tags the list API reports across the org's dashboards.
|
||||
export interface SelectedTag {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// The user-controllable filter state a view captures. `search` is a raw filter
|
||||
// DSL fragment the user types; the structured chips (created-by, updated, tags)
|
||||
// are AND-ed onto it. Sort/order are handled separately via URL query params and
|
||||
// are not part of a view snapshot.
|
||||
export interface DashboardFilterState {
|
||||
search: string;
|
||||
createdBy: string[]; // emails (created_by)
|
||||
updated: UpdatedWindow;
|
||||
tags: SelectedTag[];
|
||||
}
|
||||
|
||||
// A saved view: a named filter the org shares, persisted via the backend Views
|
||||
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
|
||||
// so a view captures the fully-combined DSL query plus the sort/order to apply.
|
||||
|
||||
122
frontend/src/pages/DashboardsListPageV2/utils/dslGrammar.ts
Normal file
122
frontend/src/pages/DashboardsListPageV2/utils/dslGrammar.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Frontend mirror of the backend dashboards-list filter DSL grammar
|
||||
// (pkg/types/dashboardtypes/list_filter.go). There is no keys/operators API, so
|
||||
// the valid keys, per-field operators, and value shapes are encoded here. Keep in
|
||||
// sync with the backend allow-lists.
|
||||
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
|
||||
|
||||
// Reserved keys the backend recognises as dashboard columns. Any other key is a
|
||||
// tag key. (`updated_by` and `source` are NOT queryable.)
|
||||
export const RESERVED_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
'created_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'locked',
|
||||
] as const;
|
||||
|
||||
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
|
||||
|
||||
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
|
||||
name: 'string',
|
||||
description: 'string',
|
||||
created_by: 'string',
|
||||
created_at: 'timestamp',
|
||||
updated_at: 'timestamp',
|
||||
locked: 'bool',
|
||||
};
|
||||
|
||||
// A reserved key maps to its column type; anything else is a tag.
|
||||
export const classifyField = (key: string): DslFieldType =>
|
||||
RESERVED_FIELD_TYPES[key.toLowerCase()] ?? 'tag';
|
||||
|
||||
// String/tag comparison operators, reusing the shared operator constants
|
||||
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
|
||||
const STRING_OPS = [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
OPERATORS.CONTAINS,
|
||||
negateOperator(OPERATORS.CONTAINS),
|
||||
OPERATORS.LIKE,
|
||||
negateOperator(OPERATORS.LIKE),
|
||||
OPERATORS.ILIKE,
|
||||
negateOperator(OPERATORS.ILIKE),
|
||||
OPERATORS.IN,
|
||||
negateOperator(OPERATORS.IN),
|
||||
];
|
||||
|
||||
// Valid operators per field type. Tags additionally support existence checks.
|
||||
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
|
||||
string: STRING_OPS,
|
||||
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
|
||||
timestamp: [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
OPERATORS['<'],
|
||||
OPERATORS['<='],
|
||||
OPERATORS['>'],
|
||||
OPERATORS['>='],
|
||||
OPERATORS.BETWEEN,
|
||||
negateOperator(OPERATORS.BETWEEN),
|
||||
],
|
||||
bool: [OPERATORS['='], OPERATORS['!=']],
|
||||
};
|
||||
|
||||
// Operators that take no value (the clause is complete after the operator).
|
||||
export const VALUELESS_OPERATORS = new Set([
|
||||
OPERATORS.EXISTS,
|
||||
negateOperator(OPERATORS.EXISTS),
|
||||
]);
|
||||
|
||||
// Operators whose value is a bracketed list (`IN ['a', 'b']`) rather than a
|
||||
// single literal — value suggestions wrap/append inside the `[...]`.
|
||||
export const LIST_OPERATORS = new Set([
|
||||
OPERATORS.IN,
|
||||
negateOperator(OPERATORS.IN),
|
||||
]);
|
||||
|
||||
// Every operator, longest spelling first, so the tokenizer matches `NOT IN`
|
||||
// before `NOT`/`IN` and `>=` before `>`. Word operators allow flexible internal
|
||||
// whitespace (`NOT IN`).
|
||||
export const OPERATOR_PATTERN = new RegExp(
|
||||
'^(' +
|
||||
[
|
||||
'NOT\\s+CONTAINS',
|
||||
'NOT\\s+LIKE',
|
||||
'NOT\\s+ILIKE',
|
||||
'NOT\\s+BETWEEN',
|
||||
'NOT\\s+EXISTS',
|
||||
'NOT\\s+IN',
|
||||
'CONTAINS',
|
||||
'ILIKE',
|
||||
'LIKE',
|
||||
'BETWEEN',
|
||||
'EXISTS',
|
||||
'IN',
|
||||
'>=',
|
||||
'<=',
|
||||
'!=',
|
||||
'<>',
|
||||
'==',
|
||||
'=',
|
||||
'<',
|
||||
'>',
|
||||
].join('|') +
|
||||
')',
|
||||
'i',
|
||||
);
|
||||
|
||||
// Canonical (uppercase, single-spaced) form of a matched operator spelling.
|
||||
export const canonicalOperator = (raw: string): string => {
|
||||
const collapsed = raw.replace(/\s+/g, ' ').trim().toUpperCase();
|
||||
return collapsed === '==' ? '=' : collapsed === '<>' ? '!=' : collapsed;
|
||||
};
|
||||
|
||||
// Characters that start an operator symbol — used to end a bare key token that
|
||||
// abuts an operator without whitespace (`name='x'`).
|
||||
export const isOperatorSymbolStart = (ch: string): boolean =>
|
||||
ch === '=' || ch === '!' || ch === '<' || ch === '>';
|
||||
|
||||
// A single-quoted DSL string literal with embedded single quotes escaped.
|
||||
export const literal = (value: string): string =>
|
||||
`'${value.replace(/'/g, "\\'")}'`;
|
||||
@@ -1,57 +1,138 @@
|
||||
import {
|
||||
applyKeySuggestion,
|
||||
buildSuggestionKeys,
|
||||
getActiveKeyToken,
|
||||
matchKeys,
|
||||
RESERVED_DSL_KEYS,
|
||||
} from './dslSuggestions';
|
||||
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
|
||||
|
||||
describe('getActiveKeyToken', () => {
|
||||
it('returns the partial key at the start', () => {
|
||||
expect(getActiveKeyToken('nam')).toStrictEqual({ token: 'nam', start: 0 });
|
||||
const source: SuggestionSource = {
|
||||
tagKeys: ['env', 'team'],
|
||||
tagValuesByKey: { env: ['prod', 'dev'] },
|
||||
creatorEmails: ['alice@x.io', 'bob@x.io'],
|
||||
currentUserEmail: 'me@x.io',
|
||||
};
|
||||
|
||||
const labels = (q: string, caret: number): string[] =>
|
||||
getSuggestions(q, caret, source).items.map((i) => i.label);
|
||||
const inserts = (q: string, caret: number): string[] =>
|
||||
getSuggestions(q, caret, source).items.map((i) => i.insertText);
|
||||
|
||||
describe('getSuggestions — key stage', () => {
|
||||
it('suggests reserved + tag keys matching the partial', () => {
|
||||
expect(labels('en', 2)).toStrictEqual(['env']);
|
||||
});
|
||||
|
||||
it('returns the partial key after AND', () => {
|
||||
const value = 'name = "x" AND en';
|
||||
expect(getActiveKeyToken(value)).toStrictEqual({ token: 'en', start: 15 });
|
||||
});
|
||||
|
||||
it('is null once an operator (space) has been typed', () => {
|
||||
expect(getActiveKeyToken('name contains')).toBeNull();
|
||||
});
|
||||
|
||||
it('is null for an empty trailing segment', () => {
|
||||
expect(getActiveKeyToken('name = "x" AND ')).toBeNull();
|
||||
it('inserts the key with a trailing space', () => {
|
||||
expect(inserts('en', 2)).toStrictEqual(['env ']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSuggestionKeys', () => {
|
||||
it('lists reserved keys plus distinct tag keys', () => {
|
||||
const keys = buildSuggestionKeys([
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'env', value: 'dev' },
|
||||
{ key: 'team', value: 'core' },
|
||||
describe('getSuggestions — operator stage', () => {
|
||||
it('offers the full tag operator set as "key op" labels', () => {
|
||||
const out = labels('env ', 4);
|
||||
expect(out).toContain('env =');
|
||||
expect(out).toContain('env CONTAINS');
|
||||
expect(out).toContain('env NOT IN');
|
||||
expect(out).toContain('env EXISTS');
|
||||
expect(out.some((l) => l.includes('REGEXP'))).toBe(false);
|
||||
});
|
||||
|
||||
it('restricts locked to = and !=', () => {
|
||||
expect(labels('locked ', 7)).toStrictEqual(['locked =', 'locked !=']);
|
||||
});
|
||||
|
||||
it('offers range operators for a timestamp key', () => {
|
||||
const out = labels('updated_at ', 11);
|
||||
expect(out).toContain('updated_at >=');
|
||||
expect(out).toContain('updated_at BETWEEN');
|
||||
expect(out).not.toContain('updated_at CONTAINS');
|
||||
});
|
||||
|
||||
it('filters operators by the typed partial', () => {
|
||||
expect(labels('env NOT ', 8)).toHaveLength(0); // "NOT " + space → past partial op, no ops
|
||||
expect(labels('env NOT', 7)).toStrictEqual([
|
||||
'env NOT CONTAINS',
|
||||
'env NOT LIKE',
|
||||
'env NOT ILIKE',
|
||||
'env NOT IN',
|
||||
'env NOT EXISTS',
|
||||
]);
|
||||
expect(keys).toStrictEqual([...RESERVED_DSL_KEYS, 'env', 'team']);
|
||||
});
|
||||
|
||||
it('inserts just the operator with a trailing space', () => {
|
||||
expect(inserts('env C', 5)).toStrictEqual(['CONTAINS ']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchKeys', () => {
|
||||
it('matches case-insensitively and excludes exact matches', () => {
|
||||
expect(matchKeys(['name', 'created_by', 'env'], 'NAM')).toStrictEqual([
|
||||
'name',
|
||||
]);
|
||||
expect(matchKeys(['name'], 'name')).toStrictEqual([]);
|
||||
describe('getSuggestions — value stage', () => {
|
||||
it('suggests creator emails (current user first, labelled) for created_by', () => {
|
||||
const out = getSuggestions('created_by = ', 13, source);
|
||||
expect(out.items[0].label).toBe('me@x.io (me)');
|
||||
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
|
||||
});
|
||||
|
||||
it('suggests true/false unquoted for locked', () => {
|
||||
expect(inserts('locked = ', 9)).toStrictEqual(['true ', 'false ']);
|
||||
});
|
||||
|
||||
it('suggests known tag values (quoted, trailing space) for a tag key', () => {
|
||||
expect(inserts('env = ', 6)).toStrictEqual(["'prod' ", "'dev' "]);
|
||||
});
|
||||
|
||||
it('filters values by the open-quote partial', () => {
|
||||
expect(inserts("env = 'pr", 9)).toStrictEqual(["'prod' "]);
|
||||
});
|
||||
|
||||
it('offers no value suggestions for free-text keys', () => {
|
||||
expect(labels('name = ', 7)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyKeySuggestion', () => {
|
||||
it('replaces the partial key with the chosen key and a trailing space', () => {
|
||||
const value = 'name = "x" AND en';
|
||||
const active = getActiveKeyToken(value);
|
||||
if (!active) {
|
||||
throw new Error('expected an active key token');
|
||||
}
|
||||
expect(applyKeySuggestion(value, active, 'env')).toBe('name = "x" AND env ');
|
||||
describe('getSuggestions — IN list values', () => {
|
||||
it('wraps a fresh IN value in a bracketed list, caret before the close', () => {
|
||||
const q = 'created_by IN ';
|
||||
const out = getSuggestions(q, q.length, source);
|
||||
const alice = out.items.find((i) => i.label === 'alice@x.io');
|
||||
expect(alice?.insertText).toBe("['alice@x.io']");
|
||||
expect(alice?.caretOffset).toBe(1);
|
||||
});
|
||||
|
||||
it('wraps tag IN values in a bracketed list', () => {
|
||||
const q = 'env IN ';
|
||||
expect(inserts(q, q.length)).toStrictEqual(["['prod']", "['dev']"]);
|
||||
});
|
||||
|
||||
it('appends to an open list and excludes already-entered values', () => {
|
||||
const q = "env IN ['prod', ]";
|
||||
const caret = q.indexOf(']'); // inside the brackets, after "'prod', "
|
||||
const out = getSuggestions(q, caret, source);
|
||||
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
|
||||
const dev = out.items[0];
|
||||
// No closing bracket (the existing `]` stays); caret lands at the end.
|
||||
expect(dev.insertText).toBe("['prod', 'dev'");
|
||||
expect(dev.caretOffset).toBe(0);
|
||||
});
|
||||
|
||||
it('treats a complete last literal (no trailing comma) as entered', () => {
|
||||
const q = "env IN ['prod'";
|
||||
const out = getSuggestions(q, q.length, source);
|
||||
// 'prod' is committed; only 'dev' remains and it appends after it.
|
||||
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
|
||||
expect(out.items[0].insertText).toBe("['prod', 'dev'");
|
||||
});
|
||||
|
||||
it('filters open-list suggestions by the fragment under the caret', () => {
|
||||
const q = "env IN ['de";
|
||||
expect(inserts(q, q.length)).toStrictEqual(["['dev'"]);
|
||||
});
|
||||
|
||||
it('uses a plain literal for a single-value operator (=)', () => {
|
||||
expect(inserts('env = ', 6)).toStrictEqual(["'prod' ", "'dev' "]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSuggestions — connector stage', () => {
|
||||
it('chains AND/OR after a valueless operator', () => {
|
||||
expect(labels('env EXISTS ', 11)).toStrictEqual(['AND', 'OR']);
|
||||
});
|
||||
|
||||
it('chains AND/OR after a complete value', () => {
|
||||
expect(labels("env = 'prod' ", 13)).toStrictEqual(['AND', 'OR']);
|
||||
expect(inserts("env = 'prod' ", 13)).toStrictEqual(['AND ', 'OR ']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +1,255 @@
|
||||
// Key-name suggestions for the dashboards-list DSL search box. The reserved keys
|
||||
// mirror the backend filter DSL (pkg/.../listfilter_visitor.go); any other key is
|
||||
// treated as a tag key, so we also surface the tag keys the list API reports.
|
||||
import type { SelectedTag } from '../types';
|
||||
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
|
||||
// suggests keys, then operators (valid for that key's field type), then values
|
||||
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
|
||||
// caret/stage detection in dslTokenizer.ts.
|
||||
import {
|
||||
classifyField,
|
||||
LIST_OPERATORS,
|
||||
literal,
|
||||
OPERATOR_MATRIX,
|
||||
RESERVED_KEYS,
|
||||
VALUELESS_OPERATORS,
|
||||
} from './dslGrammar';
|
||||
import { type CaretContext, getCaretContext } from './dslTokenizer';
|
||||
|
||||
// Reserved DSL keys the backend recognises as dashboard columns.
|
||||
export const RESERVED_DSL_KEYS: string[] = [
|
||||
'name',
|
||||
'description',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'created_by',
|
||||
'locked',
|
||||
'source',
|
||||
];
|
||||
// Show the full key set (the popup scrolls); values are capped a bit higher too.
|
||||
const KEY_LIMIT = 100;
|
||||
const VALUE_LIMIT = 50;
|
||||
|
||||
export interface ActiveKeyToken {
|
||||
token: string;
|
||||
// Index in the value string where the partial key begins.
|
||||
start: number;
|
||||
export interface SuggestionSource {
|
||||
// Reserved column keys (from the list response, falling back to RESERVED_KEYS).
|
||||
reservedKeys?: string[];
|
||||
tagKeys: string[];
|
||||
// Known values per tag key (lower-cased key → values).
|
||||
tagValuesByKey: Record<string, string[]>;
|
||||
// Creator emails for `created_by` value suggestions.
|
||||
creatorEmails: string[];
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
// The partial key the user is currently typing: the trailing segment after the
|
||||
// last top-level AND/OR (or the start), provided it hasn't yet reached an
|
||||
// operator (no whitespace). Returns null once the key is complete.
|
||||
export const getActiveKeyToken = (value: string): ActiveKeyToken | null => {
|
||||
const boundaryRe = /\b(?:AND|OR)\b/gi;
|
||||
let lastEnd = 0;
|
||||
let match = boundaryRe.exec(value);
|
||||
while (match !== null) {
|
||||
lastEnd = match.index + match[0].length;
|
||||
match = boundaryRe.exec(value);
|
||||
}
|
||||
const segment = value.slice(lastEnd);
|
||||
const leading = segment.length - segment.trimStart().length;
|
||||
const partial = segment.slice(leading);
|
||||
if (partial.length === 0 || /[\s(]/.test(partial)) {
|
||||
return null;
|
||||
}
|
||||
return { token: partial, start: lastEnd + leading };
|
||||
};
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: 'key' | 'operator' | 'value' | 'connector';
|
||||
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
|
||||
// are distinguishable from org tag keys.
|
||||
detail?: string;
|
||||
// Chars to move the caret back from the end of `insertText` after applying.
|
||||
// Used to land the caret inside a `[...]` list so multi-select can continue.
|
||||
caretOffset?: number;
|
||||
}
|
||||
|
||||
// Build the de-duplicated, ordered list of keys to offer: reserved columns plus
|
||||
// distinct tag keys from the list response.
|
||||
export const buildSuggestionKeys = (availableTags: SelectedTag[]): string[] => {
|
||||
const tagKeys = availableTags.map((t) => t.key);
|
||||
return Array.from(new Set([...RESERVED_DSL_KEYS, ...tagKeys]));
|
||||
};
|
||||
export interface SuggestionsResult {
|
||||
items: Suggestion[];
|
||||
ctx: CaretContext;
|
||||
}
|
||||
|
||||
// Keys matching the partial token (case-insensitive), excluding an exact match.
|
||||
export const matchKeys = (
|
||||
keys: string[],
|
||||
token: string,
|
||||
limit = 8,
|
||||
): string[] => {
|
||||
const lower = token.toLowerCase();
|
||||
const includesInsensitive = (haystack: string, needle: string): boolean =>
|
||||
haystack.toLowerCase().includes(needle.toLowerCase());
|
||||
|
||||
const dedupe = (values: string[]): string[] => Array.from(new Set(values));
|
||||
|
||||
const keySuggestions = (
|
||||
partial: string,
|
||||
source: SuggestionSource,
|
||||
): Suggestion[] => {
|
||||
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
|
||||
const reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
|
||||
// Show reserved keys in our defined (purpose) order — name, description, … —
|
||||
// rather than the API's alphabetical order; any extra API keys follow, then tags.
|
||||
const known = RESERVED_KEYS as readonly string[];
|
||||
const orderedReserved = [
|
||||
...known.filter((k) => reserved.includes(k)),
|
||||
...reserved.filter((k) => !known.includes(k)),
|
||||
];
|
||||
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
|
||||
const lower = partial.toLowerCase();
|
||||
return keys
|
||||
.filter((k) => k.toLowerCase().includes(lower) && k.toLowerCase() !== lower)
|
||||
.slice(0, limit);
|
||||
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
|
||||
.slice(0, KEY_LIMIT)
|
||||
.map((k) => ({
|
||||
label: k,
|
||||
insertText: `${k} `,
|
||||
kind: 'key',
|
||||
// Distinguish built-in columns from org tag keys in the popup.
|
||||
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
|
||||
}));
|
||||
};
|
||||
|
||||
// Replace the active partial key in `value` with the chosen key + a space, ready
|
||||
// for the user to type an operator.
|
||||
export const applyKeySuggestion = (
|
||||
value: string,
|
||||
active: ActiveKeyToken,
|
||||
key: string,
|
||||
): string => `${value.slice(0, active.start)}${key} `;
|
||||
const operatorSuggestions = (
|
||||
fieldKey: string,
|
||||
partial: string,
|
||||
): Suggestion[] => {
|
||||
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
|
||||
const upper = partial.toUpperCase();
|
||||
return ops
|
||||
.filter((op) => op.startsWith(upper))
|
||||
.map((op) => ({
|
||||
// Echo the key with the operator (matches the query-builder UX) so the
|
||||
// suggestion reads as the clause being built.
|
||||
label: `${fieldKey} ${op}`,
|
||||
insertText: `${op} `,
|
||||
kind: 'operator',
|
||||
}));
|
||||
};
|
||||
|
||||
// Strip a leading opening quote from a partially-typed value for matching.
|
||||
const unquotePartial = (partial: string): string =>
|
||||
partial.replace(/^['"]/, '');
|
||||
|
||||
// A complete quoted literal (`'a'` or `"a"`, escapes allowed) — used to tell an
|
||||
// already-entered list value from the fragment the user is still typing.
|
||||
const COMPLETE_LITERAL = /^(['"])(?:\\.|(?!\1).)*\1$/;
|
||||
|
||||
interface ListPartial {
|
||||
// Values already entered in the list (unquoted).
|
||||
committed: string[];
|
||||
// The value fragment the caret is currently typing (unquoted).
|
||||
fragment: string;
|
||||
}
|
||||
|
||||
// Index just past the string literal opening at `start` (mirrors the tokenizer's
|
||||
// escape handling); runs to end-of-input if unterminated.
|
||||
const skipStringLiteral = (text: string, start: number): number => {
|
||||
const quote = text[start];
|
||||
for (let i = start + 1; i < text.length; i += 1) {
|
||||
if (text[i] === '\\') {
|
||||
i += 1;
|
||||
} else if (text[i] === quote) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return text.length - 1;
|
||||
};
|
||||
|
||||
// Split a top-level, comma-separated `[...]` partial (quote-aware) into its
|
||||
// already-entered values and the fragment under the caret. A trailing comma or a
|
||||
// complete quoted last piece means the caret starts a fresh value (fragment '').
|
||||
const parseListPartial = (partial: string): ListPartial => {
|
||||
const inner = partial.trim().replace(/^[[(]/, '');
|
||||
const pieces: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < inner.length; i += 1) {
|
||||
const c = inner[i];
|
||||
if (c === "'" || c === '"') {
|
||||
i = skipStringLiteral(inner, i);
|
||||
} else if (c === '[' || c === '(') {
|
||||
depth += 1;
|
||||
} else if (c === ']' || c === ')') {
|
||||
depth = Math.max(0, depth - 1);
|
||||
} else if (c === ',' && depth === 0) {
|
||||
pieces.push(inner.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
pieces.push(inner.slice(start));
|
||||
const last = pieces[pieces.length - 1].trim();
|
||||
const lastIsComplete = last !== '' && COMPLETE_LITERAL.test(last);
|
||||
const committedPieces = lastIsComplete ? pieces : pieces.slice(0, -1);
|
||||
return {
|
||||
committed: committedPieces
|
||||
.map((p) => unquotePartial(p.trim()).replace(/['"]$/, ''))
|
||||
.filter((p) => p !== ''),
|
||||
fragment: lastIsComplete ? '' : unquotePartial(last),
|
||||
};
|
||||
};
|
||||
|
||||
const valueSuggestions = (
|
||||
ctx: CaretContext,
|
||||
source: SuggestionSource,
|
||||
): Suggestion[] => {
|
||||
if (VALUELESS_OPERATORS.has(ctx.operator)) {
|
||||
return [];
|
||||
}
|
||||
const key = ctx.fieldKey.toLowerCase();
|
||||
const type = classifyField(ctx.fieldKey);
|
||||
|
||||
// List operators (`IN`/`NOT IN`) take a `[...]` list: parse what's already in
|
||||
// the brackets so we exclude entered values and append rather than replace.
|
||||
const isList = LIST_OPERATORS.has(ctx.operator);
|
||||
const list = isList ? parseListPartial(ctx.partial) : null;
|
||||
// A fresh list has no `[` yet; once inside one the `]` already sits after the
|
||||
// caret, so we omit the closing bracket when appending.
|
||||
const inBracket = isList && /^\s*[[(]/.test(ctx.partial);
|
||||
const needle = list ? list.fragment : unquotePartial(ctx.partial);
|
||||
|
||||
if (type === 'bool') {
|
||||
return ['true', 'false']
|
||||
.filter((v) => v.startsWith(needle.toLowerCase()))
|
||||
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
|
||||
}
|
||||
|
||||
let raw: string[] = [];
|
||||
if (key === 'created_by') {
|
||||
const emails = source.currentUserEmail
|
||||
? dedupe([source.currentUserEmail, ...source.creatorEmails])
|
||||
: source.creatorEmails;
|
||||
raw = emails;
|
||||
} else if (type === 'tag') {
|
||||
raw = source.tagValuesByKey[key] ?? [];
|
||||
} else {
|
||||
// name / description / timestamps have no known value set — free text.
|
||||
return [];
|
||||
}
|
||||
|
||||
const committed = new Set(list?.committed ?? []);
|
||||
return raw
|
||||
.filter((v) => !committed.has(v) && includesInsensitive(v, needle))
|
||||
.slice(0, VALUE_LIMIT)
|
||||
.map((v) => {
|
||||
const label =
|
||||
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
|
||||
if (isList) {
|
||||
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
|
||||
return {
|
||||
label,
|
||||
// Append inside the existing `[...]` (its `]` stays after the caret),
|
||||
// or open a fresh closed list. Either way the caret lands just before
|
||||
// the `]` so the next pick continues the list.
|
||||
insertText: inBracket ? `[${values}` : `[${values}]`,
|
||||
kind: 'value',
|
||||
caretOffset: inBracket ? 0 : 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label,
|
||||
// Trailing space so picking a value lands the caret in the connector slot.
|
||||
insertText: `${literal(v)} `,
|
||||
kind: 'value',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// AND / OR chaining after a complete term.
|
||||
const CONNECTORS = ['AND', 'OR'];
|
||||
|
||||
const connectorSuggestions = (partial: string): Suggestion[] => {
|
||||
const upper = partial.toUpperCase();
|
||||
return CONNECTORS.filter((c) => c.startsWith(upper)).map((c) => ({
|
||||
label: c,
|
||||
insertText: `${c} `,
|
||||
kind: 'connector',
|
||||
}));
|
||||
};
|
||||
|
||||
// Compute the suggestions for the caret position, plus the caret context the
|
||||
// caller uses to splice a chosen suggestion back in.
|
||||
export const getSuggestions = (
|
||||
query: string,
|
||||
caret: number,
|
||||
source: SuggestionSource,
|
||||
): SuggestionsResult => {
|
||||
const ctx = getCaretContext(query, caret);
|
||||
let items: Suggestion[] = [];
|
||||
if (ctx.stage === 'key') {
|
||||
items = keySuggestions(ctx.partial, source);
|
||||
} else if (ctx.stage === 'operator') {
|
||||
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
|
||||
} else if (ctx.stage === 'value') {
|
||||
items = valueSuggestions(ctx, source);
|
||||
} else if (ctx.stage === 'connector') {
|
||||
items = connectorSuggestions(ctx.partial);
|
||||
}
|
||||
return { items, ctx };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
getCaretContext,
|
||||
spliceAtCaret,
|
||||
splitTopLevelTerms,
|
||||
} from './dslTokenizer';
|
||||
|
||||
describe('splitTopLevelTerms', () => {
|
||||
it('splits on top-level AND/OR', () => {
|
||||
const terms = splitTopLevelTerms('a AND b OR c');
|
||||
expect(terms.map((t) => t.text.trim())).toStrictEqual(['a', 'b', 'c']);
|
||||
expect(terms.map((t) => t.precedingJoiner)).toStrictEqual([
|
||||
null,
|
||||
'AND',
|
||||
'OR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores AND inside quotes', () => {
|
||||
const terms = splitTopLevelTerms("name = 'a AND b' AND env = 'x'");
|
||||
expect(terms).toHaveLength(2);
|
||||
expect(terms[0].text.trim()).toBe("name = 'a AND b'");
|
||||
expect(terms[1].text.trim()).toBe("env = 'x'");
|
||||
});
|
||||
|
||||
it('ignores OR inside parentheses', () => {
|
||||
const terms = splitTopLevelTerms('(a OR b) AND c');
|
||||
expect(terms).toHaveLength(2);
|
||||
expect(terms[1].precedingJoiner).toBe('AND');
|
||||
});
|
||||
|
||||
it('keeps an IN list as a single term', () => {
|
||||
const terms = splitTopLevelTerms("created_by IN ['a','b']");
|
||||
expect(terms).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not split inside a bare word containing and/or', () => {
|
||||
expect(splitTopLevelTerms('command = 1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCaretContext — stage detection', () => {
|
||||
const stageAt = (q: string, caret: number): string =>
|
||||
getCaretContext(q, caret).stage;
|
||||
|
||||
it('is the key stage while typing the key', () => {
|
||||
const ctx = getCaretContext('env', 3);
|
||||
expect(ctx.stage).toBe('key');
|
||||
expect(ctx.partial).toBe('env');
|
||||
expect(ctx.replaceStart).toBe(0);
|
||||
});
|
||||
|
||||
it('moves to the operator stage after the key + space', () => {
|
||||
const ctx = getCaretContext('env ', 4);
|
||||
expect(ctx.stage).toBe('operator');
|
||||
expect(ctx.fieldKey).toBe('env');
|
||||
expect(ctx.partial).toBe('');
|
||||
});
|
||||
|
||||
it('stays on the operator while typing a partial operator', () => {
|
||||
const ctx = getCaretContext('env NOT', 7);
|
||||
expect(ctx.stage).toBe('operator');
|
||||
expect(ctx.partial).toBe('NOT');
|
||||
expect(ctx.replaceStart).toBe(4);
|
||||
});
|
||||
|
||||
it('moves to the value stage after the operator + space', () => {
|
||||
const ctx = getCaretContext('env = ', 6);
|
||||
expect(ctx.stage).toBe('value');
|
||||
expect(ctx.fieldKey).toBe('env');
|
||||
expect(ctx.operator).toBe('=');
|
||||
});
|
||||
|
||||
it('recognises a multi-word operator and moves to value', () => {
|
||||
const ctx = getCaretContext('env NOT IN ', 11);
|
||||
expect(ctx.stage).toBe('value');
|
||||
expect(ctx.operator).toBe('NOT IN');
|
||||
});
|
||||
|
||||
it('reports value partial inside an open quote', () => {
|
||||
const ctx = getCaretContext("env = 'pr", 9);
|
||||
expect(ctx.stage).toBe('value');
|
||||
expect(ctx.partial).toBe("'pr");
|
||||
expect(ctx.replaceStart).toBe(6);
|
||||
});
|
||||
|
||||
it('chains a connector after EXISTS (no value stage)', () => {
|
||||
expect(stageAt('env EXISTS ', 11)).toBe('connector');
|
||||
});
|
||||
|
||||
it('moves to the connector stage after a complete value + space', () => {
|
||||
const ctx = getCaretContext("env = 'prod' ", 13);
|
||||
expect(ctx.stage).toBe('connector');
|
||||
expect(ctx.partial).toBe('');
|
||||
});
|
||||
|
||||
it('reports a partial connector keyword', () => {
|
||||
const ctx = getCaretContext("env = 'prod' AN", 15);
|
||||
expect(ctx.stage).toBe('connector');
|
||||
expect(ctx.partial).toBe('AN');
|
||||
expect(ctx.replaceStart).toBe(13);
|
||||
});
|
||||
|
||||
it('handles a key operator with no whitespace', () => {
|
||||
const ctx = getCaretContext("name='x", 7);
|
||||
expect(ctx.stage).toBe('value');
|
||||
expect(ctx.fieldKey).toBe('name');
|
||||
expect(ctx.operator).toBe('=');
|
||||
});
|
||||
|
||||
it('starts a fresh key after a top-level AND', () => {
|
||||
const q = "created_by = 'a@x' AND ";
|
||||
const ctx = getCaretContext(q, q.length);
|
||||
expect(ctx.stage).toBe('key');
|
||||
expect(ctx.partial).toBe('');
|
||||
});
|
||||
|
||||
it('detects the stage of the term under a mid-string caret', () => {
|
||||
const q = "env = AND team = 'core'";
|
||||
// caret right after the first `env ` (index 4) is the operator stage
|
||||
expect(stageAt(q, 4)).toBe('operator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spliceAtCaret', () => {
|
||||
it('splices a key suggestion and returns the caret', () => {
|
||||
const ctx = getCaretContext('env', 3);
|
||||
const { next, caret } = spliceAtCaret('env', ctx, 'name ');
|
||||
expect(next).toBe('name ');
|
||||
expect(caret).toBe(5);
|
||||
});
|
||||
|
||||
it('splices an operator into the gap', () => {
|
||||
const ctx = getCaretContext('env ', 4);
|
||||
const { next } = spliceAtCaret('env ', ctx, '= ');
|
||||
expect(next).toBe('env = ');
|
||||
});
|
||||
|
||||
it('splices a value over an open-quote partial', () => {
|
||||
const q = "env = 'pr";
|
||||
const ctx = getCaretContext(q, q.length);
|
||||
const { next } = spliceAtCaret(q, ctx, "'prod'");
|
||||
expect(next).toBe("env = 'prod'");
|
||||
});
|
||||
|
||||
it('preserves text after the caret', () => {
|
||||
const q = "env AND team = 'core'";
|
||||
const ctx = getCaretContext(q, 4); // operator gap after `env`
|
||||
const { next } = spliceAtCaret(q, ctx, '= ');
|
||||
expect(next).toBe("env = AND team = 'core'");
|
||||
});
|
||||
});
|
||||
351
frontend/src/pages/DashboardsListPageV2/utils/dslTokenizer.ts
Normal file
351
frontend/src/pages/DashboardsListPageV2/utils/dslTokenizer.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
|
||||
// the autocomplete engine (dslSuggestions) and the clause splicer (filterQuery).
|
||||
// The DSL is a boolean composition of `key OP value` terms joined by AND/OR.
|
||||
import {
|
||||
canonicalOperator,
|
||||
isOperatorSymbolStart,
|
||||
OPERATOR_PATTERN,
|
||||
VALUELESS_OPERATORS,
|
||||
} from './dslGrammar';
|
||||
|
||||
export interface Term {
|
||||
// Raw substring of the term (may carry surrounding whitespace); callers trim.
|
||||
text: string;
|
||||
start: number; // absolute start index in the query (inclusive)
|
||||
end: number; // absolute end index in the query (exclusive)
|
||||
precedingJoiner: 'AND' | 'OR' | null;
|
||||
}
|
||||
|
||||
// A top-level AND/OR keyword starting at index `i` (word-boundary aware), or null.
|
||||
const joinerAt = (
|
||||
query: string,
|
||||
i: number,
|
||||
): { value: 'AND' | 'OR'; length: number } | null => {
|
||||
const c = query[i];
|
||||
if (c !== 'A' && c !== 'a' && c !== 'O' && c !== 'o') {
|
||||
return null;
|
||||
}
|
||||
const prev = i === 0 ? '' : query[i - 1];
|
||||
if (!(i === 0 || /[^A-Za-z0-9_]/.test(prev))) {
|
||||
return null;
|
||||
}
|
||||
const m = /^(AND|OR)\b/i.exec(query.slice(i));
|
||||
return m
|
||||
? { value: m[1].toUpperCase() as 'AND' | 'OR', length: m[0].length }
|
||||
: null;
|
||||
};
|
||||
|
||||
// Index just past the string literal that opens at `start` (handles `\` escapes;
|
||||
// runs to end-of-input if unterminated).
|
||||
const skipString = (query: string, start: number): number => {
|
||||
const quote = query[start];
|
||||
let i = start + 1;
|
||||
while (i < query.length) {
|
||||
if (query[i] === '\\') {
|
||||
i += 2;
|
||||
} else if (query[i] === quote) {
|
||||
return i + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
};
|
||||
|
||||
// Split a query into its top-level (depth-0, outside quotes) AND/OR-separated
|
||||
// terms. `AND`/`OR` inside quotes or brackets/parens are ignored.
|
||||
export const splitTopLevelTerms = (query: string): Term[] => {
|
||||
const terms: Term[] = [];
|
||||
let depth = 0;
|
||||
let termStart = 0;
|
||||
let precedingJoiner: 'AND' | 'OR' | null = null;
|
||||
let i = 0;
|
||||
|
||||
const push = (end: number, next: 'AND' | 'OR' | null): void => {
|
||||
terms.push({
|
||||
text: query.slice(termStart, end),
|
||||
start: termStart,
|
||||
end,
|
||||
precedingJoiner,
|
||||
});
|
||||
precedingJoiner = next;
|
||||
};
|
||||
|
||||
while (i < query.length) {
|
||||
const c = query[i];
|
||||
if (c === "'" || c === '"') {
|
||||
i = skipString(query, i);
|
||||
continue;
|
||||
}
|
||||
if (c === '[' || c === '(') {
|
||||
depth += 1;
|
||||
} else if (c === ']' || c === ')') {
|
||||
depth = Math.max(0, depth - 1);
|
||||
} else if (depth === 0) {
|
||||
const joiner = joinerAt(query, i);
|
||||
if (joiner) {
|
||||
push(i, joiner.value);
|
||||
i += joiner.length;
|
||||
termStart = i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
push(query.length, null);
|
||||
return terms;
|
||||
};
|
||||
|
||||
export interface TermScan {
|
||||
key?: { start: number; end: number; text: string };
|
||||
// The recognised operator (canonical form), if the second token parses as one.
|
||||
operator?: { start: number; end: number; canonical: string };
|
||||
// The second token when it is NOT (yet) a recognised operator — i.e. an
|
||||
// operator the user is still typing.
|
||||
operatorPartial?: { start: number; end: number; text: string };
|
||||
value?: { start: number; end: number; text: string };
|
||||
}
|
||||
|
||||
// Scan a single term (relative coordinates) into key / operator / value tokens.
|
||||
export const scanTerm = (text: string): TermScan => {
|
||||
const n = text.length;
|
||||
let i = 0;
|
||||
const skipWs = (): void => {
|
||||
while (i < n && /\s/.test(text[i])) {
|
||||
i += 1;
|
||||
}
|
||||
};
|
||||
|
||||
skipWs();
|
||||
const scan: TermScan = {};
|
||||
|
||||
// Key: a bare token up to whitespace or an operator symbol.
|
||||
const ks = i;
|
||||
while (i < n && !/\s/.test(text[i]) && !isOperatorSymbolStart(text[i])) {
|
||||
i += 1;
|
||||
}
|
||||
if (i > ks) {
|
||||
scan.key = { start: ks, end: i, text: text.slice(ks, i) };
|
||||
}
|
||||
|
||||
skipWs();
|
||||
if (i >= n) {
|
||||
return scan;
|
||||
}
|
||||
|
||||
// Operator: try to match a known operator at the current position.
|
||||
const rest = text.slice(i);
|
||||
const m = OPERATOR_PATTERN.exec(rest);
|
||||
// A word operator must end on a boundary so `INDIA` isn't read as `IN`.
|
||||
const wordBoundaryOk =
|
||||
m &&
|
||||
(/[^A-Za-z]$/.test(m[0]) ||
|
||||
i + m[0].length >= n ||
|
||||
/[^A-Za-z]/.test(text[i + m[0].length]));
|
||||
if (m && wordBoundaryOk) {
|
||||
scan.operator = {
|
||||
start: i,
|
||||
end: i + m[0].length,
|
||||
canonical: canonicalOperator(m[0]),
|
||||
};
|
||||
i += m[0].length;
|
||||
skipWs();
|
||||
if (i < n) {
|
||||
scan.value = { start: i, end: n, text: text.slice(i, n) };
|
||||
}
|
||||
return scan;
|
||||
}
|
||||
|
||||
// Not a recognised operator yet — treat the second token as a partial op.
|
||||
const os = i;
|
||||
while (i < n && !/\s/.test(text[i])) {
|
||||
i += 1;
|
||||
}
|
||||
scan.operatorPartial = { start: os, end: i, text: text.slice(os, i) };
|
||||
return scan;
|
||||
};
|
||||
|
||||
export type CaretStage = 'key' | 'operator' | 'value' | 'connector' | 'none';
|
||||
|
||||
export interface CaretContext {
|
||||
stage: CaretStage;
|
||||
fieldKey: string; // key of the active term ('' when still typing the key)
|
||||
operator: string; // canonical operator ('' when not yet parsed)
|
||||
partial: string; // text typed so far in the active slot (for matching)
|
||||
replaceStart: number; // absolute index to start replacing at
|
||||
replaceEnd: number; // absolute index to stop replacing (the caret)
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
stage: CaretStage;
|
||||
operator: string;
|
||||
partial: string;
|
||||
replaceStartRel: number;
|
||||
}
|
||||
|
||||
// Index where the value CONTENT ends (the value token's `end` is the whole term
|
||||
// tail, incl. trailing space). A quoted/bracketed value ends after its close; an
|
||||
// unterminated one runs to the end (still being typed); a bare one ends at space.
|
||||
const valueContentEnd = (text: string, start: number): number => {
|
||||
const n = text.length;
|
||||
const ch = text[start];
|
||||
if (ch === '"' || ch === "'") {
|
||||
for (let i = start + 1; i < n; i += 1) {
|
||||
if (text[i] === ch) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (ch === '[' || ch === '(') {
|
||||
const close = ch === '[' ? ']' : ')';
|
||||
for (let i = start + 1; i < n; i += 1) {
|
||||
if (text[i] === close) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
let i = start;
|
||||
while (i < n && !/\s/.test(text[i])) {
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
};
|
||||
|
||||
// The connector slot: after a complete term, a (partial) AND/OR keyword sits in
|
||||
// the trailing whitespace, ready to chain the next term.
|
||||
const connectorSlot = (
|
||||
text: string,
|
||||
from: number,
|
||||
rel: number,
|
||||
operator: string,
|
||||
): Slot => {
|
||||
const ws = /^\s*/.exec(text.slice(from, rel));
|
||||
const start = from + (ws ? ws[0].length : 0);
|
||||
return {
|
||||
stage: 'connector',
|
||||
operator,
|
||||
partial: text.slice(start, rel),
|
||||
replaceStartRel: start,
|
||||
};
|
||||
};
|
||||
|
||||
// Slot resolution once the caret is past the key: operator vs value.
|
||||
const afterKeySlot = (text: string, scan: TermScan, rel: number): Slot => {
|
||||
const { operator, operatorPartial, value } = scan;
|
||||
|
||||
// Editing the operator (a partial second token, or within a recognised op).
|
||||
if (operatorPartial && rel <= operatorPartial.end) {
|
||||
return {
|
||||
stage: 'operator',
|
||||
operator: '',
|
||||
partial: text.slice(operatorPartial.start, rel),
|
||||
replaceStartRel: operatorPartial.start,
|
||||
};
|
||||
}
|
||||
if (operator && rel <= operator.end) {
|
||||
return {
|
||||
stage: 'operator',
|
||||
operator: '',
|
||||
partial: text.slice(operator.start, rel),
|
||||
replaceStartRel: operator.start,
|
||||
};
|
||||
}
|
||||
// Whitespace right after the key, before any operator — starting the operator.
|
||||
if (!operator && !operatorPartial) {
|
||||
return { stage: 'operator', operator: '', partial: '', replaceStartRel: rel };
|
||||
}
|
||||
// Past a recognised operator — the value slot, or (once complete) the connector.
|
||||
if (operator) {
|
||||
if (VALUELESS_OPERATORS.has(operator.canonical)) {
|
||||
// After a valueless op (e.g. EXISTS), trailing space → chain AND/OR.
|
||||
return rel > operator.end
|
||||
? connectorSlot(text, operator.end, rel, operator.canonical)
|
||||
: {
|
||||
stage: 'none',
|
||||
operator: operator.canonical,
|
||||
partial: '',
|
||||
replaceStartRel: rel,
|
||||
};
|
||||
}
|
||||
if (value) {
|
||||
// Caret past a complete value (into trailing space) → chain AND/OR.
|
||||
const contentEnd = valueContentEnd(text, value.start);
|
||||
if (rel > contentEnd) {
|
||||
return connectorSlot(text, contentEnd, rel, operator.canonical);
|
||||
}
|
||||
return {
|
||||
stage: 'value',
|
||||
operator: operator.canonical,
|
||||
partial: text.slice(value.start, rel),
|
||||
replaceStartRel: value.start,
|
||||
};
|
||||
}
|
||||
// No value typed yet — the (empty) value slot.
|
||||
return {
|
||||
stage: 'value',
|
||||
operator: operator.canonical,
|
||||
partial: '',
|
||||
replaceStartRel: rel,
|
||||
};
|
||||
}
|
||||
// A partial operator the caret has moved past — ambiguous, offer nothing.
|
||||
return { stage: 'none', operator: '', partial: '', replaceStartRel: rel };
|
||||
};
|
||||
|
||||
// Resolve which slot (key/operator/value) the caret is editing within a scanned
|
||||
// term, in term-relative coordinates.
|
||||
const resolveSlot = (text: string, scan: TermScan, rel: number): Slot => {
|
||||
const { key } = scan;
|
||||
// Still in/at the key token (or before it).
|
||||
if (!key || rel <= key.end) {
|
||||
const start = key ? key.start : rel;
|
||||
return {
|
||||
stage: 'key',
|
||||
operator: '',
|
||||
partial: key ? text.slice(key.start, rel) : '',
|
||||
replaceStartRel: Math.min(start, rel),
|
||||
};
|
||||
}
|
||||
return afterKeySlot(text, scan, rel);
|
||||
};
|
||||
|
||||
// Determine what the caret is currently editing (key / operator / value) within
|
||||
// the top-level term it sits in, plus the range a suggestion should replace.
|
||||
export const getCaretContext = (query: string, caret: number): CaretContext => {
|
||||
const pos = Math.max(0, Math.min(caret, query.length));
|
||||
const terms = splitTopLevelTerms(query);
|
||||
// The term the caret sits in: the first whose end >= caret (a joiner boundary
|
||||
// counts as the end of the preceding term).
|
||||
const term =
|
||||
terms.find((t) => pos >= t.start && pos <= t.end) ?? terms[terms.length - 1];
|
||||
|
||||
const scan = scanTerm(term.text);
|
||||
const slot = resolveSlot(term.text, scan, pos - term.start);
|
||||
return {
|
||||
stage: slot.stage,
|
||||
fieldKey: scan.key ? scan.key.text : '',
|
||||
operator: slot.operator,
|
||||
partial: slot.partial,
|
||||
replaceStart: term.start + slot.replaceStartRel,
|
||||
replaceEnd: pos,
|
||||
};
|
||||
};
|
||||
|
||||
export interface SpliceResult {
|
||||
next: string;
|
||||
caret: number;
|
||||
}
|
||||
|
||||
// Replace the active slot (ctx.replaceStart..replaceEnd) with `insertText`,
|
||||
// preserving everything after the caret, and return the new caret position.
|
||||
export const spliceAtCaret = (
|
||||
query: string,
|
||||
ctx: CaretContext,
|
||||
insertText: string,
|
||||
): SpliceResult => {
|
||||
const next =
|
||||
query.slice(0, ctx.replaceStart) + insertText + query.slice(ctx.replaceEnd);
|
||||
return { next, caret: ctx.replaceStart + insertText.length };
|
||||
};
|
||||
@@ -1,109 +1,124 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
areFilterStatesEqual,
|
||||
combineQueries,
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
isFilterStateEmpty,
|
||||
areQueriesEqual,
|
||||
createdByClause,
|
||||
isQueryEmpty,
|
||||
parseReflectedClauses,
|
||||
spliceClause,
|
||||
updatedWindowFromTimestamp,
|
||||
} from './filterQuery';
|
||||
import type { DashboardFilterState } from '../types';
|
||||
|
||||
const state = (patch: Partial<DashboardFilterState>): DashboardFilterState => ({
|
||||
...DEFAULT_FILTER_STATE,
|
||||
...patch,
|
||||
describe('createdByClause', () => {
|
||||
it('is null for no emails, = for one, IN for many', () => {
|
||||
expect(createdByClause([])).toBeNull();
|
||||
expect(createdByClause(['a@x.io'])).toBe("created_by = 'a@x.io'");
|
||||
expect(createdByClause(['a@x.io', 'b@x.io'])).toBe(
|
||||
"created_by IN ['a@x.io', 'b@x.io']",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterStateToQuery', () => {
|
||||
it('passes the raw search through, wrapped in parentheses', () => {
|
||||
expect(filterStateToQuery(state({ search: 'name contains "prod"' }))).toBe(
|
||||
'(name contains "prod")',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an equality clause for a single creator', () => {
|
||||
expect(filterStateToQuery(state({ createdBy: ['a@b.com'] }))).toBe(
|
||||
"created_by = 'a@b.com'",
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an IN clause for multiple creators', () => {
|
||||
expect(filterStateToQuery(state({ createdBy: ['a@b.com', 'c@d.com'] }))).toBe(
|
||||
"created_by IN ['a@b.com', 'c@d.com']",
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an exact equality clause per selected tag', () => {
|
||||
describe('parseReflectedClauses', () => {
|
||||
it('reflects a top-level created_by equality', () => {
|
||||
expect(
|
||||
filterStateToQuery(
|
||||
state({
|
||||
tags: [
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'team', value: 'core' },
|
||||
],
|
||||
}),
|
||||
parseReflectedClauses("created_by = 'a@x.io'").createdBy,
|
||||
).toStrictEqual(['a@x.io']);
|
||||
});
|
||||
|
||||
it('reflects a created_by IN list', () => {
|
||||
expect(
|
||||
parseReflectedClauses("created_by IN ['a@x.io','b@x.io']").createdBy,
|
||||
).toStrictEqual(['a@x.io', 'b@x.io']);
|
||||
});
|
||||
|
||||
it('does not reflect a non =/IN created_by operator', () => {
|
||||
expect(
|
||||
parseReflectedClauses("created_by != 'a@x.io'").createdBy,
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('does not reflect a nested (parenthesised) clause', () => {
|
||||
expect(
|
||||
parseReflectedClauses("(created_by = 'a@x.io') AND name = 'x'").createdBy,
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('does not reflect a duplicated key', () => {
|
||||
expect(
|
||||
parseReflectedClauses("created_by = 'a@x.io' AND created_by = 'b@x.io'")
|
||||
.createdBy,
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('reflects a recent updated_at >= as the nearest window', () => {
|
||||
const iso = dayjs().subtract(7, 'day').toISOString();
|
||||
expect(parseReflectedClauses(`updated_at >= '${iso}'`).updated).toBe('7d');
|
||||
});
|
||||
|
||||
it('falls back to any for an unrecognised updated_at cutoff', () => {
|
||||
const iso = dayjs().subtract(400, 'day').toISOString();
|
||||
expect(parseReflectedClauses(`updated_at >= '${iso}'`).updated).toBe('any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spliceClause', () => {
|
||||
it('appends a clause when the key is absent', () => {
|
||||
expect(
|
||||
spliceClause("name = 'x'", 'created_by', "created_by = 'a@x.io'"),
|
||||
).toBe("name = 'x' AND created_by = 'a@x.io'");
|
||||
});
|
||||
|
||||
it('replaces an existing top-level clause for the key', () => {
|
||||
expect(
|
||||
spliceClause(
|
||||
"created_by = 'old' AND name = 'x'",
|
||||
'created_by',
|
||||
"created_by = 'new'",
|
||||
),
|
||||
).toBe("env = 'prod' AND team = 'core'");
|
||||
).toBe("created_by = 'new' AND name = 'x'");
|
||||
});
|
||||
|
||||
it('ANDs raw search with the structured chips', () => {
|
||||
it('removes the clause when passed null', () => {
|
||||
expect(
|
||||
filterStateToQuery(
|
||||
state({
|
||||
search: 'name contains "x"',
|
||||
createdBy: ['a@b.com'],
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
}),
|
||||
spliceClause("created_by = 'a@x.io' AND name = 'x'", 'created_by', null),
|
||||
).toBe("name = 'x'");
|
||||
});
|
||||
|
||||
it('leaves a nested clause untouched and appends instead', () => {
|
||||
expect(
|
||||
spliceClause(
|
||||
"(created_by = 'a') AND name = 'x'",
|
||||
'created_by',
|
||||
"created_by = 'b'",
|
||||
),
|
||||
).toBe("(name contains \"x\") AND created_by = 'a@b.com' AND env = 'prod'");
|
||||
).toBe("(created_by = 'a') AND name = 'x' AND created_by = 'b'");
|
||||
});
|
||||
|
||||
it('returns an empty string for the default state', () => {
|
||||
expect(filterStateToQuery(DEFAULT_FILTER_STATE)).toBe('');
|
||||
it('is a no-op removing an absent key', () => {
|
||||
expect(spliceClause("name = 'x'", 'updated_at', null)).toBe("name = 'x'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFilterStateEmpty', () => {
|
||||
it('is true for the default state', () => {
|
||||
expect(isFilterStateEmpty(DEFAULT_FILTER_STATE)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when any tag is selected', () => {
|
||||
describe('updatedWindowFromTimestamp', () => {
|
||||
it('maps a ~1 day cutoff to today', () => {
|
||||
expect(
|
||||
isFilterStateEmpty(state({ tags: [{ key: 'env', value: 'prod' }] })),
|
||||
).toBe(false);
|
||||
updatedWindowFromTimestamp(dayjs().subtract(1, 'day').toISOString()),
|
||||
).toBe('today');
|
||||
});
|
||||
|
||||
it('returns null for an unquoted invalid value', () => {
|
||||
expect(updatedWindowFromTimestamp('not-a-date')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('areFilterStatesEqual', () => {
|
||||
it('ignores tag ordering', () => {
|
||||
const a = state({
|
||||
tags: [
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'team', value: 'core' },
|
||||
],
|
||||
});
|
||||
const b = state({
|
||||
tags: [
|
||||
{ key: 'team', value: 'core' },
|
||||
{ key: 'env', value: 'prod' },
|
||||
],
|
||||
});
|
||||
expect(areFilterStatesEqual(a, b)).toBe(true);
|
||||
describe('query helpers', () => {
|
||||
it('isQueryEmpty ignores whitespace', () => {
|
||||
expect(isQueryEmpty(' ')).toBe(true);
|
||||
expect(isQueryEmpty("name = 'x'")).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes differing tag selections', () => {
|
||||
expect(
|
||||
areFilterStatesEqual(
|
||||
state({ tags: [{ key: 'env', value: 'prod' }] }),
|
||||
state({ tags: [{ key: 'env', value: 'dev' }] }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineQueries', () => {
|
||||
it('drops empty fragments and ANDs the rest', () => {
|
||||
expect(combineQueries('locked = true', '', undefined, 'name = "x"')).toBe(
|
||||
'locked = true AND name = "x"',
|
||||
);
|
||||
it('areQueriesEqual trims before comparing', () => {
|
||||
expect(areQueriesEqual("name = 'x' ", " name = 'x'")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -5,9 +5,9 @@
|
||||
// - client: constrains by a client-side id set (Favorites, Recently viewed)
|
||||
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
|
||||
|
||||
import { DEFAULT_FILTER_STATE } from './filterQuery';
|
||||
import { createdByClause } from './filterQuery';
|
||||
import { BuiltinViewId } from '../types';
|
||||
import type { DashboardFilterState, ViewSection } from '../types';
|
||||
import type { ViewSection } from '../types';
|
||||
import type { DashboardListItem } from './helpers';
|
||||
|
||||
// All @signozhq icons share this component type.
|
||||
@@ -49,28 +49,25 @@ export const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
export const isClientView = (id: string): boolean =>
|
||||
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
|
||||
|
||||
// DSL the Locked view seeds into the search box, so the constraint is visible
|
||||
// (and editable) rather than applied invisibly behind the scenes.
|
||||
// DSL the Locked view applies — visible and editable in the query box rather
|
||||
// than applied invisibly behind the scenes.
|
||||
export const LOCKED_QUERY = 'locked = true';
|
||||
|
||||
// The canonical filter snapshot a built-in view applies when selected. `null`
|
||||
// for ids that aren't built-in (custom views carry their own snapshot).
|
||||
export const builtinViewSnapshot = (
|
||||
// The canonical query string a built-in view applies when selected. `null` for
|
||||
// ids that aren't built-in (custom views carry their own query).
|
||||
export const builtinViewQuery = (
|
||||
id: string,
|
||||
userEmail: string,
|
||||
): DashboardFilterState | null => {
|
||||
): string | null => {
|
||||
switch (id) {
|
||||
case BuiltinViewId.Mine:
|
||||
return {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
createdBy: userEmail ? [userEmail] : [],
|
||||
};
|
||||
return userEmail ? (createdByClause([userEmail]) ?? '') : '';
|
||||
case BuiltinViewId.Locked:
|
||||
return { ...DEFAULT_FILTER_STATE, search: LOCKED_QUERY };
|
||||
return LOCKED_QUERY;
|
||||
case BuiltinViewId.All:
|
||||
case BuiltinViewId.Pinned:
|
||||
case BuiltinViewId.Recent:
|
||||
return { ...DEFAULT_FILTER_STATE };
|
||||
return '';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1,.cls-4{fill:#669df6;}.cls-2{fill:#4285f4;}.cls-2,.cls-3,.cls-4{fill-rule:evenodd;}.cls-3{fill:#aecbfa;}</style></defs><title>Icon_24px_MemoryStore_Color</title><g data-name="Product Icons"><g ><rect class="cls-1" x="2" y="3.94" width="3.33" height="2.58"/><rect class="cls-1" x="2" y="8.45" width="3.33" height="2.58"/><rect class="cls-1" x="2" y="12.97" width="3.33" height="2.58"/><rect class="cls-1" x="2" y="17.48" width="3.33" height="2.58"/><rect class="cls-1" x="18.67" y="3.94" width="3.33" height="2.58"/><rect class="cls-1" x="18.67" y="8.45" width="3.33" height="2.58"/><rect class="cls-1" x="18.67" y="12.97" width="3.33" height="2.58"/><rect class="cls-1" x="18.67" y="17.48" width="3.33" height="2.58"/><polygon class="cls-2" points="21.33 6.52 18.67 6.52 18.67 3.94 21.33 6.52"/><polygon class="cls-2" points="21.33 11.03 18.67 11.03 18.67 8.45 21.33 11.03"/><polygon class="cls-2" points="21.33 15.55 18.67 15.55 18.67 12.97 21.33 15.55"/><polygon class="cls-2" points="21.33 20.07 18.67 20.07 18.67 17.48 21.33 20.07"/><path class="cls-3" d="M5.33,22H18.67V2H5.33Zm6-9H8l4.67-7.74V11H16l-4.67,7.74Z"/><polygon class="cls-4" points="11.33 22 11.33 18.77 16 11.03 12.67 11.03 12.67 2 18.67 2 18.67 22 11.33 22"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"id": "memorystore_redis",
|
||||
"title": "GCP Memorystore Redis",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "redis.googleapis.com/server/uptime",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/stats/memory/usage_ratio",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/stats/memory/system_memory_usage_ratio",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/stats/cpu_utilization_main_thread",
|
||||
"unit": "Seconds",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/clients/connected",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/stats/cache_hit_ratio",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/keyspace/keys",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/commands/usec_per_call",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/commands/calls",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/clients/blocked",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "redis.googleapis.com/stats/reject_connections_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Memorystore Redis Overview",
|
||||
"description": "Overview of GCP Memorystore Redis metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Memorystore Redis with SigNoz
|
||||
|
||||
Collect key GCP Memorystore Redis metrics and view them with an out of the box dashboard.
|
||||
@@ -729,11 +729,7 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
|
||||
value, err := normalizeFunctionValue(operator, functionName, params[1:])
|
||||
if err != nil {
|
||||
v.errors = append(v.errors, err.Error())
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
value := params[1:]
|
||||
|
||||
conds, ok := v.buildConditions(key, matchingFieldKeys(key, v.fieldKeys), operator, value)
|
||||
if !ok {
|
||||
@@ -749,44 +745,6 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
||||
return v.builder.Or(conds...)
|
||||
}
|
||||
|
||||
// normalizeFunctionValue validates and normalizes the value argument(s) of a has-family
|
||||
// function call, returning them in the wrapper slice the condition builder unwraps.
|
||||
//
|
||||
// - has/hasToken take exactly one scalar value. More than one argument, or an array
|
||||
// argument, is rejected rather than silently dropping the extras.
|
||||
// - hasAny/hasAll take a set of values, supplied either as a single array literal
|
||||
// (hasAny(k, ['a','b'])) or as several scalar arguments (hasAny(k, 'a', 'b')); the
|
||||
// latter are folded into one list so no argument is silently ignored.
|
||||
func normalizeFunctionValue(operator qbtypes.FilterOperator, functionName string, valueParams []any) (any, error) {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasToken:
|
||||
if len(valueParams) != 1 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` expects exactly one value argument", functionName)
|
||||
}
|
||||
if _, isArray := valueParams[0].([]any); isArray {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` expects a single scalar value, not an array", functionName)
|
||||
}
|
||||
return valueParams, nil
|
||||
case qbtypes.FilterOperatorHasAny, qbtypes.FilterOperatorHasAll:
|
||||
// A single array literal is already the value set.
|
||||
if len(valueParams) == 1 {
|
||||
if _, isArray := valueParams[0].([]any); isArray {
|
||||
return valueParams, nil
|
||||
}
|
||||
}
|
||||
// Otherwise fold the positional scalar arguments into one list.
|
||||
values := make([]any, 0, len(valueParams))
|
||||
for _, p := range valueParams {
|
||||
if _, isArray := p.([]any); isArray {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` expects either a single array literal or scalar values, not a mix of the two", functionName)
|
||||
}
|
||||
values = append(values, p)
|
||||
}
|
||||
return []any{values}, nil
|
||||
}
|
||||
return valueParams, nil
|
||||
}
|
||||
|
||||
// VisitFunctionParamList handles the parameter list for function calls.
|
||||
func (v *filterExpressionVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
|
||||
params := ctx.AllFunctionParam()
|
||||
|
||||
@@ -40,10 +40,12 @@ func isBodyJSONSearch(key *telemetrytypes.TelemetryFieldKey, columns []*schema.C
|
||||
return false
|
||||
}
|
||||
|
||||
// conditionForArrayFunction builds has/hasAny/hasAll over a body JSON path — via the JSON
|
||||
// access plan (flag on) or legacy typed extraction (flag off).
|
||||
// conditionForArrayFunction builds `has/hasAny/hasAll(<arrayFieldExpr>, value)` over a
|
||||
// body JSON array field. The field expression uses the JSON accessor (flag on) or
|
||||
// legacy string extraction (flag off); value[0] is the needle.
|
||||
func (c *conditionBuilder) conditionForArrayFunction(
|
||||
ctx context.Context,
|
||||
startNs, endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
@@ -60,48 +62,24 @@ func (c *conditionBuilder) conditionForArrayFunction(
|
||||
needle = args[0]
|
||||
}
|
||||
|
||||
var fieldExpr string
|
||||
if c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
|
||||
// JSON access plan: data-type collision handling, nested array paths.
|
||||
valueType, needle := InferDataType(needle, operator, key)
|
||||
return NewJSONConditionBuilder(key, valueType).buildArrayFunctionCondition(operator, needle, sb)
|
||||
fe, err := c.fm.FieldFor(ctx, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fieldExpr = fe
|
||||
} else {
|
||||
// legacy string-body path; value drives array-type inference (e.g. `[*]` paths)
|
||||
fieldExpr, _ = GetBodyJSONKey(ctx, key, qbtypes.FilterOperatorUnknown, value)
|
||||
}
|
||||
|
||||
// legacy string-body path: type-matched array extraction, OR-ed with a scalar comparison
|
||||
// for a scalar body value (coalesced to false so NOT has() matches missing-key rows).
|
||||
elemType := legacyElemType(needle)
|
||||
arrayExpr := getBodyJSONArrayKey(key, elemType)
|
||||
scalarExpr, scalarGuard, hasScalar := getBodyJSONScalarKey(key, elemType)
|
||||
if list, ok := needle.([]any); ok {
|
||||
vals := make([]any, len(list))
|
||||
for i, v := range list {
|
||||
vals[i] = legacyCoerceNeedle(v, elemType)
|
||||
}
|
||||
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(vals))
|
||||
if !hasScalar {
|
||||
return arrayCond, nil
|
||||
}
|
||||
var membership string
|
||||
if operator == qbtypes.FilterOperatorHasAll {
|
||||
eqs := make([]string, len(vals))
|
||||
for i, v := range vals {
|
||||
eqs[i] = sb.E(scalarExpr, v)
|
||||
}
|
||||
membership = sb.And(eqs...)
|
||||
} else {
|
||||
membership = sb.In(scalarExpr, vals...)
|
||||
}
|
||||
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(membership, scalarGuard)), nil
|
||||
}
|
||||
typedNeedle := legacyCoerceNeedle(needle, elemType)
|
||||
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(typedNeedle))
|
||||
if !hasScalar {
|
||||
return arrayCond, nil
|
||||
}
|
||||
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(sb.E(scalarExpr, typedNeedle), scalarGuard)), nil
|
||||
return fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), fieldExpr, sb.Var(needle)), nil
|
||||
}
|
||||
|
||||
// conditionForHasToken builds a hasToken full-text search over the body column, resolving the
|
||||
// column from the key name + use_json_body flag.
|
||||
// conditionForHasToken builds `hasToken(LOWER(<bodyColumn>), LOWER(<needle>))`, a
|
||||
// full-text token search over the body column. It resolves the column from the key
|
||||
// name + use_json_body flag, validates the field/value, and tags errors with the doc URL.
|
||||
func (c *conditionBuilder) conditionForHasToken(
|
||||
ctx context.Context,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
@@ -114,37 +92,28 @@ func (c *conditionBuilder) conditionForHasToken(
|
||||
needle = args[0]
|
||||
}
|
||||
|
||||
// TODO(Tushar): thread orgID here to evaluate correctly
|
||||
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
|
||||
columnName := LogsV2BodyColumn
|
||||
if bodyJSONEnabled {
|
||||
if key.Name != LogsV2BodyColumn && key.Name != bodyMessageField {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` only supports body/body.message field as first parameter").WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
columnName = bodyMessageField
|
||||
} else if key.Name != LogsV2BodyColumn {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
|
||||
// hasToken matches string tokens only.
|
||||
if _, ok := needle.(string); !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` expects value parameter to be a string").WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
|
||||
// TODO(Tushar): thread orgID here to evaluate correctly
|
||||
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
|
||||
if !bodyJSONEnabled {
|
||||
// legacy: token search over the plain body string column only.
|
||||
if key.Name != LogsV2BodyColumn {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", LogsV2BodyColumn, sb.Var(needle)), nil
|
||||
}
|
||||
|
||||
// JSON mode: a bare body/body.message key searches the body.message column; any other body
|
||||
// field is a token search over its JSON string field, incl. strings nested in arrays.
|
||||
// `body.message` resolves to a body-context key named `message`, so match that too — else it
|
||||
// falls through and emits dynamicElement over the already-typed String column, which errors.
|
||||
if key.Name == LogsV2BodyColumn || key.Name == bodyMessageField ||
|
||||
(key.FieldContext == telemetrytypes.FieldContextBody && key.Name == messageSubField) {
|
||||
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", bodyMessageField, sb.Var(needle)), nil
|
||||
}
|
||||
if key.FieldContext == telemetrytypes.FieldContextBody {
|
||||
return NewJSONConditionBuilder(key, telemetrytypes.FieldDataTypeString).buildTokenFunctionCondition(needle, sb)
|
||||
}
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` only supports the body field or a body JSON string field as first parameter").WithUrl(hasTokenFunctionDocURL)
|
||||
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", columnName, sb.Var(needle)), nil
|
||||
}
|
||||
|
||||
func (c *conditionBuilder) conditionFor(
|
||||
@@ -155,7 +124,8 @@ func (c *conditionBuilder) conditionFor(
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) (string, error) {
|
||||
// hasToken resolves from the key name + flag alone (no column resolution), so handle it first.
|
||||
// hasToken is a token search over the body column resolved purely from the key
|
||||
// name + flag, independent of column resolution, so handle it before anything else.
|
||||
if operator == qbtypes.FilterOperatorHasToken {
|
||||
return c.conditionForHasToken(ctx, key, value, sb)
|
||||
}
|
||||
@@ -165,9 +135,10 @@ func (c *conditionBuilder) conditionFor(
|
||||
return "", err
|
||||
}
|
||||
|
||||
// has/hasAny/hasAll take the body-JSON path, not the normal operator paths.
|
||||
// has/hasAny/hasAll build `has(<arrayFieldExpr>, value)` over body JSON arrays
|
||||
// rather than going through the normal operator paths, so handle them up front.
|
||||
if operator.IsArrayFunctionOperator() {
|
||||
return c.conditionForArrayFunction(ctx, key, operator, value, columns, sb)
|
||||
return c.conditionForArrayFunction(ctx, startNs, endNs, key, operator, value, columns, sb)
|
||||
}
|
||||
|
||||
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
|
||||
@@ -431,6 +402,23 @@ func (c *conditionBuilder) ConditionFor(
|
||||
}
|
||||
}
|
||||
|
||||
// has/hasAny/hasAll need an array field: in JSON-body mode drop non-array matches so a
|
||||
// scalar errors clearly instead of failing at ClickHouse runtime (legacy mode skips this).
|
||||
if operator.IsArrayFunctionOperator() &&
|
||||
c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
|
||||
arrayKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.FieldDataType.IsArray() {
|
||||
arrayKeys = append(arrayKeys, k)
|
||||
}
|
||||
}
|
||||
if len(arrayKeys) == 0 {
|
||||
return nil, warnings, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `%s` expects key parameter to be an array field; no array fields found", operator.FunctionName())
|
||||
}
|
||||
keys = arrayKeys
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
cond, err := c.conditionForKey(ctx, startNs, endNs, k, operator, value, sb)
|
||||
|
||||
@@ -44,66 +44,34 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
|
||||
category: "json",
|
||||
query: "has(body.requestor_list[*], 'index_service')",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."requestor_list"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."requestor_list"') = ? AND JSONType(body, 'requestor_list') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{"index_service", "index_service"},
|
||||
expectedQuery: `WHERE has(JSONExtract(JSON_QUERY(body, '$."requestor_list"[*]'), 'Array(String)'), ?)`,
|
||||
expectedArgs: []any{"index_service"},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "has(body.int_numbers[*], 2)",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."int_numbers"[*]'), 'Array(Nullable(Float64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."int_numbers"'), 'Nullable(Float64)') = ? AND JSONType(body, 'int_numbers') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{float64(2), float64(2)},
|
||||
expectedQuery: `WHERE has(JSONExtract(JSON_QUERY(body, '$."int_numbers"[*]'), 'Array(Float64)'), ?)`,
|
||||
expectedArgs: []any{float64(2)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "has(body.bool[*], true)",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."bool"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."bool"') = ? AND JSONType(body, 'bool') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{"true", "true"},
|
||||
expectedQuery: `WHERE has(JSONExtract(JSON_QUERY(body, '$."bool"[*]'), 'Array(Bool)'), ?)`,
|
||||
expectedArgs: []any{true},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "NOT has(body.nested_num[*].float_nums[*], 2.2)",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE NOT (has(JSONExtract(JSON_QUERY(body, '$."nested_num"[*]."float_nums"[*]'), 'Array(Nullable(Float64))'), ?))`,
|
||||
expectedQuery: `WHERE NOT (has(JSONExtract(JSON_QUERY(body, '$."nested_num"[*]."float_nums"[*]'), 'Array(Float64)'), ?))`,
|
||||
expectedArgs: []any{float64(2.2)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "has(body.tags, 'production')",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') = ? AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{"production", "production"},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "hasAny(body.tags, ['critical', 'test'])",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (hasAny(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') IN (?, ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{[]any{"critical", "test"}, "critical", "test"},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "hasAll(body.tags, ['production', 'web'])",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (hasAll(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull(((JSON_VALUE(body, '$."tags"') = ? AND JSON_VALUE(body, '$."tags"') = ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{[]any{"production", "web"}, "production", "web"},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "has(body.ids, \"200\")",
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{int64(200), int64(200)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "body.message = hello",
|
||||
|
||||
@@ -1561,25 +1561,6 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
expectedArgs: []any{"download"},
|
||||
expectedErrorContains: "function `hasToken` expects value parameter to be a string",
|
||||
},
|
||||
// extra / mis-shaped value arguments are rejected, not silently dropped.
|
||||
{
|
||||
category: "hasExtraArgs",
|
||||
query: "has(body.tags[*], \"a\", \"b\")",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "function `has` expects exactly one value argument",
|
||||
},
|
||||
{
|
||||
category: "hasArrayArg",
|
||||
query: "has(body.tags[*], [\"a\", \"b\"])",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "function `has` expects a single scalar value, not an array",
|
||||
},
|
||||
{
|
||||
category: "hasTokenExtraArgs",
|
||||
query: "hasToken(body, \"a\", \"b\")",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "function `hasToken` expects exactly one value argument",
|
||||
},
|
||||
|
||||
// Basic materialized key
|
||||
{
|
||||
|
||||
@@ -96,18 +96,6 @@ func applyNotCondition(operator qbtypes.FilterOperator) (bool, qbtypes.FilterOpe
|
||||
return false, operator
|
||||
}
|
||||
|
||||
// branchArrayExpr returns the ClickHouse array expression for a given array-type branch
|
||||
// at this hop. The JSON branch reads Array(JSON(...)) directly; the Dynamic branch filters
|
||||
// the Array(Dynamic) down to its JSON elements and maps them to JSON.
|
||||
func (c *jsonConditionBuilder) branchArrayExpr(node *telemetrytypes.JSONAccessNode, branch telemetrytypes.JSONAccessBranchType) string {
|
||||
fieldPath := node.FieldPath()
|
||||
if branch == telemetrytypes.BranchDynamic {
|
||||
dynBaseExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", fieldPath)
|
||||
return fmt.Sprintf("arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), %s))", dynBaseExpr)
|
||||
}
|
||||
return fmt.Sprintf("dynamicElement(%s, 'Array(JSON(max_dynamic_types=%d, max_dynamic_paths=%d))')", fieldPath, node.MaxDynamicTypes, node.MaxDynamicPaths)
|
||||
}
|
||||
|
||||
// buildAccessNodeBranches builds conditions for each branch of the access node.
|
||||
func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if current == nil {
|
||||
@@ -115,15 +103,31 @@ func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.J
|
||||
}
|
||||
|
||||
currAlias := current.Alias()
|
||||
// At this hop, compute the child condition per array branch (JSON before Dynamic) and
|
||||
// wrap each in arrayExists over the corresponding array expression.
|
||||
fieldPath := current.FieldPath()
|
||||
// Determine availability of Array(JSON) and Array(Dynamic) at this hop
|
||||
hasArrayJSON := current.Branches[telemetrytypes.BranchJSON] != nil
|
||||
hasArrayDynamic := current.Branches[telemetrytypes.BranchDynamic] != nil
|
||||
|
||||
// Then, at this hop, compute child per branch and wrap
|
||||
branches := make([]string, 0, 2)
|
||||
for _, branch := range current.BranchesInOrder() {
|
||||
childGroup, err := c.recurseArrayHops(current.Branches[branch], operator, value, sb)
|
||||
if hasArrayJSON {
|
||||
jsonArrayExpr := fmt.Sprintf("dynamicElement(%s, 'Array(JSON(max_dynamic_types=%d, max_dynamic_paths=%d))')", fieldPath, current.MaxDynamicTypes, current.MaxDynamicPaths)
|
||||
childGroupJSON, err := c.recurseArrayHops(current.Branches[telemetrytypes.BranchJSON], operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", currAlias, childGroup, c.branchArrayExpr(current, branch)))
|
||||
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", currAlias, childGroupJSON, jsonArrayExpr))
|
||||
}
|
||||
if hasArrayDynamic {
|
||||
dynBaseExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", fieldPath)
|
||||
dynFilteredExpr := fmt.Sprintf("arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), %s))", dynBaseExpr)
|
||||
|
||||
// Create the Query for Dynamic array
|
||||
childGroupDyn, err := c.recurseArrayHops(current.Branches[telemetrytypes.BranchDynamic], operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", currAlias, childGroupDyn, dynFilteredExpr))
|
||||
}
|
||||
|
||||
if len(branches) == 1 {
|
||||
@@ -305,174 +309,6 @@ func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytype
|
||||
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
|
||||
}
|
||||
|
||||
// buildArrayFunctionCondition builds a has/hasAny/hasAll condition over a body JSON path,
|
||||
// with contains-all semantics uniform across every leaf shape:
|
||||
// - has(v) = the path HAS v
|
||||
// - hasAny([v...]) = the path has ANY listed value (OR of has)
|
||||
// - hasAll([v...]) = the path has ALL listed values (AND of has)
|
||||
//
|
||||
// "the path has v" is an existential match resolved per leaf shape: for an array-typed leaf
|
||||
// (top-level `body.tags`, or nested `body.education[].scores`) it is native membership; for a
|
||||
// scalar leaf — whether reached through an array hop (`body.items[].sku`) or a plain scalar
|
||||
// path (`body.level`) — it is `<elem> = v`, wrapped in arrayExists over any array hops. So
|
||||
// `hasAll(body.education[].name, ['a','b'])` = "some element is a AND some element is b", and
|
||||
// for a plain scalar hasAll collapses to has (a one-element set can hold at most one value).
|
||||
//
|
||||
// Element comparisons reuse DataTypeCollisionHandledFieldName so a numeric literal against an
|
||||
// Int64 array (or a numeric literal against a String array) no longer silently misses.
|
||||
func (c *jsonConditionBuilder) buildArrayFunctionCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if len(c.key.JSONPlan) == 0 {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` could not resolve a JSON access plan for field `%s`", operator.FunctionName(), c.key.Name)
|
||||
}
|
||||
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasAny:
|
||||
return c.buildOredRootChains(func(node *telemetrytypes.JSONAccessNode) (string, error) {
|
||||
return c.arrayFunctionLeaf(node, operator, value, sb)
|
||||
}, sb)
|
||||
case qbtypes.FilterOperatorHasAll:
|
||||
// contains-all: AND of a per-value "has" so the AND sits outside the array hops.
|
||||
values := toAnyList(value)
|
||||
conditions := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
v := v
|
||||
cond, err := c.buildOredRootChains(func(node *telemetrytypes.JSONAccessNode) (string, error) {
|
||||
return c.arrayFunctionLeaf(node, qbtypes.FilterOperatorHas, v, sb)
|
||||
}, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0], nil
|
||||
}
|
||||
return sb.And(conditions...), nil
|
||||
}
|
||||
return "", qbtypes.ErrUnsupportedOperator
|
||||
}
|
||||
|
||||
// buildOredRootChains applies leafFn down every JSONPlan root (base + promoted), wrapping each
|
||||
// in its arrayExists chain, and ORs the per-root results.
|
||||
func (c *jsonConditionBuilder) buildOredRootChains(leafFn func(*telemetrytypes.JSONAccessNode) (string, error), sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
conditions := make([]string, 0, len(c.key.JSONPlan))
|
||||
for _, root := range c.key.JSONPlan {
|
||||
cond, err := c.buildArrayExistsChain(root, leafFn, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0], nil
|
||||
}
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
// buildArrayExistsChain wraps the terminal condition (produced by leafFn) in an arrayExists
|
||||
// over every array hop between the root and the terminal. For a terminal root (a top-level
|
||||
// array leaf) it simply returns leafFn(root).
|
||||
func (c *jsonConditionBuilder) buildArrayExistsChain(node *telemetrytypes.JSONAccessNode, leafFn func(*telemetrytypes.JSONAccessNode) (string, error), sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node == nil {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
|
||||
}
|
||||
if node.IsTerminal {
|
||||
return leafFn(node)
|
||||
}
|
||||
|
||||
branches := make([]string, 0, 2)
|
||||
for _, branch := range node.BranchesInOrder() {
|
||||
childCond, err := c.buildArrayExistsChain(node.Branches[branch], leafFn, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", node.Alias(), childCond, c.branchArrayExpr(node, branch)))
|
||||
}
|
||||
if len(branches) == 1 {
|
||||
return branches[0], nil
|
||||
}
|
||||
return sb.Or(branches...), nil
|
||||
}
|
||||
|
||||
// arrayFunctionLeaf builds the existential comparison for has/hasAny at a terminal node (hasAll
|
||||
// composes from has in buildArrayFunctionCondition). For an array leaf it delegates to native
|
||||
// membership; for a scalar leaf it compares the element directly.
|
||||
func (c *jsonConditionBuilder) arrayFunctionLeaf(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
return c.arrayLeafMembership(node, operator, value, sb)
|
||||
}
|
||||
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorHas:
|
||||
return c.arrayFuncScalarLeaf(node, qbtypes.FilterOperatorEqual, value, sb)
|
||||
case qbtypes.FilterOperatorHasAny:
|
||||
return c.arrayFuncScalarLeaf(node, qbtypes.FilterOperatorIn, toAnyList(value), sb)
|
||||
}
|
||||
return "", qbtypes.ErrUnsupportedOperator
|
||||
}
|
||||
|
||||
// arrayLeafMembership builds native membership for an array-typed leaf, reusing
|
||||
// buildArrayMembershipCondition (which handles data-type collisions on each element).
|
||||
func (c *jsonConditionBuilder) arrayLeafMembership(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorHas:
|
||||
return c.buildArrayMembershipCondition(node, qbtypes.FilterOperatorEqual, value, sb)
|
||||
case qbtypes.FilterOperatorHasAny:
|
||||
return c.buildArrayMembershipCondition(node, qbtypes.FilterOperatorIn, toAnyList(value), sb)
|
||||
}
|
||||
return "", qbtypes.ErrUnsupportedOperator
|
||||
}
|
||||
|
||||
// arrayFuncScalarLeaf builds `<elemExpr> <op> value` for a scalar leaf reached through an
|
||||
// array hop, applying data-type collision handling like the standard primitive path.
|
||||
// Coalesced to false so a missing key is a non-match, not NULL (NOT has() must match it).
|
||||
func (c *jsonConditionBuilder) arrayFuncScalarLeaf(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
|
||||
fieldExpr, value = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, fieldExpr, operator)
|
||||
cond, err := c.applyOperator(sb, fieldExpr, operator, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("ifNull(%s, false)", cond), nil
|
||||
}
|
||||
|
||||
// buildTokenFunctionCondition builds a hasToken search over a body JSON string field:
|
||||
// hasToken(LOWER(<elem>), LOWER(?)) wrapped in arrayExists over any array hops between the
|
||||
// root and the terminal. The field must resolve to a String leaf or a String array.
|
||||
func (c *jsonConditionBuilder) buildTokenFunctionCondition(needle any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if len(c.key.JSONPlan) == 0 {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` could not resolve a JSON access plan for field `%s`", c.key.Name)
|
||||
}
|
||||
|
||||
return c.buildOredRootChains(func(node *telemetrytypes.JSONAccessNode) (string, error) {
|
||||
return c.tokenLeaf(node, needle, sb)
|
||||
}, sb)
|
||||
}
|
||||
|
||||
// tokenLeaf builds the hasToken match at a terminal node: a direct match for a String leaf
|
||||
// (coalesced to false, as in arrayFuncScalarLeaf), or an arrayExists over the elements for a
|
||||
// String array leaf. hasToken is string-only, so any other element type is rejected.
|
||||
func (c *jsonConditionBuilder) tokenLeaf(node *telemetrytypes.JSONAccessNode, needle any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
switch node.TerminalConfig.ElemType {
|
||||
case telemetrytypes.String:
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, 'String')", node.FieldPath())
|
||||
return fmt.Sprintf("ifNull(hasToken(LOWER(%s), LOWER(%s)), false)", fieldExpr, sb.Var(needle)), nil
|
||||
case telemetrytypes.ArrayString:
|
||||
arrayExpr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
|
||||
return fmt.Sprintf("arrayExists(x -> hasToken(LOWER(x), LOWER(%s)), %s)", sb.Var(needle), arrayExpr), nil
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` only supports string fields; field `%s` is `%s`", c.key.Name, node.TerminalConfig.Key.FieldDataType.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
// toAnyList normalizes a has-family value into a slice; a scalar becomes a one-element list.
|
||||
func toAnyList(value any) []any {
|
||||
if list, ok := value.([]any); ok {
|
||||
return list
|
||||
}
|
||||
return []any{value}
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, fieldExpr string, operator qbtypes.FilterOperator, value any) (string, error) {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
|
||||
@@ -602,7 +602,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
name: "Simple has filter",
|
||||
filter: "has(body.education[].parameters, 1.65)",
|
||||
expected: TestExpected{
|
||||
WhereClause: "(arrayExists(`body_v2.education`-> arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education`-> arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))",
|
||||
WhereClause: "(has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?) OR has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?))",
|
||||
Args: []any{1.65, 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{
|
||||
"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic].",
|
||||
@@ -613,8 +613,8 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
name: "Flat path hasAll filter",
|
||||
filter: "hasAll(body.user.permissions, ['read', 'write'])",
|
||||
expected: TestExpected{
|
||||
WhereClause: "(arrayExists(x -> x = ?, dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))')) AND arrayExists(x -> x = ?, dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))')))",
|
||||
Args: []any{"read", "write", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
WhereClause: "hasAll(dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'), ?)",
|
||||
Args: []any{[]any{"read", "write"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -739,8 +739,8 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
name: "Nested path hasAny filter",
|
||||
filter: "hasAny(education[].awards[].participated[].members, ['Piyush', 'Tushar'])",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.education`-> (arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{"Piyush", "Tushar", "Piyush", "Tushar", "Piyush", "Tushar", "Piyush", "Tushar", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
WhereClause: "hasAny(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
|
||||
Args: []any{[]any{"Piyush", "Tushar"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -755,8 +755,8 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
name: "dynamic_array_element_compare_HAS_STRING",
|
||||
filter: "has(interests[].entities[].product_codes, '2002')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'))), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{int64(2002), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
WhereClause: "has(arrayFlatten(arrayConcat(arrayMap(`body_v2.interests`->arrayMap(`body_v2.interests[].entities`->dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
|
||||
Args: []any{"2002", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -771,146 +771,10 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
name: "dynamic_array_element_compare_HAS_INT",
|
||||
filter: "has(interests[].entities[].product_codes, 1001)",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'))), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
WhereClause: "has(arrayFlatten(arrayConcat(arrayMap(`body_v2.interests`->arrayMap(`body_v2.interests[].entities`->dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
|
||||
Args: []any{float64(1001), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
|
||||
// ── scalar leaf reached through an array ───
|
||||
{
|
||||
name: "Nested primitive leaf has",
|
||||
filter: "has(body.education[].name, 'IIT')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{"IIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Nested primitive leaf hasAny",
|
||||
filter: "hasAny(body.education[].name, ['IIT', 'MIT'])",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') IN (?, ?), false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{"IIT", "MIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// contains-all: single-value hasAll over a nested leaf collapses to has.
|
||||
name: "Nested primitive leaf hasAll single collapses to has",
|
||||
filter: "hasAll(body.education[].name, 'IIT')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{"IIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// contains-all: "some element is IIT AND some element is MIT" (AND of per-value has).
|
||||
name: "Nested primitive leaf hasAll multi (contains-all)",
|
||||
filter: "hasAll(body.education[].name, ['IIT', 'MIT'])",
|
||||
expected: TestExpected{
|
||||
WhereClause: "(arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) AND arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))",
|
||||
Args: []any{"IIT", "MIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── numeric literal against an Int64 array is collision-handled ───
|
||||
{
|
||||
name: "Nested Int64 array has collision",
|
||||
filter: "has(body.education[].scores, 90)",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.education`-> arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`scores`, 'Array(Nullable(Int64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{float64(90), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── hasAny folds multiple scalar arguments into one value set ─────
|
||||
{
|
||||
name: "hasAny folds multiple scalar args",
|
||||
filter: "hasAny(body.user.permissions, 'read', 'write')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(x -> x IN (?, ?), dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'))",
|
||||
Args: []any{"read", "write", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── hasToken over JSON string fields (nested leaf, top-level array, nested array) ──
|
||||
{
|
||||
name: "hasToken nested string leaf",
|
||||
filter: "hasToken(body.education[].name, 'harvard')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.education`-> ifNull(hasToken(LOWER(dynamicElement(`body_v2.education`.`name`, 'String')), LOWER(?)), false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{"harvard", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hasToken top-level string array",
|
||||
filter: "hasToken(body.user.permissions, 'admin')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'))",
|
||||
Args: []any{"admin", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hasToken nested string array",
|
||||
filter: "hasToken(body.education[].awards[].participated[].members, 'piyush')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "arrayExists(`body_v2.education`-> (arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
|
||||
Args: []any{"piyush", "piyush", "piyush", "piyush", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
|
||||
// ── hasToken over the message field: bare body and explicit body.message are
|
||||
// equivalent, both target the body.message column directly (no dynamicElement) ──
|
||||
{
|
||||
name: "hasToken bare body",
|
||||
filter: "hasToken(body, 'production')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "hasToken(LOWER(body.message), LOWER(?))",
|
||||
Args: []any{"production", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{bodySearchDefaultWarning},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hasToken explicit body.message",
|
||||
filter: "hasToken(body.message, 'production')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "hasToken(LOWER(body.message), LOWER(?))",
|
||||
Args: []any{"production", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
|
||||
// ── scalar (non-array) leaf: treated as a single-element set ─────────────
|
||||
{
|
||||
name: "Scalar leaf has",
|
||||
filter: "has(body.user.name, 'alice')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false)",
|
||||
Args: []any{"alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Scalar leaf hasAny",
|
||||
filter: "hasAny(body.user.name, ['alice', 'bob'])",
|
||||
expected: TestExpected{
|
||||
WhereClause: "ifNull(dynamicElement(body_v2.`user.name`, 'String') IN (?, ?), false)",
|
||||
Args: []any{"alice", "bob", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// contains-all: single-value hasAll over a plain scalar collapses to has.
|
||||
name: "Scalar leaf hasAll single collapses to has",
|
||||
filter: "hasAll(body.user.name, 'alice')",
|
||||
expected: TestExpected{
|
||||
WhereClause: "ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false)",
|
||||
Args: []any{"alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// contains-all over a one-element set: the scalar must equal every value, so
|
||||
// distinct values never match.
|
||||
name: "Scalar leaf hasAll multi (contains-all)",
|
||||
filter: "hasAll(body.user.name, ['alice', 'bob'])",
|
||||
expected: TestExpected{
|
||||
WhereClause: "(ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false) AND ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false))",
|
||||
Args: []any{"alice", "bob", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -130,121 +130,3 @@ func GetBodyJSONKey(_ context.Context, key *telemetrytypes.TelemetryFieldKey, op
|
||||
func GetBodyJSONKeyForExists(_ context.Context, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, _ any) string {
|
||||
return fmt.Sprintf("JSON_EXISTS(body, '$.%s')", getBodyJSONPath(key))
|
||||
}
|
||||
|
||||
// legacyElemType infers the has-family element type from the needle (legacy has no schema). It
|
||||
// scans EVERY value so the chosen array type and all coerced needles agree — else ClickHouse
|
||||
// raises "no supertype ... String" (code 386). Int64 stays distinct from Float64 so a quoted
|
||||
// integer is exact past 2^53 (unquoted literals already arrive as float64, parsed upstream).
|
||||
func legacyElemType(needle any) telemetrytypes.FieldDataType {
|
||||
list, ok := needle.([]any)
|
||||
if !ok {
|
||||
list = []any{needle}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return telemetrytypes.FieldDataTypeString
|
||||
}
|
||||
allInt, allNumeric := true, true
|
||||
for _, v := range list {
|
||||
switch t := v.(type) {
|
||||
case float32, float64:
|
||||
allInt = false
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
// integer Go types stay int-exact
|
||||
case string:
|
||||
if _, err := strconv.ParseInt(t, 10, 64); err != nil {
|
||||
allInt = false
|
||||
}
|
||||
if _, err := strconv.ParseFloat(t, 64); err != nil {
|
||||
allNumeric = false
|
||||
}
|
||||
default:
|
||||
// booleans (and anything else) -> String; a bool renders to 'true'/'false', so a
|
||||
// bool needle only matches genuine JSON booleans, not truthy numbers/strings.
|
||||
allInt, allNumeric = false, false
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case allInt:
|
||||
return telemetrytypes.FieldDataTypeInt64
|
||||
case allNumeric:
|
||||
return telemetrytypes.FieldDataTypeFloat64
|
||||
default:
|
||||
return telemetrytypes.FieldDataTypeString
|
||||
}
|
||||
}
|
||||
|
||||
// legacyCoerceNeedle coerces a needle to elem type dt so its bound-arg type matches the
|
||||
// extracted column (legacyElemType guarantees it's coercible).
|
||||
func legacyCoerceNeedle(v any, dt telemetrytypes.FieldDataType) any {
|
||||
switch dt {
|
||||
case telemetrytypes.FieldDataTypeInt64:
|
||||
if s, ok := v.(string); ok {
|
||||
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return v
|
||||
case telemetrytypes.FieldDataTypeFloat64:
|
||||
if s, ok := v.(string); ok {
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
return f
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return bodyArrayNeedleString(v)
|
||||
}
|
||||
}
|
||||
|
||||
// getBodyJSONArrayKey extracts the leaf as Array(Nullable(<dt>)) — Nullable so a value of a
|
||||
// different JSON type maps to NULL instead of corrupting (e.g. a non-numeric string → 0).
|
||||
func getBodyJSONArrayKey(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) string {
|
||||
arrKey := *key
|
||||
if !strings.HasSuffix(arrKey.Name, "[*]") && !strings.HasSuffix(arrKey.Name, "[]") {
|
||||
arrKey.Name += "[*]"
|
||||
}
|
||||
return fmt.Sprintf("JSONExtract(JSON_QUERY(body, '$.%s'), 'Array(Nullable(%s))')", getBodyJSONPath(&arrKey), dt.CHDataType())
|
||||
}
|
||||
|
||||
// getBodyJSONScalarKey builds the single-element-set fallback for a scalar body value: the leaf
|
||||
// extracted as a scalar of type dt, plus a guard restricting it to a genuinely scalar body. The
|
||||
// guard is required because JSON_VALUE returns '' for an array/object/missing value, which would
|
||||
// otherwise zero-value match (has(x,0) / has(x,false) / has(x,'') on any array). ok=false when
|
||||
// the path still traverses an array ([*]/[]).
|
||||
func getBodyJSONScalarKey(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) (expr string, guard string, ok bool) {
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(key.Name, "[*]"), "[]")
|
||||
if strings.Contains(name, "[") {
|
||||
return "", "", false
|
||||
}
|
||||
scalarKey := *key
|
||||
scalarKey.Name = name
|
||||
path := getBodyJSONPath(&scalarKey)
|
||||
if dt == telemetrytypes.FieldDataTypeString {
|
||||
expr = fmt.Sprintf("JSON_VALUE(body, '$.%s')", path)
|
||||
} else {
|
||||
// Nullable so a scalar of a different type (e.g. a bool/string where a number is
|
||||
// searched) extracts to NULL rather than the type's default (0/false), which would
|
||||
// otherwise zero-value match has(x, 0).
|
||||
expr = fmt.Sprintf("JSONExtract(JSON_VALUE(body, '$.%s'), 'Nullable(%s)')", path, dt.CHDataType())
|
||||
}
|
||||
keys := strings.Split(name, ".")
|
||||
for i, k := range keys {
|
||||
keys[i] = "'" + k + "'"
|
||||
}
|
||||
guard = fmt.Sprintf("JSONType(body, %s) NOT IN ('Array', 'Object', 'Null')", strings.Join(keys, ", "))
|
||||
return expr, guard, true
|
||||
}
|
||||
|
||||
func bodyArrayNeedleString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(t), 'f', -1, 64)
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,8 +495,8 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$.\"user_names\"') = ? AND JSONType(body, 'user_names') NOT IN ('Array', 'Object', 'Null')), false)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"john_doe", "john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)'), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ var (
|
||||
|
||||
// GCP services.
|
||||
GCPServiceCloudSQLPostgres = ServiceID{valuer.NewString("cloudsql_postgres")}
|
||||
GCPServiceMemorystoreRedis = ServiceID{valuer.NewString("memorystore_redis")}
|
||||
)
|
||||
|
||||
func (ServiceID) Enum() []any {
|
||||
@@ -74,6 +75,7 @@ func (ServiceID) Enum() []any {
|
||||
AzureServiceCassandraDB,
|
||||
AzureServiceRedis,
|
||||
GCPServiceCloudSQLPostgres,
|
||||
GCPServiceMemorystoreRedis,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +114,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
|
||||
},
|
||||
CloudProviderTypeGCP: {
|
||||
GCPServiceCloudSQLPostgres,
|
||||
GCPServiceMemorystoreRedis,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -563,6 +563,214 @@ def test_logs_json_body_nested_keys(
|
||||
assert all(code == 200 for code in status_codes)
|
||||
|
||||
|
||||
def test_logs_json_body_array_membership(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert logs with JSON bodies containing arrays
|
||||
|
||||
Tests:
|
||||
1. Search by has(body.tags[*], "value") - string array
|
||||
2. Search by has(body.ids[*], 123) - numeric array
|
||||
3. Search by has(body.flags[*], true) - boolean array
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
|
||||
log1_body = json.dumps(
|
||||
{
|
||||
"tags": ["production", "api", "critical"],
|
||||
"ids": [100, 200, 300],
|
||||
"flags": [True, False, True],
|
||||
"users": [
|
||||
{"name": "alice", "role": "admin"},
|
||||
{"name": "bob", "role": "user"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
log2_body = json.dumps(
|
||||
{
|
||||
"tags": ["staging", "api", "test"],
|
||||
"ids": [200, 400, 500],
|
||||
"flags": [False, False, True],
|
||||
"users": [
|
||||
{"name": "charlie", "role": "user"},
|
||||
{"name": "david", "role": "admin"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
log3_body = json.dumps(
|
||||
{
|
||||
"tags": ["production", "web", "important"],
|
||||
"ids": [100, 600, 700],
|
||||
"flags": [True, True, False],
|
||||
"users": [
|
||||
{"name": "alice", "role": "admin"},
|
||||
{"name": "eve", "role": "user"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
resources={"service.name": "app-service"},
|
||||
attributes={},
|
||||
body=log1_body,
|
||||
severity_text="INFO",
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
resources={"service.name": "app-service"},
|
||||
attributes={},
|
||||
body=log2_body,
|
||||
severity_text="INFO",
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
resources={"service.name": "app-service"},
|
||||
attributes={},
|
||||
body=log3_body,
|
||||
severity_text="INFO",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Test 1: Search by has(body.tags[*], "production")
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"schemaVersion": "v1",
|
||||
"start": int((now - timedelta(seconds=10)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"requestType": "raw",
|
||||
"compositeQuery": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": False,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"filter": {"expression": 'has(body.tags[*], "production")'},
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
],
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
rows = results[0]["rows"]
|
||||
assert len(rows) == 2 # log1 and log3 have "production" in tags
|
||||
tags_list = [json.loads(row["data"]["body"])["tags"] for row in rows]
|
||||
assert all("production" in tags for tags in tags_list)
|
||||
|
||||
# Test 2: Search by has(body.ids[*], 200)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"schemaVersion": "v1",
|
||||
"start": int((now - timedelta(seconds=10)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"requestType": "raw",
|
||||
"compositeQuery": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": False,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"filter": {"expression": "has(body.ids[*], 200)"},
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
],
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
rows = results[0]["rows"]
|
||||
assert len(rows) == 2 # log1 and log2 have 200 in ids
|
||||
ids_list = [json.loads(row["data"]["body"])["ids"] for row in rows]
|
||||
assert all(200 in ids for ids in ids_list)
|
||||
|
||||
# Test 3: Search by has(body.flags[*], true)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"schemaVersion": "v1",
|
||||
"start": int((now - timedelta(seconds=10)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"requestType": "raw",
|
||||
"compositeQuery": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": False,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"filter": {"expression": "has(body.flags[*], true)"},
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
],
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
rows = results[0]["rows"]
|
||||
assert len(rows) == 3 # All logs have true in flags
|
||||
flags_list = [json.loads(row["data"]["body"])["flags"] for row in rows]
|
||||
assert all(True in flags for flags in flags_list)
|
||||
|
||||
|
||||
def test_logs_json_body_listing(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user