grafana-flamegraph: Add storybook stories for major components (#113375)

This commit is contained in:
Bryan Huhta
2025-11-25 15:19:29 -06:00
committed by GitHub
parent 235ebbf5c5
commit fee3f12e4e
13 changed files with 850 additions and 43 deletions
@@ -0,0 +1,66 @@
// This script is used to copy assets from the public folder to a temporary static folder within
// the .storybook directory.
// We selectively limit assets that are uploaded to the Storybook bucket to prevent rate limiting
// when publishing new storybook.
// Note: Storybook has a static copying feature but it copies entire directories which can contain thousands of icons.
import fs from 'fs-extra';
import { resolve } from 'node:path';
// avoid importing from @grafana/data to prevent error: window is not defined
import { availableIconsIndex, IconName } from '../../grafana-data/src/types/icon.ts';
import { getIconSubDir } from '../../grafana-ui/src/components/Icon/utils.ts';
const __dirname = import.meta.dirname;
// doesn't require uploading 1000s of unused assets.
const iconPaths = Object.keys(availableIconsIndex)
.filter((iconName) => !iconName.startsWith('fa '))
.map((iconName) => {
const subDir = getIconSubDir(iconName as IconName, 'default');
return {
from: `../../../public/img/icons/${subDir}/${iconName}.svg`,
to: `./static/public/build/img/icons/${subDir}/${iconName}.svg`,
};
});
export function copyAssetsSync() {
const assets = [
{
from: '../../../public/fonts',
to: './static/public/fonts',
},
{
from: '../../../public/img/grafana_text_logo-dark.svg',
to: './static/public/img/grafana_text_logo-dark.svg',
},
{
from: '../../../public/img/grafana_text_logo-light.svg',
to: './static/public/img/grafana_text_logo-light.svg',
},
{
from: '../../../public/img/fav32.png',
to: './static/public/img/fav32.png',
},
{
from: '../../../public/lib',
to: './static/public/lib',
},
...iconPaths,
// copy over the MSW mock service worker so we can mock requests in Storybook
{
from: '../../../public/mockServiceWorker.js',
to: './static/mockServiceWorker.js',
},
];
for (const asset of assets) {
const fromPath = resolve(__dirname, asset.from);
const toPath = resolve(__dirname, asset.to);
if (!fs.existsSync(toPath)) {
fs.copySync(fromPath, toPath, {
filter: (src) => !fs.lstatSync(src).isSymbolicLink(),
});
}
}
}
@@ -0,0 +1,53 @@
import type { StorybookConfig } from '@storybook/react-webpack5';
import { copyAssetsSync } from './copyAssets.ts';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
copyAssetsSync();
const config: StorybookConfig = {
stories: ['../src/**/*.story.tsx'],
addons: [
{
name: '@storybook/preset-scss',
options: {
styleLoaderOptions: {
// this is required for theme switching .use() and .unuse()
injectType: 'lazyStyleTag',
},
cssLoaderOptions: {
url: false,
importLoaders: 2,
},
sassLoaderOptions: {
sassOptions: {
// silencing these warnings since we're planning to remove sass when angular is gone
silenceDeprecations: ['import', 'global-builtin'],
},
},
},
},
'@storybook/addon-webpack5-compiler-swc',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
staticDirs: ['static'],
webpackFinal: async (config) => {
const webpack = await import('webpack');
// Define process.env for browser context
config.plugins?.push(
new webpack.default.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.STORYBOOK_THEME': JSON.stringify(process.env.STORYBOOK_THEME || 'system'),
})
);
return config;
},
};
export default config;
@@ -0,0 +1,54 @@
import { Preview } from '@storybook/react';
import { getBuiltInThemes, getTimeZone, getTimeZones, GrafanaTheme2 } from '@grafana/data';
import { withTheme } from '../src/utils/storybook/withTheme';
import { withTimeZone } from '../src/utils/storybook/withTimeZone';
const allowedExtraThemes: string[] = [];
if (process.env.NODE_ENV === 'development') {
allowedExtraThemes.push('debug');
allowedExtraThemes.push('desertbloom');
allowedExtraThemes.push('gildedgrove');
allowedExtraThemes.push('gloom');
allowedExtraThemes.push('sapphiredusk');
allowedExtraThemes.push('tron');
}
const preview: Preview = {
decorators: [withTheme(), withTimeZone()],
globalTypes: {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'system',
toolbar: {
icon: 'paintbrush',
items: getBuiltInThemes(allowedExtraThemes).map((theme) => ({
value: theme.id,
title: theme.name,
})),
showName: true,
},
},
timeZone: {
description: 'Set the timezone for the storybook preview',
defaultValue: getTimeZone(),
toolbar: {
icon: 'globe',
items: getTimeZones(true)
.filter((timezone) => !!timezone)
.map((timezone) => ({
title: timezone,
value: timezone,
})),
},
},
},
initialGlobals: {
theme: process.env.STORYBOOK_THEME || 'system',
},
};
export default preview;
+8 -1
View File
@@ -36,7 +36,8 @@
"clean": "rimraf ./dist ./compiled ./package.tgz",
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js",
"postpack": "mv package.json.bak package.json"
"postpack": "mv package.json.bak package.json",
"storybook": "storybook dev -p 6006"
},
"browserslist": [
"defaults",
@@ -60,11 +61,15 @@
"@babel/preset-env": "7.28.0",
"@babel/preset-react": "7.27.1",
"@rollup/plugin-node-resolve": "16.0.1",
"@storybook/addon-webpack5-compiler-swc": "^4.0.2",
"@storybook/react": "^10.0.8",
"@storybook/react-webpack5": "^10.0.8",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.1.2",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/d3": "^7",
"@types/fs-extra": "^11",
"@types/jest": "^29.5.4",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
@@ -73,11 +78,13 @@
"@types/tinycolor2": "1.4.6",
"babel-jest": "29.7.0",
"esbuild": "0.25.8",
"fs-extra": "^11.3.2",
"jest": "^29.6.4",
"jest-canvas-mock": "2.5.2",
"rollup": "^4.22.4",
"rollup-plugin-esbuild": "6.2.1",
"rollup-plugin-node-externals": "^8.0.0",
"storybook": "^10.0.8",
"ts-jest": "29.4.0",
"ts-node": "10.9.2",
"typescript": "5.9.2"
@@ -0,0 +1,31 @@
import { Meta, StoryObj } from '@storybook/react';
import { createDataFrame } from '@grafana/data';
import { ColorScheme, SelectedView } from '../types';
import FlameGraph from './FlameGraph';
import { CollapsedMap, FlameGraphDataContainer } from './dataTransform';
import { data } from './testData/dataBasic';
const meta: Meta<typeof FlameGraph> = {
title: 'FlameGraph',
component: FlameGraph,
args: {
rangeMin: 0,
rangeMax: 1,
textAlign: 'left',
colorScheme: ColorScheme.PackageBased,
selectedView: SelectedView.Both,
search: '',
},
};
export default meta;
export const Basic: StoryObj<typeof meta> = {
render: (args) => {
const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: false });
return <FlameGraph {...args} data={dataContainer} collapsedMap={new CollapsedMap()} />;
},
};
@@ -0,0 +1,32 @@
import { DataFrameDTO } from '@grafana/data';
export const data: DataFrameDTO = {
name: 'response',
refId: 'A',
// @ts-ignore
meta: { preferredVisualisationType: 'flamegraph' },
fields: [
{
name: 'level',
values: [0, 1, 2, 3, 2, 1],
},
{
name: 'value',
values: [10000, 5000, 4000, 3000, 500, 3000],
config: {
unit: 'ms',
},
},
{
name: 'self',
values: [10000, 500, 1000, 3000, 500, 3000],
config: {
unit: 'ms',
},
},
{
name: 'label',
values: ['total', 'fn_1', 'fn_2', 'fn_3', 'fn_4', 'fn_5'],
},
],
};
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react';
import { createDataFrame } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { data } from './FlameGraph/testData/dataNestedSet';
import FlameGraphContainer, { Props } from './FlameGraphContainer';
const WrappedFlameGraph = (props: Omit<Props, 'getTheme'>) => {
const theme = useTheme2();
const df = createDataFrame(data);
return <FlameGraphContainer {...props} data={df} getTheme={() => theme} />;
};
const meta: Meta<typeof FlameGraphContainer> = {
title: 'FlameGraphContainer',
render: (args) => {
return <WrappedFlameGraph {...args} />;
},
};
export default meta;
export const Basic: StoryObj<typeof meta> = {};
@@ -0,0 +1,41 @@
import { Meta, StoryObj } from '@storybook/react';
import { createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
import { data } from '../FlameGraph/testData/dataNestedSet';
import { ColorScheme } from '../types';
import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
const meta: Meta<typeof FlameGraphTopTableContainer> = {
title: 'TopTable',
component: FlameGraphTopTableContainer,
args: {
colorScheme: ColorScheme.ValueBased,
},
decorators: [
(Story) => (
<div style={{ width: '100%', height: '600px' }}>
<Story />
</div>
),
],
};
export default meta;
export const Basic: StoryObj<typeof meta> = {
render: (args) => {
const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true });
return (
<FlameGraphTopTableContainer
{...args}
data={dataContainer}
onSymbolClick={() => {}}
onSearch={() => {}}
onSandwich={() => {}}
/>
);
},
};
@@ -144,6 +144,7 @@ function buildFilteredTable(data: FlameGraphDataContainer, matchedLabels?: Set<s
// Add current call to the stack
callStack.push(label);
}
return filteredTable;
}
@@ -0,0 +1,36 @@
import { Decorator } from '@storybook/react';
import * as React from 'react';
import { getThemeById, ThemeContext } from '@grafana/data';
import { GlobalStyles } from '@grafana/ui';
interface ThemeableStoryProps {
themeId: string;
}
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
const theme = getThemeById(themeId);
const css = `
#storybook-root {
padding: ${theme.spacing(2)};
}
body {
background: ${theme.colors.background.primary};
}
`;
return (
<ThemeContext.Provider value={theme}>
<GlobalStyles />
<style>{css}</style>
{children}
</ThemeContext.Provider>
);
};
export const withTheme =
(): Decorator =>
// eslint-disable-next-line react/display-name
(story, context) => <ThemeableStory themeId={context.globals.theme}>{story()}</ThemeableStory>;
@@ -0,0 +1,12 @@
import { Decorator } from '@storybook/react';
import { useEffect } from 'react';
import { setTimeZoneResolver } from '@grafana/data';
export const withTimeZone = (): Decorator => (Story, context) => {
useEffect(() => {
setTimeZoneResolver(() => context.globals.timeZone ?? 'browser');
}, [context.globals.timeZone]);
return Story();
};