diff --git a/apps/studio/components/interfaces/Reports/ReportLints.utils.tsx b/apps/studio/components/interfaces/Reports/ReportLints.utils.tsx index bd6641455fc..106f2337eda 100644 --- a/apps/studio/components/interfaces/Reports/ReportLints.utils.tsx +++ b/apps/studio/components/interfaces/Reports/ReportLints.utils.tsx @@ -1,4 +1,5 @@ import { LINT_TYPES, Lint } from 'data/lint/lint-query' +import { AlertCircle, Box, Eye, Key, Lock, Table2, Unlock } from 'lucide-react' import Link from 'next/link' import { Button } from 'ui' @@ -6,40 +7,79 @@ const assertUnreachable = (n: never) => { console.error('Unhandled lint type', n) } -export function getHumanReadableTitle(title: LINT_TYPES) { - switch (title) { - case 'unindexed_foreign_keys': - return 'Unindexed foreign keys' - case 'auth_users_exposed': - return 'Exposed Auth Users' - case 'auth_rls_initplan': - return 'Auth RLS Initialization Plan' - case 'no_primary_key': - return 'No Primary Key' - case 'unused_index': - return 'Unused Index' - case 'multiple_permissive_policies': - return 'Multiple Permissive Policies' - case 'function_search_path_mutable': - return 'Function Search Path Mutable' - case 'rls_enabled_no_policy': - return 'RLS Enabled No Policy' - case 'policy_exists_rls_disabled': - return 'Policy Exists RLS Disabled' - case 'rls_disabled_in_public': - return 'RLS Disabled in Public' - case 'security_definer_view': - return 'Security Definer View' - case 'duplicate_index': - return 'Duplicate Index' - case 'extension_in_public': - return 'Extension in Public' - default: - assertUnreachable(title) - throw new Error('This case should never be reached') - } +interface LintInfo { + title: string + icon: JSX.Element } +export const lintInfoMap = [ + { + name: 'unindexed_foreign_keys', + title: 'Unindexed foreign keys', + icon: , + }, + { + name: 'auth_users_exposed', + title: 'Exposed Auth Users', + icon: , + }, + { + name: 'auth_rls_initplan', + title: 'Auth RLS Initialization Plan', + icon: , + }, + { + name: 'no_primary_key', + title: 'No Primary Key', + icon: , + }, + { + name: 'unused_index', + title: 'Unused Index', + icon: , + }, + { + name: 'multiple_permissive_policies', + title: 'Multiple Permissive Policies', + icon: , + }, + { + name: 'function_search_path_mutable', + title: 'Function Search Path Mutable', + icon: , + }, + { + name: 'rls_enabled_no_policy', + title: 'RLS Enabled No Policy', + icon: , + }, + { + name: 'policy_exists_rls_disabled', + title: 'Policy Exists RLS Disabled', + icon: , + }, + { + name: 'rls_disabled_in_public', + title: 'RLS Disabled in Public', + icon: , + }, + { + name: 'security_definer_view', + title: 'Security Definer View', + icon: , + }, + { + name: 'duplicate_index', + title: 'Duplicate Index', + icon: , + }, + { + name: 'extension_in_public', + title: 'Extension in Public', + icon: , + }, +] + export const LintCTA = ({ title, projectRef, @@ -57,6 +97,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}`} target="_blank" rel="noreferrer" + className="no-underline" > Create an index @@ -66,7 +107,12 @@ export const LintCTA = ({ case 'auth_users_exposed': return ( @@ -74,7 +120,12 @@ export const LintCTA = ({ case 'auth_rls_initplan': return ( @@ -82,7 +133,12 @@ export const LintCTA = ({ case 'no_primary_key': return ( @@ -94,6 +150,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}&table=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View index @@ -106,6 +163,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}&table=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View index @@ -118,6 +176,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View policies @@ -130,6 +189,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/database/functions?schema=${metadata?.schema}&search=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View functions @@ -142,6 +202,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View policies @@ -154,6 +215,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View policies @@ -166,6 +228,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View policies @@ -181,6 +244,7 @@ export const LintCTA = ({ href={`/project/${projectRef}/database/extensions?filter=${metadata?.name}`} target="_blank" rel="noreferrer" + className="no-underline" > View extension @@ -191,3 +255,14 @@ export const LintCTA = ({ return <> } } + +export const entityTypeIcon = (type: string) => { + switch (type) { + case 'table': + return + case 'view': + return + default: + return + } +} diff --git a/apps/studio/components/interfaces/Reports/ReportLintsTableRow.tsx b/apps/studio/components/interfaces/Reports/ReportLintsTableRow.tsx deleted file mode 100644 index ff7b1ea23ec..00000000000 --- a/apps/studio/components/interfaces/Reports/ReportLintsTableRow.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Eye, HelpCircle, Table2 } from 'lucide-react' - -import { useParams } from 'common' -import Table from 'components/to-be-cleaned/Table' -import { LINT_TYPES, Lint } from 'data/lint/lint-query' -import { Badge, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui' -import { Markdown } from '../Markdown' -import { LintCTA, getHumanReadableTitle } from './ReportLints.utils' - -type ReportLintsTableRowProps = { - lint: Lint -} - -const ReportLintsTableRow = ({ lint }: ReportLintsTableRowProps) => { - const { ref } = useParams() - // TODO: Comment out functionality for ignoring lints because it relied on local storage. Will revisit later. - // const [selectedLint, setSelectedLint] = useState(null) - - // const [lintIgnoreList, setLintIgnoreList] = useLocalStorageQuery( - // LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST, - // [] - // ) - // const isIgnored = lintIgnoreList.includes(lint.cache_key) - - // if the lint type can't be handled (there's no CTA text defined), don't render it - if (!LINT_TYPES.includes(lint.name)) { - console.log('Unhandled lint type:', lint.name) - return null - } - - // const toggleLintIgnore = () => { - // let currentIgnoreList = [] - // const cacheKey = lint.cache_key - - // // Check if the cacheKey exists in the array and ignore or unignore it - // const index = lintIgnoreList.indexOf(cacheKey) - // if (index !== -1) { - // currentIgnoreList = lintIgnoreList.filter((l) => l !== cacheKey) - // } else { - // currentIgnoreList = lintIgnoreList.concat(cacheKey) - // } - // setLintIgnoreList(currentIgnoreList) - // setSelectedLint(null) - // } - - return ( - <> - - - - {lint.level} - - - - -
-
-

{getHumanReadableTitle(lint.name)}

- - - - - - {lint.description} - - -
- - {(lint.metadata?.type === 'table' || lint.metadata?.type === 'view') && ( -
- {lint.metadata?.schema && ( -
-

schema

-

{lint.metadata?.schema}

-
- )} -
- {lint.metadata?.type === 'table' && ( - - )} - {lint.metadata?.type === 'view' && ( - - )} -

{lint.metadata?.name}

-
-
- )} -
- -
- {lint.detail && ( - - )} - {lint.remediation && ( -

- You can read more about this lint rule and ways to remedy it{' '} - - here - -

- )} -
-
- -
- - {/* - -
-
-
- {/* setSelectedLint(null)} - onConfirm={() => toggleLintIgnore()} - header={

Confirm to {isIgnored ? 'unignore' : 'ignore'} this lint

} - > -
- -

- {isIgnored - ? 'Unignoring this lint will remove it from the Ignored Issues list. It will move it back to the main list above.' - : 'Ignoring this lint will remove it from the main list. It will still be visible in the Ignored Issues list below.'} -

-
-
-
*/} - - ) -} - -export default ReportLintsTableRow diff --git a/apps/studio/data/lint/lint-query.ts b/apps/studio/data/lint/lint-query.ts index 1a62dd28793..6cdcfcaa574 100644 --- a/apps/studio/data/lint/lint-query.ts +++ b/apps/studio/data/lint/lint-query.ts @@ -126,7 +126,7 @@ where -- Standard View, accessible to anon or authenticated that is security_definer ( c.relkind = 'v' -- v for view - -- Exclude security invoker views + -- Exclude security invoker views and not ( lower(coalesce(c.reloptions::text,'{}'))::text[] && array[ @@ -141,7 +141,7 @@ where -- Standard View, security invoker, but no RLS enabled on auth.users ( c.relkind in ('v') -- v for view - -- is security invoker + -- is security invoker and ( lower(coalesce(c.reloptions::text,'{}'))::text[] && array[ @@ -151,7 +151,7 @@ where 'security_invoker=on' ] ) - and not pg_class_auth_users.relrowsecurity + and not pg_class_auth_users.relrowsecurity ) ) group by @@ -663,7 +663,7 @@ where and not c.relrowsecurity) union all ( -select +select 'extension_in_public' as name, 'WARN' as level, 'EXTERNAL' as facing, @@ -706,6 +706,7 @@ export const LINT_TYPES = [ 'rls_disabled_in_public', 'extension_in_public', ] as const + export type LINT_TYPES = (typeof LINT_TYPES)[number] export type Lint = { diff --git a/apps/studio/pages/project/[ref]/database/linter.tsx b/apps/studio/pages/project/[ref]/database/linter.tsx index 039790ad105..ef2d6d39a90 100644 --- a/apps/studio/pages/project/[ref]/database/linter.tsx +++ b/apps/studio/pages/project/[ref]/database/linter.tsx @@ -1,224 +1,467 @@ -import { sortBy } from 'lodash' -import { Check, ExternalLink, Loader } from 'lucide-react' -import { useMemo, useState } from 'react' +import { Eye, MessageSquareMore, Table2, TextSearch, X } from 'lucide-react' +import { useRef, useState } from 'react' -import { getHumanReadableTitle } from 'components/interfaces/Reports/ReportLints.utils' -import ReportLintsTableRow from 'components/interfaces/Reports/ReportLintsTableRow' +import { InformationCircleIcon } from '@heroicons/react/16/solid' +import { useParams } from 'common' + +import { lintInfoMap } from 'components/interfaces/Reports/ReportLints.utils' import { DatabaseLayout } from 'components/layouts' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import Table from 'components/to-be-cleaned/Table' -import { FilterPopover } from 'components/ui/FilterPopover' import { FormHeader } from 'components/ui/Forms' -import { LINT_TYPES, useProjectLintsQuery } from 'data/lint/lint-query' +import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' import { useSelectedProject } from 'hooks' +import { useRouter } from 'next/router' +import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' import type { NextPageWithLayout } from 'types' -import { Button, LoadingLine } from 'ui' +import { + Button, + LoadingLine, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, + Tabs_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, + Tooltip_Shadcn_, + cn, +} from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' + +import { FilterPopover } from 'components/ui/FilterPopover' +import ReactMarkdown from 'react-markdown' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { Markdown } from '../../../../components/interfaces/Markdown' +import { + LintCTA, + entityTypeIcon, +} from '../../../../components/interfaces/Reports/ReportLints.utils' + +enum LINTER_LEVELS { + ERROR = 'ERROR', + WARN = 'WARN', + INFO = 'INFO', +} + +const LINT_TABS = [ + { + id: LINTER_LEVELS.ERROR, + label: 'Errors', + description: 'You should consider these issues urgent and and fix them as soon as you can.', + }, + { + id: LINTER_LEVELS.WARN, + label: 'Warnings ', + description: 'You should try and read through these issues and fix them if necessary.', + }, + { + id: LINTER_LEVELS.INFO, + label: 'Info ', + description: 'You should read through these suggestions and consider implementing them.', + }, +] const ProjectLints: NextPageWithLayout = () => { const project = useSelectedProject() - const [filters, setFilters] = useState({ - levels: [] as string[], - types: [] as string[], - }) - // const [lintIgnoreList] = useLocalStorageQuery( - // LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST, - // [] - // ) + const router = useRouter() + const { ref } = useParams() + const gridRef = useRef(null) + const { preset } = useParams() + + // need to maintain a list of filters for each tab + const [filters, setFilters] = useState([ + { level: LINTER_LEVELS.ERROR, filters: [] }, + { level: LINTER_LEVELS.WARN, filters: [] }, + { level: LINTER_LEVELS.INFO, filters: [] }, + ]) + + const [currentTab, setCurrentTab] = useState( + (preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR + ) + const [selectedRow, setSelectedRow] = useState() + const [selectedLint, setSelectedLint] = useState(null) + const [view, setView] = useState<'details' | 'suggestion'>('details') const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - // sort the lints by level, ERROR should be at the top. - const lints = sortBy(data || [], (lint) => { - if (lint.level === 'ERROR') return 0 - if (lint.level === 'WARN') return 1 - if (lint.level === 'INFO') return 2 - return 3 - }) + const activeLints = data || [] - const activeLints = lints - // const [ignoredLints, activeLints] = partition(lints, (lint) => - // lintIgnoreList.includes(lint.cache_key) - // ) - const filteredLints = useMemo(() => { - return activeLints - .filter((x) => (filters.levels.length > 0 ? filters.levels.includes(x.level) : x)) - .filter((x) => (filters.types.length > 0 ? filters.types.includes(x.name) : x)) - }, [activeLints, filters.levels, filters.types]) + 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 warnLintsCount = activeLints.filter((x) => x.level === 'WARN').length const errorLintsCount = activeLints.filter((x) => x.level === 'ERROR').length + const infoLintsCount = activeLints.filter((x) => x.level === 'INFO').length - const filterOptions = useMemo(() => { + const filterOptions = lintInfoMap // only show filters for lint types which are present in the results and not ignored - return LINT_TYPES.filter((type) => activeLints.some((lint) => lint.name === type)).map( - (type) => ({ - name: getHumanReadableTitle(type), - value: type, - }) + .filter((item) => + activeLints.some((lint) => lint.name === item.name && lint.level === currentTab) ) - }, [activeLints]) + .map((type) => ({ + name: type.title, + value: type.name, + })) + + const updateFilters = (level: any, newFilters: any) => { + // Create a copy of the current filters state + const updatedFilters = [...filters] + // Find the index of the filter object corresponding to the provided level + const index = updatedFilters.findIndex((filter) => filter.level === level) + if (index !== -1) { + // Update the filters array at the found index with the new filters + updatedFilters[index] = { ...updatedFilters[index], filters: newFilters } + // Update the filters state with the updated array + setFilters(updatedFilters) + } + } + + const lintCountLabel = (count: number, label: string) => ( + <> + {isLoading ? ( + + ) : ( + <> + {count} {label} + + )} + + ) + + const lintCols = [ + { + id: 'name', + name: 'Issue type', + description: undefined, + minWidth: 240, + value: (row: any) => ( +
+ + {lintInfoMap.find((item) => row.name === item.name)?.icon} + + {

{lintInfoMap.find((item) => row.name === item.name)?.title}

} +
+ ), + }, + { + id: 'metadata.name', + name: 'Entity/item', + description: undefined, + minWidth: 230, + value: (row: any) => ( +
+ {entityTypeIcon(row.metadata?.type)} + {`${row.metadata.schema}.${row.metadata.name}`} +
+ ), + }, + { + id: 'description', + name: 'Description', + description: undefined, + minWidth: 400, + value: (row: any) => {row.description}, + }, + ] + + const columns = lintCols.map((col) => { + const result: Column = { + key: col.id, + name: col.name, + resizable: true, + minWidth: col.minWidth ?? 120, + headerCellClass: 'first:pl-6 cursor-pointer', + renderHeaderCell: () => { + return ( +
+
+

{col.name}

+ {col.description &&

{col.description}

} +
+
+ ) + }, + renderCell: (props) => { + const value = col.value(props.row) + + return ( +
+ {value} +
+ ) + }, + } + return result + }) return ( - - -
- -
- -
-
-
-

Filter by

- setFilters({ ...filters, levels: values })} - /> - setFilters({ ...filters, types: values })} - /> -
-

- Identified {activeLints.length} problems{' '} - {warnLintsCount > 0 || errorLintsCount > 0 - ? `(${errorLintsCount > 0 ? `${errorLintsCount} errors` : ''}${errorLintsCount > 0 && warnLintsCount > 0 ? ', ' : ''}${warnLintsCount > 0 ? `${warnLintsCount} warnings` : ''})` - : null} -

-
-
- - -
-
+ {tab.id === currentTab && ( +
+ )} +
+ + + -
- - Level - , - - Problem - , - , - ]} - body={[ - - - - - , - ...(activeLints.length === 0 - ? [ - - - {isLoading ? ( -
- -

Checking database for issues...

+ {tab.label} + + + + + {tab.description} + +
+ + {tab.id === LINTER_LEVELS.ERROR && lintCountLabel(errorLintsCount, 'errors')} + {tab.id === LINTER_LEVELS.WARN && lintCountLabel(warnLintsCount, 'warnings')} + {tab.id === LINTER_LEVELS.INFO && lintCountLabel(infoLintsCount, 'suggestions')} + + + ))} + + + +
+ {LINT_TABS.map((tab) => ( +
+ filter.level === currentTab)?.filters || []} + onSaveFilters={(values) => updateFilters(currentTab, values)} + /> +
+ ))} +
+
+
+
+ + + + { + const isSelected = idx === selectedRow + return [ + `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`, + `${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`, + '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', + '[&>.rdg-cell:first-child>div]:ml-4', + ].join(' ') + }} + renderers={{ + renderRow(idx, props) { + return ( + { + if (typeof idx === 'number' && idx >= 0) { + setSelectedRow(idx) + setSelectedLint(props.row) + gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx }) + } + }} + /> + ) + }, + noRowsFallback: isLoading ? ( +
+ +
+ ) : ( +
+ +
+

No issues detected

+

+ Congrats! There are no suggestions available for this database +

+
+
+ ), + }} + /> +
+ {selectedLint !== null && ( + <> + + + + +
+

How are these suggestions generated?

+
- - {/* {ignoredLints.length > 0 && ( -
- - - -
- Ignored problems ({ignoredLints.length}) -
-
- -
- -

- {isLoading ? ( - <> - - Checking for database issues - - ) : ( - 'No issues have been found for this database' - )} -

-
- - ) : ( - <> - {ignoredLints.map((lint) => { - return - })} - - ) - } - /> - - - - - )} */} - - + + ) }