feat(app): refactor Sources components and add custom Mantine UI variants (#1561)

Co-authored-by: Drew Davis <drew.davis@clickhouse.com>
This commit is contained in:
Elizabet Oliveira 2026-01-07 14:02:36 +00:00 committed by GitHub
parent ae12ca1670
commit 5dded38f87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1116 additions and 210 deletions

View file

@ -0,0 +1,11 @@
---
"@hyperdx/app": minor
---
Refactor Sources components and add custom Mantine UI variants
- Move SourceForm to Sources/ subfolder with reusable SourcesList component
- Add primary, secondary, and danger button/action icon variants
- Improve Storybook with font switching and component stories
- Update ErrorBoundary styling with danger variant

3
.gitignore vendored
View file

@ -61,6 +61,9 @@ e2e/cypress/results
**/playwright/.cache/
**/.auth/
# storybook
**/storybook-static/
# scripts
scripts/*.csv
**/venv

View file

@ -23,6 +23,22 @@
- Define TypeScript interfaces for props
- Use proper keys for lists, memoization for expensive computations
## Mantine UI Components
The project uses Mantine UI with **custom variants** defined in `packages/app/src/theme/mantineTheme.ts`:
### Custom Button Variants
- `variant="primary"` - Light green button for primary actions
- `variant="secondary"` - Default styled button for secondary actions
- `variant="danger"` - Light red button for destructive actions
### Custom ActionIcon Variants
- `variant="primary"` - Light green action icon
- `variant="secondary"` - Default styled action icon
- `variant="danger"` - Light red action icon for destructive actions
These are valid variants - do not replace them with standard Mantine variants like `variant="light" color="red"`.
## Refactoring
- Edit files directly - don't create `component-v2.tsx` copies

View file

@ -36,6 +36,7 @@
"app:dev": "concurrently -k -n 'API,APP,ALERTS-TASK,COMMON-UTILS' -c 'green.bold,blue.bold,yellow.bold,magenta' 'nx run @hyperdx/api:dev' 'nx run @hyperdx/app:dev' 'nx run @hyperdx/api:dev-task check-alerts' 'nx run @hyperdx/common-utils:dev'",
"app:dev:local": "concurrently -k -n 'APP,COMMON-UTILS' -c 'blue.bold,magenta' 'nx run @hyperdx/app:dev:local' 'nx run @hyperdx/common-utils:dev'",
"app:lint": "nx run @hyperdx/app:ci:lint",
"app:storybook": "nx run @hyperdx/app:storybook",
"dev": "yarn build:common-utils && dotenvx run --convention=nextjs -- docker compose -f docker-compose.dev.yml up -d && yarn app:dev && docker compose -f docker-compose.dev.yml down",
"dev:local": "IS_LOCAL_APP_MODE='DANGEROUSLY_is_local_app_mode💀' yarn dev",
"dev:down": "docker compose -f docker-compose.dev.yml down",

View file

@ -21,6 +21,15 @@ const config: StorybookConfig = {
options: {},
},
staticDirs: ['./public'],
webpackFinal: async config => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
'next/router': require.resolve('next/router'),
};
}
return config;
},
};
export default config;

View file

