From 0e6da974f555b3b43d355a3c491b7d38db4f4d82 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Wed, 22 Nov 2023 16:27:52 +0700 Subject: [PATCH] feat: action kill query --- app/[name]/clickhouse-queries.ts | 3 +- components/data-table/actions/action-item.tsx | 89 +++++++++ components/data-table/actions/action-menu.tsx | 38 ++++ components/data-table/actions/actions.ts | 13 ++ components/data-table/actions/types.ts | 1 + components/data-table/cell.tsx | 44 ++-- components/data-table/columns.tsx | 14 +- components/data-table/data-table.tsx | 2 - components/ui/toast.tsx | 127 ++++++++++++ components/ui/toaster.tsx | 35 ++++ components/ui/use-toast.ts | 189 ++++++++++++++++++ lib/types/query-config.ts | 18 +- package.json | 1 + yarn.lock | 36 ++-- 14 files changed, 559 insertions(+), 51 deletions(-) create mode 100644 components/data-table/actions/action-item.tsx create mode 100644 components/data-table/actions/action-menu.tsx create mode 100644 components/data-table/actions/actions.ts create mode 100644 components/data-table/actions/types.ts create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/use-toast.ts diff --git a/app/[name]/clickhouse-queries.ts b/app/[name]/clickhouse-queries.ts index aebac5cc..803352bc 100644 --- a/app/[name]/clickhouse-queries.ts +++ b/app/[name]/clickhouse-queries.ts @@ -22,11 +22,12 @@ export const queries: Array = [ 'readable_read_rows', 'readable_total_rows_approx', 'readable_memory_usage', + 'query_id', ], columnFormats: { query: ColumnFormat.CodeToggle, elapsed: ColumnFormat.Duration, - query_id: ColumnFormat.None, + query_id: [ColumnFormat.Action, ['kill-query']], }, relatedCharts: [ [ diff --git a/components/data-table/actions/action-item.tsx b/components/data-table/actions/action-item.tsx new file mode 100644 index 00000000..b0d669f1 --- /dev/null +++ b/components/data-table/actions/action-item.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useState } from 'react' +import { + CheckCircledIcon, + ExclamationTriangleIcon, + UpdateIcon, +} from '@radix-ui/react-icons' + +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { useToast } from '@/components/ui/use-toast' + +import { killQuery } from './actions' +import type { Action } from './types' + +type Message = { + message: string +} + +interface ActionButtonProps { + action: Action + value: any +} + +export function ActionItem({ action, value }: ActionButtonProps) { + const { toast, dismiss } = useToast() + const [status, updateStatus] = useState< + 'none' | 'loading' | 'success' | 'failed' + >('none') + + const availableActions: { + [key: string]: { label: string; handler: (_: FormData) => Promise } + } = { + 'kill-query': { + label: 'Kill Query', + handler: killQuery.bind(null, value), + }, + } + + const { label, handler } = availableActions[action] + + return ( +
{ + updateStatus('loading') + toast({ title: 'Message', description: 'Loading...' }) + + try { + const msg: Message = await handler(formData) + console.debug('Action Response', msg) + updateStatus('success') + toast({ title: 'Message', description: msg.message }) + } catch (e) { + updateStatus('failed') + toast({ title: 'Error', description: `${e}`, variant: 'destructive' }) + } finally { + dismiss() + } + }} + > + + {status == 'loading' && ( + + {label} + + )} + + {status == 'failed' && ( + + {' '} + {label} + + )} + + {status == 'success' && ( + + {label} + + )} + + {status == 'none' && ( + + )} + +
+ ) +} diff --git a/components/data-table/actions/action-menu.tsx b/components/data-table/actions/action-menu.tsx new file mode 100644 index 00000000..e284cd7d --- /dev/null +++ b/components/data-table/actions/action-menu.tsx @@ -0,0 +1,38 @@ +import { MoreHorizontal } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +import { ActionItem } from './action-item' +import type { Action } from './types' + +interface ActionButtonProps { + value: any + actions: Action[] +} + +export function ActionMenu({ value, actions }: ActionButtonProps) { + return ( + + + + + + Actions + + {actions.map((action) => ( + + ))} + + + ) +} diff --git a/components/data-table/actions/actions.ts b/components/data-table/actions/actions.ts new file mode 100644 index 00000000..e3cf9434 --- /dev/null +++ b/components/data-table/actions/actions.ts @@ -0,0 +1,13 @@ +'use server' + +import { fetchData } from '@/lib/clickhouse' + +export async function killQuery(queryId: string) { + console.log('Killing query', queryId) + const res = await fetchData(`KILL QUERY WHERE query_id = '${queryId}'`) + console.log('Killed query', queryId, res) + + return { + message: `Killed query ${queryId}`, + } +} diff --git a/components/data-table/actions/types.ts b/components/data-table/actions/types.ts new file mode 100644 index 00000000..53c6dc7e --- /dev/null +++ b/components/data-table/actions/types.ts @@ -0,0 +1 @@ +export type Action = 'kill-query' diff --git a/components/data-table/cell.tsx b/components/data-table/cell.tsx index f8a90a46..ceec8b8d 100644 --- a/components/data-table/cell.tsx +++ b/components/data-table/cell.tsx @@ -1,5 +1,4 @@ import { CheckCircledIcon, CrossCircledIcon } from '@radix-ui/react-icons' -import { MoreHorizontal } from 'lucide-react' import dayjs from '@/lib/dayjs' import { formatReadableQuantity } from '@/lib/format-readable' @@ -9,20 +8,19 @@ import { AccordionItem, AccordionTrigger, } from '@/components/ui/accordion' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { ColumnFormat } from '@/components/data-table/columns' +import { ActionMenu } from './actions/action-menu' +import type { Action } from './actions/types' + const CODE_TRUNCATE_LENGTH = 50 -export const formatCell = (row: any, value: any, format: ColumnFormat) => { +export const formatCell = ( + row: any, + value: any, + format: ColumnFormat, + columnFormatOptions: Action[] = [] +) => { switch (format) { case ColumnFormat.Code: return {value} @@ -39,7 +37,12 @@ export const formatCell = (row: any, value: any, format: ColumnFormat) => { } return ( - + row.toggleExpanded(value === 'code')} + > @@ -71,22 +74,7 @@ export const formatCell = (row: any, value: any, format: ColumnFormat) => { ) case ColumnFormat.Action: - return ( - - - - - - Actions - - Kill Query - Detail - - - ) + return case ColumnFormat.Badge: return ( diff --git a/components/data-table/columns.tsx b/components/data-table/columns.tsx index df686ffb..a15e45c5 100644 --- a/components/data-table/columns.tsx +++ b/components/data-table/columns.tsx @@ -49,11 +49,16 @@ export const getColumnDefs = (config: QueryConfig): ColumnDef[] => { return configColumns.map((column) => { const name = normalizeColumnName(column) - const columnFormat = + const format = config.columnFormats?.[column] || config.columnFormats?.[name] || ColumnFormat.None + let [columnFormat, columnFormatOptions] = [format, undefined] + if (Array.isArray(format) && format.length === 2) { + ;[columnFormat, columnFormatOptions] = format + } + return { id: name, accessorKey: column, @@ -78,7 +83,12 @@ export const getColumnDefs = (config: QueryConfig): ColumnDef[] => { cell: ({ row, getValue }) => { const value = getValue() - const formatted = formatCell(row, value, columnFormat) + const formatted = formatCell( + row, + value, + columnFormat, + columnFormatOptions + ) return formatted }, diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx index f17c9d86..51f50dcf 100644 --- a/components/data-table/data-table.tsx +++ b/components/data-table/data-table.tsx @@ -72,8 +72,6 @@ export function DataTable({ const [columnVisibility, setColumnVisibility] = useState( initialColumnVisibility ) - console.log('columnVisibility', columnVisibility) - console.log('columnDefs', columnDefs) // Sorting const [sorting, setSorting] = useState([]) diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 00000000..7099bdb9 --- /dev/null +++ b/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from 'react' +import { Cross2Icon } from '@radix-ui/react-icons' +import * as ToastPrimitives from '@radix-ui/react-toast' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none', + { + variants: { + variant: { + default: 'bg-background text-foreground border', + destructive: + 'destructive border-destructive bg-destructive text-destructive-foreground group', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx new file mode 100644 index 00000000..c8d9878a --- /dev/null +++ b/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +'use client' + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/components/ui/use-toast.ts b/components/ui/use-toast.ts new file mode 100644 index 00000000..b7602361 --- /dev/null +++ b/components/ui/use-toast.ts @@ -0,0 +1,189 @@ +// Inspired by react-hot-toast library +import * as React from 'react' + +import type { ToastActionElement, ToastProps } from '@/components/ui/toast' + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: 'ADD_TOAST', + UPDATE_TOAST: 'UPDATE_TOAST', + DISMISS_TOAST: 'DISMISS_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST', +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType['ADD_TOAST'] + toast: ToasterToast + } + | { + type: ActionType['UPDATE_TOAST'] + toast: Partial + } + | { + type: ActionType['DISMISS_TOAST'] + toastId?: ToasterToast['id'] + } + | { + type: ActionType['REMOVE_TOAST'] + toastId?: ToasterToast['id'] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: 'REMOVE_TOAST', + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case 'UPDATE_TOAST': + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case 'DISMISS_TOAST': { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case 'REMOVE_TOAST': + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: 'UPDATE_TOAST', + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) + + dispatch({ + type: 'ADD_TOAST', + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), + } +} + +export { useToast, toast } diff --git a/lib/types/query-config.ts b/lib/types/query-config.ts index 820c6ffd..de3f76f8 100644 --- a/lib/types/query-config.ts +++ b/lib/types/query-config.ts @@ -6,6 +6,22 @@ export interface QueryConfig { description?: string sql: string columns: string[] - columnFormats?: { [key: string]: ColumnFormat } + /** + * Column format can be specified as a enum ColumnFormat + * or an array of two elements [ColumnFormat.Action, arg] + * + * Example: + * + * ```ts + * columnFormats: { + * query: ColumnFormat.Code, + * changed: ColumnFormat.Boolean, + * query_id: [ColumnFormat.Action, ['kill-query']], + * } + * ``` + */ + columnFormats?: { + [key: string]: ColumnFormat | [ColumnFormat.Action, string[]] + } relatedCharts?: string[] | [string, ChartProps][] } diff --git a/package.json b/package.json index 40a6fc51..e6e9a621 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-table": "^8.10.7", "@tremor/react": "^3.11.1", diff --git a/yarn.lock b/yarn.lock index 43f153aa..75755e0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -853,23 +853,6 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.5" -"@radix-ui/react-menubar@^1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@radix-ui/react-menubar/-/react-menubar-1.0.4.tgz#7d46ababfec63db3868d9ed79366686634c1201a" - integrity sha512-bHgUo9gayKZfaQcWSSLr++LyS0rgh+MvD89DE4fJ6TkGHvjHgPaBZf44hdka7ogOxIOdj9163J+5xL2Dn4qzzg== - dependencies: - "@babel/runtime" "^7.13.10" - "@radix-ui/primitive" "1.0.1" - "@radix-ui/react-collection" "1.0.3" - "@radix-ui/react-compose-refs" "1.0.1" - "@radix-ui/react-context" "1.0.1" - "@radix-ui/react-direction" "1.0.1" - "@radix-ui/react-id" "1.0.1" - "@radix-ui/react-menu" "2.0.6" - "@radix-ui/react-primitive" "1.0.3" - "@radix-ui/react-roving-focus" "1.0.4" - "@radix-ui/react-use-controllable-state" "1.0.1" - "@radix-ui/react-navigation-menu@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.1.4.tgz#654151310c3f9a29afd19fb60ddc7977e54b8a3d" @@ -1055,6 +1038,25 @@ "@radix-ui/react-roving-focus" "1.0.4" "@radix-ui/react-use-controllable-state" "1.0.1" +"@radix-ui/react-toast@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.5.tgz#f5788761c0142a5ae9eb97f0051fd3c48106d9e6" + integrity sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-visually-hidden" "1.0.3" + "@radix-ui/react-tooltip@^1.0.7": version "1.0.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"