diff --git a/.changeset/sources-refactor-ui-variants.md b/.changeset/sources-refactor-ui-variants.md new file mode 100644 index 000000000..90562d583 --- /dev/null +++ b/.changeset/sources-refactor-ui-variants.md @@ -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 + diff --git a/.gitignore b/.gitignore index 31004dafa..9bc6ca347 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ e2e/cypress/results **/playwright/.cache/ **/.auth/ +# storybook +**/storybook-static/ + # scripts scripts/*.csv **/venv diff --git a/agent_docs/code_style.md b/agent_docs/code_style.md index 43bc9cac0..fd0a1d957 100644 --- a/agent_docs/code_style.md +++ b/agent_docs/code_style.md @@ -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 diff --git a/package.json b/package.json index c6be2971e..387016655 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/.storybook/main.ts b/packages/app/.storybook/main.ts index a1eed1374..eafb73359 100644 --- a/packages/app/.storybook/main.ts +++ b/packages/app/.storybook/main.ts @@ -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; diff --git a/packages/app/.storybook/preview.tsx b/packages/app/.storybook/preview.tsx index e698f59da..6ac5a5403 100644 --- a/packages/app/.storybook/preview.tsx +++ b/packages/app/.storybook/preview.tsx @@ -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) => ( - - - - - - - - ), + (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 ( +
+ + + + + + + +
+ ); + }, ], loaders: [mswLoader], parameters: { diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index a2f3c6b0d..4e7bb649d 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -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'; diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx index 61645b04c..6a11ffbb8 100644 --- a/packages/app/src/TeamPage.tsx +++ b/packages/app/src/TeamPage.tsx @@ -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() { Connections - + {connections?.map(c => ( @@ -149,91 +140,15 @@ function ConnectionsSection() { } function SourcesSection() { - const { data: connections } = useConnections(); - const { data: sources } = useSources(); - - const [editedSourceId, setEditedSourceId] = useState(null); - const [isCreatingSource, setIsCreatingSource] = useState(false); - return ( Sources - - - {sources?.map(s => ( - - -
- {s.name} - - - {capitalizeFirstLetter(s.kind)} - - - {connections?.find(c => c.id === s.connection)?.name} - - - {s.from && ( - <> - - {s.from.databaseName} - { - s.kind === SourceKind.Metric - ? '' - : '.' /** Metrics dont have table names */ - } - {s.from.tableName} - - )} - - - -
- {editedSourceId !== s.id && ( - - )} - {editedSourceId === s.id && ( - - )} -
- {editedSourceId === s.id && ( - setEditedSourceId(null)} - /> - )} - -
- ))} - {!IS_LOCAL_MODE && isCreatingSource && ( - { - setIsCreatingSource(false); - }} - onCancel={() => setIsCreatingSource(false)} - /> - )} - {!IS_LOCAL_MODE && !isCreatingSource && ( - - )} -
-
+
); } @@ -242,7 +157,7 @@ function IntegrationsSection() { Integrations - + @@ -290,7 +205,7 @@ function TeamNameSection() { Team Name - + {isEditingTeamName ? (
@@ -552,7 +467,7 @@ function TeamQueryConfigSection() { ClickHouse Client Settings - + API Keys - + Ingestion API Key {team?.apiKey && ( @@ -726,7 +641,7 @@ function ApiKeysSection() { {!isLoadingMe && me != null && ( - + Personal API Access Key diff --git a/packages/app/src/components/ConfirmDeleteMenu.tsx b/packages/app/src/components/ConfirmDeleteMenu.tsx index eaec0a8f2..e451bfeac 100644 --- a/packages/app/src/components/ConfirmDeleteMenu.tsx +++ b/packages/app/src/components/ConfirmDeleteMenu.tsx @@ -9,7 +9,7 @@ export default function ConfirmDeleteMenu({ return ( - diff --git a/packages/app/src/components/ErrorBoundary.stories.tsx b/packages/app/src/components/ErrorBoundary.stories.tsx index 9cc5306d3..60ecd7fba 100644 --- a/packages/app/src/components/ErrorBoundary.stories.tsx +++ b/packages/app/src/components/ErrorBoundary.stories.tsx @@ -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 ( + + This component rendered successfully! + + ); +}; + +const meta: Meta = { + 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 default meta; + +type Story = StoryObj; + +/** Error caught with default message */ +export const ErrorCaught: Story = { + name: 'Error Caught (Default)', + render: () => ( + + + + ), }; -export const Default = () => ( - - - -); +/** Error caught with custom message */ +export const CustomMessage: Story = { + name: 'Error Caught (Custom Message)', + render: () => ( + + + + ), +}; -export const WithRetry = () => ( - {}}> - - -); +/** Error caught showing the error details */ +export const ShowErrorMessage: Story = { + name: 'Show Error Message', + render: () => ( + + + + ), +}; -export const WithMessage = () => ( - {}} - message="An error occurred while rendering the event details. Contact support - for more help." - > - - -); +/** Error caught with retry button */ +export const WithRetryButton: Story = { + name: 'With Retry Button', + render: () => ( + + + + ), +}; -export const WithErrorMessage = () => ( - {}} message="Don't panic" showErrorMessage> - - -); +/** Error caught with custom retry handler */ +export const WithCustomRetry: Story = { + name: 'With Custom Retry Handler', + render: () => ( + alert('Custom retry handler called!')} + > + + + ), +}; -export default meta; +/** No error - normal render */ +export const NoError: Story = { + name: 'No Error (Normal Render)', + render: () => ( + + + + ), +}; diff --git a/packages/app/src/components/ErrorBoundary.tsx b/packages/app/src/components/ErrorBoundary.tsx index 29c25c13f..4f7d5c92f 100644 --- a/packages/app/src/components/ErrorBoundary.tsx +++ b/packages/app/src/components/ErrorBoundary.tsx @@ -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 }) => ( } + color="red" + icon={} title={message || 'Something went wrong'} > {(showErrorMessage || showRetry) && ( @@ -42,7 +42,7 @@ export const ErrorBoundary = ({ diff --git a/packages/app/src/components/OnboardingModal.tsx b/packages/app/src/components/OnboardingModal.tsx index e93ef7ac6..15508b07a 100644 --- a/packages/app/src/components/OnboardingModal.tsx +++ b/packages/app/src/components/OnboardingModal.tsx @@ -18,7 +18,7 @@ import { useUpdateSource, } from '@/source'; -import { TableSourceForm } from './SourceForm'; +import { TableSourceForm } from './Sources/SourceForm'; async function addOtelDemoSources({ connectionId, diff --git a/packages/app/src/components/Sources/SourceForm.stories.tsx b/packages/app/src/components/Sources/SourceForm.stories.tsx new file mode 100644 index 000000000..b54bc89ba --- /dev/null +++ b/packages/app/src/components/Sources/SourceForm.stories.tsx @@ -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; + return HttpResponse.json({ id: 'new-source-id', ...body }); + }), + updateSource: http.put('*/api/sources/:id', async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json(body); + }), + deleteSource: http.delete('*/api/sources/:id', () => { + return HttpResponse.json({ success: true }); + }), +}; + +const meta: Meta = { + title: 'Components/Sources/SourceForm', + component: TableSourceForm, + parameters: { + layout: 'padded', + msw: { + handlers: defaultHandlers, + }, + }, + decorators: [ + Story => ( + + + + ), + ], + 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; + +/** 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 + }, + }, +}; diff --git a/packages/app/src/components/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm.tsx similarity index 97% rename from packages/app/src/components/SourceForm.tsx rename to packages/app/src/components/Sources/SourceForm.tsx index 046ecda3a..78fc712c7 100644 --- a/packages/app/src/components/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm.tsx @@ -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({ } > - - Source Settings - - {onCancel && ( - - )} - {isNew ? ( - - ) : ( - <> - deleteSource.mutate({ id: sourceId ?? '' })} - /> - - - )} - - + Source Settings + + {onCancel && ( + + )} + {isNew ? ( + + ) : ( + <> + deleteSource.mutate({ id: sourceId ?? '' })} + /> + + + )} + ); } diff --git a/packages/app/src/components/Sources/Sources.module.scss b/packages/app/src/components/Sources/Sources.module.scss new file mode 100644 index 000000000..6b3680928 --- /dev/null +++ b/packages/app/src/components/Sources/Sources.module.scss @@ -0,0 +1,3 @@ +.sourcesCard { + background-color: var(--color-bg); +} diff --git a/packages/app/src/components/Sources/SourcesList.stories.tsx b/packages/app/src/components/Sources/SourcesList.stories.tsx new file mode 100644 index 000000000..75f80aa77 --- /dev/null +++ b/packages/app/src/components/Sources/SourcesList.stories.tsx @@ -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 = { + title: 'Components/Sources/SourcesList', + component: SourcesList, + parameters: { + layout: 'padded', + msw: { + handlers: defaultHandlers, + }, + }, + decorators: [ + Story => ( + + + + ), + ], + 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; + +/* 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([]); + }), + }, + }, + }, +}; diff --git a/packages/app/src/components/Sources/SourcesList.tsx b/packages/app/src/components/Sources/SourcesList.tsx new file mode 100644 index 000000000..4ce87d0d6 --- /dev/null +++ b/packages/app/src/components/Sources/SourcesList.tsx @@ -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(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 ( + + + + + Loading sources... + + + + ); + } + + if (error) { + return ( + + } + title="Failed to load sources" + color="red" + variant="light" + > + + {error instanceof Error + ? error.message + : 'An error occurred while loading data sources.'} + + + + + ); + } + + const isEmpty = !sources || sources.length === 0; + + return ( + + + {isEmpty && !isCreatingSource && showEmptyState && ( + + + + No data sources configured yet. + + + Add a source to start querying your data. + + + )} + + {sources?.map((s, index) => ( + + +
+ + {s.name} + + + + {capitalizeFirstLetter(s.kind)} + + + {connections?.find(c => c.id === s.connection)?.name} + + + {s.from && ( + <> + + {s.from.databaseName} + {s.kind === SourceKind.Metric ? '' : '.'} + {s.from.tableName} + + )} + + + +
+ + setEditedSourceId(editedSourceId === s.id ? null : s.id) + } + > + {editedSourceId === s.id ? ( + + ) : ( + + )} + +
+ {editedSourceId === s.id && ( + + setEditedSourceId(null)} + /> + + )} + {index < (sources?.length ?? 0) - 1 && } +
+ ))} + + {isCreatingSource && ( + <> + {sources && sources.length > 0 && } + setIsCreatingSource(false)} + onCancel={() => setIsCreatingSource(false)} + /> + + )} + + {!IS_LOCAL_MODE && !isCreatingSource && ( + 0 ? 'md' : 0} + > + + + )} +
+
+ ); +} diff --git a/packages/app/src/components/Sources/index.ts b/packages/app/src/components/Sources/index.ts new file mode 100644 index 000000000..7adec0aec --- /dev/null +++ b/packages/app/src/components/Sources/index.ts @@ -0,0 +1,9 @@ +export { + LogTableModelForm, + MetricTableModelForm, + SessionTableModelForm, + TableSourceForm, + TraceTableModelForm, +} from './SourceForm'; +export type { SourcesListProps } from './SourcesList'; +export { SourcesList } from './SourcesList'; diff --git a/packages/app/src/stories/ActionIcon.stories.tsx b/packages/app/src/stories/ActionIcon.stories.tsx new file mode 100644 index 000000000..464597181 --- /dev/null +++ b/packages/app/src/stories/ActionIcon.stories.tsx @@ -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 = () => ( + +
+ + Primary + + + + + + + + + + + + + + + +
+ +
+ + Secondary + + + + + + + + + + + + + + + +
+ +
+ + Danger + + + + + + + + + + + + + + + +
+
+); + +export const Sizes = () => ( + + + ActionIcon Sizes + + + + + + + + + + + + + +); + +export const CommonUseCases = () => ( + + + Common Use Cases + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/app/src/stories/Button.stories.tsx b/packages/app/src/stories/Button.stories.tsx index 54193f0a0..a98230fdc 100644 --- a/packages/app/src/stories/Button.stories.tsx +++ b/packages/app/src/stories/Button.stories.tsx @@ -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 = () => ( - +export default meta; + +export const CustomVariants = () => ( + +
+ + Primary + + + + + + + +
+ +
+ + Secondary + + + + + + +
+ +
+ + Danger + + + + + + +
+
); -export default meta; +export const Sizes = () => ( + + + Button Sizes + + + + + + + + +); diff --git a/packages/app/src/theme/mantineTheme.ts b/packages/app/src/theme/mantineTheme.ts index 76a82f6df..5d59c75f2 100644 --- a/packages/app/src/theme/mantineTheme.ts +++ b/packages/app/src/theme/mantineTheme.ts @@ -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 {}; }, }),