Chore/add export functionality to advisors (#33030)

* Add export functionality to security and performance advisors

* Add download results button to query performance advisor

* Add refresh buttons to security and performance advisors

* Add LoadingLine to Query performance to make UI consistent with the other advisors

* Minor change
This commit is contained in:
Joshen Lim authored and GitHub committed 2025-01-23 17:06:28 +08:00
1 parent 79e656f735
commit 794529eddf
9 files changed
+188 -216

No files matched your search

@@ -1,6 +1,10 @@
import { useParams } from 'common'
import { LINTER_LEVELS, LINT_TABS } from 'components/interfaces/Linter/Linter.constants'
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
import { FilterPopover } from 'components/ui/FilterPopover'
import { Lint } from 'data/lint/lint-query'
import { RefreshCw } from 'lucide-react'
import { Button } from 'ui'
interface LinterFiltersProps {
filterOptions: {
@@ -8,18 +12,26 @@ interface LinterFiltersProps {
value: string
}[]
activeLints: Lint[]
filteredLints: Lint[]
currentTab: LINTER_LEVELS
filters: { level: LINTER_LEVELS; filters: string[] }[]
isLoading: boolean
setFilters: (value: { level: LINTER_LEVELS; filters: string[] }[]) => void
onClickRefresh: () => void
}
const LinterFilters = ({
filterOptions,
activeLints,
filteredLints,
currentTab,
filters,
isLoading,
setFilters,
onClickRefresh,
}: LinterFiltersProps) => {
const { ref } = useParams()
const updateFilters = (level: LINTER_LEVELS, newFilters: string[]) => {
const updatedFilters = [...filters]
@@ -31,11 +43,12 @@ const LinterFilters = ({
}
return (
<div className="bg-surface-200 p-2 px-6 py-2 border-t -mt-px">
<div className="px-6 py-2 -mt-px flex bg-surface-200 items-center justify-between border-t">
{LINT_TABS.map((tab) => (
<div key={tab.id} className={tab.id === currentTab ? '' : 'hidden'}>
<FilterPopover
name="Filter"
className="w-52"
options={filterOptions}
disabled={activeLints.filter((x) => x.level === tab.id).length === 0}
labelKey="name"
@@ -45,6 +58,24 @@ const LinterFilters = ({
/>
</div>
))}
<div className="flex items-center gap-x-2">
<Button
size="tiny"
type="default"
disabled={isLoading}
onClick={onClickRefresh}
icon={
<RefreshCw className={`text-foreground-light ${isLoading ? 'animate-spin' : ''}`} />
}
>
Refresh
</Button>
<DownloadResultsButton
align="end"
results={filteredLints}
fileName={`Supabase Performance Security Lints (${ref})`}
/>
</div>
</div>
)
}
@@ -13,6 +13,7 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import {
Button,
LoadingLine,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
Tabs_Shadcn_,
@@ -44,6 +45,8 @@ export const QueryPerformance = ({
const { project } = useProjectContext()
const state = useDatabaseSelectorStateSnapshot()
const { isLoading, isRefetching } = queryPerformanceQuery
const [page, setPage] = useState<QUERY_PERFORMANCE_REPORT_TYPES>(
(preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING
)
@@ -190,14 +193,11 @@ export const QueryPerformance = ({
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<div className="px-6 py-3 bg-surface-200 border-t -mt-px">
<QueryPerformanceFilterBar
queryPerformanceQuery={queryPerformanceQuery}
onResetReportClick={() => {
setShowResetgPgStatStatements(true)
}}
/>
</div>
<QueryPerformanceFilterBar
queryPerformanceQuery={queryPerformanceQuery}
onResetReportClick={() => setShowResetgPgStatStatements(true)}
/>
<LoadingLine loading={isLoading || isRefetching} />
<QueryPerformanceGrid queryPerformanceQuery={queryPerformanceQuery} />
@@ -210,9 +210,7 @@ export const QueryPerformance = ({
className="absolute top-1.5 right-3 px-1.5"
type="text"
size="tiny"
onClick={() => {
setShowBottomSection(false)
}}
onClick={() => setShowBottomSection(false)}
>
<X size="14" />
</Button>
@@ -2,7 +2,9 @@ import { ArrowDown, ArrowUp, RefreshCw } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
import { FilterPopover } from 'components/ui/FilterPopover'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
@@ -27,6 +29,7 @@ export const QueryPerformanceFilterBar = ({
onResetReportClick?: () => void
}) => {
const router = useRouter()
const { ref } = useParams()
const { project } = useProjectContext()
const [showBottomSection] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.QUERY_PERF_SHOW_BOTTOM_SECTION,
@@ -86,7 +89,7 @@ export const QueryPerformanceFilterBar = ({
}
return (
<div className="flex justify-between items-center">
<div className="px-6 py-2 bg-surface-200 border-t -mt-px flex justify-between items-center">
<div className="flex items-center gap-x-4">
<div className="flex items-center gap-x-2">
<p className="text-xs prose">Filter by</p>
@@ -126,29 +129,27 @@ export const QueryPerformanceFilterBar = ({
<div className="flex gap-2 items-center">
{!showBottomSection && onResetReportClick && (
<Button
onClick={() => {
onResetReportClick()
}}
type="default"
>
<Button type="default" onClick={() => onResetReportClick()}>
Reset report
</Button>
)}
<Button
type="default"
size="tiny"
type="default"
onClick={() => queryPerformanceQuery.runQuery()}
disabled={isLoading || isRefetching}
icon={
<RefreshCw
size={12}
className={`text-foreground-light ${isLoading || isRefetching ? 'animate-spin' : ''}`}
/>
}
>
Refresh
</Button>
<DownloadResultsButton
results={queryPerformanceQuery.data ?? []}
fileName={`Supabase Query Performance (${ref})`}
/>
</div>
</div>
)
@@ -1,155 +0,0 @@
import { compact, isObject, isString, map } from 'lodash'
import { ChevronDownIcon, Clipboard, Download } from 'lucide-react'
import { markdownTable } from 'markdown-table'
import { useMemo, useRef } from 'react'
import { CSVLink } from 'react-csv'
import { toast } from 'sonner'
import { useParams } from 'common'
import { TelemetryActions } from 'common/telemetry-constants'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { copyToClipboard } from 'lib/helpers'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
export type ResultsDropdownProps = {
id: string
}
const ResultsDropdown = ({ id }: ResultsDropdownProps) => {
const { project } = useProjectContext()
const snapV2 = useSqlEditorV2StateSnapshot()
const result = snapV2.results?.[id]?.[0] ?? undefined
const csvRef = useRef<CSVLink & HTMLAnchorElement & { link: HTMLAnchorElement }>(null)
const { ref } = useParams()
const org = useSelectedOrganization()
const { mutate: sendEvent } = useSendEventMutation()
const csvData = useMemo(() => {
if (result?.rows) {
const rows = Array.from(result.rows || []).map((row) => {
return map(row, (v, k) => {
if (isString(v)) {
// replace all newlines with the character \n
// escape all quotation marks
return v.replaceAll(/\n/g, '\\n').replaceAll(/"/g, '""')
}
if (isObject(v)) {
// replace all quotation marks with two quotation marks to escape them.
return JSON.stringify(v).replaceAll(/\"/g, '""')
}
return v
})
})
return compact(rows)
}
return ''
}, [result])
const headers = useMemo(() => {
if (result?.rows) {
const firstRow = Array.from(result.rows || [])[0]
if (firstRow) {
return Object.keys(firstRow)
}
}
// if undefined is returned no headers will be set. In this case, no headers would be better
// than malformed headers.
return undefined
}, [result])
function onDownloadCSV() {
csvRef.current?.link.click()
sendEvent({
action: TelemetryActions.SQL_EDITOR_RESULT_DOWNLOAD_CSV_CLICKED,
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
}
function onCopyAsMarkdown() {
if (navigator) {
if (!result || !result.rows) return 'results is empty'
if (result.rows.constructor !== Array && !!result.error) return result.error
if (result.rows.length == 0) return 'results is empty'
const columns = Object.keys(result.rows[0])
const rows = result.rows.map((x) => {
let temp: any[] = []
columns.forEach((col) => temp.push(x[col]))
return temp
})
const table = [columns].concat(rows)
const markdownData = markdownTable(table)
copyToClipboard(markdownData, () => {
toast.success('Copied results to clipboard')
sendEvent({
action: TelemetryActions.SQL_EDITOR_RESULT_COPY_MARKDOWN_CLICKED,
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
})
}
}
function onCopyAsJSON() {
if (navigator) {
if (!result || !result.rows) return 'results is empty'
if (result.rows.constructor !== Array && !!result.error) return result.error
if (result.rows.length == 0) return 'results is empty'
copyToClipboard(JSON.stringify(result.rows, null, 2), () => {
toast.success('Copied results to clipboard')
sendEvent({
action: TelemetryActions.SQL_EDITOR_RESULT_COPY_JSON_CLICKED,
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
})
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" iconRight={<ChevronDownIcon size={14} />}>
Export
</Button>
</DropdownMenuTrigger>
<CSVLink
ref={csvRef}
className="hidden"
headers={headers}
data={csvData}
filename={`supabase_${project?.ref}_${snapV2.snippets[id]?.snippet.name}.csv`}
/>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem onClick={onDownloadCSV} className="space-x-2">
<Download size={14} />
<p>Download CSV</p>
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopyAsMarkdown} className="space-x-2">
<Clipboard size={14} />
<p>Copy as markdown</p>
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopyAsJSON} className="space-x-2">
<Clipboard size={14} />
<p>Copy as JSON</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default ResultsDropdown
@@ -1,12 +1,12 @@
import { toast } from 'sonner'
import { useParams } from 'common'
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
import { Snippet } from 'data/content/sql-folders-query'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_ } from 'ui'
import { ChartConfig } from './ChartConfig'
import ResultsDropdown from './ResultsDropdown'
import UtilityActions from './UtilityActions'
import UtilityTabResults from './UtilityTabResults'
@@ -114,7 +114,13 @@ const UtilityPanel = ({
<TabsTrigger_Shadcn_ className="py-3 text-xs" value="chart">
<span className="translate-y-[1px]">Chart</span>
</TabsTrigger_Shadcn_>
{result?.rows && <ResultsDropdown id={id} />}
{result?.rows && (
<DownloadResultsButton
type="text"
results={result.rows as any[]}
fileName={`Supabase Snippet ${snippet.name}`}
/>
)}
</div>
<UtilityActions
id={id}
@@ -0,0 +1,105 @@
import { ChevronDown, Clipboard, Download } from 'lucide-react'
import { markdownTable } from 'markdown-table'
import { useMemo, useRef } from 'react'
import { CSVLink } from 'react-csv'
import { toast } from 'sonner'
import { copyToClipboard } from 'lib/helpers'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
interface DownloadResultsButtonProps {
type?: 'text' | 'default'
align?: 'start' | 'center' | 'end'
results: any[]
fileName: string
onCopyAsMarkdown?: () => void
onCopyAsJSON?: () => void
}
export const DownloadResultsButton = ({
type = 'default',
align = 'start',
results,
fileName,
onCopyAsMarkdown,
onCopyAsJSON,
}: DownloadResultsButtonProps) => {
const csvRef = useRef<CSVLink & HTMLAnchorElement & { link: HTMLAnchorElement }>(null)
const headers = useMemo(() => {
if (results) {
const firstRow = Array.from(results)[0]
if (firstRow) return Object.keys(firstRow)
}
return undefined
}, [results])
const copyAsMarkdown = () => {
if (navigator) {
if (results.length == 0) toast('Results are empty')
const columns = Object.keys(results[0])
const rows = results.map((x) => {
let temp: any[] = []
columns.forEach((col) => temp.push(x[col]))
return temp
})
const table = [columns].concat(rows)
const markdownData = markdownTable(table)
copyToClipboard(markdownData, () => {
toast.success('Copied results to clipboard')
onCopyAsMarkdown?.()
})
}
}
const copyAsJSON = () => {
if (navigator) {
if (results.length === 0) return toast('Results are empty')
copyToClipboard(JSON.stringify(results, null, 2), () => {
toast.success('Copied results to clipboard')
onCopyAsJSON?.()
})
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type={type} iconRight={<ChevronDown />} disabled={results.length === 0}>
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-44">
<DropdownMenuItem className="gap-x-2" onClick={() => csvRef.current?.link.click()}>
<Download size={14} />
<p>Download CSV</p>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyAsMarkdown} className="gap-x-2">
<Clipboard size={14} />
<p>Copy as markdown</p>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyAsJSON} className="gap-x-2">
<Clipboard size={14} />
<p>Copy as JSON</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CSVLink
ref={csvRef}
className="hidden"
headers={headers}
data={results}
filename={`${fileName}.csv`}
/>
</>
)
}
+4 -2
View File
@@ -24,6 +24,7 @@ interface FilterPopoverProps<T> {
labelClass?: string
maxHeightClass?: string
clearButtonText?: string
className?: string
onSaveFilters: (options: string[]) => void
}
@@ -39,6 +40,7 @@ export const FilterPopover = <T extends Record<string, any>>({
buttonType,
disabled,
labelClass,
className,
maxHeightClass = 'h-[205px]',
clearButtonText = 'Clear',
onSaveFilters,
@@ -81,7 +83,7 @@ export const FilterPopover = <T extends Record<string, any>>({
</div>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-44" align="start">
<PopoverContent_Shadcn_ className={cn('p-0 w-44', className)} align="start">
<div className="border-b border-overlay bg-surface-200 rounded-t pb-1 px-3">
<span className="text-xs text-foreground-light">
{title ?? `Select ${name.toLowerCase()}`}
@@ -108,7 +110,7 @@ export const FilterPopover = <T extends Record<string, any>>({
/>
<Label_Shadcn_
htmlFor={option[valueKey]}
className={cn('flex items-center gap-x-2', labelClass)}
className={cn('flex items-center gap-x-2 text-xs', labelClass)}
>
{icon && (
<img
@@ -16,7 +16,7 @@ import { LoadingLine } from 'ui'
const ProjectLints: NextPageWithLayout = () => {
const project = useSelectedProject()
const { preset, id } = useParams()
const { ref, preset, id } = useParams()
// need to maintain a list of filters for each tab
const [filters, setFilters] = useState<{ level: LINTER_LEVELS; filters: string[] }[]>([
@@ -24,7 +24,6 @@ const ProjectLints: NextPageWithLayout = () => {
{ level: LINTER_LEVELS.WARN, filters: [] },
{ level: LINTER_LEVELS.INFO, filters: [] },
])
const [currentTab, setCurrentTab] = useState<LINTER_LEVELS>(
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
)
@@ -34,25 +33,15 @@ const ProjectLints: NextPageWithLayout = () => {
projectRef: project?.ref,
})
let clientLints: Lint[] = []
const activeLints = useMemo(() => {
return [...(data ?? []), ...clientLints]?.filter((x) => x.categories.includes('PERFORMANCE'))
return [...(data ?? [])]?.filter((x) => x.categories.includes('PERFORMANCE'))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
useEffect(() => {
// check the URL for an ID and set the selected lint
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
}, [id, activeLints])
const currentTabFilters = (filters.find((filter) => filter.level === currentTab)?.filters ||
[]) as string[]
const filteredLints = activeLints
.filter((x) => x.level === currentTab)
.filter((x) => (currentTabFilters.length > 0 ? currentTabFilters.includes(x.name) : x))
const filterOptions = lintInfoMap
// only show filters for lint types which are present in the results and not ignored
.filter((item) =>
@@ -63,6 +52,11 @@ const ProjectLints: NextPageWithLayout = () => {
value: type.name,
}))
useEffect(() => {
// check the URL for an ID and set the selected lint
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
}, [id, activeLints])
return (
<div className="h-full flex flex-col">
<FormHeader
@@ -80,9 +74,12 @@ const ProjectLints: NextPageWithLayout = () => {
<LinterFilters
filterOptions={filterOptions}
activeLints={activeLints}
filteredLints={filteredLints}
currentTab={currentTab}
filters={filters}
isLoading={isLoading || isRefetching}
setFilters={setFilters}
onClickRefresh={refetch}
/>
<LoadingLine loading={isRefetching} />
<LinterDataGrid
@@ -16,7 +16,7 @@ import { LoadingLine } from 'ui'
const ProjectLints: NextPageWithLayout = () => {
const project = useSelectedProject()
const { preset, id } = useParams()
const { ref, preset, id } = useParams()
// need to maintain a list of filters for each tab
const [filters, setFilters] = useState<{ level: LINTER_LEVELS; filters: string[] }[]>([
@@ -24,42 +24,21 @@ const ProjectLints: NextPageWithLayout = () => {
{ level: LINTER_LEVELS.WARN, filters: [] },
{ level: LINTER_LEVELS.INFO, filters: [] },
])
const [currentTab, setCurrentTab] = useState<LINTER_LEVELS>(
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
)
const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
const {
data,
isLoading: areLintsLoading,
isRefetching,
refetch: refetchLintsQuery,
} = useProjectLintsQuery({
const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({
projectRef: project?.ref,
})
const isLoading = areLintsLoading
const refetch = () => {
refetchLintsQuery()
}
const activeLints = (data ?? []).filter((lint) => lint.categories.includes('SECURITY'))
useEffect(() => {
// check the URL for an ID and set the selected lint
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
}, [id, activeLints])
const currentTabFilters = (filters.find((filter) => filter.level === currentTab)?.filters ||
[]) as string[]
const filteredLints = activeLints
.filter((x) => x.level === currentTab)
.filter((x) => (currentTabFilters.length > 0 ? currentTabFilters.includes(x.name) : x))
const filterOptions = lintInfoMap
// only show filters for lint types which are present in the results and not ignored
.filter((item) =>
@@ -70,6 +49,11 @@ const ProjectLints: NextPageWithLayout = () => {
value: type.name,
}))
useEffect(() => {
// check the URL for an ID and set the selected lint
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
}, [id, activeLints])
return (
<div className="h-full flex flex-col">
<FormHeader
@@ -86,10 +70,13 @@ const ProjectLints: NextPageWithLayout = () => {
/>
<LinterFilters
filterOptions={filterOptions}
filteredLints={filteredLints}
activeLints={activeLints}
currentTab={currentTab}
filters={filters}
isLoading={isLoading || isRefetching}
setFilters={setFilters}
onClickRefresh={refetch}
/>
<LoadingLine loading={isRefetching} />
<LinterDataGrid