Skip to content

Support configuring colors in line graphs, bar charts, and numbers #1360

Description

@benjreinhart

Context

PR hyperdxio/hyperdx#2265 shipped a static color picker for number tiles: the tile's value renders in the picked palette token, with fallback to the default text color when unset.

The next slice of HDX-1360 is conditional color rules: the user defines an ordered list of conditions, each carrying a color, and the displayed value gets the color of the last matching rule. When no rule matches, the tile's static color (from #2265) applies.

This matches the Grafana thresholds model: rules are evaluated in order, the LAST match wins. The schema is intentionally generic so a future table-tile slice can attach the same condition primitive on a per-column basis without schema change.

Goal

A dashboard editor can:

  1. Pick a static tile color (already shipped in [HDX-1360] feat(app): number tile static color picker #2265).
  2. Open a "Conditional colors" section in the Display Settings drawer.
  3. Add up to 10 ordered rules. Each rule: operator + value + palette-token color + optional label.
  4. Drag rules to reorder (reuses the existing @dnd-kit/sortable + verticalListSortingStrategy pattern from packages/app/src/components/DashboardDndContext.tsx).
  5. Save. All choices persist as palette tokens and round-trip via internal save.

At render time, the number tile evaluates rules in order; the LAST matching rule's color wins; if nothing matches, it falls back to the static tile color from #2265; if that is unset, it falls back to the default text color.

Operator semantics (Grafana style, last-match-wins)

This is identical to Grafana panel thresholds, where rules are sorted ascending and "the last threshold whose value is at or below the current data value applies."

Why last-match-wins, not first-match: when the user lists ascending ranges (>= 0 green, >= 100 yellow, >= 500 red), they list them in natural order and the highest matching range wins automatically. Exact-match overrides (e.g. eq "CRIT" red) go LAST and override any range. The user controls priority via order.

Schema (common-utils)

Add to packages/common-utils/src/types.ts, alongside ChartPaletteTokenSchema:

const ColorConditionSchema = z.discriminatedUnion('operator', [
  // Numeric ordered operators
  z.object({
    operator: z.enum(['gt', 'gte', 'lt', 'lte']),
    value: z.number().finite(),
    color: ChartPaletteTokenSchema,
    label: z.string().max(40).optional(),
  }),
  z.object({
    operator: z.literal('between'),
    value: z.tuple([z.number().finite(), z.number().finite()]),
    color: ChartPaletteTokenSchema,
    label: z.string().max(40).optional(),
  }),
  // Equality (number OR string)
  z.object({
    operator: z.enum(['eq', 'neq']),
    value: z.union([z.number().finite(), z.string().max(200)]),
    color: ChartPaletteTokenSchema,
    label: z.string().max(40).optional(),
  }),
  // String operators (used by future table tile; allowed at schema level on all tiles)
  z.object({
    operator: z.enum(['contains', 'startsWith', 'endsWith']),
    value: z.string().min(1).max(200),
    color: ChartPaletteTokenSchema,
    label: z.string().max(40).optional(),
  }),
  z.object({
    operator: z.literal('regex'),
    value: z.string().min(1).max(500).refine(
      v => { try { new RegExp(v); return true; } catch { return false; } },
      { message: 'Invalid regex pattern' },
    ),
    color: ChartPaletteTokenSchema,
    label: z.string().max(40).optional(),
  }),
]);

export type ColorCondition = z.infer<typeof ColorConditionSchema>;
export { ColorConditionSchema };

Then add colorRules to SharedChartSettingsSchema next to the existing color:

colorRules: z.array(ColorConditionSchema).max(10).optional()

Stays at the shared level (same pattern as color and numberFormat). The UI gates by tile type. The schema does NOT discriminate on tile type for colorRules; future table tile uses the same primitive.

Export ColorConditionSchema so the future table-tile slice and the eventual external-API parity PR both reuse it.

Resolver (app)

New helper in packages/app/src/utils.ts, next to resolveChartColor from #2265:

export function resolveConditionalColor(
  value: number | string | null | undefined,
  rules: ColorCondition[] | undefined,
  fallback: ChartPaletteToken | undefined,
): ChartPaletteToken | undefined {
  if (!rules || rules.length === 0 || value == null) return fallback;
  let match: ChartPaletteToken | undefined = fallback;
  for (const rule of rules) {
    if (evaluateColorCondition(value, rule)) match = rule.color;
  }
  return match;
}

evaluateColorCondition(value, rule) walks the discriminated union:

  • Numeric ops (gt, gte, lt, lte, between): return false if typeof value !== 'number'.
  • eq / neq: strict comparison; cross-type returns false ("5" vs 5 is no match).
  • String ops (contains, startsWith, endsWith, regex): return false if typeof value !== 'string'. regex uses new RegExp(rule.value).test(value) wrapped in try/catch (bad regex returns false at runtime; schema-side .refine is best-effort, runtime guard is the last line of defense).

Number tile in v1 always passes a numeric value, so string ops become no-ops in practice; they exist in the schema for table-tile reuse.

UI (app)

In packages/app/src/components/ChartDisplaySettingsDrawer.tsx, add a "Conditional colors" section below the existing "Color" section (the AC3 one shipped in #2265). Gate visibility on displayType === DisplayType.Number, mirroring how the "Color" section is gated at lines 110-116 (the showGroupByColumnsOnLeft / isTimeChart pattern).

New component: packages/app/src/components/ColorRulesEditor.tsx. Layout:

  • Header: "Conditional colors" with a small hint: "Falls back to the tile color when no rule matches."
  • Sortable list of rules. Each row contains:
    • Drag handle (left).
    • Operator dropdown: >, >=, <, <=, between, =, . String operators (contains, startsWith, endsWith, regex) are hidden on number tile; they become available when the same editor is reused on a table tile in a future slice.
    • Value input(s): single number input for ordered ops; two number inputs for between; single text-or-number input for eq/neq.
    • Color picker (reuses ColorSwatchInput from [HDX-1360] feat(app): number tile static color picker #2265).
    • Optional label input.
    • Delete button (trash icon).
  • "Add rule" button at the bottom. Disabled at 10 rules.

Sortable list: use @dnd-kit/sortable with verticalListSortingStrategy, MouseSensor (activationConstraint: { distance: 8 }) + TouchSensor (activationConstraint: { delay: 200, tolerance: 5 }). Copy the activation pattern verbatim from DashboardDndContext.tsx. Each rule row uses useSortable({ id: rule.localId }). Rule localIds are generated client-side via crypto.randomUUID() (or an existing helper if one exists in the repo); they are stripped before save (rules persist as a plain array; array index is identity).

Wire to the form via Controller like the existing color picker: <Controller name="colorRules" control={control} render={...}>. Pass through EditTimeChartForm's displaySettings memo and handleUpdateDisplaySettings writeback, mirroring how color is wired in #2265.

Render (app)

In packages/app/src/components/DBNumberChart.tsx, call resolveConditionalColor(displayedValue, config.colorRules, config.color) once per render. Apply via the existing Mantine <Text c={getColorFromCSSToken(...)}> pattern shipped in #2265.

When the tile is in a state with no value to evaluate (loading, error, empty), keep current behavior; do not apply rules.

Reuse design (table tile, future slice)

ColorConditionSchema is universal. A future table-tile slice attaches per-column rules:

// On TableChartConfig (future, NOT in this issue)
columnColorRules: z.record(z.string(), z.array(ColorConditionSchema).max(10)).optional()

The same resolveConditionalColor works per-cell once the per-column rules are passed. The same ColorRulesEditor component is reused, wrapped in a per-column tab UI.

That work is OUT OF SCOPE for this issue. The design constraint is "do not preclude it." The implementation here must keep the schema and resolver in shared locations the future table slice can import.

Validation rules

Rule Where enforced
colorRules array length 0 to 10 Zod .max(10)
Each rule has a valid operator from the union Zod discriminated union
Numeric operators reject string values Zod (typed value)
String operators reject number values Zod (typed value)
Numeric value finite (no NaN, no Infinity) Zod .finite()
color must be a known ChartPaletteToken Zod enum (existing)
Regex pattern is parseable Zod .refine(v => { try { new RegExp(v); return true; } catch { return false; } })
between tuple has exactly 2 values Zod .tuple([num, num]) (first <= second is a UI hint only; the schema does not enforce ordering, so an inverted between can be intentional)
String operators reject empty value Zod .min(1)
label max 40 chars Zod .max(40)

colorRules is allowed at the schema level on all tile types (kept universal for future table reuse). The UI in v1 only renders the section when displayType === DisplayType.Number. External-API parity (separate ticket; see References) mirrors this.

Test matrix

Schema tests (common-utils)

Positive:

  • Each operator parses with a valid value and a valid palette token.
  • colorRules accepts 0, 1, 5, 10 rules.
  • between parses with first > second (allowed; UI hint only).
  • regex parses with a valid pattern.
  • eq parses with both number and string values.

Negative:

  • 11 rules rejected.
  • Unknown operator rejected.
  • Numeric operator with string value rejected.
  • String operator with number value rejected.
  • Invalid palette token rejected.
  • NaN and Infinity rejected on numeric ops.
  • Empty string on contains / startsWith / endsWith / regex rejected.
  • Unparseable regex rejected.
  • label > 40 chars rejected.

Resolver tests (app)

Positive:

  • No rules: returns fallback.
  • One matching rule: returns that rule's color.
  • Two matching rules in order: returns the LAST one (Grafana semantic).
  • Mixed numeric and string rules with a numeric value: numeric rules can match; string rules never do.
  • eq with cross-type (rule value "5", runtime value 5): no match.
  • null / undefined value: returns fallback.

Negative:

  • Bad regex (somehow made it past schema): caught by try/catch, treated as no match.

UI tests (app)

Positive:

  • "Add rule" appends an empty rule with sensible defaults (operator gt, color chart-1).
  • Dragging a rule reorders the list (assert against form state after onDragEnd; or use the dnd-kit testing approach used in dashboardSections.test.tsx).
  • Each operator's value-input shape renders correctly: single input for ordered ops, two inputs for between, text-or-number input for eq/neq.
  • Selecting a color from the swatch popover sets the rule's color.
  • Deleting a rule removes it from the list and from form state.
  • "Add rule" button disables at 10 rules.

Negative:

  • Trying to add an 11th rule via the button shows the button as disabled.
  • Invalid regex (typed into a regex rule's value input) shows a visual error indicator; resolver try/catch ensures runtime safety.

Integration test (DBNumberChart)

Mirror __tests__/DBNumberChart.test.tsx from #2265. Add a case that:

  1. Renders a number tile with color: 'chart-success' AND colorRules: [{ operator: 'gte', value: 100, color: 'chart-warning' }, { operator: 'gte', value: 500, color: 'chart-error' }].
  2. With a value of 50, expects chart-success (no rule matches; fallback to tile color).
  3. With a value of 200, expects chart-warning (first rule matches, second doesn't; last match is rule 1).
  4. With a value of 1000, expects chart-error (both rules match; last match wins).

File locations

Layer File Change
common-utils packages/common-utils/src/types.ts Add ColorConditionSchema, ColorCondition. Add colorRules to SharedChartSettingsSchema. Export both.
common-utils tests packages/common-utils/src/__tests__/types.test.ts (or wherever schema tests live) Schema positive + negative tests.
app packages/app/src/utils.ts Add resolveConditionalColor and evaluateColorCondition.
app packages/app/src/components/ColorRulesEditor.tsx New component.
app packages/app/src/components/ColorRulesEditor.stories.tsx Storybook (empty / 1 rule / 10 rules / each operator) across hyperdx and clickstack themes in light + dark.
app tests packages/app/src/components/__tests__/ColorRulesEditor.test.tsx RTL tests per matrix.
app packages/app/src/components/ChartDisplaySettingsDrawer.tsx Add "Conditional colors" section gated on displayType === DisplayType.Number.
app packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx Pass colorRules through displaySettings memo and writeback.
app packages/app/src/components/DBNumberChart.tsx Wire resolveConditionalColor.
app tests packages/app/src/components/__tests__/DBNumberChart.test.tsx Add integration cases.

NO changes to API schema (packages/api/src/utils/zod.ts) or external-API round-trip (packages/api/src/routers/external-api/v2/utils/dashboards.ts) in this issue. External-API parity is a SEPARATE follow-up; open it after this lands (mirror the #2359 / #2265 pattern).

Tier expectation

Tier 3 (multi-layer: common-utils + app). Same shape as #2265, which landed at Tier 3 (840 prod lines; story files count toward the classifier per .github/scripts/pr-triage-classify.js:31-36). Predict locally:

node ~/nerve-workspace/scripts/predict-tier.js --base origin/main

If the diff approaches Tier 4 (> 1000 prod lines, agent-prefix > 400), split into:

  1. Schema-only PR (dormant colorRules field on SharedChartSettingsSchema). Tier 2.
  2. UI PR (resolver, editor, drawer integration, render). Tier 2 or 3 depending on size.

Keep story files lean.

Branch and PR conventions

  • Branch: alex/HDX-1360-number-conditional-colors. Do NOT use claude/, agent/, cursor/, or ai/ prefixes; those tag the PR as agent-branch and tighten the Tier-2 threshold to < 50 lines AND <= 3 prod files.
  • Push directly to hyperdxio/hyperdx (no fork); PR alex:alex/HDX-1360-number-conditional-colors -> main.
  • Commits authored with the operator's git identity. Co-Authored-By: Claude ... trailers ALLOWED on hyperdx (per repo policy).
  • PR body: first person, no em-dashes, no AI hallmarks ("Let me", "I'll go ahead", "comprehensive", "robust", "seamless", "leverage"). Run python3 ~/nerve-workspace/scripts/prose-lint.py before opening.
  • Changeset: required. yarn changeset with a minor bump (user-facing feature).
  • Link this issue in the PR body. Add before/after screenshots (light + dark, both themes).

Pre-PR checklist

  • make ci-lint && make ci-unit pass locally.
  • Schema positive + negative tests pass.
  • Resolver tests cover last-match-wins, mixed types, null value, bad regex.
  • UI tests cover add / reorder / delete / each operator's value-input shape.
  • Integration test in DBNumberChart.test.tsx covers the success / warning / error scenario.
  • node ~/nerve-workspace/scripts/predict-tier.js --base origin/main confirms Tier 3 (or split).
  • python3 ~/nerve-workspace/scripts/prose-lint.py clean on PR body.
  • Storybook stories cover light + dark for both hyperdx and clickstack themes.
  • Manual verification: open a dashboard, edit a number tile, set rules gte 100 chart-warning, gte 500 chart-error (in that order). Confirm value 50 renders the static color, 200 renders warning, 1000 renders error.
  • No as casts. No any types.
  • Changeset added.

References

Out of scope

  • Per-series conditional colors on line / bar / pie. Future slice; depends on this primitive plus AC1.
  • Table-cell conditional colors. Future slice; reuses the schema and resolver introduced here.
  • External-API parity (Zod schemas in packages/api/src/utils/zod.ts, OpenAPI regen, round-trip tests). Open a separate ticket after this lands, mirroring [HDX-1360] External API parity for number-tile color #2359.
  • Customer-facing docs for conditional colors on clickhouse-docs. Open after API parity merges so both authoring surfaces are covered together.
  • Auto-color-shift hints in the picker.
  • Free-form hex color input.
  • Threshold-driven alerting.

Acceptance criteria (this issue)

  • ColorConditionSchema defined in common-utils and exported.
  • colorRules added to SharedChartSettingsSchema (optional, max 10).
  • resolveConditionalColor helper exported from packages/app/src/utils.ts; last-match-wins; falls back to color then to default.
  • UI section in ChartDisplaySettingsDrawer, gated on number tile.
  • Drag-to-reorder via @dnd-kit/sortable, copying DashboardDndContext.tsx sensors and strategy.
  • Schema tests positive + negative.
  • Resolver tests positive + negative.
  • UI tests for add / reorder / delete / each operator.
  • Integration test in DBNumberChart.test.tsx covers the success / warning / error scenario.
  • PR opens at Tier 3 max; split if Tier 4.
  • No changes to API schemas or external API round-trip (those are a separate ticket).
  • Changeset added; PR body in first person, no em-dashes.

Metadata

Metadata

Assignees

Labels

FrontendCreated by Linear-GitHub SyncenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions