diff --git a/app/api/clean/route.ts b/app/api/clean/route.ts index 0a56d8a3..76cd8e40 100644 --- a/app/api/clean/route.ts +++ b/app/api/clean/route.ts @@ -50,7 +50,7 @@ async function cleanupHangQuery( }) const data: { last_cleanup: string }[] = await response.json() lastCleanup = data.length > 0 ? new Date(data[0].last_cleanup) : new Date() - console.debug('[Middleware] Last cleanup:', lastCleanup) + console.debug('[/api/clean] Last cleanup:', lastCleanup) } catch (error) { throw new Error(`Error when getting last cleanup: ${error}`) } @@ -86,7 +86,7 @@ async function cleanupHangQuery( killQueryResp = await resp.json() console.log( - '[Middleware] queries found:', + '[/api/clean] queries found:', killQueryResp.data.map((row) => row.query_id).join(', ') ) @@ -134,7 +134,7 @@ async function cleanupHangQuery( }, {} as Record ) - console.log('[Middleware] Kill status:', killStatus) + console.log('[/api/clean] Kill status:', killStatus) const value = { kind: 'SystemKillQuery', @@ -154,7 +154,7 @@ async function cleanupHangQuery( return value } catch (error) { - console.error("[Middleware] 'SystemKillQuery' event creating error:", error) + console.error("[/api/clean] 'SystemKillQuery' event creating error:", error) return } } diff --git a/app/background.tsx b/app/background-jobs.tsx similarity index 52% rename from app/background.tsx rename to app/background-jobs.tsx index 9f02517a..b6b5c4cc 100644 --- a/app/background.tsx +++ b/app/background-jobs.tsx @@ -1,12 +1,8 @@ 'use client' -import { usePathname, useSearchParams } from 'next/navigation' import { useEffect } from 'react' -export function Background() { - const pathname = usePathname() - const searchParams = useSearchParams() - +export function BackgroundJobs() { useEffect(() => { async function callCleanApi() { await fetch('/api/clean') diff --git a/app/database/[database]/[table]/@dictionary/page.tsx b/app/database/[database]/[table]/@dictionary/page.tsx new file mode 100644 index 00000000..ff8f6817 --- /dev/null +++ b/app/database/[database]/[table]/@dictionary/page.tsx @@ -0,0 +1,46 @@ +import { ServerComponentLazy } from '@/components/server-component-lazy' +import { engineType } from '../engine-type' +import { Extras } from '../extras/extras' +import { SampleData } from '../extras/sample-data' +import { TableDDL } from '../extras/table-ddl' + +interface Props { + params: { + database: string + table: string + } +} + +export default async function Dictionary({ + params: { database, table }, +}: Props) { + const engine = await engineType(database, table) + if (engine !== 'Dictionary') return <> + + const dictUsage = `SELECT dictGet('${database}.${table}', 'key', 'value')` + + return ( +
+ + +
+

Dictionary usage

+
+          {dictUsage}
+        
+
+ +
+

Dictionary DDL

+ +
+ + +
+

Sample Data

+ +
+
+
+ ) +} diff --git a/app/database/[database]/[table]/@materializedview/materialized-view.tsx b/app/database/[database]/[table]/@materializedview/materialized-view.tsx new file mode 100644 index 00000000..c2b74f13 --- /dev/null +++ b/app/database/[database]/[table]/@materializedview/materialized-view.tsx @@ -0,0 +1,34 @@ +import { engineType } from '../engine-type' +import { Extras } from '../extras/extras' +import { TableDDL } from '../extras/table-ddl' + +interface Props { + params: { + database: string + table: string + } +} + +export default async function MaterializedView({ + params: { database, table }, +}: Props) { + const engine = await engineType(database, table) + if (engine !== 'MaterializedView') return <> + + return ( +
+ + +
+

+ MaterializedView:{' '} + + {database}.{table} + +

+ + +
+
+ ) +} diff --git a/app/database/[database]/[table]/@mergetree/page.tsx b/app/database/[database]/[table]/@mergetree/page.tsx new file mode 100644 index 00000000..09e1ea0e --- /dev/null +++ b/app/database/[database]/[table]/@mergetree/page.tsx @@ -0,0 +1,80 @@ +import { TextAlignBottomIcon } from '@radix-ui/react-icons' +import Link from 'next/link' + +import { DataTable } from '@/components/data-table/data-table' +import { Button } from '@/components/ui/button' +import { fetchDataWithCache } from '@/lib/clickhouse' +import { Extras } from '../extras/extras' + +import { config, type Row } from '../config' +import { engineType } from '../engine-type' + +interface Props { + params: { + database: string + table: string + } +} + +export default async function MergeTree({ + params: { database, table }, +}: Props) { + const engine = await engineType(database, table) + if (engine.includes('MergeTree') === false) return <> + + const columns = await fetchDataWithCache()({ + query: config.sql, + query_params: { + database, + table, + }, + }) + + let description = '' + try { + const raw = await fetchDataWithCache()<{ comment: string }[]>({ + query: ` + SELECT comment + FROM system.tables + WHERE (database = {database: String}) + AND (name = {table: String}) + `, + query_params: { database, table }, + }) + + description = raw?.[0]?.comment || '' + } catch (e) { + console.error('Error fetching table description', e) + } + + return ( + } + topRightToolbarExtras={ + + } + config={config} + data={columns} + /> + ) +} + +const TopRightToolbarExtras = ({ + database, + table, +}: { + database: string + table: string +}) => ( + + + +) diff --git a/app/database/[database]/[table]/@view/page.tsx b/app/database/[database]/[table]/@view/page.tsx new file mode 100644 index 00000000..88e0556b --- /dev/null +++ b/app/database/[database]/[table]/@view/page.tsx @@ -0,0 +1,27 @@ +import { engineType } from '../engine-type' +import { Extras } from '../extras/extras' +import { TableDDL } from '../extras/table-ddl' + +interface Props { + params: { + database: string + table: string + } +} + +export default async function View({ params: { database, table } }: Props) { + const engine = await engineType(database, table) + if (engine !== 'View') return <> + + return ( +
+ + +
+

View definition:

+ + +
+
+ ) +} diff --git a/app/database/[database]/[table]/engine-type.ts b/app/database/[database]/[table]/engine-type.ts new file mode 100644 index 00000000..e6fa9782 --- /dev/null +++ b/app/database/[database]/[table]/engine-type.ts @@ -0,0 +1,20 @@ +import { fetchDataWithCache } from '@/lib/clickhouse' + +export const engineType = async (database: string, table: string) => { + try { + const resp = await fetchDataWithCache()<{ engine: string }[]>({ + query: ` + SELECT engine + FROM system.tables + WHERE (database = {database: String}) + AND (name = {table: String}) + `, + query_params: { database, table }, + }) + + return resp?.[0]?.engine || '' + } catch (error) { + console.error(error) + return '' + } +} diff --git a/app/database/[database]/[table]/extras/extras.tsx b/app/database/[database]/[table]/extras/extras.tsx new file mode 100644 index 00000000..c3f216ef --- /dev/null +++ b/app/database/[database]/[table]/extras/extras.tsx @@ -0,0 +1,40 @@ +import { ArrowLeftIcon } from '@radix-ui/react-icons' +import Link from 'next/link' + +import { Button } from '@/components/ui/button' +import { AlternativeTables } from './alternative-tables' +import { RunningQueriesButton } from './runnning-queries-button' +import { SampleDataButton } from './sample-data-button' +import { ShowDDL } from './show-ddl-button' +import { TableInfo } from './table-info' + +export const Extras = ({ + database, + table, +}: { + database: string + table: string +}) => ( +
+
+ + + + +
+ +
+ + + + +
+
+) diff --git a/app/database/[database]/[table]/layout.tsx b/app/database/[database]/[table]/layout.tsx new file mode 100644 index 00000000..d0611c9e --- /dev/null +++ b/app/database/[database]/[table]/layout.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +interface LayoutProps { + children: React.ReactNode + mergetree: React.ReactNode + dictionary: React.ReactNode + materializedview: React.ReactNode + view: React.ReactNode +} + +export default function Layout({ + view, + dictionary, + materializedview, + mergetree, +}: LayoutProps) { + return ( +
+ {view} + {dictionary} + {materializedview} + {mergetree} +
+ ) +} diff --git a/app/database/[database]/[table]/page.tsx b/app/database/[database]/[table]/page.tsx index 80c4530f..36df614d 100644 --- a/app/database/[database]/[table]/page.tsx +++ b/app/database/[database]/[table]/page.tsx @@ -1,189 +1,3 @@ -import { ArrowLeftIcon, TextAlignBottomIcon } from '@radix-ui/react-icons' -import Link from 'next/link' - -import { DataTable } from '@/components/data-table/data-table' -import { ServerComponentLazy } from '@/components/server-component-lazy' -import { Button } from '@/components/ui/button' -import { fetchDataWithCache } from '@/lib/clickhouse' - -import { config, type Row } from './config' -import { AlternativeTables } from './extras/alternative-tables' -import { RunningQueriesButton } from './extras/runnning-queries-button' -import { SampleData } from './extras/sample-data' -import { SampleDataButton } from './extras/sample-data-button' -import { ShowDDL } from './extras/show-ddl-button' -import { TableDDL } from './extras/table-ddl' -import { TableInfo } from './extras/table-info' - -export const revalidate = 600 - -interface ColumnsPageProps { - params: { - database: string - table: string - } +export default function Page() { + return <> } - -export default async function ColumnsPage({ - params: { database, table }, -}: ColumnsPageProps) { - // Detects the engine type. - const resp = await fetchDataWithCache()<{ engine: string }[]>({ - query: ` - SELECT engine - FROM system.tables - WHERE (database = {database: String}) - AND (name = {table: String}) - `, - query_params: { database, table }, - }) - - const engine = resp?.[0]?.engine || '' - - if (engine === 'MaterializedView') { - return ( -
- - -
-

- MaterializedView:{' '} - - {database}.{table} - -

- -
-
- ) - } else if (engine === 'View') { - return ( -
- - -
-

View definition:

- - -
-
- ) - } else if (engine === 'Dictionary') { - const dictUsage = `SELECT dictGet('${database}.${table}', 'key', 'value')` - - return ( -
- - -
-

Dictionary usage

-
-            {dictUsage}
-          
-
- -
-

Dictionary DDL

- -
- - -
-

Sample Data

- -
-
-
- ) - } else { - const columns = await fetchDataWithCache()({ - query: config.sql, - query_params: { - database, - table, - }, - }) - - let description = '' - try { - const raw = await fetchDataWithCache()<{ comment: string }[]>({ - query: ` - SELECT comment - FROM system.tables - WHERE (database = {database: String}) - AND (name = {table: String}) - `, - query_params: { database, table }, - }) - - description = raw?.[0]?.comment || '' - } catch (e) { - console.error('Error fetching table description', e) - } - - return ( -
- } - topRightToolbarExtras={ - - } - config={config} - data={columns} - /> - - -
-

Sample Data

- -
-
-
- ) - } -} - -const Extras = ({ database, table }: { database: string; table: string }) => ( -
-
- - - - -
- -
- - - - -
-
-) - -const TopRightToolbarExtras = ({ - database, - table, -}: { - database: string - table: string -}) => ( - - - -) diff --git a/app/database/[database]/breadcrumb.tsx b/app/database/[database]/breadcrumb.tsx new file mode 100644 index 00000000..286d2b51 --- /dev/null +++ b/app/database/[database]/breadcrumb.tsx @@ -0,0 +1,84 @@ +import { ChevronDownIcon, SlashIcon } from '@radix-ui/react-icons' +import Link from 'next/link' + +import { ErrorAlert } from '@/components/error-alert' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { fetchDataWithCache } from '@/lib/clickhouse' + +import { listDatabases } from '../queries' + +interface Props { + database: string +} + +export async function DatabaseBreadcrumb({ database }: Props) { + let databases: { name: string; count: number }[] = [] + try { + // List database names and number of tables + databases = await fetchDataWithCache()({ + query: listDatabases, + }) + + if (!databases.length) { + return ( + + ) + } + } catch (e: any) { + return ( + + ) + } + + let currentCount = databases.find((db) => db.name === database)?.count + + return ( + + + + Database + + + + + + + + + {database} ({currentCount} {currentCount == 1 ? 'table' : 'tables'}) + + + + {databases.map(({ name, count }) => ( + + + {name} + +
({count})
+
+ ))} +
+
+
+
+ ) +} diff --git a/app/database/[database]/layout.tsx b/app/database/[database]/layout.tsx index 5b4cd09a..8ddeb939 100644 --- a/app/database/[database]/layout.tsx +++ b/app/database/[database]/layout.tsx @@ -1,23 +1,7 @@ -import { ChevronDownIcon, SlashIcon } from '@radix-ui/react-icons' -import Link from 'next/link' +import { DatabaseBreadcrumb } from './breadcrumb' -import { ErrorAlert } from '@/components/error-alert' -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbSeparator, -} from '@/components/ui/breadcrumb' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { fetchDataWithCache } from '@/lib/clickhouse' - -import { listDatabases } from '../queries' +import { SingleLineSkeleton, TableSkeleton } from '@/components/skeleton' +import { Suspense } from 'react' interface TableListProps { params: { @@ -32,58 +16,12 @@ export default async function TableListPage({ params: { database }, children, }: TableListProps) { - let databases: { name: string; count: number }[] = [] - try { - // List database names and number of tables - databases = await fetchDataWithCache()({ query: listDatabases }) - - if (!databases.length) { - return - } - } catch (e: any) { - return ( - - ) - } - - let currentCount = databases.find((db) => db.name === database)?.count - return (
- - - - Database - - - - - - - - - {database} ({currentCount}{' '} - {currentCount == 1 ? 'table' : 'tables'}) - - - - {databases.map(({ name, count }) => ( - - - {name} - -
({count})
-
- ))} -
-
-
-
- - {children} + }> + + + }>{children}
) } diff --git a/app/database/[database]/page.tsx b/app/database/[database]/page.tsx index def26b88..38f2bf2b 100644 --- a/app/database/[database]/page.tsx +++ b/app/database/[database]/page.tsx @@ -1,13 +1,11 @@ import { ColumnFormat } from '@/components/data-table/column-defs' import { DataTable } from '@/components/data-table/data-table' -import { Button } from '@/components/ui/button' import { fetchData } from '@/lib/clickhouse' import { type QueryConfig } from '@/lib/types/query-config' -import { TextAlignBottomIcon } from '@radix-ui/react-icons' import { type RowData } from '@tanstack/react-table' -import Link from 'next/link' import { listTables } from '../../database/queries' +import { Toolbar } from './toolbar' interface TableListProps { params: { @@ -63,19 +61,7 @@ export default async function TableListPage({ title={`Database: ${database}`} config={config} data={tables} - topRightToolbarExtras={} + topRightToolbarExtras={} /> ) } - -const ToolbarExtras = ({ database }: { database: string }) => ( - - - -) diff --git a/app/database/[database]/toolbar.tsx b/app/database/[database]/toolbar.tsx new file mode 100644 index 00000000..c6c1895e --- /dev/null +++ b/app/database/[database]/toolbar.tsx @@ -0,0 +1,15 @@ +import { Button } from '@/components/ui/button' +import { TextAlignBottomIcon } from '@radix-ui/react-icons' +import Link from 'next/link' + +export const Toolbar = ({ database }: { database: string }) => ( + + + +) diff --git a/app/database/loading.tsx b/app/database/loading.tsx deleted file mode 100644 index 9567c8ab..00000000 --- a/app/database/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { UpdateIcon } from '@radix-ui/react-icons' - -export default function Loading() { - return ( -
- - Loading tables ... -
- ) -} diff --git a/app/database/page.tsx b/app/database/page.tsx index e1f91483..91a02def 100644 --- a/app/database/page.tsx +++ b/app/database/page.tsx @@ -1,32 +1,9 @@ import { redirect } from 'next/navigation' -import { ErrorAlert } from '@/components/error-alert' -import { fetchData } from '@/lib/clickhouse' - -import { listDatabases } from './queries' - export const dynamic = 'force-dynamic' export const revalidate = 30 -export default async function TablePage() { - let databases: { name: string; count: number }[] = [] - try { - // List database names and number of tables - databases = await fetchData({ query: listDatabases }) - - if (!databases.length) { - return - } - } catch (e: any) { - return ( - - ) - } - - const targetUrl = `/tables/${databases[0].name}` - - // Redirect to the first database - redirect(targetUrl) - - return `Redirecting to ${targetUrl} ...` +export default function TablePage() { + // Redirect to the default database + redirect('/tables/default') } diff --git a/app/layout.tsx b/app/layout.tsx index 1d7ba940..4b58432b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,7 +8,7 @@ import { AppProvider } from '@/app/context' import { Header } from '@/components/header' import { Toaster } from '@/components/ui/toaster' import { Suspense } from 'react' -import { Background } from './background' +import { BackgroundJobs } from './background-jobs' import { PageView } from './pageview' const inter = Inter({ subsets: ['latin'] }) @@ -20,8 +20,6 @@ export const metadata: Metadata = { description: 'Simple UI for ClickHouse Monitoring', } -export const dynamic = 'force-dynamic' - export default function RootLayout({ children, }: { @@ -52,7 +50,7 @@ export default function RootLayout({ - + diff --git a/components/error-alert.cy.tsx b/components/error-alert.cy.tsx index 8678df4d..032eeea7 100644 --- a/components/error-alert.cy.tsx +++ b/components/error-alert.cy.tsx @@ -22,4 +22,15 @@ describe('', () => { cy.contains('Something went wrong').should('be.visible') cy.get('div').should('have.class', 'bg-green-300') }) + + it('renders with debug query', () => { + cy.mount( + + ) + cy.contains('SELECT 1').should('be.visible') + }) }) diff --git a/components/error-alert.tsx b/components/error-alert.tsx index 406f9a79..38bc10f0 100644 --- a/components/error-alert.tsx +++ b/components/error-alert.tsx @@ -1,11 +1,11 @@ -import React from 'react' - import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import React from 'react' interface ErrorAlertProps { title?: string message?: string | React.ReactNode | React.ReactNode[] + query?: string reset?: () => void className?: string } @@ -13,28 +13,29 @@ interface ErrorAlertProps { export function ErrorAlert({ title = 'Something went wrong!', message = 'Checking console for more details.', + query, reset, className, }: ErrorAlertProps) { + const renderContent = ( + content: string | React.ReactNode | React.ReactNode[] + ) => ( +
+ {content} +
+ ) + return ( {title} -
- {message} -
- - {reset ? ( - - ) : null} + )}
) diff --git a/components/menu/count-badge.tsx b/components/menu/count-badge.tsx index 5072e08c..2614c92e 100644 --- a/components/menu/count-badge.tsx +++ b/components/menu/count-badge.tsx @@ -20,10 +20,12 @@ export async function CountBadge({ data = await fetchData<{ 'count()': string }[]>({ query: sql, format: 'JSONEachRow', - clickhouse_settings: { use_query_cache: 1, query_cache_ttl: 60 }, + clickhouse_settings: { use_query_cache: 1, query_cache_ttl: 120 }, }) } catch (e: any) { - console.error(`Could not get count for sql: ${sql}, error: ${e}`) + console.error( + `: could not get count for sql: ${sql}, error: ${e}` + ) return null } diff --git a/components/menu/menu-navigation-style.tsx b/components/menu/menu-navigation-style.tsx index 2e9cd2a7..a70cc5f0 100644 --- a/components/menu/menu-navigation-style.tsx +++ b/components/menu/menu-navigation-style.tsx @@ -57,7 +57,7 @@ function SingleItem({ item }: { item: MenuItem }) { {item.icon && } {item.title} {item.countSql ? ( - + ) : null} @@ -76,7 +76,7 @@ function HasChildItems({ item }: { item: MenuItem }) { {item.icon && } {item.title} {item.countSql ? ( - + ) : null} @@ -92,7 +92,7 @@ function HasChildItems({ item }: { item: MenuItem }) { {childItem.icon && } {childItem.title} {childItem.countSql ? ( - + ReactNode) + errorFallback?: null | string | number | ((props: FallbackProps) => ReactNode) } export function ServerComponentLazy({ children, - fallback, + errorFallback, }: ServerComponentLazyProps) { let fallbackRender: (props: FallbackProps) => ReactNode - if (fallback === null) { + if (errorFallback === null) { fallbackRender = () => null - } else if (typeof fallback === 'string' || typeof fallback === 'number') { - fallbackRender = () => {fallback} + } else if ( + typeof errorFallback === 'string' || + typeof errorFallback === 'number' + ) { + fallbackRender = () => {errorFallback} } else { fallbackRender = defaultFallbackRender } diff --git a/components/skeleton.tsx b/components/skeleton.tsx index 931ec3f9..fc47e9e8 100644 --- a/components/skeleton.tsx +++ b/components/skeleton.tsx @@ -15,13 +15,31 @@ export function ChartSkeleton() { ) } -export function TableSkeleton() { +export function TableSkeleton({ + rows = 3, + cols = 4, +}: { + rows?: number + cols?: number +}) { return ( -
-
- - -
+
+ {Array.from({ length: rows }).map((_, i) => ( +
+ {Array.from({ length: cols }).map((_, j) => ( + + ))} +
+ ))} +
+ ) +} + +export function SingleLineSkeleton() { + return ( +
+ +
) } diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index c8205145..e32267a3 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -45,6 +45,7 @@ export const fetchData = async < format = 'JSONEachRow', clickhouse_settings, }: QueryParams): Promise => { + const start = new Date() const client = getClient({ web: false }) const resultSet = await client.query({ query: QUERY_COMMENT + query, @@ -56,6 +57,8 @@ export const fetchData = async < const query_id = resultSet.query_id const data = await resultSet.json() + const end = new Date() + const duration = (end.getTime() - start.getTime()) / 1000 console.debug( `--> Query (${query_id}):`, @@ -65,7 +68,13 @@ export const fetchData = async < if (data === null) { console.debug(`<-- Response (${query_id}):`, 'null\n') } else if (Array.isArray(data)) { - console.debug(`<-- Response (${query_id}):`, data.length, 'rows\n') + console.debug( + `<-- Response (${query_id}):`, + data.length, + `rows in`, + duration, + 's\n' + ) } else if ( typeof data === 'object' && data.hasOwnProperty('rows') && @@ -79,7 +88,7 @@ export const fetchData = async < '\n' ) } else if (typeof data === 'object' && data.hasOwnProperty('rows')) { - console.debug(`<-- Response (${query_id}):`, data.rows, 'rows\n') + console.debug(`<-- Response (${query_id}): ${data.rows} rows\n`) } else { console.debug(`<-- Response (${query_id}):`, data, '\n') }