Skip to content
Merged
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/required-dashboard-filters.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: Allow configuring dashboard filters as required
50 changes: 49 additions & 1 deletion packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3160,7 +3160,7 @@
]
},
"FilterInput": {
"description": "A drop-down filter on a dashboard. Depending on type, filters can either broadcast\nselected value(s) to every tile (isBroadcastEnabled), expose selected\nvalues as a variable (isVariableEnabled) or both. Typically a filter should\ndo just one of these things.\n",
"description": "A drop-down filter on a dashboard. Depending on type, filters can either broadcast\nselected value(s) to every tile (isBroadcastEnabled), expose selected\nvalues as a variable (isVariableEnabled) or both. Typically a filter should\ndo just one of these things. Any filter may additionally be marked\nrequired via minSelections, which blocks the tiles that read it until it\nhas a selected value - or every tile on the dashboard, with\nisGlobalRequirement.\n",
"oneOf": [
{
"$ref": "#/components/schemas/QueryExpressionFilterInput"
Expand Down Expand Up @@ -3271,6 +3271,22 @@
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*$",
"description": "Token tiles reference this filter's selected value by, as\n`$variableName`. Must start with a letter and may contain only\nletters, numbers, and underscores. Defaults to the display name with\nwhitespace replaced by underscores and remaining illegal characters\nremoved, so a variable-enabled filter whose name derives nothing\nusable must send this field explicitly. Variable names must be unique\nacross a dashboard's variable-enabled filters. Names the variable\nonly, so the field is rejected when isVariableEnabled is not true, and\nis omitted from responses for such a filter.\n",
"example": "environment"
},
"minSelections": {
"type": "integer",
"enum": [
0,
1
],
"default": 0,
"description": "Minimum number of values that must be selected before tiles load.\nSet to 1 to make the filter required. Only 0 and 1 are accepted.\nOmit the field (or send 0) for the default optional behavior.\n",
"example": 1
},
"isGlobalRequirement": {
"type": "boolean",
"default": false,
"description": "Widens a required filter's block to every tile on the dashboard. False\n(the default) blocks only the tiles that read the filter: those\nreferencing its variableName, and those its broadcast applies to.\nIgnored unless minSelections is 1.\n",
"example": true
}
}
},
Expand Down Expand Up @@ -3337,6 +3353,22 @@
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*$",
"description": "Token tiles reference this filter's selected value by, as\n`$variableName`. Must start with a letter and may contain only\nletters, numbers, and underscores. Defaults to the display name with\nwhitespace replaced by underscores and remaining illegal characters\nremoved, so a variable-enabled filter whose name derives nothing\nusable must send this field explicitly. Variable names must be unique\nacross a dashboard's variable-enabled filters. Names the variable\nonly, so the field is rejected when isVariableEnabled is not true, and\nis omitted from responses for such a filter.\n",
"example": "environment"
},
"minSelections": {
"type": "integer",
"enum": [
0,
1
],
"default": 0,
"description": "Minimum number of values that must be selected before tiles load.\nSet to 1 to make the filter required. Only 0 and 1 are accepted.\nOmit the field (or send 0) for the default optional behavior.\n",
"example": 1
},
"isGlobalRequirement": {
"type": "boolean",
"default": false,
"description": "Widens a required filter's block to every tile on the dashboard. False\n(the default) blocks only the tiles that read the filter: those\nreferencing its variableName, and those its broadcast applies to.\nIgnored unless minSelections is 1.\n",
"example": true
}
}
},
Expand Down Expand Up @@ -3406,6 +3438,22 @@
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*$",
"description": "Token that tiles reference this filter's selected value by, as\n`$variableName`. Must start with a letter and may contain only\nletters, numbers, and underscores. Defaults to the display name with\nwhitespace replaced by underscores and remaining illegal characters\nremoved. Variable names must be unique across a dashboard's\nvariable-enabled filters.\n",
"example": "pod"
},
"minSelections": {
"type": "integer",
"enum": [
0,
1
],
"default": 0,
"description": "Minimum number of values that must be selected before tiles load.\nSet to 1 to make the filter required. Only 0 and 1 are accepted.\nOmit the field (or send 0) for the default optional behavior.\n",
"example": 1
},
"isGlobalRequirement": {
"type": "boolean",
"default": false,
"description": "Widens a required filter's block to every tile on the dashboard. False\n(the default) blocks only the tiles that read the filter: those\nreferencing its variableName, and those its broadcast applies to.\nIgnored unless minSelections is 1.\n",
"example": true
}
}
},
Expand Down
149 changes: 143 additions & 6 deletions packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1855,6 +1855,14 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
},
});

const staticFilter = (overrides: Record<string, unknown> = {}) => ({
type: 'STATIC_LIST' as const,
name: 'Environment',
options: ['prod', 'staging', 'dev'],
variableName: 'env',
...overrides,
});

it('should round-trip filters on create, get, and update', async () => {
const sourceId = ctx.traceSource._id.toString();

Expand Down Expand Up @@ -2579,15 +2587,144 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
});
});

