Indexes.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import { useParams } from 'common'
  2. import { sortBy } from 'lodash'
  3. import { AlertCircle, Search, Trash } from 'lucide-react'
  4. import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
  5. import { useEffect, useRef, useState } from 'react'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. Card,
  10. SidePanel,
  11. Table,
  12. TableBody,
  13. TableCell,
  14. TableHead,
  15. TableHeader,
  16. TableRow,
  17. } from 'ui'
  18. import { Input } from 'ui-patterns/DataInputs/Input'
  19. import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
  20. import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  21. import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
  22. import { CreateIndexSidePanel } from './CreateIndexSidePanel'
  23. import AlertError from '@/components/ui/AlertError'
  24. import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
  25. import SchemaSelector from '@/components/ui/SchemaSelector'
  26. import { Shortcut } from '@/components/ui/Shortcut'
  27. import { useDatabaseIndexDeleteMutation } from '@/data/database-indexes/index-delete-mutation'
  28. import { useIndexesQuery, type DatabaseIndex } from '@/data/database-indexes/indexes-query'
  29. import { useSchemasQuery } from '@/data/database/schemas-query'
  30. import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
  31. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  32. import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
  33. import { onSearchInputEscape } from '@/lib/keyboard'
  34. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  35. import { useShortcut } from '@/state/shortcuts/useShortcut'
  36. export const Indexes = () => {
  37. const { data: project } = useSelectedProjectQuery()
  38. const { schema: urlSchema, table } = useParams()
  39. const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
  40. const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
  41. const searchInputRef = useRef<HTMLInputElement>(null)
  42. const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
  43. const {
  44. data: allIndexes,
  45. error: indexesError,
  46. isPending: isLoadingIndexes,
  47. isSuccess: isSuccessIndexes,
  48. isError: isErrorIndexes,
  49. } = useIndexesQuery({
  50. schema: selectedSchema,
  51. projectRef: project?.ref,
  52. connectionString: project?.connectionString,
  53. })
  54. const [showCreateIndex, setShowCreateIndex] = useQueryState(
  55. 'new',
  56. parseAsBoolean.withDefault(false)
  57. )
  58. const [editIndexId, setEditIndexId] = useQueryState('edit', parseAsString)
  59. const selectedIndex = allIndexes?.find((idx) => idx.name === editIndexId)
  60. const [deleteIndexId, setDeleteIndexId] = useQueryState('delete', parseAsString)
  61. const selectedIndexToDelete = allIndexes?.find((idx) => idx.name === deleteIndexId)
  62. const {
  63. data: schemas,
  64. isPending: isLoadingSchemas,
  65. isSuccess: isSuccessSchemas,
  66. isError: isErrorSchemas,
  67. } = useSchemasQuery({
  68. projectRef: project?.ref,
  69. connectionString: project?.connectionString,
  70. })
  71. const {
  72. mutate: deleteIndex,
  73. isPending: isExecuting,
  74. isSuccess: isSuccessDelete,
  75. } = useDatabaseIndexDeleteMutation({
  76. onSuccess: async () => {
  77. setDeleteIndexId(null)
  78. toast.success('Successfully deleted index')
  79. },
  80. })
  81. const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
  82. useShortcut(
  83. SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
  84. () => {
  85. searchInputRef.current?.focus()
  86. searchInputRef.current?.select()
  87. },
  88. { label: 'Search indexes' }
  89. )
  90. useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
  91. setSearch('')
  92. })
  93. const sortedIndexes = sortBy(allIndexes ?? [], (index) => index.name.toLocaleLowerCase())
  94. const indexes =
  95. search.length > 0
  96. ? sortedIndexes.filter((index) => index.name.includes(search) || index.table.includes(search))
  97. : sortedIndexes
  98. const onConfirmDeleteIndex = (index: DatabaseIndex) => {
  99. if (!project) return console.error('Project is required')
  100. deleteIndex({
  101. projectRef: project.ref,
  102. connectionString: project.connectionString,
  103. name: index.name,
  104. schema: selectedSchema,
  105. })
  106. }
  107. useEffect(() => {
  108. if (urlSchema !== undefined) {
  109. const schema = schemas?.find((s) => s.name === urlSchema)
  110. if (schema !== undefined) setSelectedSchema(schema.name)
  111. }
  112. }, [urlSchema, isSuccessSchemas])
  113. useEffect(() => {
  114. if (table !== undefined) setSearch(table)
  115. }, [table])
  116. useEffect(() => {
  117. if (isSuccessIndexes && !!editIndexId && !selectedIndex) {
  118. toast('Index not found')
  119. setEditIndexId(null)
  120. }
  121. }, [isSuccessIndexes, editIndexId, selectedIndex, setEditIndexId])
  122. useEffect(() => {
  123. if (isSuccessIndexes && !!deleteIndexId && !selectedIndexToDelete && !isSuccessDelete) {
  124. toast('Index not found')
  125. setDeleteIndexId(null)
  126. }
  127. }, [isSuccessIndexes, deleteIndexId, selectedIndexToDelete, isSuccessDelete, setDeleteIndexId])
  128. return (
  129. <>
  130. <div className="pb-8">
  131. <div className="flex flex-col gap-y-4">
  132. <div className="flex items-center gap-2 flex-wrap">
  133. {isLoadingSchemas && <ShimmeringLoader className="w-[260px]" />}
  134. {isErrorSchemas && (
  135. <div className="w-[260px] text-foreground-light text-sm border px-3 py-1.5 rounded-sm flex items-center space-x-2">
  136. <AlertCircle strokeWidth={2} size={16} />
  137. <p>Failed to load schemas</p>
  138. </div>
  139. )}
  140. {isSuccessSchemas && (
  141. <Shortcut
  142. id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
  143. onTrigger={() => setSchemaSelectorOpen(true)}
  144. side="bottom"
  145. tooltipOpen={schemaSelectorOpen ? false : undefined}
  146. >
  147. <SchemaSelector
  148. className="w-full lg:w-[180px]"
  149. size="tiny"
  150. showError={false}
  151. selectedSchemaName={selectedSchema}
  152. onSelectSchema={setSelectedSchema}
  153. open={schemaSelectorOpen}
  154. onOpenChange={setSchemaSelectorOpen}
  155. />
  156. </Shortcut>
  157. )}
  158. <Input
  159. ref={searchInputRef}
  160. size="tiny"
  161. value={search}
  162. className="w-full lg:w-52"
  163. onChange={(e) => setSearch(e.target.value)}
  164. onKeyDown={onSearchInputEscape(search, setSearch)}
  165. placeholder="Search for an index"
  166. icon={<Search />}
  167. />
  168. {!isSchemaLocked && (
  169. <Shortcut
  170. id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
  171. label="Create new index"
  172. onTrigger={() => setShowCreateIndex(true)}
  173. options={{ enabled: isSuccessSchemas }}
  174. side="bottom"
  175. >
  176. <Button
  177. className="ml-auto grow lg:grow-0"
  178. type="primary"
  179. onClick={() => setShowCreateIndex(true)}
  180. disabled={!isSuccessSchemas}
  181. >
  182. Create index
  183. </Button>
  184. </Shortcut>
  185. )}
  186. </div>
  187. {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="indexes" />}
  188. {isLoadingIndexes && <GenericSkeletonLoader />}
  189. {isErrorIndexes && (
  190. <AlertError error={indexesError as any} subject="Failed to retrieve database indexes" />
  191. )}
  192. {isSuccessIndexes && (
  193. <div className="w-full overflow-hidden">
  194. <Card>
  195. <Table>
  196. <TableHeader>
  197. <TableRow>
  198. <TableHead key="table">Table</TableHead>
  199. <TableHead key="columns">Columns</TableHead>
  200. <TableHead key="name">Name</TableHead>
  201. <TableHead key="buttons" />
  202. </TableRow>
  203. </TableHeader>
  204. <TableBody>
  205. {indexes.length === 0 && search.length === 0 && (
  206. <TableRow>
  207. <TableCell colSpan={4}>
  208. <p className="text-sm text-foreground">No indexes created yet</p>
  209. <p className="text-sm text-foreground-light">
  210. There are no indexes found in the schema "{selectedSchema}"
  211. </p>
  212. </TableCell>
  213. </TableRow>
  214. )}
  215. {indexes.length === 0 && search.length > 0 && (
  216. <TableRow>
  217. <TableCell colSpan={4}>
  218. <p className="text-sm text-foreground">No results found</p>
  219. <p className="text-sm text-foreground-light">
  220. Your search for "{search}" did not return any results
  221. </p>
  222. </TableCell>
  223. </TableRow>
  224. )}
  225. {indexes.length > 0 &&
  226. indexes.map((index) => (
  227. <TableRow key={index.name}>
  228. <TableCell>
  229. <p title={index.table}>{index.table}</p>
  230. </TableCell>
  231. <TableCell>
  232. <p title={index.columns}>{index.columns}</p>
  233. </TableCell>
  234. <TableCell>
  235. <p title={index.name}>{index.name}</p>
  236. </TableCell>
  237. <TableCell>
  238. <div className="flex justify-end items-center space-x-2">
  239. <Button type="default" onClick={() => setEditIndexId(index.name)}>
  240. View definition
  241. </Button>
  242. {!isSchemaLocked && (
  243. <Button
  244. aria-label="Delete index"
  245. type="text"
  246. className="px-1"
  247. icon={<Trash />}
  248. onClick={() => setDeleteIndexId(index.name)}
  249. />
  250. )}
  251. </div>
  252. </TableCell>
  253. </TableRow>
  254. ))}
  255. </TableBody>
  256. </Table>
  257. </Card>
  258. </div>
  259. )}
  260. </div>
  261. </div>
  262. <SidePanel
  263. size="xlarge"
  264. visible={!!selectedIndex}
  265. header={
  266. <>
  267. <span>Index:</span>
  268. <code className="text-sm ml-2">{selectedIndex?.name}</code>
  269. </>
  270. }
  271. onCancel={() => setEditIndexId(null)}
  272. >
  273. <div className="h-full">
  274. <div className="relative h-full">
  275. <CodeEditor
  276. isReadOnly
  277. id={selectedIndex?.name ?? ''}
  278. language="pgsql"
  279. defaultValue={selectedIndex?.definition ?? ''}
  280. />
  281. </div>
  282. </div>
  283. </SidePanel>
  284. <CreateIndexSidePanel visible={showCreateIndex} onClose={() => setShowCreateIndex(false)} />
  285. <ConfirmationModal
  286. variant="warning"
  287. size="medium"
  288. loading={isExecuting}
  289. visible={!!selectedIndexToDelete}
  290. title={
  291. <>
  292. Confirm to delete index{' '}
  293. <code className="text-code-inline">{selectedIndexToDelete?.name}</code>
  294. </>
  295. }
  296. confirmLabel="Confirm delete"
  297. confirmLabelLoading="Deleting..."
  298. onConfirm={() =>
  299. selectedIndexToDelete !== undefined ? onConfirmDeleteIndex(selectedIndexToDelete) : {}
  300. }
  301. onCancel={() => setDeleteIndexId(null)}
  302. alert={{
  303. title: 'This action cannot be undone',
  304. description:
  305. 'Deleting an index that is still in use will cause queries to slow down, and in some cases causing significant performance issues.',
  306. }}
  307. className="pt-0"
  308. >
  309. <ul className="mt-4 space-y-5">
  310. <li className="flex gap-3">
  311. <div>
  312. <strong className="text-sm">Before deleting this index, consider:</strong>
  313. <ul className="space-y-2 mt-2 text-sm text-foreground-light">
  314. <li className="list-disc ml-6">This index is no longer in use</li>
  315. <li className="list-disc ml-6">
  316. The table which the index is on is not currently in use, as dropping an index
  317. requires a short exclusive access lock on the table.
  318. </li>
  319. </ul>
  320. </div>
  321. </li>
  322. </ul>
  323. </ConfirmationModal>
  324. </>
  325. )
  326. }