diff --git a/apps/studio/components/interfaces/Reports/ReportLints.utils.tsx b/apps/studio/components/interfaces/Reports/ReportLints.utils.tsx
new file mode 100644
index 00000000000..14e26da19fb
--- /dev/null
+++ b/apps/studio/components/interfaces/Reports/ReportLints.utils.tsx
@@ -0,0 +1,104 @@
+import { LINT_TYPES, Lint } from 'data/lint/lint-query'
+import Link from 'next/link'
+import { Button } from 'ui'
+
+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'
+ default:
+ assertUnreachable(title)
+ throw new Error('This case should never be reached')
+ }
+}
+
+export const LintCTA = ({
+ title,
+ projectRef,
+ metadata,
+}: {
+ title: LINT_TYPES
+ projectRef: string
+ metadata: Lint['metadata']
+}) => {
+ switch (title) {
+ case 'unindexed_foreign_keys':
+ return (
+
+ )
+
+ case 'auth_users_exposed':
+ return (
+
+ )
+ case 'auth_rls_initplan':
+ return (
+
+ )
+ case 'no_primary_key':
+ return (
+
+ )
+ case 'unused_index':
+ return (
+
+ )
+ case 'multiple_permissive_policies':
+ return (
+
+ )
+ default:
+ assertUnreachable(title)
+ return <>>
+ }
+}
diff --git a/apps/studio/components/interfaces/Reports/ReportLintsTableRow.tsx b/apps/studio/components/interfaces/Reports/ReportLintsTableRow.tsx
new file mode 100644
index 00000000000..7d31413a126
--- /dev/null
+++ b/apps/studio/components/interfaces/Reports/ReportLintsTableRow.tsx
@@ -0,0 +1,170 @@
+import { Eye, EyeIcon, EyeOff, HelpCircle, Table2 } from 'lucide-react'
+import { useState } from 'react'
+
+import { useParams } from 'common'
+import Table from 'components/to-be-cleaned/Table'
+import { LINT_TYPES, Lint } from 'data/lint/lint-query'
+import { useLocalStorageQuery } from 'hooks'
+import { LOCAL_STORAGE_KEYS } from 'lib/constants'
+import {
+ Badge,
+ Button,
+ Modal,
+ 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()
+ 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)) {
+ 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 the best ways to remedy it{' '}
+
+ here
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {isIgnored ? 'Unignore problem' : 'Ignore problem'}
+
+
+
+
+
+ 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/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
index 4d84ce59141..681b221152e 100644
--- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
+++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
@@ -108,6 +108,11 @@ const ReportsMenu = () => {
key: 'query-performance',
url: `/project/${ref}/reports/query-performance`,
},
+ {
+ name: 'Project Linter',
+ key: 'linter',
+ url: `/project/${ref}/reports/linter`,
+ },
],
},
]
diff --git a/apps/studio/data/lint/keys.ts b/apps/studio/data/lint/keys.ts
new file mode 100644
index 00000000000..7a59b16dce2
--- /dev/null
+++ b/apps/studio/data/lint/keys.ts
@@ -0,0 +1,3 @@
+export const lintKeys = {
+ lint: (projectRef: string | undefined) => ['projects', projectRef, 'lint'] as const,
+}
diff --git a/apps/studio/data/lint/lint-query.ts b/apps/studio/data/lint/lint-query.ts
new file mode 100644
index 00000000000..c9f1a3bca03
--- /dev/null
+++ b/apps/studio/data/lint/lint-query.ts
@@ -0,0 +1,405 @@
+import { UseQueryOptions, useQuery } from '@tanstack/react-query'
+import { executeSql } from '../sql/execute-sql-query'
+import { lintKeys } from './keys'
+
+export const LINT_SQL = `(
+with foreign_keys as (
+ select
+ cl.relnamespace::regnamespace as schema_,
+ cl.oid::regclass as table_,
+ ct.conname as fkey_name,
+ ct.conkey col_attnums
+ from
+ pg_constraint ct
+ join pg_class cl -- fkey owning table
+ on ct.conrelid = cl.oid
+ left join pg_depend d
+ on d.objid = cl.oid
+ and d.deptype = 'e'
+ where
+ ct.contype = 'f' -- foreign key constraints
+ and d.objid is null -- exclude tables that are dependencies of extensions
+ and cl.relnamespace::regnamespace::text not in (
+ 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'
+ )
+),
+index_ as (
+ select
+ indrelid::regclass as table_,
+ indexrelid::regclass as index_,
+ string_to_array(indkey::text, ' ')::smallint[] as col_attnums
+ from
+ pg_index
+ where
+ indisvalid
+)
+select
+ 'unindexed_foreign_keys' as name,
+ 'INFO' as level,
+ 'EXTERNAL' as facing,
+ 'Identifies foreign key constraints without a covering index, which can impact database performance.' as description,
+ format(
+ 'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.',
+ fk.schema_,
+ fk.table_,
+ fk.table_,
+ fk.fkey_name
+ ) as detail,
+ 'https://supabase.github.io/splinter/0001_unindexed_foreign_keys' as remediation,
+ jsonb_build_object(
+ 'schema', fk.schema_,
+ 'name', fk.table_,
+ 'type', 'table',
+ 'fkey_name', fk.fkey_name,
+ 'fkey_columns', fk.col_attnums
+ ) as metadata,
+ format('0001_unindexed_foreign_keys_%s_%s_%s', fk.schema_, fk.table_, fk.fkey_name) as cache_key
+from
+ foreign_keys fk
+ left join index_ idx
+ on fk.table_ = idx.table_
+ and fk.col_attnums = idx.col_attnums
+where
+ idx.index_ is null
+order by
+ fk.table_,
+ fk.fkey_name)
+union all
+(
+select
+ 'auth_users_exposed' as name,
+ 'WARN' as level,
+ 'EXTERNAL' as facing,
+ 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in the public schema, potentially compromising user data security.' as description,
+ format(
+ 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.',
+ c.relname
+ ) as detail,
+ 'https://supabase.github.io/splinter/0002_auth_users_exposed' as remediation,
+ jsonb_build_object(
+ 'schema', 'public',
+ 'name', c.relname,
+ 'type', 'view',
+ 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)
+ ) as metadata,
+ format('auth_users_exposed_%s_%s', 'public', c.relname) as cache_key
+from
+ pg_depend d
+ join pg_rewrite r
+ on r.oid = d.objid
+ join pg_class c
+ on c.oid = r.ev_class
+ join pg_namespace n
+ on n.oid = c.relnamespace
+where
+ d.refobjid = 'auth.users'::regclass
+ and d.deptype = 'n'
+ and c.relkind in ('v', 'm') -- v for view, m for materialized view
+ and n.nspname = 'public'
+ and (
+ pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
+ or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
+ )
+ -- Exclude self
+ and c.relname <> '0002_auth_users_exposed'
+group by
+ c.relname, c.oid)
+union all
+(/*
+Usage of auth.uid(), auth.role() ... are common in RLS policies.
+
+A naive policy like
+
+ create policy "rls_test_select" on test_table
+ to authenticated
+ using ( auth.uid() = user_id )
+
+will re-evaluate the auth.uid() function for every row. That can result in 100s of times slower performance
+https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select
+
+To resolve that issue, the function calls can be wrapped like "(select auth.uid())" which causes the value to
+be executed exactly 1 time per query
+
+For example:
+
+ create policy "rls_test_select" on test_table
+ to authenticated
+ using ( (select auth.uid()) = user_id )
+
+NOTE:
+ This lint requires search_path = '' or 'auth' not in search_path
+ because qual and with_check are dependent on search_path to determine if function calls include the "auth" schema
+*/
+
+
+
+with policies as (
+ select
+ nsp.nspname as schema_,
+ polrelid::regclass table_,
+ pc.relrowsecurity is_rls_active,
+ polname as policy_name,
+ polpermissive as is_permissive, -- if not, then restrictive
+ (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,
+ case polcmd
+ when 'r' then 'SELECT'
+ when 'a' then 'INSERT'
+ when 'w' then 'UPDATE'
+ when 'd' then 'DELETE'
+ when '*' then 'ALL'
+ end as command,
+ qual,
+ with_check
+ from
+ pg_policy pa
+ join pg_class pc
+ on pa.polrelid = pc.oid
+ join pg_namespace nsp
+ on pc.relnamespace = nsp.oid
+ join pg_policies pb
+ on pc.relname = pb.tablename
+ and nsp.nspname = pb.schemaname
+ and pa.polname = pb.policyname
+)
+select
+ 'auth_rls_initplan' as name,
+ 'WARN' as level,
+ 'EXTERNAL' as facing,
+ 'Detects if calls to \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as description,
+ format(
+ 'Table \`%s\` has a row level security policy \`%s\` that re-evaluates an auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select for more.',
+ table_,
+ policy_name
+ ) as detail,
+ 'https://supabase.github.io/splinter/0003_auth_rls_initplan' as remediation,
+ jsonb_build_object(
+ 'schema', schema_,
+ 'name', table_,
+ 'type', 'table'
+ ) as metadata,
+ format('auth_rls_init_plan_%s_%s_%s', schema_, table_, policy_name) as cache_key
+from
+ policies
+where
+ is_rls_active
+ and (
+ (
+ -- Example: auth.uid()
+ qual ~ '(auth)\.(uid|jwt|role|email)\(\)'
+ -- Example: select auth.uid()
+ and lower(qual) !~ 'select\s+(auth)\.(uid|jwt|role|email)\(\)'
+ )
+ or
+ (
+ -- Example: auth.uid()
+ with_check ~ '(auth)\.(uid|jwt|role|email)\(\)'
+ -- Example: select auth.uid()
+ and lower(with_check) !~ 'select\s+(auth)\.(uid|jwt|role|email)\(\)'
+ )
+ ))
+union all
+(
+select
+ 'no_primary_key' as name,
+ 'INFO' as level,
+ 'EXTERNAL' as facing,
+ 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as description,
+ format(
+ 'Table \`%s.%s\` does not have a primary key',
+ pgns.nspname,
+ pgc.relname
+ ) as detail,
+ 'https://supabase.github.io/splinter/0004_no_primary_key' as remediation,
+ jsonb_build_object(
+ 'schema', pgns.nspname,
+ 'name', pgc.relname,
+ 'type', 'table'
+ ) as metadata,
+ format(
+ 'no_primary_key_%s_%s',
+ pgns.nspname,
+ pgc.relname
+ ) as cache_key
+from
+ pg_class pgc
+ join pg_namespace pgns
+ on pgns.oid = pgc.relnamespace
+ left join pg_index pgi
+ on pgi.indrelid = pgc.oid
+where
+ pgc.relkind = 'r' -- regular tables
+ and pgns.nspname not in (
+ 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
+ )
+group by
+ pgc.oid,
+ pgns.nspname,
+ pgc.relname
+having
+ max(coalesce(pgi.indisprimary, false)::int) = 0)
+union all
+(
+select
+ 'unused_index' as name,
+ 'INFO' as level,
+ 'EXTERNAL' as facing,
+ 'Detects if an index has never been used and may be a candidate for removal.' as description,
+ format(
+ 'Index \`%s\` on table \`%s.%s\` has not been used',
+ psui.indexrelname,
+ psui.schemaname,
+ psui.relname
+ ) as detail,
+ 'https://supabase.github.io/splinter/0005_unused_index' as remediation,
+ jsonb_build_object(
+ 'schema', psui.schemaname,
+ 'name', psui.relname,
+ 'type', 'table'
+ ) as metadata,
+ format(
+ 'unused_index_%s_%s_%s',
+ psui.schemaname,
+ psui.relname,
+ psui.indexrelname
+ ) as cache_key
+
+from
+ pg_catalog.pg_stat_user_indexes psui
+ join pg_catalog.pg_index pi
+ on psui.indexrelid = pi.indexrelid
+where
+ psui.idx_scan = 0
+ and not pi.indisunique
+ and not pi.indisprimary
+ and psui.schemaname not in (
+ 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
+ ))
+union all
+(
+select
+ 'multiple_permissive_policies' as name,
+ 'WARN' as level,
+ 'EXTERNAL' as facing,
+ 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as description,
+ format(
+ 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`',
+ n.nspname,
+ c.relname,
+ r.rolname,
+ act.cmd,
+ array_agg(p.polname order by p.polname)
+ ) as detail,
+ 'https://supabase.github.io/splinter/0006_multiple_permissive_policies' as remediation,
+ jsonb_build_object(
+ 'schema', n.nspname,
+ 'name', c.relname,
+ 'type', 'table'
+ ) as metadata,
+ format(
+ 'multiple_permissive_policies_%s_%s_%s_%s',
+ n.nspname,
+ c.relname,
+ r.rolname,
+ act.cmd
+ ) as cache_key
+from
+ pg_catalog.pg_policy p
+ join pg_catalog.pg_class c on p.polrelid = c.oid
+ join pg_catalog.pg_namespace n on c.relnamespace = n.oid
+ join pg_catalog.pg_roles r
+ on p.polroles @> array[r.oid]
+ or p.polroles = array[0::oid],
+ lateral (
+ select x.cmd
+ from unnest((
+ select
+ case p.polcmd
+ when 'r' then array['SELECT']
+ when 'a' then array['INSERT']
+ when 'w' then array['UPDATE']
+ when 'd' then array['DELETE']
+ when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']
+ else array['ERROR']
+ end as actions
+ )) x(cmd)
+ ) act(cmd)
+where
+ c.relkind = 'r' -- regular tables
+ and n.nspname not in (
+ 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
+ )
+ and r.rolname not like 'pg_%'
+ and r.rolname not like 'supabase%admin'
+ and not r.rolbypassrls
+group by
+ n.nspname,
+ c.relname,
+ r.rolname,
+ act.cmd
+having
+ count(1) > 1)`.trim()
+
+// Array of all lint rules we handle right now.
+export const LINT_TYPES = [
+ 'unindexed_foreign_keys',
+ 'auth_users_exposed',
+ 'no_primary_key',
+ 'unused_index',
+ 'multiple_permissive_policies',
+ 'auth_rls_initplan',
+] as const
+export type LINT_TYPES = (typeof LINT_TYPES)[number]
+
+export type Lint = {
+ name: LINT_TYPES
+ level: 'ERROR' | 'WARN' | 'INFO'
+ facing: string
+ description: string
+ detail: string
+ remediation: any
+ metadata: {
+ schema?: string
+ name?: string
+ type?: 'table' | 'view'
+ fkey_name?: string
+ fkey_columns?: number[]
+ } | null
+ cache_key: string
+}
+
+export type ProjectLintsVariables = {
+ projectRef?: string
+ connectionString?: string
+}
+
+const getProjectLints = async (
+ { projectRef, connectionString }: ProjectLintsVariables,
+ signal?: AbortSignal
+) => {
+ const { result } = await executeSql(
+ {
+ projectRef,
+ connectionString,
+ sql: LINT_SQL,
+ queryKey: lintKeys.lint(projectRef),
+ },
+ signal
+ )
+ return result
+}
+
+export type ProjectLintsData = Lint[]
+export type ProjectLintsError = unknown
+
+export const useProjectLintsQuery = (
+ { projectRef, connectionString }: ProjectLintsVariables,
+ { enabled, ...options }: UseQueryOptions = {}
+) =>
+ useQuery(
+ lintKeys.lint(projectRef),
+ ({ signal }) => getProjectLints({ projectRef, connectionString }, signal),
+ {
+ enabled: enabled && typeof projectRef !== 'undefined',
+ ...options,
+ }
+ )
diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts
index d0388fd4c7d..1e236e61867 100644
--- a/apps/studio/lib/constants/index.ts
+++ b/apps/studio/lib/constants/index.ts
@@ -48,6 +48,8 @@ export const LOCAL_STORAGE_KEYS = {
PGBOUNCER_IPV6_DEPRECATION_WARNING: 'pgbouncer-ipv6-deprecation-warning-dismissed',
VERCEL_IPV6_DEPRECATION_WARNING: 'vercel-ipv6-deprecation-warning-dismissed',
PGBOUNCER_DEPRECATION_WARNING: 'pgbouncer-deprecation-warning-dismissed',
+
+ PROJECT_LINT_IGNORE_LIST: 'supabase-project-lint-ignore-list',
}
export const OPT_IN_TAGS = {
diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx
index 5a18e8e3669..46b944bdb1a 100644
--- a/apps/studio/pages/project/[ref]/auth/policies.tsx
+++ b/apps/studio/pages/project/[ref]/auth/policies.tsx
@@ -73,7 +73,6 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false)
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState()
- const isAiAssistantEnabled = useIsRLSAIAssistantEnabled()
useEffect(() => {
if (search) setSearchString(search)
diff --git a/apps/studio/pages/project/[ref]/reports/linter.tsx b/apps/studio/pages/project/[ref]/reports/linter.tsx
new file mode 100644
index 00000000000..e3d3404aab0
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/reports/linter.tsx
@@ -0,0 +1,229 @@
+import { partition, sortBy } from 'lodash'
+import { Check, ExternalLink, Loader } from 'lucide-react'
+import { useMemo, useState } from 'react'
+
+import { AccordionTrigger } from '@ui/components/shadcn/ui/accordion'
+import { FilterPopover } from 'components/interfaces/AuditLogs'
+import ReportLintsTableRow from 'components/interfaces/Reports/ReportLintsTableRow'
+import { ReportsLayout } from 'components/layouts'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import Table from 'components/to-be-cleaned/Table'
+import { useProjectLintsQuery } from 'data/lint/lint-query'
+import { useLocalStorageQuery, useSelectedProject } from 'hooks'
+import { LOCAL_STORAGE_KEYS } from 'lib/constants'
+import type { NextPageWithLayout } from 'types'
+import {
+ AccordionContent_Shadcn_,
+ AccordionItem_Shadcn_,
+ Accordion_Shadcn_,
+ Button,
+ LoadingLine,
+} from 'ui'
+
+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 { 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 [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 warnLintsCount = activeLints.filter((x) => x.level === 'WARN').length
+ const errorLintsCount = activeLints.filter((x) => x.level === 'ERROR').length
+
+ return (
+
+
+
+
+
Project Linter
+
+ Identify common schema problems in your project's database
+
+
+
+
+
+
+
+
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}
+
+
+
+
+
+
+
+ Level
+ ,
+
+ Problem
+ ,
+ ,
+ ]}
+ body={[
+
+
+
+
+ ,
+ ...(activeLints.length === 0
+ ? [
+
+
+ {isLoading ? (
+
+
+
Checking project for issues...
+
+ ) : (
+
+
+
+ No issues have been found for this project
+
+
+ )}
+
+ ,
+ ]
+ : (filters.levels.length > 0 || filters.types.length > 0) &&
+ filteredLints.length === 0
+ ? [
+
+
+
+ No problems found based on the selected filters
+
+
+ ,
+ ]
+ : filteredLints.map((lint) => {
+ return
+ })),
+ ]}
+ />
+
+
+ {ignoredLints.length > 0 && (
+
+
+
+
+
+ Ignored problems ({ignoredLints.length})
+
+
+
+
+
+
+ {isLoading ? (
+ <>
+
+ Checking for project issues
+ >
+ ) : (
+ 'No issues have been found for this project'
+ )}
+
+
+
+ ) : (
+ <>
+ {ignoredLints.map((lint) => {
+ return
+ })}
+ >
+ )
+ }
+ />
+
+
+
+
+ )}
+
+
+ )
+}
+
+ProjectLints.getLayout = (page) => {page}
+
+export default ProjectLints