mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 23:29:08 +03:00
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useRef, useCallback, memo } from 'react'
|
|
import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { Button } from 'ui'
|
|
import { Ban, ChevronDown, Loader2, Terminal, Trash2, XCircle } from 'lucide-react'
|
|
import dayjs from 'dayjs'
|
|
import hljs from 'highlight.js/lib/core'
|
|
import sql from 'highlight.js/lib/languages/sql'
|
|
import 'highlight.js/styles/atom-one-dark.css'
|
|
import { format as sqlFormat } from 'sql-formatter'
|
|
import sqlExecutionsState, { useSqlExecutions, type SqlExecution } from 'state/sql-executions'
|
|
import clsx from 'clsx'
|
|
import { Separator } from 'ui'
|
|
import Link from 'next/link'
|
|
|
|
hljs.registerLanguage('sql', sql)
|
|
|
|
const formatTime = (ms: number) => {
|
|
const hours = Math.floor(ms / 3600000)
|
|
const minutes = Math.floor((ms % 3600000) / 60000)
|
|
const seconds = Math.floor((ms % 60000) / 1000)
|
|
const milliseconds = ms % 1000
|
|
|
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
|
|
}
|
|
|
|
const highlightSQL = (sql: string) => {
|
|
try {
|
|
const formattedSQL = sqlFormat(sql, {
|
|
language: 'postgresql',
|
|
keywordCase: 'upper',
|
|
linesBetweenQueries: 2,
|
|
indentStyle: 'standard',
|
|
logicalOperatorNewline: 'before',
|
|
expressionWidth: 50,
|
|
})
|
|
const highlightedSQL = hljs.highlight(formattedSQL, { language: 'sql' }).value
|
|
return { __html: highlightedSQL }
|
|
} catch (error) {
|
|
console.error('Error formatting SQL:', error)
|
|
const highlightedSQL = hljs.highlight(sql, { language: 'sql' }).value
|
|
return { __html: highlightedSQL }
|
|
}
|
|
}
|
|
|
|
// Memoize the SQLStatementItem component
|
|
const SQLStatementItem = memo(function SQLStatementItem({
|
|
statement,
|
|
}: {
|
|
statement: SqlExecution
|
|
}) {
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
const [isOverflowing, setIsOverflowing] = useState(false)
|
|
const sqlRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (sqlRef.current) {
|
|
setIsOverflowing(sqlRef.current.scrollHeight > 300)
|
|
}
|
|
}, [statement.sql])
|
|
|
|
return (
|
|
<div className="py-5 flex flex-col gap-5 px-5">
|
|
<div className="flex items-center gap-2">
|
|
<Tooltip_Shadcn_>
|
|
<TooltipTrigger_Shadcn_>
|
|
<div className="text-foreground-muted text-left">
|
|
-- {dayjs(statement.timestamp).format('YYYY-MM-DD HH:mm:ss.SSS')}
|
|
</div>
|
|
</TooltipTrigger_Shadcn_>
|
|
<TooltipContent_Shadcn_ side="top" className="flex flex-col gap-1 font-mono">
|
|
<div>
|
|
<span className="text-zinc-400">Local: </span>
|
|
{dayjs(statement.timestamp).format('DD MMM YYYY, HH:mm:ss.SSS')}
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-400">UTC: </span>
|
|
{dayjs(statement.timestamp).format('DD MMM YYYY, HH:mm:ss.SSS [UTC]')}
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-400">Timestamp: </span>
|
|
{statement.timestamp}
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-400">Status: </span>
|
|
{statement.status}
|
|
</div>
|
|
</TooltipContent_Shadcn_>
|
|
</Tooltip_Shadcn_>
|
|
|
|
{statement.url && (
|
|
<Link
|
|
href={statement.url}
|
|
className="text-foreground-muted hover:text-foreground transition"
|
|
>
|
|
{statement.url}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative flex flex-col gap-2">
|
|
<div
|
|
ref={sqlRef}
|
|
className={clsx(
|
|
'hljs overflow-x-auto whitespace-pre-wrap !bg-transparent text-xs',
|
|
!isExpanded && 'max-h-[300px] overflow-hidden'
|
|
)}
|
|
dangerouslySetInnerHTML={highlightSQL(statement.sql)}
|
|
/>
|
|
|
|
{isOverflowing && !isExpanded && (
|
|
<>
|
|
<div className="absolute bottom-0 inset-x-0 h-[150px] bg-gradient-to-t from-black from-20% via-black/50 to-transparent pointer-events-none" />
|
|
<div className="absolute bottom-8 left-0">
|
|
<Button
|
|
type="outline"
|
|
size="tiny"
|
|
onClick={() => setIsExpanded(true)}
|
|
className="rounded-full"
|
|
>
|
|
Show More
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{isOverflowing && isExpanded && (
|
|
<div>
|
|
<Button
|
|
type="outline"
|
|
size="tiny"
|
|
onClick={() => setIsExpanded(false)}
|
|
className="rounded-full"
|
|
>
|
|
Show Less
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<RunningTimer statement={statement} />
|
|
{statement.status === 'completed' && (
|
|
<motion.div
|
|
className="flex items-center gap-2"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<span className="text-foreground-muted">
|
|
Completed in {formatTime(statement.duration ?? 0)}
|
|
</span>
|
|
</motion.div>
|
|
)}
|
|
{statement.status === 'error' && (
|
|
<motion.div
|
|
className="flex items-center gap-2"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div className="h-2 w-2 rounded-full bg-destructive-300" />
|
|
<span className="text-destructive">Error</span>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{statement.status === 'error' && statement.error && (
|
|
<div className="text-destructive bg-destructive-100 border border-destructive-300 rounded px-4 py-3 flex flex-col gap-2">
|
|
{statement.error.title && (
|
|
<span className="font-bold text-destructive-600">{statement.error.title}</span>
|
|
)}
|
|
<span className="text-destructive-600 text-xs">
|
|
{statement.error.message || statement.error}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
// Add memo to exports
|
|
export const SQLStatementsViewer = memo(function SQLStatementsViewer() {
|
|
const { executions } = useSqlExecutions()
|
|
const [showJumpButton, setShowJumpButton] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const isScrolledToBottomRef = useRef(true)
|
|
|
|
// This useEffect should run whenever executions change
|
|
useEffect(() => {
|
|
if (isScrolledToBottomRef.current && containerRef.current) {
|
|
requestAnimationFrame(() => {
|
|
if (containerRef.current) {
|
|
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
|
}
|
|
})
|
|
}
|
|
}, [executions]) // Make sure we're watching executions
|
|
|
|
const isScrolledToBottom = useCallback(() => {
|
|
if (!containerRef.current) return true
|
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
|
|
return Math.abs(scrollHeight - clientHeight - scrollTop) < 10
|
|
}, [])
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
if (containerRef.current) {
|
|
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
|
}
|
|
setShowJumpButton(false)
|
|
}, [])
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const atBottom = isScrolledToBottom()
|
|
isScrolledToBottomRef.current = atBottom
|
|
setShowJumpButton(!atBottom)
|
|
}, [isScrolledToBottom])
|
|
|
|
return (
|
|
<div className="relative w-full h-full flex flex-col dark">
|
|
<div className="flex items-center justify-between h-8 border-b border-border/60 px-5 bg-black shadow-inner text-gray fill-gray">
|
|
<div className="flex items-center gap-2">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="lightgray"
|
|
className="size-5"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M2.25 6a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V6Zm3.97.97a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1 0 1.06l-2.25 2.25a.75.75 0 0 1-1.06-1.06l1.72-1.72-1.72-1.72a.75.75 0 0 1 0-1.06Zm4.28 4.28a.75.75 0 0 0 0 1.5h3a.75.75 0 0 0 0-1.5h-3Z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="text-xs font-mono text-white">Supabase Console</span>
|
|
</div>
|
|
<button onClick={() => sqlExecutionsState.clearExecutions()}>
|
|
<Ban size={12} />
|
|
</button>
|
|
</div>
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full h-full text-white font-mono text-sm overflow-auto scroll-smooth overflow-anchor-auto py-8"
|
|
onScroll={handleScroll}
|
|
>
|
|
<div className="flex flex-col gap-2 divide-y divide-border/50">
|
|
<AnimatePresence initial={false}>
|
|
{executions.map((execution, index) => (
|
|
<SQLStatementItem key={index} statement={execution} />
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
{showJumpButton && (
|
|
<Button
|
|
type="default"
|
|
className="absolute left-1/2 bottom-4 -translate-x-1/2 bg-black text-zinc-100 hover:bg-zinc-700 rounded-full px-4 py-2 flex items-center gap-2"
|
|
onClick={scrollToBottom}
|
|
icon={<ChevronDown className="h-4 w-4" />}
|
|
>
|
|
<span>Scroll to bottom</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
function RunningTimer({ statement }: { statement: SqlExecution }) {
|
|
const [elapsedTime, setElapsedTime] = useState(0)
|
|
const [showTimer, setShowTimer] = useState(false)
|
|
|
|
// Memoize the timer logic
|
|
useEffect(() => {
|
|
if (statement.status !== 'running') return
|
|
|
|
const startTime = statement.startedAt
|
|
const timerInterval = setInterval(() => {
|
|
setElapsedTime(Date.now() - startTime)
|
|
}, 100) // Increased interval from 10ms to 100ms
|
|
|
|
const timerTimeout = setTimeout(() => {
|
|
setShowTimer(true)
|
|
}, 200)
|
|
|
|
return () => {
|
|
clearInterval(timerInterval)
|
|
clearTimeout(timerTimeout)
|
|
}
|
|
}, [statement.status, statement.startedAt])
|
|
|
|
if (statement.status !== 'running') {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
className="flex items-center gap-2"
|
|
initial={false}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
>
|
|
<Loader2 className="h-4 w-4 animate-spin text-foreground-muted" />
|
|
<span className="text-foreground">
|
|
{showTimer ? `Running for ${formatTime(elapsedTime)}...` : 'Executing...'}
|
|
</span>
|
|
</motion.div>
|
|
)
|
|
}
|