@ -1,10 +1,11 @@
import React from 'react';
import { NextAdapter } from 'next-query-params';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { QueryClient, QueryClientProvider } from 'react-query';
import { QueryParamProvider } from 'use-query-params';
import type { Preview } from '@storybook/nextjs';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ibmPlexMono, inter, roboto, robotoMono } from '../src/fonts';
import { meHandler } from '../src/mocks/handlers';
import { ThemeWrapper } from '../src/ThemeWrapper';
@ -31,29 +32,75 @@ export const globalTypes = {
defaultValue: 'light',
toolbar: {
icon: 'mirror',
title: 'Theme',
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
],
},
},
font: {
name: 'Font',
description: 'App font family',
defaultValue: 'inter',
toolbar: {
icon: 'typography',
title: 'Font',
items: [
{ value: 'inter', title: 'Inter' },
{ value: 'roboto', title: 'Roboto' },
{ value: 'ibm-plex-mono', title: 'IBM Plex Mono' },
{ value: 'roboto-mono', title: 'Roboto Mono' },
],
},
},
};
initialize();
const queryClient = new QueryClient();
const fontMap = {
inter: inter,
roboto: roboto,
'ibm-plex-mono': ibmPlexMono,
'roboto-mono': robotoMono,
};
// Create a new QueryClient for each story to avoid cache pollution between stories
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
});
const preview: Preview = {
decorators: [
(Story, context) => (
<QueryClientProvider client={queryClient}>
<QueryParamProvider adapter={NextAdapter}>
<ThemeWrapper colorScheme={context.globals.theme || 'light'}>
<Story />
</ThemeWrapper>
</QueryParamProvider>
</QueryClientProvider>
),
(Story, context) => {
// Create a fresh QueryClient for each story render
const [queryClient] = React.useState(() => createQueryClient());
const selectedFont = context.globals.font || 'inter';
const font = fontMap[selectedFont as keyof typeof fontMap] || inter;
const fontFamily = font.style.fontFamily;
return (
<div className={font.className}>
<QueryClientProvider client={queryClient}>
<QueryParamProvider adapter={NextAdapter}>
<ThemeWrapper
colorScheme={context.globals.theme || 'light'}
fontFamily={fontFamily}
>
<Story />
</ThemeWrapper>
</QueryParamProvider>
</QueryClientProvider>
</div>
);
},
],
loaders: [mswLoader],
parameters: {

View file

@ -83,7 +83,7 @@ import { InputControlled } from '@/components/InputControlled';
import OnboardingModal from '@/components/OnboardingModal';
import SearchPageActionBar from '@/components/SearchPageActionBar';
import SearchTotalCountChart from '@/components/SearchTotalCountChart';
import { TableSourceForm } from '@/components/SourceForm';
import { TableSourceForm } from '@/components/Sources/SourceForm';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import { Tags } from '@/components/Tags';

View file

@ -1,12 +1,9 @@
import { Fragment, useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import Head from 'next/head';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { DEFAULT_METADATA_MAX_ROWS_TO_READ } from '@hyperdx/common-utils/dist/core/metadata';
import {
SourceKind,
TeamClickHouseSettings,
} from '@hyperdx/common-utils/dist/types';
import { TeamClickHouseSettings } from '@hyperdx/common-utils/dist/types';
import {
Box,
Button,
@ -27,19 +24,15 @@ import {
import { notifications } from '@mantine/notifications';
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconClipboard,
IconDatabase,
IconHelpCircle,
IconPencil,
IconServer,
IconX,
} from '@tabler/icons-react';
import { ConnectionForm } from '@/components/ConnectionForm';
import SelectControlled from '@/components/SelectControlled';
import { TableSourceForm } from '@/components/SourceForm';
import { SourcesList } from '@/components/Sources/SourcesList';
import { IS_LOCAL_MODE } from '@/config';
import { PageHeader } from './components/PageHeader';
@ -49,8 +42,6 @@ import api from './api';
import { useConnections } from './connection';
import { DEFAULT_QUERY_TIMEOUT, DEFAULT_SEARCH_ROW_LIMIT } from './defaults';
import { withAppNav } from './layout';
import { useSources } from './source';
import { capitalizeFirstLetter } from './utils';
function ConnectionsSection() {
const { data: connections } = useConnections();
@ -64,7 +55,7 @@ function ConnectionsSection() {
<Box id="connections">
<Text size="md">Connections</Text>
<Divider my="md" />
<Card variant="muted">
<Card>
<Stack mb="md">
{connections?.map(c => (
<Box key={c.id}>
@ -149,91 +140,15 @@ function ConnectionsSection() {
}
function SourcesSection() {
const { data: connections } = useConnections();
const { data: sources } = useSources();
const [editedSourceId, setEditedSourceId] = useState<string | null>(null);
const [isCreatingSource, setIsCreatingSource] = useState(false);
return (
<Box id="sources">
<Text size="md">Sources</Text>
<Divider my="md" />
<Card variant="muted">
<Stack>
{sources?.map(s => (
<Fragment key={s.id}>
<Flex justify="space-between" align="center">
<div>
<Text>{s.name}</Text>
<Text size="xxs" c="dimmed" mt="xs" component="div">
<Group gap="xs">
{capitalizeFirstLetter(s.kind)}
<Group gap={2}>
<IconServer size={14} />
{connections?.find(c => c.id === s.connection)?.name}
</Group>
<Group gap={2}>
{s.from && (
<>
<IconDatabase size={14} />
{s.from.databaseName}
{
s.kind === SourceKind.Metric
? ''
: '.' /** Metrics dont have table names */
}
{s.from.tableName}
</>
)}
</Group>
</Group>
</Text>
</div>
{editedSourceId !== s.id && (
<Button
variant="subtle"
onClick={() => setEditedSourceId(s.id)}
size="sm"
>
<IconChevronDown size={14} />
</Button>
)}
{editedSourceId === s.id && (
<Button
variant="subtle"
onClick={() => setEditedSourceId(null)}
size="sm"
>
<IconChevronUp size={14} />
</Button>
)}
</Flex>
{editedSourceId === s.id && (
<TableSourceForm
sourceId={s.id}
onSave={() => setEditedSourceId(null)}
/>
)}
<Divider />
</Fragment>
))}
{!IS_LOCAL_MODE && isCreatingSource && (
<TableSourceForm
isNew
onCreate={() => {
setIsCreatingSource(false);
}}
onCancel={() => setIsCreatingSource(false)}
/>
)}
{!IS_LOCAL_MODE && !isCreatingSource && (
<Button variant="default" onClick={() => setIsCreatingSource(true)}>
Add Source
</Button>
)}
</Stack>
</Card>
<SourcesList
withBorder={false}
variant="default"
showEmptyState={false}
/>
</Box>
);
}
@ -242,7 +157,7 @@ function IntegrationsSection() {
<Box id="integrations">
<Text size="md">Integrations</Text>
<Divider my="md" />
<Card variant="muted">
<Card>
<Stack gap="md">
<WebhooksSection />
</Stack>
@ -290,7 +205,7 @@ function TeamNameSection() {
<Box id="team_name">
<Text size="md">Team Name</Text>
<Divider my="md" />
<Card variant="muted">
<Card>
{isEditingTeamName ? (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Group gap="xs">
@ -552,7 +467,7 @@ function TeamQueryConfigSection() {
<Box id="team_name">
<Text size="md">ClickHouse Client Settings</Text>
<Divider my="md" />
<Card variant="muted">
<Card>
<Stack>
<ClickhouseSettingForm
settingKey="searchRowLimit"
@ -670,7 +585,7 @@ function ApiKeysSection() {
<Box id="api_keys">
<Text size="md">API Keys</Text>
<Divider my="md" />
<Card variant="muted" mb="md">
<Card mb="md">
<Text mb="md">Ingestion API Key</Text>
<Group gap="xs">
{team?.apiKey && (
@ -726,7 +641,7 @@ function ApiKeysSection() {
</Modal>
</Card>
{!isLoadingMe && me != null && (
<Card variant="muted">
<Card>
<Card.Section p="md">
<Text mb="md">Personal API Access Key</Text>
<APIKeyCopyButton value={me.accessKey} dataTestId="api-key" />

View file

@ -9,7 +9,7 @@ export default function ConfirmDeleteMenu({
return (
<Menu withArrow>
<Menu.Target>
<Button variant="outline" size="xs">
<Button variant="danger" size="xs">
Delete
</Button>
</Menu.Target>

View file

@ -1,42 +1,105 @@
import type { Meta } from '@storybook/nextjs';
import { Box, Text } from '@mantine/core';
import type { Meta, StoryObj } from '@storybook/nextjs';
import { ErrorBoundary } from './ErrorBoundary';
const meta: Meta = {
title: 'ErrorBoundary',
// Component that throws an error for testing
const BuggyComponent = ({ shouldThrow = true }: { shouldThrow?: boolean }) => {
if (shouldThrow) {
throw new Error('This is a test error from BuggyComponent!');
}
return (
<Box p="md">
<Text>This component rendered successfully!</Text>
</Box>
);
};
const meta: Meta<typeof ErrorBoundary> = {
title: 'Components/ErrorBoundary',
component: ErrorBoundary,
parameters: {
layout: 'padded',
},
argTypes: {
message: {
control: 'text',
description: 'Custom error message title',
},
showErrorMessage: {
control: 'boolean',
description: 'Whether to show the actual error message',
},
allowReset: {
control: 'boolean',
description: 'Whether to show a reset/retry button',
},
},
};
const BadComponent = () => {
throw new Error('Error message');
};
export const Default = () => (
<ErrorBoundary>
<BadComponent />
</ErrorBoundary>
);
export const WithRetry = () => (
<ErrorBoundary onRetry={() => {}}>
<BadComponent />
</ErrorBoundary>
);
export const WithMessage = () => (
<ErrorBoundary
onRetry={() => {}}
message="An error occurred while rendering the event details. Contact support
for more help."
>
<BadComponent />
</ErrorBoundary>
);
export const WithErrorMessage = () => (
<ErrorBoundary onRetry={() => {}} message="Don't panic" showErrorMessage>
<BadComponent />
</ErrorBoundary>
);
export default meta;
type Story = StoryObj<typeof meta>;
/** Error caught with default message */
export const ErrorCaught: Story = {
name: 'Error Caught (Default)',
render: () => (
<ErrorBoundary>
<BuggyComponent shouldThrow />
</ErrorBoundary>
),
};
/** Error caught with custom message */
export const CustomMessage: Story = {
name: 'Error Caught (Custom Message)',
render: () => (
<ErrorBoundary message="Oops! Something broke.">
<BuggyComponent shouldThrow />
</ErrorBoundary>
),
};
/** Error caught showing the error details */
export const ShowErrorMessage: Story = {
name: 'Show Error Message',
render: () => (
<ErrorBoundary showErrorMessage>
<BuggyComponent shouldThrow />
</ErrorBoundary>
),
};
/** Error caught with retry button */
export const WithRetryButton: Story = {
name: 'With Retry Button',
render: () => (
<ErrorBoundary allowReset showErrorMessage>
<BuggyComponent shouldThrow />
</ErrorBoundary>
),
};
/** Error caught with custom retry handler */
export const WithCustomRetry: Story = {
name: 'With Custom Retry Handler',
render: () => (
<ErrorBoundary
showErrorMessage
onRetry={() => alert('Custom retry handler called!')}
>
<BuggyComponent shouldThrow />
</ErrorBoundary>
),
};
/** No error - normal render */
export const NoError: Story = {
name: 'No Error (Normal Render)',
render: () => (
<ErrorBoundary>
<BuggyComponent shouldThrow={false} />
</ErrorBoundary>
),
};

View file

@ -1,7 +1,7 @@
import React from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
import { Alert, Button, Stack, Text } from '@mantine/core';
import { IconInfoCircleFilled } from '@tabler/icons-react';
import { IconExclamationCircle } from '@tabler/icons-react';
type ErrorBoundaryProps = {
children: React.ReactNode;
@ -31,8 +31,8 @@ export const ErrorBoundary = ({
fallbackRender={({ error, resetErrorBoundary }) => (
<Alert
p="xs"
color="orange"
icon={<IconInfoCircleFilled size={16} />}
color="red"
icon={<IconExclamationCircle size={16} />}
title={message || 'Something went wrong'}
>
{(showErrorMessage || showRetry) && (
@ -42,7 +42,7 @@ export const ErrorBoundary = ({
<Button
onClick={onRetry || resetErrorBoundary}
size="compact-xs"
color="orange"
variant="danger"
>
Retry
</Button>

View file

@ -18,7 +18,7 @@ import {
useUpdateSource,
} from '@/source';
import { TableSourceForm } from './SourceForm';
import { TableSourceForm } from './Sources/SourceForm';
async function addOtelDemoSources({
connectionId,

View file

@ -0,0 +1,137 @@
import { delay, http, HttpResponse } from 'msw';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { Box, Card, Text } from '@mantine/core';
import type { Meta, StoryObj } from '@storybook/nextjs';
import { TableSourceForm } from './SourceForm';
// Mock data
const mockConnections = [
{
id: 'conn-1',
name: 'Local ClickHouse',
host: 'localhost:8123',
username: 'default',
},
];
const mockSources = [
{
id: 'source-logs',
name: 'Logs',
kind: SourceKind.Log,
connection: 'conn-1',
from: { databaseName: 'default', tableName: 'otel_logs' },
timestampValueExpression: 'Timestamp',
},
{
id: 'source-traces',
name: 'Traces',
kind: SourceKind.Trace,
connection: 'conn-1',
from: { databaseName: 'default', tableName: 'otel_traces' },
timestampValueExpression: 'Timestamp',
},
];
const mockDatabases = ['default', 'system', 'logs'];
const mockTables = [
'otel_logs',
'otel_traces',
'otel_metrics_gauge',
'otel_metrics_sum',
'otel_metrics_histogram',
];
// MSW handlers for API mocking (using named handlers for easy per-story overrides)
const defaultHandlers = {
connections: http.get('*/api/connections', () => {
return HttpResponse.json(mockConnections);
}),
sources: http.get('*/api/sources', () => {
return HttpResponse.json(mockSources);
}),
sourceById: http.get('*/api/sources/:id', ({ params }) => {
const source = mockSources.find(s => s.id === params.id);
if (source) {
return HttpResponse.json(source);
}
return new HttpResponse(null, { status: 404 });
}),
databases: http.get('*/api/clickhouse/databases', () => {
return HttpResponse.json(mockDatabases);
}),
tables: http.get('*/api/clickhouse/tables', () => {
return HttpResponse.json(mockTables);
}),
createSource: http.post('*/api/sources', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: 'new-source-id', ...body });
}),
updateSource: http.put('*/api/sources/:id', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(body);
}),
deleteSource: http.delete('*/api/sources/:id', () => {
return HttpResponse.json({ success: true });
}),
};
const meta: Meta<typeof TableSourceForm> = {
title: 'Components/Sources/SourceForm',
component: TableSourceForm,
parameters: {
layout: 'padded',
msw: {
handlers: defaultHandlers,
},
},
decorators: [
Story => (
<Card withBorder p="md" style={{ maxWidth: 900 }}>
<Story />
</Card>
),
],
argTypes: {
isNew: {
control: 'boolean',
description: 'Whether this is a new source (create mode)',
},
defaultName: {
control: 'text',
description: 'Default name for new sources',
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Create new source form */
export const CreateNew: Story = {
name: 'Create New Source',
args: {
isNew: true,
defaultName: 'My New Source',
onCreate: () => {
// Source created
},
onCancel: () => {
// Cancelled
},
},
};
/** Edit existing source */
export const EditExisting: Story = {
name: 'Edit Existing Source',
args: {
sourceId: 'source-logs',
onSave: () => {
// Saved
},
},
};

View file

@ -61,13 +61,13 @@ import {
MV_GRANULARITY_OPTIONS,
} from '@/utils/materializedViews';
import ConfirmDeleteMenu from './ConfirmDeleteMenu';
import { ConnectionSelectControlled } from './ConnectionSelect';
import { DatabaseSelectControlled } from './DatabaseSelect';
import { DBTableSelectControlled } from './DBTableSelect';
import { InputControlled } from './InputControlled';
import SelectControlled from './SelectControlled';
import { SQLInlineEditorControlled } from './SQLInlineEditor';
import ConfirmDeleteMenu from '../ConfirmDeleteMenu';
import { ConnectionSelectControlled } from '../ConnectionSelect';
import { DatabaseSelectControlled } from '../DatabaseSelect';
import { DBTableSelectControlled } from '../DBTableSelect';
import { InputControlled } from '../InputControlled';
import SelectControlled from '../SelectControlled';
import { SQLInlineEditorControlled } from '../SQLInlineEditor';
const DEFAULT_DATABASE = 'default';
@ -1824,42 +1824,7 @@ export function TableSourceForm({
}
>
<Stack gap="md" mb="md">
<Flex justify="space-between" align="center" mb="lg">
<Text>Source Settings</Text>
<Group>
{onCancel && (
<Button variant="outline" onClick={onCancel} size="xs">
Cancel
</Button>
)}
{isNew ? (
<Button
variant="outline"
color="green"
onClick={_onCreate}
size="xs"
loading={createSource.isPending}
>
Save New Source
</Button>
) : (
<>
<ConfirmDeleteMenu
onDelete={() => deleteSource.mutate({ id: sourceId ?? '' })}
/>
<Button
variant="outline"
color="green"
onClick={_onSave}
size="xs"
loading={createSource.isPending}
>
Save Source
</Button>
</>
)}
</Group>
</Flex>
<Text mb="lg">Source Settings</Text>
<FormRow label={'Name'}>
<InputControlled
control={control}
@ -1914,6 +1879,37 @@ export function TableSourceForm({
)}
</Stack>
<TableModelForm control={control} setValue={setValue} kind={kind} />
<Group justify="flex-end" mt="lg">
{onCancel && (
<Button variant="secondary" onClick={onCancel} size="xs">
Cancel
</Button>
)}
{isNew ? (
<Button
variant="primary"
onClick={_onCreate}
size="xs"
loading={createSource.isPending}
>
Save New Source
</Button>
) : (
<>
<ConfirmDeleteMenu
onDelete={() => deleteSource.mutate({ id: sourceId ?? '' })}
/>
<Button
variant="primary"
onClick={_onSave}
size="xs"
loading={createSource.isPending}
>
Save Source
</Button>
</>
)}
</Group>
</div>
);
}

View file

@ -0,0 +1,3 @@
.sourcesCard {
background-color: var(--color-bg);
}

View file

@ -0,0 +1,168 @@
import { delay, http, HttpResponse } from 'msw';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { Box } from '@mantine/core';
import type { Meta, StoryObj } from '@storybook/nextjs';
import { SourcesList } from './SourcesList';
const mockConnections = [
{
id: 'conn-1',
name: 'Local ClickHouse',
host: 'localhost:8123',
username: 'default',
},
];
const mockSources = [
{
id: 'source-logs',
name: 'Logs',
kind: SourceKind.Log,
connection: 'conn-1',
from: { databaseName: 'default', tableName: 'otel_logs' },
timestampValueExpression: 'Timestamp',
},
{
id: 'source-traces',
name: 'Traces',
kind: SourceKind.Trace,
connection: 'conn-1',
from: { databaseName: 'default', tableName: 'otel_traces' },
timestampValueExpression: 'Timestamp',
},
];
// Default handlers that return mock data
const defaultHandlers = {
connections: http.get('*/api/connections', () => {
return HttpResponse.json(mockConnections);
}),
sources: http.get('*/api/sources', () => {
return HttpResponse.json(mockSources);
}),
};
const meta: Meta<typeof SourcesList> = {
title: 'Components/Sources/SourcesList',
component: SourcesList,
parameters: {
layout: 'padded',
msw: {
handlers: defaultHandlers,
},
},
decorators: [
Story => (
<Box style={{ maxWidth: 800 }}>
<Story />
</Box>
),
],
argTypes: {
variant: {
control: 'select',
options: ['compact', 'default'],
description: 'Visual variant for text/icon sizing',
},
withCard: {
control: 'boolean',
description: 'Whether to wrap in a Card component',
},
withBorder: {
control: 'boolean',
description: 'Whether the card has a border',
},
showEmptyState: {
control: 'boolean',
description: 'Whether to show empty state UI',
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/* Default with mock data - compact variant (for GettingStarted) */
export const Default: Story = {
name: 'Compact Variant (GettingStarted)',
args: {
variant: 'compact',
withCard: true,
withBorder: true,
},
};
/* Default variant (for TeamPage) */
export const DefaultVariant: Story = {
name: 'Default Variant (TeamPage)',
args: {
variant: 'default',
withCard: true,
withBorder: false,
showEmptyState: false,
},
};
/* Loading State - simulates slow API */
export const Loading: Story = {
name: 'Loading State',
parameters: {
msw: {
handlers: {
connections: http.get('*/api/connections', async () => {
await delay('infinite');
return HttpResponse.json([]);
}),
sources: http.get('*/api/sources', async () => {
await delay('infinite');
return HttpResponse.json([]);
}),
},
},
},
};
/* Error State - simulates API failure */
export const Error: Story = {
name: 'Error State',
parameters: {
msw: {
handlers: {
connections: http.get('*/api/connections', () => {
return HttpResponse.json(
{ message: 'Failed to connect to database' },
{ status: 500 },
);
}),
sources: http.get('*/api/sources', () => {
return HttpResponse.json(
{ message: 'Failed to fetch sources' },
{ status: 500 },
);
}),
},
},
},
};
/* Empty State - no sources configured */
export const Empty: Story = {
name: 'Empty (No Sources)',
args: {
showEmptyState: true,
},
parameters: {
msw: {
handlers: {
connections: http.get('*/api/connections', () => {
return HttpResponse.json([]);
}),
sources: http.get('*/api/sources', () => {
return HttpResponse.json([]);
}),
},
},
},
};

View file

@ -0,0 +1,242 @@
import React, { useState } from 'react';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Alert,
Box,
Button,
Card,
Divider,
Flex,
Group,
Loader,
Stack,
Text,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconChevronDown,
IconChevronUp,
IconPlus,
IconRefresh,
IconServer,
IconStack,
} from '@tabler/icons-react';
import { IS_LOCAL_MODE } from '@/config';
import { useConnections } from '@/connection';
import { useSources } from '@/source';
import { capitalizeFirstLetter } from '@/utils';
import { TableSourceForm } from './SourceForm';
import styles from './Sources.module.scss';
export interface SourcesListProps {
/** Callback when add source button is clicked */
onAddSource?: () => void;
/** Whether to wrap content in a Card component (default: true) */
withCard?: boolean;
/** Whether the card has a border (default: true) */
withBorder?: boolean;
/** Custom className for the card */
cardClassName?: string;
/** Visual variant: 'compact' for smaller text, 'default' for standard sizing */
variant?: 'compact' | 'default';
/** Whether to show empty state UI (default: true) */
showEmptyState?: boolean;
}
export function SourcesList({
onAddSource,
withCard = true,
withBorder = true,
cardClassName,
variant = 'compact',
showEmptyState = true,
}: SourcesListProps) {
const {
data: connections,
isLoading: isLoadingConnections,
error: connectionsError,
refetch: refetchConnections,
} = useConnections();
const {
data: sources,
isLoading: isLoadingSources,
error: sourcesError,
refetch: refetchSources,
} = useSources();
const [editedSourceId, setEditedSourceId] = useState<string | null>(null);
const [isCreatingSource, setIsCreatingSource] = useState(false);
const isLoading = isLoadingConnections || isLoadingSources;
const error = connectionsError || sourcesError;
const handleRetry = () => {
refetchConnections();
refetchSources();
};
// Sizing based on variant
const textSize = variant === 'compact' ? 'sm' : 'md';
const subtextSize = variant === 'compact' ? 'xs' : 'sm';
const iconSize = variant === 'compact' ? 11 : 14;
const buttonSize = variant === 'compact' ? 'xs' : 'sm';
const Wrapper = withCard ? Card : React.Fragment;
const wrapperProps = withCard
? {
withBorder,
p: 'md',
radius: 'sm',
className: cardClassName ?? styles.sourcesCard,
}
: {};
if (isLoading) {
return (
<Wrapper {...wrapperProps}>
<Flex justify="center" align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed" ml="sm">
Loading sources...
</Text>
</Flex>
</Wrapper>
);
}
if (error) {
return (
<Wrapper {...wrapperProps}>
<Alert
icon={<IconAlertCircle size={16} />}
title="Failed to load sources"
color="red"
variant="light"
>
<Text size="sm" mb="sm">
{error instanceof Error
? error.message
: 'An error occurred while loading data sources.'}
</Text>
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconRefresh size={14} />}
onClick={handleRetry}
>
Retry
</Button>
</Alert>
</Wrapper>
);
}
const isEmpty = !sources || sources.length === 0;
return (
<Wrapper {...wrapperProps}>
<Stack gap="md">
{isEmpty && !isCreatingSource && showEmptyState && (
<Flex direction="column" align="center" py="xl" gap="sm">
<IconStack size={32} color="var(--color-text-muted)" />
<Title size="sm" ta="center" c="var(--color-text-muted)">
No data sources configured yet.
</Title>
<Text size="xs" ta="center" c="var(--color-text-muted)">
Add a source to start querying your data.
</Text>
</Flex>
)}
{sources?.map((s, index) => (
<React.Fragment key={s.id}>
<Flex justify="space-between" align="center">
<div>
<Text size={textSize} fw={500}>
{s.name}
</Text>
<Text size={subtextSize} c="dimmed" mt={4}>
<Group gap="xs">
{capitalizeFirstLetter(s.kind)}
<Group gap={4}>
<IconServer size={iconSize} />
{connections?.find(c => c.id === s.connection)?.name}
</Group>
<Group gap={4}>
{s.from && (
<>
<IconStack size={iconSize} />
{s.from.databaseName}
{s.kind === SourceKind.Metric ? '' : '.'}
{s.from.tableName}
</>
)}
</Group>
</Group>
</Text>
</div>
<ActionIcon
variant="secondary"
size={buttonSize}
onClick={() =>
setEditedSourceId(editedSourceId === s.id ? null : s.id)
}
>
{editedSourceId === s.id ? (
<IconChevronUp size={iconSize + 2} />
) : (
<IconChevronDown size={iconSize + 2} />
)}
</ActionIcon>
</Flex>
{editedSourceId === s.id && (
<Box mt="xs">
<TableSourceForm
sourceId={s.id}
onSave={() => setEditedSourceId(null)}
/>
</Box>
)}
{index < (sources?.length ?? 0) - 1 && <Divider />}
</React.Fragment>
))}
{isCreatingSource && (
<>
{sources && sources.length > 0 && <Divider />}
<TableSourceForm
isNew
onCreate={() => setIsCreatingSource(false)}
onCancel={() => setIsCreatingSource(false)}
/>
</>
)}
{!IS_LOCAL_MODE && !isCreatingSource && (
<Flex
justify="flex-end"
pt={sources && sources.length > 0 ? 'md' : 0}
>
<Button
variant="secondary"
size={buttonSize}
leftSection={<IconPlus size={14} />}
onClick={() => {
setIsCreatingSource(true);
onAddSource?.();
}}
>
Add source
</Button>
</Flex>
)}
</Stack>
</Wrapper>
);
}

View file

@ -0,0 +1,9 @@
export {
LogTableModelForm,
MetricTableModelForm,
SessionTableModelForm,
TableSourceForm,
TraceTableModelForm,
} from './SourceForm';
export type { SourcesListProps } from './SourcesList';
export { SourcesList } from './SourcesList';

View file

@ -0,0 +1,128 @@
import { ActionIcon, Group, Stack, Text } from '@mantine/core';
import type { Meta } from '@storybook/nextjs';
import {
IconCheck,
IconEdit,
IconPlus,
IconSettings,
IconTrash,
IconX,
} from '@tabler/icons-react';
const meta: Meta = {
title: 'Components/ActionIcon',
component: ActionIcon,
parameters: {
layout: 'centered',
},
};
export default meta;
export const CustomVariants = () => (
<Stack gap="xl">
<div>
<Text size="sm" fw={600} mb="xs">
Primary
</Text>
<Group>
<ActionIcon variant="primary" size="sm">
<IconCheck size={16} />
</ActionIcon>
<ActionIcon variant="primary" size="md">
<IconCheck size={18} />
</ActionIcon>
<ActionIcon variant="primary" size="lg">
<IconCheck size={20} />
</ActionIcon>
<ActionIcon variant="primary" disabled>
<IconCheck size={18} />
</ActionIcon>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Secondary
</Text>
<Group>
<ActionIcon variant="secondary" size="sm">
<IconEdit size={16} />
</ActionIcon>
<ActionIcon variant="secondary" size="md">
<IconEdit size={18} />
</ActionIcon>
<ActionIcon variant="secondary" size="lg">
<IconEdit size={20} />
</ActionIcon>
<ActionIcon variant="secondary" disabled>
<IconEdit size={18} />
</ActionIcon>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Danger
</Text>
<Group>
<ActionIcon variant="danger" size="sm">
<IconTrash size={16} />
</ActionIcon>
<ActionIcon variant="danger" size="md">
<IconTrash size={18} />
</ActionIcon>
<ActionIcon variant="danger" size="lg">
<IconTrash size={20} />
</ActionIcon>
<ActionIcon variant="danger" disabled>
<IconTrash size={18} />
</ActionIcon>
</Group>
</div>
</Stack>
);
export const Sizes = () => (
<Stack gap="md">
<Text size="sm" fw={600}>
ActionIcon Sizes
</Text>
<Group align="center">
<ActionIcon variant="primary" size="xs">
<IconPlus size={14} />
</ActionIcon>
<ActionIcon variant="primary" size="sm">
<IconPlus size={16} />
</ActionIcon>
<ActionIcon variant="primary" size="md">
<IconPlus size={18} />
</ActionIcon>
</Group>
</Stack>
);
export const CommonUseCases = () => (
<Stack gap="md">
<Text size="sm" fw={600}>
Common Use Cases
</Text>
<Group>
<ActionIcon variant="primary" aria-label="Confirm">
<IconCheck size={18} />
</ActionIcon>
<ActionIcon variant="danger" aria-label="Cancel">
<IconX size={18} />
</ActionIcon>
<ActionIcon variant="secondary" aria-label="Settings">
<IconSettings size={18} />
</ActionIcon>
<ActionIcon variant="secondary" aria-label="Edit">
<IconEdit size={18} />
</ActionIcon>
<ActionIcon variant="danger" aria-label="Delete">
<IconTrash size={18} />
</ActionIcon>
</Group>
</Stack>
);

View file

@ -1,25 +1,92 @@
import { Button } from '@mantine/core';
import { Button, Group, Stack, Text } from '@mantine/core';
import type { Meta } from '@storybook/nextjs';
import { IconStarFilled } from '@tabler/icons-react';
// Just a test story, can be deleted
import {
IconArrowRight,
IconCheck,
IconPlus,
IconTrash,
} from '@tabler/icons-react';
const meta: Meta = {
title: 'Button',
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
};
export const Default = () => (
<Button
variant="light"
leftSection={<IconStarFilled size={14} />}
size="compact-sm"
>
Assign exception to Warren
</Button>
export default meta;
export const CustomVariants = () => (
<Stack gap="xl">
<div>
<Text size="sm" fw={600} mb="xs">
Primary
</Text>
<Group>
<Button variant="primary">Primary</Button>
<Button variant="primary" leftSection={<IconCheck size={16} />}>
Confirm
</Button>
<Button variant="primary" rightSection={<IconArrowRight size={16} />}>
Continue
</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Secondary
</Text>
<Group>
<Button variant="secondary">Secondary</Button>
<Button variant="secondary" leftSection={<IconPlus size={16} />}>
Add Item
</Button>
<Button variant="secondary" disabled>
Disabled
</Button>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Danger
</Text>
<Group>
<Button variant="danger">Danger</Button>
<Button variant="danger" leftSection={<IconTrash size={16} />}>
Delete
</Button>
<Button variant="danger" disabled>
Disabled
</Button>
</Group>
</div>
</Stack>
);
export default meta;
export const Sizes = () => (
<Stack gap="md">
<Text size="sm" fw={600}>
Button Sizes
</Text>
<Group align="center">
<Button variant="primary" size="xxs">
XXS
</Button>
<Button variant="primary" size="xs">
XS
</Button>
<Button variant="primary" size="sm">
SM
</Button>
<Button variant="primary" size="md">
MD
</Button>
</Group>
</Stack>
);

View file

@ -6,6 +6,7 @@ import {
rem,
Select,
Text,
Tooltip,
} from '@mantine/core';
export const makeTheme = ({
@ -78,6 +79,13 @@ export const makeTheme = ({
fontFamily,
},
components: {
Tooltip: Tooltip.extend({
styles: () => ({
tooltip: {
fontFamily: 'var(--mantine-font-family)',
},
}),
}),
Modal: {
styles: {
header: {
@ -221,6 +229,49 @@ export const makeTheme = ({
return { root: {} };
},
styles: (_theme, props) => {
// Primary variant - light green style
if (props.variant === 'primary') {
return {
root: {
backgroundColor: 'var(--mantine-color-green-light)',
color: 'var(--mantine-color-green-light-color)',
'&:hover': {
backgroundColor: 'var(--mantine-color-green-light-hover)',
},
},
};
}
// Secondary variant - similar to default
if (props.variant === 'secondary') {
return {
root: {
backgroundColor: 'var(--color-bg-body)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
'&:hover': {
backgroundColor: 'var(--color-bg-hover)',
},
},
};
}
// Danger variant - light red style
if (props.variant === 'danger') {
return {
root: {
backgroundColor: 'var(--mantine-color-red-light)',
color: 'var(--mantine-color-red-light-color)',
'&:hover': {
backgroundColor: 'var(--mantine-color-red-light-hover)',
},
},
};
}
return {};
},
}),
SegmentedControl: {
styles: {
@ -237,7 +288,7 @@ export const makeTheme = ({
variant: 'subtle',
color: 'gray',
},
styles: (theme, props) => {
styles: (_theme, props) => {
// Subtle variant stays transparent
if (props.variant === 'subtle') {
return {
@ -271,6 +322,46 @@ export const makeTheme = ({
};
}
// Primary variant - light green style
if (props.variant === 'primary') {
return {
root: {
backgroundColor: 'var(--mantine-color-green-light)',
color: 'var(--mantine-color-green-light-color)',
'&:hover': {
backgroundColor: 'var(--mantine-color-green-light-hover)',
},
},
};
}
// Secondary variant - similar to default
if (props.variant === 'secondary') {
return {
root: {
backgroundColor: 'var(--color-bg-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
'&:hover': {
backgroundColor: 'var(--color-bg-hover)',
},
},
};
}
// Danger variant - light red style
if (props.variant === 'danger') {
return {
root: {
backgroundColor: 'var(--mantine-color-red-light)',
color: 'var(--mantine-color-red-light-color)',
'&:hover': {
backgroundColor: 'var(--mantine-color-red-light-hover)',
},
},
};
}
return {};
},
}),