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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,27 @@ jobs:
path: ui
- name: Run lint
run: yarn lint
test:
needs: install
runs-on: ${{ github.repository_owner == 'grafana' && 'ubuntu-x64' || 'ubuntu-latest' }}
defaults:
run:
working-directory: ui
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: 'false'
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 24
- run: corepack enable
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: node_modules
path: ui
- name: Run tests
run: yarn test
build:
needs: install
runs-on: ${{ github.repository_owner == 'grafana' && 'ubuntu-x64' || 'ubuntu-latest' }}
Expand Down
6 changes: 4 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"lint:fix": "prettier --write . && eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@grafana/flamegraph": "patch:@grafana/flamegraph@npm%3A12.4.2#~/.yarn/patches/@grafana-flamegraph-npm-12.4.2-6f441dce57.patch",
Expand All @@ -32,7 +33,8 @@
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
"vite": "^8.0.1",
"vitest": "^4.1.4"
},
"packageManager": "yarn@4.13.0",
"resolutions": {
Expand Down
9 changes: 3 additions & 6 deletions ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export async function fetchFlamegraph(params: {
return { names, levels };
}

interface Point {
export interface Point {
value: number;
timestamp: number;
}
Expand All @@ -139,16 +139,13 @@ export async function fetchTimeline(params: {
start: number;
end: number;
step: number;
}): Promise<number[]> {
}): Promise<Point[]> {
const data = await post<SelectSeriesResponse>(
'/querier.v1.QuerierService/SelectSeries',
params,
);

const points = data.series?.[0]?.points ?? [];
if (!points.length) return [];

return points.map((p) => p.value);
return data.series?.[0]?.points ?? [];
}

function parseProfileTypeId(
Expand Down
100 changes: 17 additions & 83 deletions ui/src/components/TimeSeries.tsx
Original file line number Diff line number Diff line change
@@ -1,85 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { Empty } from '@components/core/Empty';
import { profileTypeUnit } from '@api/client';
import {
toDisplayValue,
niceMax,
yAxisFormatter,
parseRangeMs,
tickStepMs,
formatTickTime,
} from './timeseries';
import './TimeSeries.css';

function toDisplayValue(raw: number, unit: string): number {
if (unit === 'ns') return raw / 1e9;
return raw;
}

function niceMax(value: number): number {
if (value <= 0) return 1;
const exp = Math.floor(Math.log10(value));
const mag = Math.pow(10, exp);
const norm = value / mag;
if (norm <= 1) return mag;
if (norm <= 2) return 2 * mag;
if (norm <= 5) return 5 * mag;
return 10 * mag;
}

function yAxisFormatter(displayMax: number): (v: number) => string {
let divisor = 1,
suffix = '';
if (displayMax >= 1e9) {
divisor = 1e9;
suffix = 'G';
} else if (displayMax >= 1e6) {
divisor = 1e6;
suffix = 'M';
} else if (displayMax >= 1e3) {
divisor = 1e3;
suffix = 'k';
} else if (displayMax < 1e-3 && displayMax > 0) {
divisor = 1e-6;
suffix = 'µ';
} else if (displayMax < 1 && displayMax > 0) {
divisor = 1e-3;
suffix = 'm';
}
return (v: number) => {
if (v === 0) return '0';
return `${parseFloat((v / divisor).toPrecision(3))}${suffix}`;
};
}

function parseRangeMs(range: string): number {
const m = range.match(/^now-(\d+)([mhd])$/);
if (!m) return 3_600_000;
const mult: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
return parseInt(m[1]) * (mult[m[2]] ?? 60_000);
}

function tickStepMs(durationMs: number): number {
const m = 60_000,
h = 3_600_000,
d = 86_400_000;
if (durationMs <= 15 * m) return m;
if (durationMs <= 2 * h) return 5 * m;
if (durationMs <= 4 * h) return 15 * m;
if (durationMs <= 8 * h) return 30 * m;
if (durationMs <= 12 * h) return h;
if (durationMs <= d) return 2 * h;
if (durationMs <= 7 * d) return 12 * h;
return d;
}

function formatTickTime(ts: number, stepMs: number): string {
const d = new Date(ts);
if (stepMs >= 86_400_000) {
return d.toLocaleDateString(undefined, {
month: 'numeric',
day: 'numeric',
});
}
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

export function TimeSeries({
data,
timeRange,
Expand All @@ -88,7 +19,7 @@ export function TimeSeries({
endMs,
onRangeSelect,
}: {
data: number[];
data: { value: number; timestamp: number }[];
timeRange: string;
profileTypeId: string;
startMs?: number;
Expand Down Expand Up @@ -155,18 +86,21 @@ export function TimeSeries({
const rangeStart = rangeEnd - durationMs;
timeRef.current = { rangeStart, durationMs, onRangeSelect };

const max = Math.max(...data);
const norm = max === 0 ? data.map(() => 0) : data.map((v) => v / max);
const max = Math.max(...data.map((d) => d.value));
const norm = max === 0 ? data.map(() => 0) : data.map((d) => d.value / max);

const pts = norm.map(
(v, i) =>
[(i / Math.max(n - 1, 1)) * W, H - 4 - v * (H - 10)] as [number, number],
[
((data[i].timestamp - rangeStart) / durationMs) * W,
H - 4 - v * (H - 10),
] as [number, number],
);

const area =
`M 0,${H} ` +
`M ${pts[0][0].toFixed(1)},${H} ` +
pts.map(([x, y]) => `L ${x.toFixed(1)},${y.toFixed(1)}`).join(' ') +
` L ${W},${H} Z`;
` L ${pts[pts.length - 1][0].toFixed(1)},${H} Z`;

const line =
`M ${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)} ` +
Expand Down
165 changes: 165 additions & 0 deletions ui/src/components/timeseries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import {
toDisplayValue,
niceMax,
yAxisFormatter,
parseRangeMs,
tickStepMs,
formatTickTime,
} from './timeseries';

const MINUTE_MS = 60_000;
const HOUR_MS = 3_600_000;
const DAY_MS = 86_400_000;

describe('toDisplayValue', () => {
it('divides nanoseconds by 1e9', () => {
expect(toDisplayValue(1_000_000_000, 'ns')).toBe(1);
expect(toDisplayValue(500_000_000, 'ns')).toBe(0.5);
});

it('passes other units through unchanged', () => {
expect(toDisplayValue(42, 'bytes')).toBe(42);
expect(toDisplayValue(42, 'count')).toBe(42);
});
});

describe('niceMax', () => {
it('returns 1 for zero or negative input', () => {
expect(niceMax(0)).toBe(1);
expect(niceMax(-5)).toBe(1);
});

it('rounds up to the nearest 1, 2, 5, or 10 magnitude', () => {
expect(niceMax(1)).toBe(1);
expect(niceMax(1.5)).toBe(2);
expect(niceMax(3)).toBe(5);
expect(niceMax(7)).toBe(10);
expect(niceMax(150)).toBe(200);
expect(niceMax(300)).toBe(500);
expect(niceMax(800)).toBe(1000);
});

it('handles large values', () => {
expect(niceMax(1_200_000)).toBe(2_000_000);
expect(niceMax(9_999_999)).toBe(10_000_000);
});
});

describe('yAxisFormatter', () => {
it('formats zero as "0" regardless of scale', () => {
expect(yAxisFormatter(1000)(0)).toBe('0');
expect(yAxisFormatter(1e9)(0)).toBe('0');
});

it('uses G suffix for values >= 1e9', () => {
const fmt = yAxisFormatter(2e9);
expect(fmt(1e9)).toBe('1G');
expect(fmt(1.5e9)).toBe('1.5G');
});

it('uses M suffix for values >= 1e6', () => {
const fmt = yAxisFormatter(5e6);
expect(fmt(1e6)).toBe('1M');
});

it('uses k suffix for values >= 1e3', () => {
const fmt = yAxisFormatter(2000);
expect(fmt(1000)).toBe('1k');
expect(fmt(1500)).toBe('1.5k');
});

it('formats sub-1 values without suffix', () => {
const fmt = yAxisFormatter(0.5);
expect(fmt(0.5)).toBe('500m');
});

it('formats plain values with no suffix', () => {
const fmt = yAxisFormatter(100);
expect(fmt(50)).toBe('50');
expect(fmt(100)).toBe('100');
});
});

describe('parseRangeMs', () => {
it('parses minutes', () => {
expect(parseRangeMs('now-5m')).toBe(5 * MINUTE_MS);
expect(parseRangeMs('now-30m')).toBe(30 * MINUTE_MS);
});

it('parses hours', () => {
expect(parseRangeMs('now-1h')).toBe(HOUR_MS);
expect(parseRangeMs('now-6h')).toBe(6 * HOUR_MS);
});

it('parses days', () => {
expect(parseRangeMs('now-1d')).toBe(DAY_MS);
expect(parseRangeMs('now-7d')).toBe(7 * DAY_MS);
});

it('falls back to 1 hour for unrecognized formats', () => {
expect(parseRangeMs('last-5m')).toBe(HOUR_MS);
expect(parseRangeMs('')).toBe(HOUR_MS);
expect(parseRangeMs('now-5x')).toBe(HOUR_MS);
});
});

describe('tickStepMs', () => {
it('uses 1-minute steps for durations up to 15 minutes', () => {
expect(tickStepMs(5 * MINUTE_MS)).toBe(MINUTE_MS);
expect(tickStepMs(15 * MINUTE_MS)).toBe(MINUTE_MS);
});

it('uses 5-minute steps for durations up to 2 hours', () => {
expect(tickStepMs(30 * MINUTE_MS)).toBe(5 * MINUTE_MS);
expect(tickStepMs(2 * HOUR_MS)).toBe(5 * MINUTE_MS);
});

it('uses 15-minute steps for durations up to 4 hours', () => {
expect(tickStepMs(3 * HOUR_MS)).toBe(15 * MINUTE_MS);
expect(tickStepMs(4 * HOUR_MS)).toBe(15 * MINUTE_MS);
});

it('uses 30-minute steps for durations up to 8 hours', () => {
expect(tickStepMs(6 * HOUR_MS)).toBe(30 * MINUTE_MS);
expect(tickStepMs(8 * HOUR_MS)).toBe(30 * MINUTE_MS);
});

it('uses 1-hour steps for durations up to 12 hours', () => {
expect(tickStepMs(10 * HOUR_MS)).toBe(HOUR_MS);
expect(tickStepMs(12 * HOUR_MS)).toBe(HOUR_MS);
});

it('uses 2-hour steps for durations up to 1 day', () => {
expect(tickStepMs(18 * HOUR_MS)).toBe(2 * HOUR_MS);
expect(tickStepMs(DAY_MS)).toBe(2 * HOUR_MS);
});

it('uses 12-hour steps for durations up to 7 days', () => {
expect(tickStepMs(3 * DAY_MS)).toBe(12 * HOUR_MS);
expect(tickStepMs(7 * DAY_MS)).toBe(12 * HOUR_MS);
});

it('uses 1-day steps for durations beyond 7 days', () => {
expect(tickStepMs(10 * DAY_MS)).toBe(DAY_MS);
expect(tickStepMs(30 * DAY_MS)).toBe(DAY_MS);
});
});

describe('formatTickTime', () => {
// 2024-03-15 14:30:00 UTC
const ts = Date.UTC(2024, 2, 15, 14, 30, 0);

it('formats as HH:MM for sub-day steps', () => {
const result = formatTickTime(ts, HOUR_MS);
// Hours depend on local timezone, so just verify the HH:MM pattern
expect(result).toMatch(/^\d{2}:\d{2}$/);
});

it('formats as a date string for day-level steps', () => {
const result = formatTickTime(ts, DAY_MS);
// toLocaleDateString with month/day numeric — just check it's not HH:MM
expect(result).not.toMatch(/^\d{2}:\d{2}$/);
expect(result.length).toBeGreaterThan(0);
});
});
Loading
Loading