Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dashboard-filter-value-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/api': patch
'@hyperdx/app': patch
---

feat: Accept variable-keyed dashboard filter values
51 changes: 50 additions & 1 deletion packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,17 @@
"description": "Query language for the where clause."
},
"SavedFilterValue": {
"description": "A single saved dashboard filter selection. Either a rendered SQL condition, or a selection addressed by the name of the dashboard variable it belongs to.\n",
"oneOf": [
{
"$ref": "#/components/schemas/SqlSavedFilterValue"
},
{
"$ref": "#/components/schemas/VariableSavedFilterValue"
}
]
},
"SqlSavedFilterValue": {
"type": "object",
"required": [
"condition"
Expand All @@ -845,16 +856,54 @@
"sql"
],
"default": "sql",
"description": "Filter type. Currently only \"sql\" is supported.",
"description": "Filter type.",
"example": "sql"
},
"condition": {
"type": "string",
"maxLength": 10000,
"description": "SQL filter condition. For example use expressions in the form \"column IN ('value')\".",
"example": "ServiceName IN ('hdx-oss-dev-api')"
}
}
},
"VariableSavedFilterValue": {
"type": "object",
"required": [
"type",
"name",
"values"
],
"properties": {
"type": {
"type": "string",
"enum": [
"variable"
],
"description": "Filter type.",
"example": "variable"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"description": "The variableName of the dashboard variable this selection belongs to. Only allowed for variable-enabled filters.\n",
"example": "service"
},
"values": {
"type": "array",
"maxItems": 1000,
"description": "Selected values",
"items": {
"type": "string",
"maxLength": 10000
},
"example": [
"hdx-oss-dev-api"
]
}
}
},
"MetricDataType": {
"type": "string",
"enum": [
Expand Down
102 changes: 102 additions & 0 deletions packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7095,6 +7095,108 @@ describe('External API v2 Dashboards - new format', () => {
});
});

describe('savedFilterValues', () => {
const sqlValue = { type: 'sql', condition: "Env IN ('prod')" };
const variableValue = {
type: 'variable',
name: 'svc',
values: ['accounting', 'frontend'],
};

it('should persist and return a variable-keyed value verbatim', async () => {
const response = await authRequest('post', BASE_URL)
.send(
createMockDashboard(traceSource._id.toString(), {
savedFilterValues: [variableValue],
}),
)
.expect(200);

expect(response.body.data.savedFilterValues).toEqual([variableValue]);

const dashboardInDb = await Dashboard.findById(
response.body.data.id,
).lean();
expect(dashboardInDb?.savedFilterValues).toEqual([variableValue]);
});

it('should accept a variable-keyed value with no values selected', async () => {
const emptySelection = { type: 'variable', name: 'svc', values: [] };
const response = await authRequest('post', BASE_URL)
.send(
createMockDashboard(traceSource._id.toString(), {
savedFilterValues: [emptySelection],
}),
)
.expect(200);

expect(response.body.data.savedFilterValues).toEqual([emptySelection]);
});

// The regression this format change exists to prevent: GET returns whatever
// is stored, so a write schema that only accepted the sql shape would make a
// dashboard holding a variable value un-updatable by echoing its own body.
it('should accept a mixed array echoed straight back from GET', async () => {
const created = await authRequest('post', BASE_URL)
.send(
createMockDashboardWithIds(traceSource._id.toString(), {
savedFilterValues: [sqlValue, variableValue],
}),
)
.expect(200);

const fetched = await authRequest(
'get',
`${BASE_URL}/${created.body.data.id}`,
).expect(200);
expect(fetched.body.data.savedFilterValues).toEqual([
sqlValue,
variableValue,
]);

const echoed = await authRequest(
'put',
`${BASE_URL}/${created.body.data.id}`,
)
.send(fetched.body.data)
.expect(200);
expect(echoed.body.data.savedFilterValues).toEqual([
sqlValue,
variableValue,
]);
});

it('should return 400 for a variable-keyed value missing name', async () => {
await authRequest('post', BASE_URL)
.send(
createMockDashboard(traceSource._id.toString(), {
savedFilterValues: [{ type: 'variable', values: ['a'] }],
}),
)
.expect(400);
});

it('should return 400 for a variable-keyed value missing values', async () => {
await authRequest('post', BASE_URL)
.send(
createMockDashboard(traceSource._id.toString(), {
savedFilterValues: [{ type: 'variable', name: 'svc' }],
}),
)
.expect(400);
});

it('should return 400 for an unknown saved filter value type', async () => {
await authRequest('post', BASE_URL)
.send(
createMockDashboard(traceSource._id.toString(), {
savedFilterValues: [{ type: 'nonsense', name: 'svc', values: [] }],
}),
)
.expect(400);
});
});

