From 794529eddf3e9d454fa2bbc169768bfd2a853d67 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 23 Jan 2025 17:06:28 +0800 Subject: [PATCH] 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 --- .../interfaces/Linter/LinterFilters.tsx | 33 +++- .../QueryPerformance/QueryPerformance.tsx | 20 +-- .../QueryPerformanceFilterBar.tsx | 19 ++- .../UtilityPanel/ResultsDropdown.tsx | 155 ------------------ .../SQLEditor/UtilityPanel/UtilityPanel.tsx | 10 +- .../components/ui/DownloadResultsButton.tsx | 105 ++++++++++++ apps/studio/components/ui/FilterPopover.tsx | 6 +- .../project/[ref]/advisors/performance.tsx | 23 ++- .../pages/project/[ref]/advisors/security.tsx | 33 ++-- 9 files changed, 188 insertions(+), 216 deletions(-) delete mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/ResultsDropdown.tsx create mode 100644 apps/studio/components/ui/DownloadResultsButton.tsx diff --git a/apps/studio/components/interfaces/Linter/LinterFilters.tsx b/apps/studio/components/interfaces/Linter/LinterFilters.tsx index 1fcab85e326..ff93e170674 100644 --- a/apps/studio/components/interfaces/Linter/LinterFilters.tsx +++ b/apps/studio/components/interfaces/Linter/LinterFilters.tsx @@ -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 ( -
+
{LINT_TABS.map((tab) => (
x.level === tab.id).length === 0} labelKey="name" @@ -45,6 +58,24 @@ const LinterFilters = ({ />
))} +
+ + +
) } diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx index c19a6bae911..c9fdfb6778a 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx @@ -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( (preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING ) @@ -190,14 +193,11 @@ export const QueryPerformance = ({ -
- { - setShowResetgPgStatStatements(true) - }} - /> -
+ setShowResetgPgStatStatements(true)} + /> + @@ -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)} > diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx index debc3a41c48..f346b74cfc7 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx @@ -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 ( -
+

Filter by

@@ -126,29 +129,27 @@ export const QueryPerformanceFilterBar = ({
{!showBottomSection && onResetReportClick && ( - )} +
) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ResultsDropdown.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ResultsDropdown.tsx deleted file mode 100644 index 0d6bd02e820..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ResultsDropdown.tsx +++ /dev/null @@ -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(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 ( - - - - - - - - - - -

Download CSV

-
- - -

Copy as markdown

-
- - -

Copy as JSON

-
-
-
- ) -} - -export default ResultsDropdown diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index eef6544215a..954059ffb99 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -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 = ({ Chart - {result?.rows && } + {result?.rows && ( + + )}
void + onCopyAsJSON?: () => void +} + +export const DownloadResultsButton = ({ + type = 'default', + align = 'start', + results, + fileName, + onCopyAsMarkdown, + onCopyAsJSON, +}: DownloadResultsButtonProps) => { + const csvRef = useRef(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 ( + <> + + + + + + csvRef.current?.link.click()}> + +

Download CSV

+
+ + +

Copy as markdown

+
+ + +

Copy as JSON

+
+
+
+ + + ) +} diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx index f3448d2880d..d3eed31644a 100644 --- a/apps/studio/components/ui/FilterPopover.tsx +++ b/apps/studio/components/ui/FilterPopover.tsx @@ -24,6 +24,7 @@ interface FilterPopoverProps { labelClass?: string maxHeightClass?: string clearButtonText?: string + className?: string onSaveFilters: (options: string[]) => void } @@ -39,6 +40,7 @@ export const FilterPopover = >({ buttonType, disabled, labelClass, + className, maxHeightClass = 'h-[205px]', clearButtonText = 'Clear', onSaveFilters, @@ -81,7 +83,7 @@ export const FilterPopover = >({
- +
{title ?? `Select ${name.toLowerCase()}`} @@ -108,7 +110,7 @@ export const FilterPopover = >({ /> {icon && ( { 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( (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 (
{ { 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( (preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR ) - const [selectedLint, setSelectedLint] = useState(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 (
{ />