Compare commits

..

5 Commits

Author SHA1 Message Date
nikhilmantri0902
b19b89b004 chore: wording fix 2026-09-15 17:07:57 +05:30
nikhilmantri0902
9fb05731a7 chore: sql compiler doc added 2026-09-15 17:01:18 +05:30
Abhi kumar
861e5f75cf fix(dashboards): restore all series when the isolated legend row is clicked (#12864)
#### Description

- Clicking the legend row of the only series still showing now restores
every series, so the isolating click undoes itself — hiding that last
series is refused, which left no way back to all from the row itself.
- With more than one series showing, the row click is unchanged: isolate
when everything is showing, hide/show otherwise.
- Applies to the pie legend and to Enter/Space too — same row component,
same handler.

#### Additional Information

The legend test file moved to `components/Legend/__tests__/`, next to
the component it covers.
2026-09-15 10:07:15 +00:00
Abhi kumar
755cca13cf feat(dashboards): revamp panel chart legend interaction (#12838)
#### Description

- The legend's two undiscoverable click targets are replaced by one
explicit set. A row click depends on what's on screen — isolate when
everything is showing, move the isolation while one series is showing,
hide/show that series otherwise — and the marker stays a separate target
so a single series can still be excluded in one click.
- Hover-revealed row actions: `+` to show a hidden series alongside the
current ones, a crosshair to isolate (or restore all when it's already
alone), and copy for the full untruncated series name. Their reveal is
pure CSS.
- Hovering a row lifts that series on the chart and dims the rest; the
dim is dropped as soon as the highlighted series is hidden, which
previously left every remaining series faded and read as an isolation.
- At least one series always stays visible, so a panel can no longer be
emptied.
- The bottom legend reserves exactly the one or two rows it shows — the
old estimate was built from a 40px row while rows are 28px with a 2px
gap, so two rows never quite fit. The right-side legend keeps its search
box and gains a `Showing N of M series` readout.
- Tooltip series rows now use the same marker and mono type as the
legend.

<img width="1961" height="637" alt="image"
src="https://github.com/user-attachments/assets/07c78273-dcd7-4c60-b3fc-7014a76aa693"
/>
<img width="425" height="154" alt="image"
src="https://github.com/user-attachments/assets/2ffba264-0067-4de2-b8b7-cf8d09c305be"
/>


https://github.com/user-attachments/assets/d5514736-2980-49b9-b62a-0dca8156c960



#### Additional Information

- All of this lives in the shared `lib/uPlotV2` legend, so it lands on
V1 dashboard panels, V2 panels, the alert chart preview, infra
monitoring and Pie at once. The logs/traces explorer still uses the
older `lib/uPlotLib` legend and is untouched.
- `Legend` is now presentational only; visibility lives with each chart
type — `PlotContext` for uPlot, `usePieInteractions` for the donut —
which is what lets both share one row component.
- `LegendRow` is memoised: the chart's cursor focus updates the focused
series on every pointer move over the plot, which re-runs the list's row
renderer.
- Legend hover no longer sets React state (it was re-rendering the whole
legend per row), and the legend dropped a `ResizeObserver` that only
existed to centre a single row.

Closes https://github.com/SigNoz/pulse-pod/issues/340
2026-09-15 07:12:09 +00:00
Vinicius Lourenço
e0c0ac4ea6 ci(storybook): add job to test storybooks (#12841)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Basically ensures our storybooks does not fail silently while we update
a page, this will give us errors/warnings for missing mocked APIs and
also for console.error logged by the page, I added few exceptions to
cover most of the basic use-cases we have today.

The implementation here is a little different from signoz/components
because there's so many pages and heavy components that the normal
vitest way fails and produce flakys enough to be unusable.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/6051
2026-09-15 02:40:24 +00:00
51 changed files with 3400 additions and 1496 deletions

View File

@@ -79,6 +79,36 @@ jobs:
run: |
cd frontend && pnpm generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
storybook:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: mcr.microsoft.com/playwright:v1.57.0-noble
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: install-frontend
run: cd frontend && pnpm install
- name: test-storybook
run: cd frontend && pnpm test:storybook --shard=${{ matrix.shard }}/${{ strategy.job-total }}
web-settings:
if: |
github.event_name == 'merge_group' ||

View File

@@ -21,4 +21,5 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
- [Service](service.md) - Managed service lifecycle with `factory.Service`
- [SQL](sql.md) - Database and SQL patterns
- [SQL Compiler](sqlcompiler.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`

View File

@@ -0,0 +1,84 @@
# SQL Compiler
List pages (dashboards, alert rules) share one filter DSL in their search bars. [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go) compiles a DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. Telemetry filters are a different pipeline. They stay on querybuilder's ClickHouse visitor.
The package owns everything generic about the language. A module adopting it writes exactly one thing: a `FieldResolver` that says which keys exist and what each maps to.
## What is the DSL?
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere. The shapes that matter:
- Boolean structure: parentheses > `NOT` > `AND` > `OR`; adjacent terms with no connective are an implicit `AND`.
- Comparisons: `key OP value`, e.g. `name CONTAINS cpu`, `created_at > '2025-01-01T00:00:00Z'`, `labels.team IN ('infra', 'platform')`, `labels.env EXISTS`. See the `comparison` rule in the grammar for the full operator list.
- Free text: a bare or quoted token with no key. Quoting is the escape hatch for a phrase that looks like DSL.
- Values: bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
## What does the framework already cover?
```go
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
```
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. On top of parsing, the package handles:
- Syntax errors, collected with line/column positions instead of failing on the first one.
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
- Predicate builders the resolver calls back into:
| Builder | Handles |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
| `BuildBoolComparison` | `= true/false` |
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
- Typed value extraction (`ExtractSingleStringValue`, `ExtractStringValueList`, ...) with accumulated errors: the user sees every problem in the query at once.
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
## When do I write a FieldResolver?
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
```go
type FieldResolver interface {
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
ResolveFreeText(v *Visitor, value string) string
}
```
Rules for implementing one:
- Declare the key namespace in `pkg/types/<domain>`: `DSLKey` constants plus a `ReservedOps` map of key to allowed operators. The list API advertises these as `reservedKeywords`, so the frontend suggestions never go stale.
- Reject a disallowed operator with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all errors.
- Map a key to a column expression through `v.Formatter` (`JSONExtractString`, `LowerExpression`), never by hand, so the expression is valid on both SQLite and Postgres.
- Delegate the predicate to the `Build*` helpers above; do not hand-build SQL or manage arguments yourself.
- For a key that lives in a relation table (dashboard tags, rule labels), build an `EXISTS` subquery on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. For a negative operator, build the positive predicate and toggle `NotExists` on the outer builder.
The reference implementation is the dashboards resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go): reserved keys backed by columns and JSON paths, tag keys via `EXISTS` subqueries, free text across name, description and tags.
## How to wire it in?
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code, as in [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go):
```go
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
}
```
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
## What should I remember?
- One DSL, one compiler; a new list page adds a `FieldResolver`, not a new parser or SQL layer.
- Keys and allowed operators live in `pkg/types/<domain>` and are advertised as `reservedKeywords`.
- Column expressions go through `v.Formatter`; predicates go through the `Build*` helpers.
- Report problems with `v.AddError` and return `""`; errors accumulate.
- Relation-table keys use `EXISTS` subqueries on their own builder; negation toggles `NotExists`.
- This package is for the relational store only; telemetry filtering stays in querybuilder.

View File

@@ -1,9 +1,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet"
/>
<!--
`index.html` links Inter from Google Fonts here. The link is parsed before the
msw worker has started, so that one request escapes the iframe while every
other font request the app makes is answered with an empty stylesheet; the
local faces stand in for both.
-->
<link rel="stylesheet" href="storybook-fonts.css" />
<link rel="stylesheet" href="css/uPlot.min.css" />
<script>

View File

@@ -0,0 +1,50 @@
/*
* The five families the app pulls from Google Fonts, served from the files in
* `public/fonts` instead. `msw/appShellHandlers.ts` answers the CDN with an
* empty stylesheet so no request leaves the iframe, which without this left
* every story on a fallback for the four families `src/styles.scss` imports at
* runtime, and on whatever the Inter link in `index.html` happened to fetch
* before the worker had started.
*/
@font-face {
font-family: 'Inter';
/* The comma is part of the filename and the dev server serves it raw, so it
stays literal inside the quoted url rather than percent-encoded. */
src: url('fonts/Inter-VariableFont_opsz,wght.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Work Sans';
src: url('fonts/WorkSans-VariableFont_wght.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Space Mono';
src: url('fonts/SpaceMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('fonts/GeistMonoVF.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,85 @@
import { getStoryContext } from '@storybook/test-runner';
import type { TestRunnerConfig } from '@storybook/test-runner';
import type { Page } from 'playwright';
const IGNORED_MESSAGES = [
// `preview-head.html` swaps a local stylesheet in for Google Fonts, but the
// browser still warns on the real cross-origin one it briefly requests
// before msw starts (no CORS headers), regardless of story content.
/Can't access cssRules/,
// Pre-existing dev-server noise, unrelated to any story.
/Couldn't load preload assets/,
// Fires because a Jest-driven browser sets a global testing flag React
// checks for; unrelated to anything a story does.
/current testing environment is not configured to support act/,
// React and antd route dev-only warnings (missing keys, DOM nesting, API
// deprecations) through `console.error` under this prefix; app-wide and
// tracked separately from story regressions.
/^Warning: /,
// msw's own warning when its response listener count grows across many
// story visits in one browser session; not a story defect.
/MaxListenersExceededWarning/,
// `preview-head.html`'s CSP intentionally blocks third-party iframes
// (YouTube embeds, the docs pane) so they hit the real network instead of
// an unanswered msw request; the block is the point, not a bug.
/violates the following Content Security Policy directive/,
];
const messagesByPage = new WeakMap<Page, string[]>();
/**
* Only `console.error` fails a story. `console.warn` is dev-time advice from
* app code (e.g. `aggregateData is null`) and from the runner itself; an
* unmocked `/api/` call is a `console.error` in `src/storybook/msw/handlers.ts`.
*/
const config: TestRunnerConfig = {
// msw logs every mocked request at `log`; keep it out of the failure dump
// unless the job is re-run with debug logging (GitHub sets RUNNER_DEBUG=1).
logLevel: process.env.RUNNER_DEBUG === '1' ? 'info' : 'warn',
async preVisit(page): Promise<void> {
const existing = messagesByPage.get(page);
if (existing) {
existing.length = 0;
return;
}
const messages: string[] = [];
messagesByPage.set(page, messages);
page.on('console', (message) => {
if (
message.type() === 'error' &&
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
) {
messages.push(`[error] ${message.text()}`);
}
});
// The console message alone ("Failed to load resource") doesn't name the
// URL; pairing it with the response is what makes a missing mock
// actionable instead of just a status code.
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push(`[response] ${response.status()} ${response.url()}`);
}
});
},
async postVisit(page, context): Promise<void> {
const messages = messagesByPage.get(page) ?? [];
if (messages.length === 0) {
return;
}
// A story that deliberately mocks a failure response (e.g. a 500 to test
// an error state) logs the error it's testing for; opt it out per-story
// with `parameters: { allowConsoleErrors: true }`.
const storyContext = await getStoryContext(page, context);
if (storyContext.parameters?.allowConsoleErrors) {
return;
}
throw new Error(
`Story "${context.name}" logged console error/warning:\n${messages.join('\n')}`,
);
},
};
export default config;

View File

@@ -9,6 +9,7 @@
"dev": "vite",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"test:storybook": "bash scripts/test-storybook.sh",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",
@@ -162,6 +163,7 @@
"@jest/types": "30.2.0",
"@storybook/addon-a11y": "10.5.9",
"@storybook/react-vite": "10.5.9",
"@storybook/test-runner": "0.24.5",
"@testing-library/dom": "8.20.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
@@ -233,7 +235,7 @@
},
"overrides": {
"@babel/core@<=7.29.0": ">=7.29.6 <8",
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
"@istanbuljs/load-nyc-config>js-yaml": ">=4.3.1 <5",
"cookie@<0.7.0": ">=0.7.1 <1",
"dompurify@<=3.4.10": ">=3.4.11 <4",
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
@@ -242,6 +244,14 @@
"prismjs@<1.30.0": ">=1.30.0 <2",
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2",
"brace-expansion@<1.1.18": ">=1.1.18 <2",
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3",
"brace-expansion@>=5.0.0 <5.0.9": ">=5.0.9 <6",
"fast-uri@<3.1.5": ">=3.1.5 <4",
"immutable@<5.1.8": ">=5.1.8 <6",
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
"less@<4.5.0": ">=4.5.0 <5",
"nanoid@<3.3.18": ">=3.3.18 <4"
}
}

1076
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
story_count=$(find src -name '*.stories.tsx' | wc -l)
if [ "$story_count" -eq 0 ]; then
echo "No *.stories.tsx found under src/" >&2
exit 1
fi
# jest splits the sorted story files into contiguous shards and exits 1 when a
# shard is empty, which happens on every shard above the file count. The runner
# rejects jest's own `--passWithNoTests`, so skip those shards here.
for arg in "$@"; do
if [[ $arg == --shard=* ]]; then
shard_index=${arg#--shard=}
if [ "${shard_index%%/*}" -gt "$story_count" ]; then
echo "Skipping ${arg#--shard=}: only ${story_count} story files"
exit 0
fi
fi
done
pnpm storybook --ci --quiet &
SB_PID=$!
trap 'kill "$SB_PID" 2>/dev/null || true' EXIT
until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
sleep 1
done
pnpm exec test-storybook --ci --maxWorkers=2 "$@"

View File

@@ -1,4 +1,4 @@
import { AxiosError, AxiosResponse } from 'axios';
import { AxiosError, AxiosResponse, isCancel } from 'axios';
import { ErrorResponse } from 'types/api';
import { ErrorStatusCode } from 'types/common';
@@ -42,6 +42,16 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
};
}
if (request) {
// Avoid logging error when the request was just cancelled for whatever reason
if (isCancel(error)) {
return {
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
};
}
// client never received a response, or request never left
console.error('client never received a response, or request never left');

View File

@@ -376,7 +376,9 @@ export default function BillingContainer(): JSX.Element {
</Typography.Link>
</AuthZTooltip>
{` if your payment information has changed. Email us at `}
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
<Typography.Text as="span" color="muted">
cloud-support@signoz.io
</Typography.Text>
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
</Typography>
);

View File

@@ -174,6 +174,7 @@ export default function ServiceTraces({
columns={columns}
dataSource={top5Services}
pagination={false}
rowKey="serviceName"
className="services-table"
onRow={(record: ServicesList): Record<string, unknown> => ({
onClick: (event: React.MouseEvent): void => {

View File

@@ -216,7 +216,7 @@ function K8sOptionsSidePanel<TData>({
);
return (
<div className={styles.columnItem} key={column.id}>
<Typography.Text size="sm" className={styles.columnLabel}>
<Typography.Text as="span" size="sm" className={styles.columnLabel}>
{column.label}
</Typography.Text>
{column.canBeHidden ? (

View File

@@ -0,0 +1,57 @@
@use '../../../../styles/scrollbar' as *;
.container {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
// Let the flex children shrink below their content height so the series list
// scrolls within the capped legend height instead of overflowing the wrapper
// (the default min-height:auto would block the shrink).
min-height: 0;
}
.scroller {
// flex:1 + min-height:0 pins the scroller to the space left after the
// toolbar instead of growing to fit every row.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
padding-right: var(--spacing-2);
overflow-x: hidden;
overscroll-behavior: contain;
@include custom-scrollbar;
}
.gridItem {
// Or the item keeps its content width and the label never ellipsizes.
min-width: 0;
max-width: 100%;
}
.gridList {
min-width: 0;
display: grid;
grid-auto-flow: row;
// min() keeps the column inside a narrow panel, where a wider one would push
// the row's actions out of the clipped area.
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
);
gap: var(--spacing-1) var(--spacing-4);
}
.container.isRight .gridList {
grid-template-columns: 1fr;
}
.emptyState {
padding: var(--spacing-16) 0;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
text-align: center;
}

View File

@@ -1,204 +0,0 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
padding-right: 8px;
.legend-search-input {
font-size: 12px;
}
}
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
.legend-empty-state {
font-size: 12px;
color: var(--l2-foreground);
text-align: center;
padding: 12px;
padding: 2rem 0;
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
.virtuoso-grid-list {
min-width: 0;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
column-gap: 12px;
}
.virtuoso-grid-item {
min-width: 0;
}
&.legend-virtuoso-container-right {
.virtuoso-grid-list {
grid-template-columns: 1fr;
}
}
&.legend-virtuoso-container-single-row {
.virtuoso-grid-list {
grid-template-columns: repeat(
auto-fit,
minmax(var(--legend-average-width, 240px), max-content)
);
justify-content: center;
}
}
@include custom-scrollbar;
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
&.legend-item-right {
width: 100%;
}
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-item-label-trigger {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.legend-marker {
border-width: 2px;
border-style: solid;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
user-select: none;
}
.legend-copy-button {
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
// Shrink the shared icon Button (defaults to a 2rem square) to the
// compact legend row via its size tokens.
--button-height: auto;
--button-width: auto;
--button-padding: 2px;
opacity: 0;
flex-shrink: 0;
color: var(--l2-foreground);
border-radius: 4px;
transition:
opacity 0.15s ease,
color 0.15s ease;
&:hover {
color: var(--l1-foreground);
}
}
&:hover {
background: var(--l3-background);
.legend-copy-button {
opacity: 1;
}
}
}

View File

@@ -1,139 +1,106 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendPosition, LegendProps } from '../types';
import { LegendAction, LegendPosition, LegendProps } from '../types';
import './Legend.styles.scss';
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
import LegendRow from './LegendRow';
import LegendToolbar from './LegendToolbar';
import { filterLegendItems, getShownSeriesState } from './utils';
export const MAX_LEGEND_WIDTH = 240;
import styles from './Legend.module.scss';
/**
* Presentational legend. Renders the supplied `items` (markers + labels, an
* optional copy button, and a search box for the RIGHT position) and delegates
* all interaction to the container handlers. Source-agnostic — the uPlot
* charts feed it via UPlotLegend; Pie feeds it directly.
* Presentational legend, source-agnostic: the uPlot charts feed it via
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
*/
export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex,
onClick,
onMouseMove,
onMouseLeave,
onAction,
showCopy = true,
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const [filterQuery, setFilterQuery] = useState('');
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const { width: containerWidth } = useResizeObserver(legendContainerRef);
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
const isRightPosition = position === LegendPosition.RIGHT;
const isSingleRow = useMemo(() => {
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
return false;
}
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, items.length, position, containerWidth]);
const visibleLegendItems = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return items;
}
const query = legendSearchQuery.trim().toLowerCase();
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => {
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
return (
<div
key={item.seriesIndex}
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: markerColor }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</TooltipSimple>
{showCopy && (
<CopyButton
value={item.label ?? ''}
size={12}
className="legend-copy-button"
ariaLabel={`Copy ${item.label}`}
testId="legend-copy"
/>
)}
</div>
);
},
[focusedSeriesIndex, position, showCopy],
const { visibleCount, soleShownSeriesIndex } = useMemo(
() => getShownSeriesState(items),
[items],
);
const isEmptyState = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return false;
}
return visibleLegendItems.length === 0;
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
// A bottom legend gets two rows; spending one on chrome costs more chart than
// the readout is worth.
const showToolbar = isRightPosition && items.length > 0;
const showFilter = showToolbar;
const effectiveQuery = showFilter ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
[items, effectiveQuery],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
const isAllShown = visibleCount === items.length;
// A row that unmounts under the pointer never fires its own mouseleave.
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<LegendRow
key={item.seriesIndex}
item={item}
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
isAllShown={isAllShown}
isFocused={focusedSeriesIndex === item.seriesIndex}
showCopy={showCopy}
onAction={onAction}
/>
),
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
className={cx(styles.container, {
[styles.isRight]: isRightPosition,
})}
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
onMouseLeave={handleMouseLeave}
data-testid="legend-container"
>
{searchEnabled && (
<div className="legend-search-container">
<Input
allowClear
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
data-testid="legend-search-input"
className="legend-search-input"
/>
</div>
{showToolbar && (
<LegendToolbar
visibleCount={visibleCount}
totalCount={items.length}
showFilter={showFilter}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>
)}
{isEmptyState ? (
<div className="legend-empty-state">
No series found matching &quot;{legendSearchQuery}&quot;
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
</div>
) : (
<VirtuosoGrid
className={cx(
'legend-virtuoso-container',
`legend-virtuoso-container-${position.toLowerCase()}`,
{ 'legend-virtuoso-container-single-row': isSingleRow },
)}
className={styles.scroller}
listClassName={styles.gridList}
itemClassName={styles.gridItem}
data={visibleLegendItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>

View File

@@ -0,0 +1,170 @@
.row {
// Width of the revealed actions, given up by the label on hover only.
--legend-actions-reserve: 78px;
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
height: 28px;
padding: 0 var(--spacing-3) 0 var(--spacing-4);
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
border-radius: var(--radius);
cursor: pointer;
transition: background 160ms linear;
&:hover,
&:focus-visible {
background: var(--l3-background);
}
}
.isFocused {
background: var(--l3-background);
}
.marker {
// Reads as a checkbox without being one: filled when shown, hollow when
// hidden, deliberately not a check glyph.
flex: 0 0 auto;
box-sizing: border-box;
position: relative;
// Above the actions, so a narrow row's chip never covers the series colour.
z-index: 4;
width: 12px;
height: 12px;
padding: 0;
appearance: none;
border-width: 1.5px;
border-style: solid;
border-radius: var(--radius);
cursor: pointer;
transition:
transform 200ms ease,
box-shadow 200ms ease,
background-color 160ms linear,
opacity 160ms linear;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
}
&:active {
transform: scale(0.9);
}
&:disabled {
cursor: default;
}
&:disabled:hover {
transform: none;
box-shadow: none;
}
}
// Series names run long and have no spaces to break on, so they need both a
// cap and a break rule or the tooltip becomes one panel-wide line.
.rowTooltip {
max-width: 420px;
white-space: normal;
overflow-wrap: anywhere;
}
.label {
flex: 1 1 auto;
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
user-select: none;
}
.isHidden .marker {
opacity: 0.45;
}
.isHidden .label {
color: var(--l3-foreground);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* Row actions */
.actions {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-3);
z-index: 3;
display: flex;
align-items: center;
gap: var(--spacing-2);
padding-left: var(--spacing-5, 10px);
// Sits on the row's hover background and masks the label's tail behind it.
background: var(--l3-background);
box-shadow: -8px 0 8px var(--l3-background);
opacity: 0;
transform: translateX(10px);
pointer-events: none;
transition:
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
}
// :focus-visible, not :focus-within — the latter also matches the click that
// just toggled the series, leaving the actions stuck open.
.row:hover .actions,
.row:focus-visible .actions,
.row:has(:focus-visible) .actions {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
// The cap spares rows sized to their actions, not their label.
.row:hover .label,
.row:focus-visible .label,
.row:has(:focus-visible) .label {
padding-right: min(var(--legend-actions-reserve), 50%);
}
.actionTrigger {
display: inline-flex;
}
.actionButton {
--button-height: 20px;
--button-width: 20px;
--button-padding: 0;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
flex-shrink: 0;
}
.actionButton.scopeButton {
--button-width: auto;
--button-padding: 0 var(--spacing-4);
--button-font-size: var(--font-size-xs);
--button-border-radius: calc(var(--radius) * 4);
--button-base-border-width: 1px;
// --l2-border is the actions bar's own background: one step up reads.
--button-base-border-color: var(--l3-border);
border-style: solid;
}
.actionButton.scopeButton:hover {
border-color: var(--l2-border);
}

View File

@@ -0,0 +1,182 @@
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendAction, OnLegendAction } from '../types';
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
import styles from './LegendRow.module.scss';
export interface LegendRowProps {
item: LegendItem;
/** The only series currently shown, so hiding it is refused. */
isSoleShown: boolean;
/** Nothing is hidden, so the row's action can only narrow the selection. */
isAllShown: boolean;
isFocused: boolean;
showCopy: boolean;
onAction: OnLegendAction;
}
/**
* One legend row. The marker is its own target for excluding a single series —
* the one thing the row click can't do while everything is showing. The actions
* overlay the label's tail rather than taking layout width, and their reveal is
* pure CSS.
*/
function LegendRow({
item,
isSoleShown,
isAllShown,
isFocused,
showCopy,
onAction,
}: LegendRowProps): JSX.Element {
const { seriesIndex, show } = item;
const label = item.label ?? '';
const isShowAllAction = show && !isAllShown;
const scopeActionLabel = isShowAllAction
? 'Show all series'
: 'Show only current series';
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
// a usable CSS colour for the marker.
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
/** Everything showing -> isolate; showing alone -> restore all. */
const handleRowClick = useCallback((): void => {
if (isSoleShown) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
seriesIndex,
});
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
const handleMarkerClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
onAction({ type: LegendAction.TOGGLE, seriesIndex });
},
[onAction, seriesIndex],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
// Let the row actions handle their own keys.
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleRowClick();
}
},
[handleRowClick],
);
const handleScopeClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
if (isShowAllAction) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({ type: LegendAction.SHOW_ONLY, seriesIndex });
},
[isShowAllAction, onAction, seriesIndex],
);
const handleMouseEnter = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex }),
[onAction, seriesIndex],
);
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
return (
<div
className={cx(styles.row, {
[styles.isHidden]: !show,
[styles.isFocused]: isFocused,
})}
data-legend-item-id={seriesIndex}
data-testid={`legend-item-${seriesIndex}`}
role="switch"
tabIndex={0}
aria-checked={show}
aria-label={label}
onClick={handleRowClick}
onKeyDown={handleKeyDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
className={styles.marker}
style={{
borderColor: seriesColor,
backgroundColor: show ? seriesColor : 'transparent',
}}
onClick={handleMarkerClick}
disabled={isSoleShown}
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
data-is-legend-marker={true}
data-testid={`legend-marker-${seriesIndex}`}
/>
<TooltipSimple
title={label}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
<span className={styles.label}>{label}</span>
</TooltipSimple>
<div className={styles.actions}>
<TooltipSimple
title={scopeActionLabel}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
{/* Radix's asChild merge strips the button's own data-testid. */}
<span className={styles.actionTrigger}>
<Button
variant="ghost"
color="secondary"
size="sm"
className={cx(styles.actionButton, styles.scopeButton)}
onClick={handleScopeClick}
aria-label={scopeActionLabel}
testId={`legend-scope-${seriesIndex}`}
>
{isShowAllAction ? 'All' : 'Only'}
</Button>
</span>
</TooltipSimple>
{showCopy && (
<CopyButton
value={label}
size={13}
className={styles.actionButton}
ariaLabel={`Copy ${label}`}
testId={`legend-copy-${seriesIndex}`}
/>
)}
</div>
</div>
);
}
export default memo(LegendRow);

View File

@@ -0,0 +1,35 @@
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-5, 10px);
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
flex-shrink: 0;
> * {
flex: 0 1 auto;
}
}
.status {
// Wraps rather than losing the count at its end.
min-width: 0;
font-family: var(--font-mono);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.searchContainer {
flex-shrink: 0;
width: 100%;
padding-right: var(--spacing-4);
padding-bottom: var(--spacing-5, 10px);
}
.searchInput {
font-size: var(--font-size-xs);
}
.searchIcon {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,56 @@
import { ChangeEvent, useCallback } from 'react';
import { Input } from 'antd';
import { Search } from '@signozhq/icons';
import styles from './LegendToolbar.module.scss';
export interface LegendToolbarProps {
visibleCount: number;
totalCount: number;
/** Search is intrinsic to the right-positioned legend. */
showFilter: boolean;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
}
/** Legend chrome: the series search box and the "Showing N of M" readout. */
export default function LegendToolbar({
visibleCount,
totalCount,
showFilter,
filterQuery,
onFilterQueryChange,
}: LegendToolbarProps): JSX.Element {
const handleFilterChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void =>
onFilterQueryChange(event.target.value),
[onFilterQueryChange],
);
return (
<>
{showFilter && (
<div className={styles.searchContainer}>
<Input
allowClear
prefix={<Search size={12} className={styles.searchIcon} />}
placeholder="Search..."
value={filterQuery}
onChange={handleFilterChange}
className={styles.searchInput}
data-testid="legend-search-input"
/>
</div>
)}
<div className={styles.toolbar}>
<span
className={styles.status}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
</div>
</>
);
}

View File

@@ -8,8 +8,8 @@ import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the toggle/focus interactions from
* the plot context (useLegendActions), then renders the presentational Legend.
* from the chart config (useLegendsSync) and the series interactions from the
* plot context (useLegendActions), then renders the presentational Legend.
* Must be rendered inside a PlotContextProvider.
*/
export default function UPlotLegend({
@@ -17,13 +17,8 @@ export default function UPlotLegend({
config,
averageLegendWidth,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
const onAction = useLegendActions();
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
@@ -33,9 +28,7 @@ export default function UPlotLegend({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
onAction={onAction}
/>
);
}

View File

@@ -0,0 +1,419 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../../hooks/useLegendActions';
import UPlotLegend from '../UPlotLegend';
import { LegendAction, LegendActionPayload, LegendPosition } from '../../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
/** The payloads of one action type, in dispatch order. */
const dispatched = (
onAction: jest.Mock,
type: LegendAction,
): LegendActionPayload[] =>
onAction.mock.calls
.map(([payload]) => payload as LegendActionPayload)
.filter((payload) => payload.type === type);
describe('UPlotLegend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onAction: jest.Mock;
beforeEach(() => {
onAction = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue(onAction);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {
it('renders the search input on a RIGHT legend', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
// The row interactions are the same in both placements.
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
});
it('renders the marker with the series colour, filled only when shown', () => {
renderLegend(LegendPosition.RIGHT);
expect(
document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
),
).toHaveStyle({
'border-color': '#ff0000',
'background-color': '#ff0000',
});
// Hidden series read as an empty checkbox.
expect(
document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
),
).toHaveStyle({ 'background-color': 'transparent' });
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('status readout', () => {
it('reports how many series are showing', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 2 of 3 series',
);
});
});
describe('filter behavior', () => {
it('filters legend items based on the query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'a');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows the empty state when nothing matches', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('ignores a whitespace-only query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('row interactions', () => {
const allShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1], show: true },
2: { ...baseLegendItemsMap[2] },
};
const mockAllShown = (): void => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: allShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
};
it('isolates the series when everything is showing', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
// Nothing the user can see is there to exclude, so the click means Only.
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('toggles the series once something is already hidden', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('excludes just that series when its marker is clicked', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-marker-0'));
// The marker is the one way to exclude a single series while
// everything is showing — the row click isolates instead.
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('stops the marker offering to hide the last series showing', () => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
});
it('labels the marker with what clicking it does', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
'aria-label',
'Hide A',
);
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
'aria-label',
'Show B',
);
});
it('adds the clicked series to the selection while one is alone', async () => {
const user = userEvent.setup();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
// Series 0 is showing alone; clicking another row builds the selection
// up rather than moving the isolation.
await user.click(screen.getByText('B'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('toggles the series on Enter and Space', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
row.focus();
await user.keyboard('{Enter}');
await user.keyboard(' ');
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
});
it('reflects visibility on the row for assistive tech', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
'aria-checked',
'true',
);
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
'aria-checked',
'false',
);
});
it('restores every series from All without also toggling the row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
// Series 0 is shown while B is hidden, so its action is All.
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('isolates the series from Only on a hidden row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-1'));
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('highlights the hovered series and clears it on leave', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
await user.hover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: 0,
});
await user.unhover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: null,
});
});
});
describe('one-series state', () => {
const soleShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
};
beforeEach(() => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: soleShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
});
it('offers All on the shown row and Only on the hidden ones', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
});
it('restores everything from All', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
});
it('restores everything when the row showing alone is clicked again', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,45 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import { filterLegendItems, getShownSeriesState } from '../utils';
const items = (shown: boolean[]): LegendItem[] =>
shown.map((show, index) => ({
seriesIndex: index + 1,
label: `series-${index}`,
color: '#000',
show,
}));
describe('getShownSeriesState', () => {
it('counts the shown series', () => {
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
visibleCount: 2,
soleShownSeriesIndex: null,
});
});
it('names the series when exactly one is shown', () => {
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
visibleCount: 1,
soleShownSeriesIndex: 2,
});
});
it('reports nothing shown', () => {
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
visibleCount: 0,
soleShownSeriesIndex: null,
});
});
});
describe('filterLegendItems', () => {
it('matches case-insensitively on the label', () => {
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
});
it('returns every item for a blank query', () => {
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,20 @@
/** Widest a single legend item is allowed to get when sizing the legend grid. */
export const MAX_LEGEND_WIDTH = 240;
/**
* Enough for a row to contain its own hover actions, which a short label would
* otherwise size a column too narrow for. Little room for the label is intended.
*/
export const MIN_LEGEND_ITEM_WIDTH = 110;
/** Marker + row padding, on top of the estimated label width. */
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
/** Must match `.row`'s height and the grid's row gap, or the reserved
* rectangle clips a row. */
export const LEGEND_ROW_HEIGHT = 28;
export const LEGEND_ROW_GAP = 2;
export const LEGEND_MAX_BOTTOM_ROWS = 2;
/** Hover delay before a row's full-name tooltip opens. */
export const LEGEND_TOOLTIP_DELAY_MS = 500;

View File

@@ -0,0 +1,34 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
export interface ShownSeriesState {
visibleCount: number;
/** The series index when exactly one series is shown, else null. */
soleShownSeriesIndex: number | null;
}
/**
* Driven by what is actually shown, never a remembered isolation: hiding series
* one at a time down to a single one is the same state as "Only".
*/
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
const shown = items.filter((item) => item.show);
return {
visibleCount: shown.length,
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
};
}
export function filterLegendItems(
items: LegendItem[],
query: string,
): LegendItem[] {
const normalisedQuery = query.trim().toLowerCase();
if (!normalisedQuery) {
return items;
}
return items.filter((item) =>
item.label?.toLowerCase().includes(normalisedQuery),
);
}

View File

@@ -12,10 +12,11 @@
}
}
// Matches the legend row's marker.
.uplotTooltipItemMarker {
border-radius: 50%;
border-radius: var(--radius);
border-style: solid;
border-width: 2px;
border-width: 1.5px;
width: 12px;
height: 12px;
box-sizing: border-box;
@@ -30,11 +31,23 @@
justify-content: space-between;
}
// The legend's mono type; the container's Inter stays for the header.
.uplotTooltipItemLabel,
.uplotTooltipItemValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.uplotTooltipItemLabel {
white-space: normal;
overflow-wrap: anywhere;
}
.uplotTooltipItemValue {
white-space: nowrap;
}
.uplotTooltipItemContentSeparator {
flex: 1;
border-width: 0.5px;

View File

@@ -25,7 +25,7 @@ export default function TooltipItem({
>
<div
className={Styles.uplotTooltipItemMarker}
style={{ borderColor: item.color }}
style={{ borderColor: item.color, backgroundColor: item.color }}
data-is-legend-marker={true}
data-testid={markerTestId}
/>
@@ -39,7 +39,7 @@ export default function TooltipItem({
className={Styles.uplotTooltipItemContentSeparator}
style={{ borderColor: item.color }}
/>
<span>{item.tooltipValue}</span>
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
</div>
</div>
);

View File

@@ -1,216 +0,0 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import UPlotLegend from '../Legend/UPlotLegend';
import { LegendPosition } from '../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
describe('UPlotLegend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onLegendClick: jest.Mock;
let onLegendMouseMove: jest.Mock;
let onLegendMouseLeave: jest.Mock;
let onFocusSeries: jest.Mock;
beforeEach(() => {
onLegendClick = jest.fn();
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue({
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onFocusSeries,
});
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {
it('renders search input when legend position is RIGHT', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('does not render search input when legend position is BOTTOM (default)', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
});
it('renders the marker with the correct border color', () => {
renderLegend(LegendPosition.RIGHT);
const legendMarker = document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
) as HTMLElement;
expect(legendMarker).toHaveStyle({
'border-color': '#ff0000',
});
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('search behavior (RIGHT position)', () => {
it('filters legend items based on search query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'A');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows empty state when no legend items match the search query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('legend actions', () => {
it('calls onLegendClick when a legend item is clicked', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(onLegendClick).toHaveBeenCalledTimes(1);
});
it('calls mouseMove when the mouse moves over a legend item', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const legendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
await user.hover(legendItem);
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
});
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const container = document.querySelector('.legend-container') as HTMLElement;
await user.hover(container);
await user.unhover(container);
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,4 +1,4 @@
import { MouseEventHandler, ReactNode } from 'react';
import { ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
@@ -115,26 +115,39 @@ export enum LegendPosition {
export interface LegendConfig {
position: LegendPosition;
}
export enum LegendAction {
TOGGLE = 'toggle',
SHOW_ONLY = 'showOnly',
SHOW_ALL = 'showAll',
HOVER = 'hover',
}
/** Everything the legend can ask of its container, as one dispatch. */
export type LegendActionPayload =
/** Row click / Space / Enter / marker click: hide or show that one series. */
| { type: LegendAction.TOGGLE; seriesIndex: number }
/** Show that series alone. */
| { type: LegendAction.SHOW_ONLY; seriesIndex: number }
/** Leave the narrowed selection and show every series. */
| { type: LegendAction.SHOW_ALL }
/** Row hover, for the chart-side highlight; null on leave. */
| { type: LegendAction.HOVER; seriesIndex: number | null };
export type OnLegendAction = (payload: LegendActionPayload) => void;
/**
* Presentational legend props. Source-agnostic: it renders whatever `items`
* it's given and delegates interaction to the container handlers, so it serves
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
*/
export interface LegendProps {
items: LegendItem[];
/** Legend placement; always supplied by the container. */
position: LegendPosition;
averageLegendWidth?: number;
/** Series index to highlight (hovered/focused). */
/** Series index highlighted by the chart cursor. */
focusedSeriesIndex: number | null;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick: MouseEventHandler<HTMLDivElement>;
onMouseMove: MouseEventHandler<HTMLDivElement>;
onMouseLeave: () => void;
onAction: OnLegendAction;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
}

View File

@@ -6,6 +6,11 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
/** Stroke-width multiplier applied to the series highlighted from the legend. */
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,

View File

@@ -8,6 +8,10 @@ import {
useMemo,
useRef,
} from 'react';
import {
LEGEND_HIGHLIGHT_DIM_ALPHA,
LEGEND_HIGHLIGHT_WIDTH_RATIO,
} from 'lib/uPlotV2/constants';
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type uPlot from 'uplot';
@@ -20,12 +24,26 @@ export interface IPlotContext {
setPlotContextInitialState: (state: PlotContextInitialState) => void;
onToggleSeriesVisibility: (seriesIndex: number) => void;
onToggleSeriesOnOff: (seriesIndex: number) => void;
/** Show this series alone. */
onShowOnlySeries: (seriesIndex: number) => void;
/** Show every series again. */
onShowAllSeries: () => void;
onFocusSeries: (seriesIndex: number | null) => void;
/** Lift one series above the rest (dim + thicken) without changing visibility. */
onHighlightSeries: (seriesIndex: number | null) => void;
syncSeriesVisibilityToLocalStorage: () => void;
}
export const PlotContext = createContext<IPlotContext | null>(null);
/** Data series (index 0 is the x-axis) currently drawn. */
const countShownSeries = (plot: uPlot): number =>
plot.series.reduce(
(count, series, index) =>
index > 0 && series.show !== false ? count + 1 : count,
0,
);
export const PlotContextProvider = ({
children,
}: PropsWithChildren): JSX.Element => {
@@ -33,6 +51,9 @@ export const PlotContextProvider = ({
const activeSeriesIndex = useRef<number | undefined>(undefined);
const idRef = useRef<string | undefined>(undefined);
const shouldSavePreferencesRef = useRef<boolean>(false);
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
const highlightedSeriesIndexRef = useRef<number | null>(null);
const setPlotContextInitialState = useCallback(
({
@@ -43,6 +64,8 @@ export const PlotContextProvider = ({
uPlotInstanceRef.current = uPlotInstance;
idRef.current = id;
activeSeriesIndex.current = undefined;
baseSeriesWidthsRef.current = new Map();
highlightedSeriesIndexRef.current = null;
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
},
[],
@@ -64,6 +87,54 @@ export const PlotContextProvider = ({
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
}, []);
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
highlightedSeriesIndexRef.current = seriesIndex;
plot.series.forEach((series, index) => {
if (index === 0) {
return;
}
if (!baseSeriesWidthsRef.current.has(index)) {
baseSeriesWidthsRef.current.set(index, series.width);
}
const baseWidth = baseSeriesWidthsRef.current.get(index);
const isHighlighted = index === seriesIndex;
/* eslint-disable no-param-reassign */
series.alpha =
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
series.width =
isHighlighted && baseWidth !== undefined
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
: baseWidth;
/* eslint-enable no-param-reassign */
});
// Only the stroke style changed, so the cached paths stay valid.
plot.redraw(false);
}, []);
/**
* Leaving the dim on a hidden series leaves every other one faded, which
* reads as an isolation rather than as one series being excluded.
*/
const clearHighlightIfHidden = useCallback((): void => {
const plot = uPlotInstanceRef.current;
const highlightedIndex = highlightedSeriesIndexRef.current;
if (!plot || highlightedIndex === null) {
return;
}
if (plot.series[highlightedIndex]?.show === false) {
onHighlightSeries(null);
}
}, [onHighlightSeries]);
const onToggleSeriesVisibility = useCallback(
(seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
@@ -103,14 +174,61 @@ export const PlotContextProvider = ({
if (!series) {
return;
}
// An empty chart is never worth reaching.
const isHiding = series.show !== false;
if (isHiding && countShownSeries(plot) <= 1) {
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage],
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
);
/** Applies `resolveShow` to every data series in one batch, then persists. */
const setSeriesVisibility = useCallback(
(resolveShow: (seriesIndex: number) => boolean): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
activeSeriesIndex.current = undefined;
plot.batch(() => {
plot.series.forEach((_, index) => {
if (index === 0) {
return;
}
plot.setSeries(index, { show: resolveShow(index) });
});
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
});
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
);
const onShowOnlySeries = useCallback(
(seriesIndex: number): void => {
setSeriesVisibility((index) => index === seriesIndex);
},
[setSeriesVisibility],
);
const onShowAllSeries = useCallback((): void => {
setSeriesVisibility(() => true);
}, [setSeriesVisibility]);
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
@@ -131,14 +249,20 @@ export const PlotContextProvider = ({
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
}),
[
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
],
);

View File

@@ -26,6 +26,7 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
series,
batch: jest.fn((fn: () => void) => fn()),
setSeries: jest.fn(),
redraw: jest.fn(),
}) as unknown as uPlot;
interface TestComponentProps {
@@ -44,7 +45,10 @@ const TestComponent = ({
syncSeriesVisibilityToLocalStorage,
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
} = usePlotContext();
const handleInit = (): void => {
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
@@ -84,6 +88,13 @@ const TestComponent = ({
>
Toggle on/off 1
</button>
<button
type="button"
data-testid="toggle-on-off-2"
onClick={(): void => onToggleSeriesOnOff(2)}
>
Toggle on/off 2
</button>
<button
type="button"
data-testid="toggle-on-off-5"
@@ -98,6 +109,34 @@ const TestComponent = ({
>
Focus series
</button>
<button
type="button"
data-testid="show-only-1"
onClick={(): void => onShowOnlySeries(1)}
>
Show only 1
</button>
<button
type="button"
data-testid="show-all"
onClick={(): void => onShowAllSeries()}
>
Show all
</button>
<button
type="button"
data-testid="highlight-1"
onClick={(): void => onHighlightSeries(1)}
>
Highlight 1
</button>
<button
type="button"
data-testid="clear-highlight"
onClick={(): void => onHighlightSeries(null)}
>
Clear highlight
</button>
</div>
);
};
@@ -273,6 +312,7 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -324,6 +364,7 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -343,6 +384,48 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('refuses to hide the last series showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// An empty chart is never a state worth reaching.
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('still shows a hidden series when only one is left showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: false },
{ label: 'Memory', show: true },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
});
});
describe('onFocusSeries', () => {
@@ -381,4 +464,193 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
});
});
describe('onShowOnlySeries', () => {
const renderWithSeries = (
series: MockSeries[],
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
const user = userEvent.setup();
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
return { plot, user };
};
it('hides every other series, leaving the x-axis alone', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
});
it('keeps isolating the series that is already the only one shown', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
});
});
describe('onShowAllSeries', () => {
it('shows every hidden series again', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-all'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
});
});
describe('onHighlightSeries', () => {
const series = (): MockSeries[] => [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true, width: 2 },
{ label: 'Memory', show: true, width: 2 },
];
it('dims the other series and thickens the highlighted one', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(3.2);
expect(plot.series[2].alpha).toBe(0.16);
expect(plot.series[2].width).toBe(2);
// Only the stroke changed, so the cached paths are reused.
expect(plot.redraw).toHaveBeenCalledWith(false);
});
it('restores every series when the highlight is cleared', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('clear-highlight'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(2);
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('drops the dim when the highlighted series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// Otherwise every remaining series stays faded and the panel reads as
// an isolation instead of one series being excluded.
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('keeps the dim when a different series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-2'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[2].alpha).toBe(0.16);
});
it('leaves visibility untouched', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,5 @@
import { renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
@@ -11,10 +12,12 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
describe('useLegendActions', () => {
let onToggleSeriesVisibility: jest.Mock;
let onToggleSeriesOnOff: jest.Mock;
let onFocusSeriesPlot: jest.Mock;
let onShowOnlySeries: jest.Mock;
let onShowAllSeries: jest.Mock;
let onFocusSeries: jest.Mock;
let onHighlightSeries: jest.Mock;
let setPlotContextInitialState: jest.Mock;
let syncSeriesVisibilityToLocalStorage: jest.Mock;
let setFocusedSeriesIndexMock: jest.Mock;
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
beforeAll(() => {
@@ -37,15 +40,20 @@ describe('useLegendActions', () => {
beforeEach(() => {
onToggleSeriesVisibility = jest.fn();
onToggleSeriesOnOff = jest.fn();
onFocusSeriesPlot = jest.fn();
onShowOnlySeries = jest.fn();
onShowAllSeries = jest.fn();
onFocusSeries = jest.fn();
onHighlightSeries = jest.fn();
setPlotContextInitialState = jest.fn();
syncSeriesVisibilityToLocalStorage = jest.fn();
setFocusedSeriesIndexMock = jest.fn();
mockUsePlotContext.mockReturnValue({
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onFocusSeries: onFocusSeriesPlot,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
setPlotContextInitialState,
syncSeriesVisibilityToLocalStorage,
});
@@ -53,149 +61,65 @@ describe('useLegendActions', () => {
cancelAnimationFrameSpy.mockClear();
});
const createMouseEvent = (options: {
legendItemId?: number;
isMarker?: boolean;
}): any => {
const { legendItemId, isMarker = false } = options;
describe('visibility actions', () => {
it('toggles a single series on row click', () => {
const { result } = renderHook(() => useLegendActions());
return {
target: {
dataset: {
...(isMarker ? { isLegendMarker: 'true' } : {}),
},
closest: jest.fn(() =>
legendItemId !== undefined
? { dataset: { legendItemId: String(legendItemId) } }
: null,
),
},
};
};
result.current({ type: LegendAction.TOGGLE, seriesIndex: 2 });
describe('onLegendClick', () => {
it('toggles series visibility when clicking on legend label', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
});
it('toggles series on/off when clicking on marker', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(
createMouseEvent({ legendItemId: 0, isMarker: true }),
);
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
// The row must never isolate — that is what "Only" is for.
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
it('does nothing when click target is not inside a legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
it('forwards the Only and All actions to the plot', () => {
const { result } = renderHook(() => useLegendActions());
result.current.onLegendClick(createMouseEvent({}));
result.current({ type: LegendAction.SHOW_ONLY, seriesIndex: 1 });
result.current({ type: LegendAction.SHOW_ALL });
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
expect(onShowAllSeries).toHaveBeenCalled();
});
});
describe('onFocusSeries', () => {
it('schedules focus update and calls plot focus handler via mouse move', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
describe('hover highlight', () => {
it('highlights the hovered series', () => {
const { result } = renderHook(() => useLegendActions());
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
expect(onHighlightSeries).toHaveBeenCalledWith(2);
});
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
it('clears the highlight on leave', () => {
const { result } = renderHook(() => useLegendActions());
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
result.current({ type: LegendAction.HOVER, seriesIndex: null });
expect(onHighlightSeries).toHaveBeenCalledWith(null);
});
it('coalesces rapid hovers into one frame', () => {
const { result } = renderHook(() => useLegendActions());
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
// Each new hover cancels the frame the previous one queued.
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
});
});
describe('onLegendMouseMove', () => {
it('focuses new series when hovering over different legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 0,
}),
);
it('cancels a pending highlight frame on unmount', () => {
jest
.spyOn(global, 'requestAnimationFrame')
.mockImplementation((): number => 7);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
const { result, unmount } = renderHook(() => useLegendActions());
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
unmount();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
});
it('does nothing when hovering over already focused series', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 1,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
});
});
describe('onLegendMouseLeave', () => {
it('cancels pending animation frame and clears focus state', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseLeave();
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
});
});
});

View File

@@ -1,117 +1,66 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
export function useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
}: {
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
focusedSeriesIndex: number | null;
}): {
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onFocusSeries: (seriesIndex: number | null) => void;
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
} {
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from '../components/types';
/**
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
* to one chart redraw per frame.
*/
export function useLegendActions(): OnLegendAction {
const {
onFocusSeries: onFocusSeriesPlot,
onToggleSeriesOnOff,
onToggleSeriesVisibility,
onShowOnlySeries,
onShowAllSeries,
onHighlightSeries,
} = usePlotContext();
const rafId = useRef<number | null>(null); // requestAnimationFrame id
const rafIdRef = useRef<number | null>(null);
const getLegendItemIdFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
const target = e.target as HTMLElement | null;
if (!target) {
return undefined;
}
const legendItemElement = target.closest<HTMLElement>(
'[data-legend-item-id]',
);
return legendItemElement?.dataset.legendItemId;
},
[],
);
const onLegendClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
if (!legendItemId) {
return;
}
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const seriesIndex = Number(legendItemId);
if (isLegendMarker) {
onToggleSeriesOnOff(seriesIndex);
return;
}
onToggleSeriesVisibility(seriesIndex);
},
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
);
const onFocusSeries = useCallback(
(seriesIndex: number | null): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(() => {
setFocusedSeriesIndex(seriesIndex);
onFocusSeriesPlot(seriesIndex);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeriesPlot],
);
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
const seriesIndex = legendItemId ? Number(legendItemId) : null;
if (seriesIndex === focusedSeriesIndex) {
return;
const cancelPendingHighlight = useCallback((): void => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
onFocusSeries(seriesIndex);
};
}, []);
const onLegendMouseLeave = useCallback(
(): void => {
// Cancel any pending RAF from handleFocusSeries to prevent race condition
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
setFocusedSeriesIndex(null);
onFocusSeries(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeries],
);
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
// Cleanup pending animation frames on unmount
useEffect(
() => (): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
return useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
onToggleSeriesOnOff(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
onShowOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
onShowAllSeries();
break;
case LegendAction.HOVER: {
const { seriesIndex } = payload;
cancelPendingHighlight();
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
onHighlightSeries(seriesIndex);
});
break;
}
default:
break;
}
},
[],
[
cancelPendingHighlight,
onHighlightSeries,
onShowAllSeries,
onShowOnlySeries,
onToggleSeriesOnOff,
],
);
return {
onLegendClick,
onFocusSeries,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -45,9 +45,7 @@ export default function Pie({
visibleData,
legendItems,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onLegendAction,
} = usePieInteractions(data, id);
const {
@@ -227,9 +225,7 @@ export default function Pie({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
onAction={onLegendAction}
/>
</div>
</div>

View File

@@ -100,17 +100,29 @@ describe('Pie', () => {
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
});
it('hides a slice when its legend marker is clicked', () => {
it('isolates a slice when its legend row is clicked with everything showing', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(3);
const marker = document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
) as HTMLElement;
fireEvent.click(marker);
fireEvent.click(screen.getByTestId('legend-item-1'));
// Nothing visible to exclude, so the click isolates: one arc left.
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('excludes a slice when its legend row is clicked with others already hidden', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
// Isolate, then add a second slice back, so nothing is isolated any more.
fireEvent.click(screen.getByTestId('legend-item-1'));
fireEvent.click(screen.getByTestId('legend-item-0'));
expect(svg.querySelectorAll('path')).toHaveLength(2);
fireEvent.click(screen.getByTestId('legend-item-0'));
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(2);
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
});

View File

@@ -1,6 +1,9 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import {
calculateAverageLegendWidth,
calculateChartDimensions,
} from 'lib/visualization/charts/utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
@@ -49,63 +52,104 @@ describe('calculateChartDimensions', () => {
expect(dims.width).toBe(784);
});
it('RIGHT: never shrinks the column below the 150px floor', () => {
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(3, 3),
});
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(850);
expect(dims.legendWidth).toBe(190);
expect(dims.width).toBe(810);
});
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
const dims = calculateChartDimensions({
containerWidth: 300,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(120);
expect(dims.width).toBe(180);
// 40% is 120px, too narrow for the column's own toolbar.
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(150);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
const dims = calculateChartDimensions({
containerWidth: 200,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(100);
expect(dims.width).toBe(100);
});
it('BOTTOM: items that fit one row reserve exactly one row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One row = line height (28) + padding (12).
// One 28px row + the wrapper's 12px bottom padding.
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: many items cap at two rows on a tall container', () => {
it('BOTTOM: more items than one row reserve exactly two rows', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
// clipped third row, and none left over.
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(430);
});
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
it('BOTTOM: items one past a row still reserve two rows', () => {
// 1000px wide fits 5 of these per row, so 6 items need a second row.
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 160,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(6),
});
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 120,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
// A whole row goes rather than a clipped one being reserved.
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(80);
});
});
describe('calculateAverageLegendWidth', () => {
it('scales with the label length', () => {
// 16px of chrome + 30 chars at 8px.
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
});
it('never drops below what a row needs to contain its hover actions', () => {
// Short or unnamed series would otherwise size a column the actions
// escape, spilling over the item beside it.
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
expect(calculateAverageLegendWidth([''])).toBe(110);
});
it('keeps the default estimate when there are no labels to measure', () => {
expect(calculateAverageLegendWidth([])).toBe(120);
});
});

View File

@@ -1,4 +1,10 @@
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
MAX_LEGEND_WIDTH,
} from 'lib/uPlotV2/components/Legend/constants';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
export interface ChartDimensions {
width: number;
@@ -13,22 +19,31 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 28;
// Two rows are worth having, but not at the cost of half the panel.
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
const MAX_RIGHT_LEGEND_WIDTH = 320;
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
// Column padding + copy button, not covered by the text-length estimate.
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
const MIN_RIGHT_LEGEND_WIDTH = 190;
// Past this the split inverts and the chart becomes the smaller half.
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
/**
* Calculates the average width of the legend items based on the labels of the series.
* Never returns less than a legend row needs to hold its own hover actions.
* @param legends - The labels of the series.
* @returns The average width of the legend items.
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
);
}
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
@@ -36,7 +51,10 @@ export function calculateAverageLegendWidth(legends: string[]): number {
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
const percentileLength = lengths[Math.max(0, index)];
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
);
}
/**
@@ -52,7 +70,9 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
* the rectangle never clips a row or reserves space for half of one. Two
* rows that would take half a short panel fall back to one row.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
@@ -100,9 +120,14 @@ export function calculateChartDimensions({
MAX_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
);
// The column's chrome outranks the 40% share on a narrow panel.
const floorWidth = Math.min(
MIN_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
);
const rightLegendWidth = Math.min(
Math.max(150, desiredLegendWidth),
maxRightLegendWidth,
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
Math.max(floorWidth, maxRightLegendWidth),
);
return {
@@ -115,8 +140,6 @@ export function calculateChartDimensions({
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
@@ -125,30 +148,30 @@ export function calculateChartDimensions({
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
);
const legendRowCount = Math.min(
2,
Math.ceil(legendItemCount / legendItemsPerRow),
// The wrapper's bottom padding is inside this height (border-box).
const heightForRows = (rowCount: number): number =>
rowCount * LEGEND_ROW_HEIGHT +
(rowCount - 1) * LEGEND_ROW_GAP +
LEGEND_PADDING;
const neededRowCount = Math.max(
1,
Math.min(
LEGEND_MAX_BOTTOM_ROWS,
Math.ceil(legendItemCount / legendItemsPerRow),
),
);
const idealBottomLegendHeight =
legendRowCount > 1
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
// Without this, short grid panels hand most of their area to the legend and
// the chart — the pie donut especially — collapses to a sliver. Dropping a
// whole row beats clipping one.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
? 1
: neededRowCount;
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,
maxAllowedLegendHeight,
);
const bottomLegendHeight = heightForRows(legendRowCount);
return {
width: containerWidth,

View File

@@ -1,9 +1,9 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from 'lib/visualization/charts/types';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
@@ -24,22 +24,6 @@ const DATA: PieSlice[] = [
{ label: 'checkout', value: 40, color: '#c' },
];
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
function legendEvent(
index: number | null,
isMarker = false,
): MouseEvent<HTMLDivElement> {
const itemEl =
index == null ? null : { dataset: { legendItemId: String(index) } };
return {
target: {
closest: (): unknown => itemEl,
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
},
} as unknown as MouseEvent<HTMLDivElement>;
}
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
@@ -59,11 +43,16 @@ describe('usePieInteractions', () => {
expect(result.current.active).toBeNull();
});
describe('marker click (toggle one)', () => {
describe('row toggle', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() => result.current.onLegendClick(legendEvent(1, true)));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
@@ -73,18 +62,50 @@ describe('usePieInteractions', () => {
{ label: 'checkout', show: true },
]);
act(() => result.current.onLegendClick(legendEvent(1, true)));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('label click (isolate / reset)', () => {
it('isolates the clicked slice, then resets on a second click', () => {
describe('the last slice showing', () => {
it('cannot be hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, false)));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
// An empty donut is never a state worth reaching.
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
});
});
describe('Only', () => {
it('isolates the slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
@@ -92,8 +113,39 @@ describe('usePieInteractions', () => {
false,
false,
]);
});
act(() => result.current.onLegendClick(legendEvent(0, false)));
it('switches the isolation to another slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
});
});
describe('All', () => {
it('brings every hidden slice back', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
expect(result.current.visibleData).toStrictEqual(DATA);
});
@@ -103,11 +155,37 @@ describe('usePieInteractions', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendMouseMove(legendEvent(2)));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
);
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() => result.current.onLegendMouseLeave());
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused slice is hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
// Otherwise every remaining arc stays dimmed and the donut reads as an
// isolation instead of one slice being excluded.
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
@@ -115,8 +193,15 @@ describe('usePieInteractions', () => {
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
act(() => result.current.onLegendMouseMove(legendEvent(1)));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
expect(result.current.active).toBeNull();
});
@@ -125,7 +210,12 @@ describe('usePieInteractions', () => {
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, true)));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
expect(mockUpdateStored).not.toHaveBeenCalled();
});

View File

@@ -1,6 +1,11 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from 'lib/uPlotV2/components/types';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getStoredSeriesVisibility,
@@ -18,27 +23,15 @@ export interface UsePieInteractionsResult {
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
// event target (the shared Legend tags each item with its seriesIndex).
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = el?.dataset.legendItemId;
return id != null ? Number(id) : null;
/** Every legend interaction, dispatched by type. */
onLegendAction: OnLegendAction;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
* uPlot legend — marker toggles one, label isolates), and persistence of the
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
* reloads. Returns the visible slices, legend items, focus index, and the
* legend container handlers.
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
* shared legend's actions, and persistence of the hidden set to localStorage
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
* slices, legend items, focus index, and the legend action dispatch.
*/
export function usePieInteractions(
data: PieSlice[],
@@ -48,7 +41,6 @@ export function usePieInteractions(
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const isolatedIndexRef = useRef<number | null>(null);
const legendItems = useMemo<LegendItem[]>(
() =>
@@ -104,65 +96,88 @@ export function usePieInteractions(
[id, data],
);
const onLegendMouseMove = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
const hoverSeries = useCallback(
(sliceIndex: number | null): void => {
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
setActive(
sliceIndex != null && !hiddenIndices.has(sliceIndex)
? data[sliceIndex]
: null,
);
},
[data, hiddenIndices],
);
// Marker click toggles just that slice on/off; label click isolates it
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
const onLegendClick = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
if (index == null) {
return;
}
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
if (isMarker) {
const next = new Set(hiddenIndices);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
const toggleSeries = useCallback(
(sliceIndex: number): void => {
const next = new Set(hiddenIndices);
if (next.has(sliceIndex)) {
next.delete(sliceIndex);
} else {
// An empty donut is never worth reaching.
if (data.length - next.size <= 1) {
return;
}
applyHidden(next);
return;
next.add(sliceIndex);
}
applyHidden(next);
},
[data.length, hiddenIndices, applyHidden],
);
const isReset = isolatedIndexRef.current === index;
isolatedIndexRef.current = isReset ? null : index;
if (isReset) {
applyHidden(new Set());
return;
}
const showOnlySeries = useCallback(
(sliceIndex: number): void => {
const next = new Set<number>();
data.forEach((_, i) => {
if (i !== index) {
next.add(i);
data.forEach((_, index) => {
if (index !== sliceIndex) {
next.add(index);
}
});
applyHidden(next);
},
[data, hiddenIndices, applyHidden],
[data, applyHidden],
);
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
const showAllSeries = useCallback(
(): void => applyHidden(new Set()),
[applyHidden],
);
const focusedIndex = active ? data.indexOf(active) : -1;
const onLegendAction = useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
toggleSeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
showOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
showAllSeries();
break;
case LegendAction.HOVER:
hoverSeries(payload.seriesIndex);
break;
default:
break;
}
},
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
);
const activeIndex = active ? data.indexOf(active) : -1;
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
// isolation rather than as one slice being excluded.
const effectiveActive =
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
const focusedIndex = effectiveActive ? activeIndex : -1;
return {
active,
active: effectiveActive,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onLegendAction,
};
}

View File

@@ -29,7 +29,6 @@
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
padding: 0 12px 12px 12px;
}
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';

View File

@@ -10,7 +10,7 @@ function Shortcuts(): JSX.Element {
const tableData = generateTableData(shortcutSection);
return (
<section className="shortcut-section">
<section key={shortcutSection} className="shortcut-section">
<Typography.Text className="shortcut-section-heading">
{shortcutSection}
</Typography.Text>

View File

@@ -45,4 +45,12 @@ export const appShellHandlers = [
rest.get('https://cms.signoz.cloud/api/release-changelogs', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(changelogResponse)),
),
// The webfonts `index.html` links and `styles.scss` imports. The story
// declares the same families over `public/fonts` in
// `.storybook/public/storybook-fonts.css`, so answering the CDN with nothing
// keeps a request from leaving the browser on every story.
rest.get('https://fonts.googleapis.com/css2', (_req, res, ctx) =>
res(ctx.status(200), ctx.text('')),
),
];

View File

@@ -55,9 +55,6 @@ func (bc *bucketCache) GetMissRanges(
// Get query window
startMs, endMs := q.Window()
stepMs := uint64(step.Milliseconds())
startOffsetMs := calculateStartOffset(q, startMs, stepMs)
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
// Generate cache key
@@ -77,8 +74,11 @@ func (bc *bucketCache) GetMissRanges(
return nil, missing
}
// Extract step interval if this is a builder query
stepMs := uint64(step.Milliseconds())
// Find missing ranges with step alignment
missing = bc.findMissingRangesWithStep(data.Buckets, startMs, endMs, stepMs, startOffsetMs)
missing = bc.findMissingRangesWithStep(data.Buckets, startMs, endMs, stepMs)
bc.logger.DebugContext(ctx, "missing ranges", slog.Any("missing", missing), slog.Uint64("step", stepMs))
// If no cached data overlaps with requested range, return empty result
@@ -95,8 +95,8 @@ func (bc *bucketCache) GetMissRanges(
// Merge buckets into a single result
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, data.Warnings)
_, isPromQL := q.(*promqlQuery)
mergedResult = bc.filterResultToTimeRange(mergedResult, startMs, endMs, stepMs, isPromQL)
// Filter the merged result to only include values within the requested time range
mergedResult = bc.filterResultToTimeRange(mergedResult, startMs, endMs)
return mergedResult, missing
}
@@ -106,9 +106,6 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
// Get query window
startMs, endMs := q.Window()
stepMs := uint64(step.Milliseconds())
startOffsetMs := calculateStartOffset(q, startMs, stepMs)
// Calculate the flux boundary - data after this point should not be cached
currentMs := uint64(time.Now().UnixMilli())
fluxBoundary := currentMs - uint64(bc.fluxInterval.Milliseconds())
@@ -149,14 +146,19 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
// Adjust start and end times to only cache complete intervals
cachableStartMs := startMs
stepMs := uint64(step.Milliseconds())
// If we have a step interval, adjust boundaries to only cache complete intervals
if stepMs > 0 {
// If start is not aligned, round up to next step boundary (first complete interval)
cachableStartMs = alignUpToStep(startMs, stepMs, startOffsetMs)
if startMs%stepMs != 0 {
cachableStartMs = ((startMs / stepMs) + 1) * stepMs
}
// If end is not aligned, round down to previous step boundary (last complete interval)
cachableEndMs = alignDownToStep(cachableEndMs, stepMs, startOffsetMs)
if cachableEndMs%stepMs != 0 {
cachableEndMs = (cachableEndMs / stepMs) * stepMs
}
// If after adjustment we have no complete intervals, don't cache
if cachableStartMs >= cachableEndMs {
@@ -204,9 +206,8 @@ func (bc *bucketCache) generateCacheKey(q qbtypes.Query) string {
return fmt.Sprintf("v5:query:%s", fingerprint)
}
// findMissingRangesWithStep identifies time ranges not covered by cached buckets
// with step alignment. Boundaries are whole steps from startOffsetMs.
func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket, startMs, endMs uint64, stepMs uint64, startOffsetMs uint64) []*qbtypes.TimeRange {
// findMissingRangesWithStep identifies time ranges not covered by cached buckets with step alignment.
func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket, startMs, endMs uint64, stepMs uint64) []*qbtypes.TimeRange {
// When step is 0 or window is too small to be cached, use simple algorithm
if stepMs == 0 || (startMs+stepMs) > endMs {
return bc.findMissingRangesBasic(buckets, startMs, endMs)
@@ -219,7 +220,8 @@ func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket
currentMs := startMs
// Check if start is not aligned - add partial window
if nextAggStart := alignUpToStep(startMs, stepMs, startOffsetMs); nextAggStart != startMs {
if startMs%stepMs != 0 {
nextAggStart := startMs - (startMs % stepMs) + stepMs
missing = append(missing, &qbtypes.TimeRange{
From: startMs,
To: min(nextAggStart, endMs),
@@ -265,7 +267,8 @@ func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket
currentMs := startMs
// Check if start is not aligned - add partial window
if nextAggStart := alignUpToStep(startMs, stepMs, startOffsetMs); nextAggStart != startMs {
if startMs%stepMs != 0 {
nextAggStart := startMs - (startMs % stepMs) + stepMs
missing = append(missing, &qbtypes.TimeRange{
From: startMs,
To: min(nextAggStart, endMs),
@@ -284,7 +287,11 @@ func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket
}
// Align bucket boundaries to step intervals
alignedBucketStart := alignUpToStep(bucket.StartMs, stepMs, startOffsetMs)
alignedBucketStart := bucket.StartMs
if bucket.StartMs%stepMs != 0 {
// Round up to next step boundary
alignedBucketStart = bucket.StartMs - (bucket.StartMs % stepMs) + stepMs
}
// Add gap before this bucket if needed
if currentMs < alignedBucketStart && currentMs < endMs {
@@ -297,12 +304,9 @@ func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket
// Update current position to the end of this bucket
// But ensure it's aligned to step boundary
bucketEnd := min(bucket.EndMs, endMs)
// The step the window ends inside reaches past it, so that stretch is
// missing however far the bucket runs.
bucketEnd = min(bucketEnd, alignDownToStep(endMs, stepMs, startOffsetMs))
if bucketEnd < endMs {
if bucketEnd%stepMs != 0 && bucketEnd < endMs {
// Round down to step boundary
bucketEnd = alignDownToStep(bucketEnd, stepMs, startOffsetMs)
bucketEnd = bucketEnd - (bucketEnd % stepMs)
}
currentMs = max(currentMs, bucketEnd)
}
@@ -319,42 +323,6 @@ func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket
return missing
}
// calculateStartOffset returns how far into a step a query's values sit. Only
// promql reports at the window start and every step after it; the rest report
// on absolute step boundaries.
func calculateStartOffset(q qbtypes.Query, startMs, stepMs uint64) uint64 {
if _, isPromQL := q.(*promqlQuery); !isPromQL || stepMs == 0 {
return 0
}
return startMs % stepMs
}
// With a 5m step and no offset the times seen by a query are 10:00, 10:05, 10:10. So 10:07
// is at an offset of 2m, and 10:05 is at 0.
//
// With a 1m step and a 30s offset the times seen are 10:00:30, 10:01:30, 10:02:30. So 10:01:00
// is at an offset of 30s.
func calculateOffsetIntoStep(timestampMs, stepMs, startOffsetMs uint64) uint64 {
if stepMs == 0 {
return 0
}
return ((timestampMs % stepMs) + stepMs - startOffsetMs%stepMs) % stepMs
}
// alignUpToStep returns the first time seen by a query at or after timestampMs.
func alignUpToStep(timestampMs, stepMs, startOffsetMs uint64) uint64 {
offset := calculateOffsetIntoStep(timestampMs, stepMs, startOffsetMs)
if offset == 0 {
return timestampMs
}
return timestampMs - offset + stepMs
}
// alignDownToStep returns the last time seen by a query at or before timestampMs.
func alignDownToStep(timestampMs, stepMs, startOffsetMs uint64) uint64 {
return timestampMs - calculateOffsetIntoStep(timestampMs, stepMs, startOffsetMs)
}
// findMissingRangesBasic is the simple algorithm without step alignment.
func (bc *bucketCache) findMissingRangesBasic(buckets []*qbtypes.CachedBucket, startMs, endMs uint64) []*qbtypes.TimeRange {
// Check if already sorted before sorting
@@ -824,23 +792,11 @@ func max(a, b uint64) uint64 {
}
// filterResultToTimeRange filters the result to only include values within the requested time range.
func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs, endMs, stepMs uint64, isPromQL bool) *qbtypes.Result {
func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs, endMs uint64) *qbtypes.Result {
if result == nil || result.Value == nil {
return result
}
maxTimestampMs := endMs
// A promql value at T is the query evaluated at T, so T == endMs is inside the
// requested range. For every other query type the value at T aggregates
// [T, T+stepMs), which the requested range contains only when T <= endMs-stepMs.
if !isPromQL {
if stepMs > 0 {
maxTimestampMs = endMs - stepMs
} else {
maxTimestampMs = endMs - 1
}
}
switch result.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
@@ -865,7 +821,7 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
// Filter values to only include those within the requested time range
for _, value := range series.Values {
timestampMs := uint64(value.Timestamp)
if timestampMs >= startMs && timestampMs <= maxTimestampMs {
if timestampMs >= startMs && timestampMs < endMs {
filteredSeries.Values = append(filteredSeries.Values, value)
}
}

View File

@@ -201,7 +201,7 @@ func BenchmarkBucketCache_FindMissingRangesWithStep(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
missing := bc.findMissingRangesWithStep(buckets, startMs, endMs, stepMs, 0)
missing := bc.findMissingRangesWithStep(buckets, startMs, endMs, stepMs)
_ = missing
}
})
@@ -327,7 +327,7 @@ func BenchmarkBucketCache_FilterResultToTimeRange(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
filtered := bc.filterResultToTimeRange(result, startMs, endMs, 0, true)
filtered := bc.filterResultToTimeRange(result, startMs, endMs)
_ = filtered
}
})

View File

@@ -3,7 +3,6 @@ package querier
import (
"context"
"fmt"
"log/slog"
"testing"
"time"
@@ -530,7 +529,7 @@ func TestBucketCache_FindMissingRanges_EdgeCases(t *testing.T) {
}
// Query range that spans all buckets
missing := bc.findMissingRangesWithStep(buckets, 500, 6500, 500, 0)
missing := bc.findMissingRangesWithStep(buckets, 500, 6500, 500)
// Expected missing ranges: 500-1000, 2000-2500, 4000-5000, 6000-6500
assert.Len(t, missing, 4)
@@ -1070,11 +1069,8 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
// Get cached data - should be filtered to requested range
cached, missing := bc.GetMissRanges(ctx, orgID, query2, qbtypes.Step{Duration: 1000 * time.Millisecond})
// The value at 3000 stands for the whole step to 4000, which reaches past the
// window, so it is left to be recomputed as a partial rather than served.
require.Len(t, missing, 1)
assert.Equal(t, uint64(3000), missing[0].From)
assert.Equal(t, uint64(3500), missing[0].To)
// Should have no missing ranges
assert.Len(t, missing, 0)
assert.NotNil(t, cached)
// Verify the cached result only contains values within the requested range
@@ -1084,77 +1080,29 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
require.Len(t, tsData.Aggregations[0].Series, 1)
series := tsData.Aggregations[0].Series[0]
require.Len(t, series.Values, 1)
assert.Len(t, series.Values, 2) // Only values at 2000 and 3000 should be included
// Verify the exact values
assert.Equal(t, int64(2000), series.Values[0].Timestamp)
assert.Equal(t, float64(20), series.Values[0].Value)
assert.Equal(t, int64(3000), series.Values[1].Timestamp)
assert.Equal(t, float64(30), series.Values[1].Value)
// Value at 1000 should not be included (before requested range)
// Value at 4000 should not be included (after requested range)
}
// A promql value is the query evaluated at a single moment rather than over a
// span, so the one at the window's end belongs to it and has to survive caching.
func TestBucketCache_PromQLKeepsTheValueAtTheWindowEnd(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Minute}
query := &promqlQuery{
logger: slog.Default(),
query: qbtypes.PromQuery{Query: "up", Step: step},
tr: qbtypes.TimeRange{From: 600_000, To: 780_000},
requestType: qbtypes.RequestTypeTimeSeries,
}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: &qbtypes.TimeSeriesData{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
Series: []*qbtypes.TimeSeries{{
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 600_000, Value: 1},
{Timestamp: 660_000, Value: 2},
{Timestamp: 720_000, Value: 3},
{Timestamp: 780_000, Value: 4},
},
}},
}},
},
})
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Empty(t, missing)
require.NotNil(t, cached)
tsData, ok := cached.Value.(*qbtypes.TimeSeriesData)
require.True(t, ok)
require.Len(t, tsData.Aggregations, 1)
require.Len(t, tsData.Aggregations[0].Series, 1)
timestamps := []int64{}
for _, value := range tsData.Aggregations[0].Series[0].Values {
timestamps = append(timestamps, value.Timestamp)
}
assert.Equal(t, []int64{600_000, 660_000, 720_000, 780_000}, timestamps)
}
func TestBucketCache_FindMissingRangesWithStep(t *testing.T) {
bc := createTestBucketCache(t)
tests := []struct {
name string
buckets []*qbtypes.CachedBucket
startMs uint64
endMs uint64
stepMs uint64
startOffsetMs uint64
expectedMiss []*qbtypes.TimeRange
description string
name string
buckets []*qbtypes.CachedBucket
startMs uint64
endMs uint64
stepMs uint64
expectedMiss []*qbtypes.TimeRange
description string
}{
{
name: "start_not_aligned_to_step",
@@ -1204,32 +1152,6 @@ func TestBucketCache_FindMissingRangesWithStep(t *testing.T) {
},
description: "Window smaller than step should use basic algorithm",
},
{
name: "start_aligned_to_its_own_offset",
buckets: []*qbtypes.CachedBucket{},
startMs: 1500,
endMs: 5000,
stepMs: 1000,
startOffsetMs: 500,
expectedMiss: []*qbtypes.TimeRange{
{From: 1500, To: 5000},
},
description: "A query reporting every 1000ms from 1500 needs no partial window at its own start",
},
{
name: "gap_lands_on_the_offset",
buckets: []*qbtypes.CachedBucket{
{StartMs: 1500, EndMs: 3500},
},
startMs: 1500,
endMs: 5500,
stepMs: 1000,
startOffsetMs: 500,
expectedMiss: []*qbtypes.TimeRange{
{From: 3500, To: 5500},
},
description: "The refetched range starts where the cached one ends, on an instant the query reports at",
},
{
name: "zero_step_uses_basic_algorithm",
buckets: []*qbtypes.CachedBucket{},
@@ -1246,7 +1168,7 @@ func TestBucketCache_FindMissingRangesWithStep(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mock current time for flux boundary tests
result := bc.findMissingRangesWithStep(tt.buckets, tt.startMs, tt.endMs, tt.stepMs, tt.startOffsetMs)
result := bc.findMissingRangesWithStep(tt.buckets, tt.startMs, tt.endMs, tt.stepMs)
// Compare lengths first
assert.Len(t, result, len(tt.expectedMiss), tt.description)

View File

@@ -175,12 +175,6 @@ func (q *promqlQuery) Fingerprint() string {
q.query.Step.String(),
}
// Two windows a fraction of a step apart describe different instants, so
// they must not share an entry.
if stepMs := uint64(q.query.Step.Milliseconds()); stepMs > 0 && q.tr.From%stepMs != 0 {
parts = append(parts, fmt.Sprintf("offset=%d", q.tr.From%stepMs))
}
return strings.Join(parts, "&")
}

View File

@@ -461,37 +461,6 @@ func TestFingerprint_PinnedProviderBypassesCache(t *testing.T) {
assert.Empty(t, q.Fingerprint())
}
// promql reports at the window start and every step after it, so a window
// starting later inside the step describes instants the earlier one never does.
func TestFingerprintSeparatesWindowsInsideAStep(t *testing.T) {
minuteStep := qbv5.Step{Duration: time.Minute}
onTheMinute := (&promqlQuery{
logger: slog.Default(),
query: qbv5.PromQuery{Query: "up", Step: minuteStep},
tr: qbv5.TimeRange{From: 600_000, To: 1_200_000},
requestType: qbv5.RequestTypeTimeSeries,
}).Fingerprint()
halfAStepLater := (&promqlQuery{
logger: slog.Default(),
query: qbv5.PromQuery{Query: "up", Step: minuteStep},
tr: qbv5.TimeRange{From: 630_000, To: 1_230_000},
requestType: qbv5.RequestTypeTimeSeries,
}).Fingerprint()
aWholeMinuteLater := (&promqlQuery{
logger: slog.Default(),
query: qbv5.PromQuery{Query: "up", Step: minuteStep},
tr: qbv5.TimeRange{From: 900_000, To: 1_500_000},
requestType: qbv5.RequestTypeTimeSeries,
}).Fingerprint()
require.NotEmpty(t, onTheMinute)
assert.NotEqual(t, onTheMinute, halfAStepLater, "windows half a step apart share no instants")
assert.Equal(t, onTheMinute, aWholeMinuteLater, "windows whole steps apart report at the same instants")
}
func TestToResultDropsNonFiniteValues(t *testing.T) {
tests := []struct {
description string

View File

@@ -58,7 +58,8 @@ def test_promql_ratio_with_zero_denominator_is_dropped_and_cached(
assert set(first["active_job"].values()) == {25.0}, sorted(set(first["active_job"].values()))
assert len(first["active_job"]) == expected_points, f"expected {expected_points} points, got {len(first['active_job'])}"
# Both reads must agree exactly, including the point promql reports at end_ms.
# The cached read excludes end_ms, the one legitimate difference.
assert set(second) == set(first), sorted(second)
for job_name, points in first.items():
assert second[job_name] == points, f"{job_name}: got {len(second[job_name])} of {len(points)} points"
expected = {ts: value for ts, value in points.items() if ts < end_ms}
assert second[job_name] == expected, f"{job_name}: got {len(second[job_name])} of {len(expected)} points"

View File

@@ -1,325 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from uuid import uuid4
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
assert_results_equal,
build_builder_query,
get_series_values,
make_query_request,
)
MINUTE_MS = 60_000
def test_builder_shortening_the_time_range_at_the_end(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
# the cache outlives the run, so a fixed name would serve the previous run's
# points back to this one
metric_name = f"cache_end_shortened_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a multiple of the 5m step makes the base query span
# two whole steps, so both its points are complete
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 300 * 300, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms_base_query = start_time_ms + 10 * MINUTE_MS
end_time_ms_shortened_query = start_time_ms + 7 * MINUTE_MS
query = [build_builder_query("A", metric_name, "max", "max", step_interval=300)]
# the 5m step splits the ten minutes into two points, each the max over its
# own step: minutes 0-4 and minutes 5-9. The second changes partway through,
# 256 until minute 7 and then 4096, so ending the range at minute 7 has to
# reach a different value than ending it at minute 10
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(16, 16, 16, 16, 16, 256, 256, 4096, 4096, 4096)[minute],
type_="Gauge",
is_monotonic=False,
)
for minute in range(10)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms, end_time_ms_base_query, query, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
points = sorted(get_series_values(base_query.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(16, False), (4096, False)]
from_cache = make_query_request(signoz, token, start_time_ms, end_time_ms_shortened_query, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms, end_time_ms_shortened_query, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "shortened end")
# the shortened end reaches only minutes 5-6 of the second point, so it comes
# back as 256 and partial, where the cached one spans all five minutes
for label, response in (("from cache", from_cache), ("uncached", uncached)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(16, False), (256, True)], label
def test_builder_shortening_the_time_range_at_the_start(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_start_shortened_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a multiple of the 5m step makes the base query span
# two whole steps, so both its points are complete
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 300 * 300, tz=UTC)
start_time_ms_base_query = int(start_time.timestamp() * 1000)
start_time_ms_shortened_query = start_time_ms_base_query + 3 * MINUTE_MS
end_time_ms = start_time_ms_base_query + 10 * MINUTE_MS
query = [build_builder_query("A", metric_name, "max", "max", step_interval=300)]
# the 5m step splits the ten minutes into two points, each the max over its
# own step: minutes 0-4 and minutes 5-9. Only minute 0 holds 65536, so a first
# point reaching it says the whole step was read even though the shortened
# range opens at minute 3
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(65536, 16, 16, 16, 16, 4096, 4096, 4096, 4096, 4096)[minute],
type_="Gauge",
is_monotonic=False,
)
for minute in range(10)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms_base_query, end_time_ms, query, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
points = sorted(get_series_values(base_query.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(65536, False), (4096, False)]
from_cache = make_query_request(signoz, token, start_time_ms_shortened_query, end_time_ms, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms_shortened_query, end_time_ms, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "shortened start")
# starting inside the first point's step flags that point partial without
# clipping its value, which still covers the whole step and so reaches the
# 65536 at minute 0
for label, response in (("from cache", from_cache), ("uncached", uncached)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(65536, True), (4096, False)], label
def test_promql_running_the_same_query_twice(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_repeat_total_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms = start_time_ms + 2 * MINUTE_MS
query = [{"type": "promql", "spec": {"name": "A", "query": f"sum(increase({metric_name}[2m]))", "step": 60}}]
# the counter opens a minute before the query so its first point has something
# to increase over, and starts far above its own rise across the range, below
# which increase clips its back-extrapolation at the counter's zero point. It
# rises by a different amount each minute, so every point is its own number
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(1000, 1010, 1030, 1060, 1100)[minute + 1],
temporality="Cumulative",
type_="Sum",
is_monotonic=True,
)
for minute in range(-1, 4)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
first = make_query_request(signoz, token, start_time_ms, end_time_ms, query, no_cache=False)
assert first.status_code == HTTPStatus.OK, first.text
second = make_query_request(signoz, token, start_time_ms, end_time_ms, query, no_cache=False)
assert second.status_code == HTTPStatus.OK, second.text
assert_results_equal(first.json(), second.json(), "A", "the same query twice")
# promql reports a point at the instant the range closes, and the second run,
# answered out of what the first one cached, has to keep it
for run, response in (("first", first), ("second", second)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["timestamp"], point["value"]) for point in points]
## at each timestamp t, promql looks at points in (t-2minutes, t].
assert returned_points == [
(start_time_ms, 20), # t = 0, points taken 1000, 1010. hence diff over 1m is 10, extrapolated to 20.
(start_time_ms + MINUTE_MS, 40), # t = 1m, points taken 1010, 1030. hence diff over 1m is 20, extrapolated to 40.
(end_time_ms, 60), # t = 2m, points taken 1030, 1060. hence diff over 1m is 30, extrapolated to 60.
], f"{run} run"
def test_promql_shifting_the_time_range(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_shift_gauge_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a whole minute is what makes the first query aligned
# to its 1m step, and the unaligned one half a step off it
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
aligned_start_time_ms = int(start_time.timestamp() * 1000)
aligned_end_time_ms = aligned_start_time_ms + 3 * MINUTE_MS
unaligned_start_time_ms = aligned_start_time_ms + MINUTE_MS // 2
unaligned_end_time_ms = aligned_end_time_ms + MINUTE_MS // 2
query = [{"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric_name}[2m])", "step": 60}}]
# a sample every 30s, rising by 100 each time. The two queries report 30s
# apart, so they land on different samples and share no value between them
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(seconds=30 * half_minute),
value=100 * (half_minute + 4),
type_="Gauge",
is_monotonic=False,
)
for half_minute in range(-3, 8)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
aligned_and_cached = make_query_request(signoz, token, aligned_start_time_ms, aligned_end_time_ms, query, no_cache=False)
assert aligned_and_cached.status_code == HTTPStatus.OK, aligned_and_cached.text
# what the cache now holds, and what the unaligned query must not be served
points = sorted(get_series_values(aligned_and_cached.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["timestamp"], point["value"]) for point in points]
## at each timestamp t, promql takes the highest sample in (t-2minutes, t],
## which is the one at t itself since the gauge only rises.
assert returned_points == [
(aligned_start_time_ms, 400), # t = 0
(aligned_start_time_ms + MINUTE_MS, 600), # t = 1m
(aligned_start_time_ms + 2 * MINUTE_MS, 800), # t = 2m
(aligned_end_time_ms, 1000), # t = 3m
]
unaligned_and_uncached = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, query, no_cache=True)
assert unaligned_and_uncached.status_code == HTTPStatus.OK, unaligned_and_uncached.text
# promql reports at the range start plus whole steps, so these points sit 30s
# off the cached ones. The first run stores them, the second reads them back
for run in ("first", "second"):
unaligned_and_cached = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, query, no_cache=False)
assert unaligned_and_cached.status_code == HTTPStatus.OK, unaligned_and_cached.text
assert_results_equal(unaligned_and_cached.json(), unaligned_and_uncached.json(), "A", f"unaligned query, {run} run")
points = sorted(get_series_values(unaligned_and_cached.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["timestamp"], point["value"]) for point in points]
## every point falls on a sample the aligned run never reported, so being
## served the cached run's answer shows up in the values and not only the
## timestamps.
assert returned_points == [
(unaligned_start_time_ms, 500), # t = 30s
(unaligned_start_time_ms + MINUTE_MS, 700), # t = 1m30s
(unaligned_start_time_ms + 2 * MINUTE_MS, 900), # t = 2m30s
(unaligned_end_time_ms, 1100), # t = 3m30s
], f"unaligned query, {run} run"
def test_builder_refreshing_a_sliding_time_range(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_sliding_{uuid4().hex[:8]}"
# 90 minutes back so even the twentieth refresh closes clear of the flux
# interval, which holds recent data out of the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=90)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
query = [build_builder_query("A", metric_name, "max", "max")]
# the 1m step gives one point per seeded minute, and a value no other minute
# carries, so a point stitched in from the wrong range reads as the wrong minute
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=1000 + minute,
type_="Gauge",
is_monotonic=False,
)
for minute in range(80)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# a dashboard left open on a one hour range, re-running a minute later each time
for refresh in range(20):
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
from_cache = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
# each refresh is stitched out of overlapping cached ranges, so this catches
# a point served twice, dropped, or carried over from an earlier refresh
points = sorted(get_series_values(from_cache.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["timestamp"], point["value"], point.get("partial", False)) for point in points]
expected_points = [(start_time_ms + minute * MINUTE_MS, 1000 + minute, False) for minute in range(refresh, refresh + 60)]
assert returned_points == expected_points, f"refresh {refresh} did not return the minutes it covers"
last_refresh_start_ms = start_time_ms + 19 * MINUTE_MS
uncached = make_query_request(signoz, token, last_refresh_start_ms, last_refresh_start_ms + 60 * MINUTE_MS, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "the twentieth refresh")