describe('DELETE /:id', () => {
it('should delete a dashboard', async () => {
const dashboard = await createTestDashboard();
Expand Down
36 changes: 35 additions & 1 deletion packages/api/src/routers/external-api/v2/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,53 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* enum: [sql, lucene]
* description: Query language for the where clause.
* SavedFilterValue:
* description: >
* A single saved dashboard filter selection. Either a rendered SQL
* condition, or a selection addressed by the name of the dashboard
* variable it belongs to.
* oneOf:
* - $ref: '#/components/schemas/SqlSavedFilterValue'
* - $ref: '#/components/schemas/VariableSavedFilterValue'
* SqlSavedFilterValue:
* type: object
* required: [condition]
* properties:
* type:
* type: string
* enum: [sql]
* default: sql
* description: Filter type. Currently only "sql" is supported.
* description: Filter type.
* example: "sql"
* condition:
* type: string
* maxLength: 10000
* description: SQL filter condition. For example use expressions in the form "column IN ('value')".
* example: "ServiceName IN ('hdx-oss-dev-api')"
* VariableSavedFilterValue:
* type: object
* required: [type, name, values]
* properties:
* type:
* type: string
* enum: [variable]
* description: Filter type.
* example: "variable"
* name:
* type: string
* minLength: 1
* maxLength: 1024
* description: >
* The variableName of the dashboard variable this selection
* belongs to. Only allowed for variable-enabled filters.
* example: "service"
* values:
* type: array
* maxItems: 1000
* description: Selected values
* items:
* type: string
* maxLength: 10000
* example: ["hdx-oss-dev-api"]
* MetricDataType:
* type: string
* enum: [sum, gauge, histogram, summary, exponential histogram]
Expand Down
16 changes: 12 additions & 4 deletions packages/api/src/utils/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
validateAlertChannelSelection,
validateAlertScheduleOffsetMinutes,
validateAlertThresholdMax,
VariableFilterValueSchema,
WebhookService,
zAlertChannel,
zAlertChannels,
Expand Down Expand Up @@ -155,10 +156,17 @@ export type ExternalDashboardFilter = z.infer<
typeof externalDashboardFilterSchema
>;

export const externalDashboardSavedFilterValueSchema = z.object({
type: z.literal('sql').optional().default('sql'),
condition: z.string().max(10000),
});
/**
* One entry in a dashboard's `savedFilterValues`: either a rendered SQL
* predicate, or a selection addressed by the dashboard variable it belongs to.
*/
export const externalDashboardSavedFilterValueSchema = z.union([
z.object({
type: z.literal('sql').optional().default('sql'),
condition: z.string().max(10000),
}),
VariableFilterValueSchema.strict(),
]);

// ================================
// Dashboards (new format)
Expand Down
10 changes: 5 additions & 5 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ import SearchWhereInput, {
getStoredLanguage,
} from './components/SearchInput/SearchWhereInput';
import { Tags } from './components/Tags';
import useDashboardFilters from './hooks/useDashboardFilters';
import useDashboardFilters, {
filterQueriesParser,
} from './hooks/useDashboardFilters';
import { useDashboardRefresh } from './hooks/useDashboardRefresh';
import { useIsVariablesEnabled } from './hooks/useIsVariablesEnabled';
import useTileSelection from './hooks/useTileSelection';
Expand Down Expand Up @@ -1829,10 +1831,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
whereLanguageParser,
);
// Get raw filter queries from URL (not processed by hook)
const [rawFilterQueries] = useQueryState(
'filters',
parseAsJsonEncoded<Filter[]>(),
);
const [rawFilterQueries] = useQueryState('filters', filterQueriesParser);

// Toggle for overlaying alert firing/recovery markers on tile charts.
// Ephemeral view state (URL param), not persisted on the dashboard.
const [showAlertAnnotations, setShowAlertAnnotations] = useQueryState(
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { parseAsJson, useQueryState } from 'nuqs';
import {
DashboardContainer,
DashboardFilter,
Filter,
DashboardFilterValue,
resolveChartPaletteToken,
SavedChartConfig,
SearchConditionLanguage,
Expand Down Expand Up @@ -38,7 +38,7 @@ export type Dashboard = {
filters?: DashboardFilter[];
savedQuery?: string | null;
savedQueryLanguage?: SearchConditionLanguage | null;
savedFilterValues?: Filter[];
savedFilterValues?: DashboardFilterValue[];
containers?: DashboardContainer[];
createdAt?: string;
updatedAt?: string;
Expand Down
20 changes: 17 additions & 3 deletions packages/app/src/hooks/useDashboardFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@ import {
import {
ChartVariable,
DashboardFilter,
DashboardFilterValue,
Filter,
} from '@hyperdx/common-utils/dist/types';

import { parseQuery } from '@/searchFilters';
import { parseAsJsonEncoded } from '@/utils/queryParsers';

const filterQueriesParser = parseAsJsonEncoded<Filter[]>();
export const filterQueriesParser = parseAsJsonEncoded<DashboardFilterValue[]>();

/**
* Narrow the persisted entries to the ones the expression-keyed reader
* understands. Variable-keyed entries are not read or written here yet.
*/
const expressionKeyedEntries = (
entries: DashboardFilterValue[] | null | undefined,
): Filter[] =>
(entries ?? []).filter((entry): entry is Filter => entry.type !== 'variable');

/**
* Whether a filter definition broadcasts its selected value onto a tile
Expand All @@ -40,7 +50,9 @@ const useDashboardFilters = (filters: DashboardFilter[]) => {
const setFilterValue = useCallback(
(expression: string, values: string[]) => {
setFilterQueries(prev => {
const { filters: filterValues } = parseQuery(prev ?? []);
const { filters: filterValues } = parseQuery(
expressionKeyedEntries(prev),
);
if (values.length === 0) {
Comment thread
pulpdrew marked this conversation as resolved.
delete filterValues[expression];
} else {
Expand All @@ -66,7 +78,9 @@ const useDashboardFilters = (filters: DashboardFilter[]) => {
filtersByExpression,
variables,
} = useMemo(() => {
const { filters: parsedFilters } = parseQuery(filterQueries ?? []);
const { filters: parsedFilters } = parseQuery(
expressionKeyedEntries(filterQueries),
);
Comment thread
pulpdrew marked this conversation as resolved.
const valuesForExistingFilters: FilterState = {};
const knownExpressions = new Set(filters.map(f => f.expression));
const ignored: string[] = [];
Expand Down
Loading
Loading