describe('static list filters', () => {
const staticFilter = (overrides: Record<string, unknown> = {}) => ({
type: 'STATIC_LIST' as const,
name: 'Environment',
options: ['prod', 'staging', 'dev'],
variableName: 'env',
describe('required filters', () => {
const queryFilter = (overrides: Record<string, unknown> = {}) => ({
type: 'QUERY_EXPRESSION' as const,
name: 'Service',
expression: 'ServiceName',
sourceId: ctx.traceSource._id.toString(),
...overrides,
});

it.each([
['QUERY_EXPRESSION', queryFilter],
['STATIC_LIST', staticFilter],
] as const)(
'round-trips a required %s filter through create, get, and update',
async (_label, makeFilter) => {
const sourceId = ctx.traceSource._id.toString();

const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Required filter round-trip',
tiles: [traceTile(sourceId)],
filters: [makeFilter({ minSelections: 1 })],
},
);
expect(createResult.isError).toBeFalsy();
const created = JSON.parse(getFirstText(createResult));
expect(created.filters[0].minSelections).toBe(1);

const getResult = await callTool(
ctx.client!,
'clickstack_get_dashboard',
{ id: created.id },
);
const fetched = JSON.parse(getFirstText(getResult));
expect(fetched.filters[0].minSelections).toBe(1);

const updateResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
id: created.id,
name: fetched.name,
tiles: fetched.tiles,
filters: fetched.filters,
},
);
expect(updateResult.isError).toBeFalsy();
expect(
JSON.parse(getFirstText(updateResult)).filters[0].minSelections,
).toBe(1);
},
);

it('omits minSelections for a filter that is not required', async () => {
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Optional filter',
tiles: [traceTile(sourceId)],
filters: [queryFilter(), queryFilter({ minSelections: 0 })],
},
);

expect(result.isError).toBeFalsy();
const created = JSON.parse(getFirstText(result));
for (const filter of created.filters) {
expect(filter).not.toHaveProperty('minSelections');
}
});

it.each([2, -1, 1.5])('rejects minSelections %s', async value => {
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Bad minSelections',
tiles: [traceTile(sourceId)],
filters: [queryFilter({ minSelections: value })],
},
);

expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('minSelections');
});

it('round-trips a dashboard-wide requirement through create and get', async () => {
const sourceId = ctx.traceSource._id.toString();

const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Dashboard-wide requirement',
tiles: [traceTile(sourceId)],
filters: [
queryFilter({ minSelections: 1, isGlobalRequirement: true }),
],
},
);
expect(createResult.isError).toBeFalsy();
const created = JSON.parse(getFirstText(createResult));
expect(created.filters[0].isGlobalRequirement).toBe(true);

const getResult = await callTool(
ctx.client!,
'clickstack_get_dashboard',
{ id: created.id },
);
expect(
JSON.parse(getFirstText(getResult)).filters[0].isGlobalRequirement,
).toBe(true);
});

it('omits the scope for a filter that is not required', async () => {
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Optional filter with a scope',
tiles: [traceTile(sourceId)],
filters: [queryFilter({ isGlobalRequirement: true })],
},
);

expect(result.isError).toBeFalsy();
expect(JSON.parse(getFirstText(result)).filters[0]).not.toHaveProperty(
'isGlobalRequirement',
);
});
});

describe('static list filters', () => {
it('round-trips a static filter through create, get, and save', async () => {
const sourceId = ctx.traceSource._id.toString();

Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/mcp/prompts/dashboards/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ Apply these before calling clickstack_save_dashboard. Each rule is enforced by t

9d. USE A STATIC_LIST FILTER WHEN THE DROPDOWN SHOULD OFFER A FIXED HAND-AUTHORED LIST. When the values are a business list (environments, tenants, tiers) or a curated subset rather than derivable from the data, declare filters: [{ type: "STATIC_LIST", name, options: ["prod", "staging"], variableName }]. It takes no expression, sourceId, or where, and it is always variable-only (there is nothing to broadcast), so tiles must reference $variableName, typically via $__filter(<expression>, $<variableName>) with the column passed explicitly (the one-argument $__filter($var) form fails because the filter has no expression of its own).

9e. MAKE A FILTER REQUIRED ONLY WHEN AN UNSCOPED VIEW IS MEANINGLESS. minSelections: 1 blocks the tiles that read the filter - the ones referencing its $variableName, and the ones its broadcast applies to - until the user picks a value. isGlobalRequirement: true widens that to every tile on the dashboard. Consider pairing either form with savedFilterValues so the dashboard opens on a sensible default rather than blocked.

10. UPDATE IS REPLACE, NOT MERGE. clickstack_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call clickstack_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted.

11. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not.
Expand Down
22 changes: 22 additions & 0 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,28 @@ const mcpDashboardFilterBaseShape = {
'Human-readable filter label shown in the dashboard filter bar dropdown.',
),
variableName: variableNameSchema.describe(VARIABLE_NAME_DESCRIPTION),
minSelections: z
.number()
.int()
.min(0)
.max(1)
.optional()
.describe(
'Set to 1 to make this filter REQUIRED. Until the user picks at least one ' +
'value for it, the tiles that read this filter refuse to load and show a ' +
'"missing required filters" message instead. By default, tiles that ' +
'reference this filter by its variable or receive a broadcasted value ' +
'from this filter will be blocked. isGlobalRequirement widens the block ' +
'to all tiles.Only 0 and 1 are accepted; omit the field (or pass 0) for ' +
'the normal optional behavior, which is usually what you want.',
),
isGlobalRequirement: z
.boolean()
.optional()
.describe(
'Widens a REQUIRED filter (minSelections: 1) to block EVERY tile on the dashboard, ' +
'not just the ones that read it. Ignored unless minSelections is 1.',
),
};

const mcpQueryExpressionFilterSchema = z
Expand Down
Loading
Loading