Feat/lints (#22003)

* Add lints, 2

* Add basic table

* Handle ignoring lints

* Add loading state

* Cleanup

* move utils

* Cleanup

* Shift lint to react query

* Merge master solve import lint issue

* Update lints UI

* Optional params

* Add entity and schema name

* Rename lints to linter, format results

* Wrap in markdown

* Adjust layout of some components

* Add CTAs to each lint

* fix ignored problems section

* Update type

* Add cta buttons for auth rls initialization issues

* Change metadata object

* Remove unneeded file.

* Add a component for CTA for a lint row.

* Make the lint query to be more type-safe.

* Various fixes to the lint table.

* Show the remediation link.

* Fix the button icons.

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-04-01 20:37:48 +00:00
1 parent 1e583bf172
commit 280928a8e6
10 files changed
+935 -4

No files matched your search

@@ -56,7 +56,7 @@ const FilterPopover = ({
defaultChecked: activeOptions.includes(option[valueKey]),
}
})}
></Checkbox.Group>
/>
</div>
<div className="flex items-center justify-end gap-2 border-t border-overlay bg-surface-200 py-2 px-3">
<Button
@@ -1,6 +1,6 @@
import { partition, sortBy } from 'lodash'
import Link from 'next/link'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
AlertDescription_Shadcn_,
@@ -30,15 +30,18 @@ import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import ProtectedSchemaWarning from '../ProtectedSchemaWarning'
import CreateIndexSidePanel from './CreateIndexSidePanel'
import { useParams } from 'common'
const Indexes = () => {
const { project } = useProjectContext()
const { schema: urlSchema, table } = useParams()
const [search, setSearch] = useState('')
const [selectedSchema, setSelectedSchema] = useState('public')
const [showCreateIndex, setShowCreateIndex] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<DatabaseIndex>()
const [selectedIndexToDelete, setSelectedIndexToDelete] = useState<DatabaseIndex>()
const { project } = useProjectContext()
const {
data: allIndexes,
refetch: refetchIndexes,
@@ -93,6 +96,17 @@ const Indexes = () => {
})
}
useEffect(() => {
if (urlSchema !== undefined) {
const schema = schemas?.find((s) => s.name === urlSchema)
if (schema !== undefined) setSelectedSchema(schema.name)
}
}, [urlSchema, isSuccessSchemas])
useEffect(() => {
if (table !== undefined) setSearch(table)
}, [table])
return (
<>
<div className="pb-8">
@@ -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 (
<Button asChild type="default">
<Link
href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}`}
target="_blank"
rel="noreferrer"
>
Create an index
</Link>
</Button>
)
case 'auth_users_exposed':
return (
<Button asChild type="default">
<Link href={`/project/${projectRef}/editor`} target="_blank" rel="noreferrer">
View table
</Link>
</Button>
)
case 'auth_rls_initplan':
return (
<Button asChild type="default">
<Link href={`/project/${projectRef}/auth/policies`} target="_blank" rel="noreferrer">
View policies
</Link>
</Button>
)
case 'no_primary_key':
return (
<Button asChild type="default">
<Link href={`/project/${projectRef}/editor`} target="_blank" rel="noreferrer">
View table
</Link>
</Button>
)
case 'unused_index':
return (
<Button asChild type="default">
<Link
href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}&table=${metadata?.name}`}
target="_blank"
rel="noreferrer"
>
View index
</Link>
</Button>
)
case 'multiple_permissive_policies':
return (
<Button asChild type="default">
<Link
href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`}
target="_blank"
rel="noreferrer"
>
View policies
</Link>
</Button>
)
default:
assertUnreachable(title)
return <></>
}
}
@@ -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<Lint | null>(null)
const [lintIgnoreList, setLintIgnoreList] = useLocalStorageQuery<string[]>(
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 (
<>
<Table.tr>
<Table.td className="w-20 align-top">
<Badge
className="!rounded w-16 font-mono text-center justify-center"
variant={
lint.level === 'ERROR' ? 'destructive' : lint.level === 'WARN' ? 'warning' : 'default'
}
>
{lint.level}
</Badge>
</Table.td>
<Table.td className="flex flex-col gap-y-2">
<div>
<div className="flex items-center gap-x-2">
<p className="text-foreground">{getHumanReadableTitle(lint.name)}</p>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<HelpCircle size={14} />
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" className="w-72">
{lint.description}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</div>
{(lint.metadata?.type === 'table' || lint.metadata?.type === 'view') && (
<div className="flex items-center gap-x-4 text-xs py-1">
{lint.metadata?.schema && (
<div className="flex items-center gap-x-1">
<p className="text-foreground-lighter">schema</p>
<p className="text-foreground font-mono">{lint.metadata?.schema}</p>
</div>
)}
<div className="flex items-center gap-x-1">
{lint.metadata?.type === 'table' && (
<Table2 size={15} strokeWidth={1.5} className="text-foreground-lighter" />
)}
{lint.metadata?.type === 'view' && (
<Eye size={15} strokeWidth={1.5} className="text-foreground-lighter" />
)}
<p className="text-foreground font-mono">{lint.metadata?.name}</p>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-y-1">
{lint.detail && (
<Markdown
className="text-foreground-light max-w-full leading-relaxed"
content={lint.detail}
/>
)}
{lint.remediation && (
<p className="text-foreground-light max-w-full leading-relaxed">
You can read more about this lint rule and the best ways to remedy it{' '}
<a
className="underline text-foreground-light transition-all hover:text-foreground hover:decoration-brand"
href={lint.remediation}
target="_blank"
rel="noreferrer"
>
here
</a>
</p>
)}
</div>
</Table.td>
<Table.td>
<div className="flex items-center justify-end gap-x-2">
<LintCTA title={lint.name} projectRef={ref!} metadata={lint.metadata} />
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="text"
size="small"
className="px-1"
onClick={(event) => {
event.stopPropagation()
setSelectedLint(lint)
}}
icon={isIgnored ? <EyeIcon strokeWidth={1} /> : <EyeOff strokeWidth={1} />}
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
{isIgnored ? 'Unignore problem' : 'Ignore problem'}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</div>
</Table.td>
</Table.tr>
<Modal
size="small"
alignFooter="right"
visible={selectedLint !== null}
onCancel={() => setSelectedLint(null)}
onConfirm={() => toggleLintIgnore()}
header={<h3>Confirm to {isIgnored ? 'unignore' : 'ignore'} this lint</h3>}
>
<div className="py-4">
<Modal.Content>
<p className="text-sm">
{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.'}
</p>
</Modal.Content>
</div>
</Modal>
</>
)
}
export default ReportLintsTableRow
@@ -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`,
},
],
},
]
+3
View File
@@ -0,0 +1,3 @@
export const lintKeys = {
lint: (projectRef: string | undefined) => ['projects', projectRef, 'lint'] as const,
}
+405
View File
@@ -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.<function>()\` 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.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.<function>()\` with \`(select auth.<function>())\`. 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 = <TData = ProjectLintsData>(
{ projectRef, connectionString }: ProjectLintsVariables,
{ enabled, ...options }: UseQueryOptions<ProjectLintsData, ProjectLintsError, TData> = {}
) =>
useQuery<ProjectLintsData, ProjectLintsError, TData>(
lintKeys.lint(projectRef),
({ signal }) => getProjectLints({ projectRef, connectionString }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
+2
View File
@@ -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 = {
@@ -73,7 +73,6 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false)
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<PostgresPolicy>()
const isAiAssistantEnabled = useIsRLSAIAssistantEnabled()
useEffect(() => {
if (search) setSearchString(search)
@@ -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<string[]>(
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 (
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12 flex items-center justify-between">
<div className="flec flex-col gap-y-1">
<h3 className="text-xl text-foreground">Project Linter</h3>
<div className="text-sm text-foreground-lighter">
Identify common schema problems in your project's database
</div>
</div>
</div>
<div className="col-span-12 flex items-center justify-between">
<div className="flex items-center gap-x-4">
<div className="flex items-center gap-x-2">
<p className="text-xs prose">Filter by</p>
<FilterPopover
name="Level"
options={[
{ name: 'Info', value: 'INFO' },
{ name: 'Warning', value: 'WARN' },
{ name: 'Error', value: 'ERROR' },
]}
labelKey="name"
valueKey="value"
activeOptions={filters.levels}
onSaveFilters={(values) => setFilters({ ...filters, levels: values })}
/>
<FilterPopover
name="Type"
options={[
{ name: 'Unindexed foreign keys', value: 'unindexed_foreign_keys' },
{ name: 'Auth users exposed', value: 'auth_users_exposed' },
{ name: 'No primary key', value: 'no_primary_key' },
{ name: 'Unused index', value: 'unused_index' },
{ name: 'Multiple permissive policies', value: 'multiple_permissive_policies' },
]}
labelKey="name"
valueKey="value"
activeOptions={filters.types}
onSaveFilters={(values) => setFilters({ ...filters, types: values })}
/>
</div>
<p className="text-foreground-light text-xs">
Identified {activeLints.length} problems{' '}
{warnLintsCount > 0 || errorLintsCount > 0
? `(${errorLintsCount > 0 ? `${errorLintsCount} errors` : ''}${errorLintsCount > 0 && warnLintsCount > 0 ? ', ' : ''}${warnLintsCount > 0 ? `${warnLintsCount} warnings` : ''})`
: null}
</p>
</div>
<div className="flex items-center gap-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<a href="/" target="_blank" rel="noreferrer">
Documentation
</a>
</Button>
<Button
type="primary"
disabled={isLoading || isRefetching}
loading={isLoading || isRefetching}
onClick={() => refetch()}
>
Rerun linter
</Button>
</div>
</div>
<div className="col-span-12">
<Table
head={[
<Table.th key="level" className="py-2">
Level
</Table.th>,
<Table.th key="header-type" className="py-2">
Problem
</Table.th>,
<Table.th key="header-expand" className="py-2 text-right"></Table.th>,
]}
body={[
<Table.tr key="loader">
<Table.td colSpan={12} className="!p-0">
<LoadingLine loading={isLoading || isRefetching} />
</Table.td>
</Table.tr>,
...(activeLints.length === 0
? [
<Table.tr key="empty-state">
<Table.td colSpan={6} className="p-3 py-12">
{isLoading ? (
<div className="flex items-center gap-x-2">
<Loader className="animate-spin" size={12} />
<p className="text-foreground-light">Checking project for issues...</p>
</div>
) : (
<div className="flex items-center gap-x-2">
<Check size={12} />
<p className="text-foreground-light">
No issues have been found for this project
</p>
</div>
)}
</Table.td>
</Table.tr>,
]
: (filters.levels.length > 0 || filters.types.length > 0) &&
filteredLints.length === 0
? [
<Table.tr key="empty-state">
<Table.td colSpan={6} className="p-3 py-12">
<p className="text-foreground-light">
No problems found based on the selected filters
</p>
</Table.td>
</Table.tr>,
]
: filteredLints.map((lint) => {
return <ReportLintsTableRow key={lint.cache_key} lint={lint} />
})),
]}
/>
</div>
{ignoredLints.length > 0 && (
<div className="col-span-12 flex flex-col text-sm max-w-none gap-8 py-4">
<Accordion_Shadcn_ type="single" collapsible>
<AccordionItem_Shadcn_ value="1" className="border-none">
<AccordionTrigger className="px-4 bg-surface-100 rounded border [&[data-state=open]]:rounded-b-none hover:no-underline">
<div className="text-sm text-foreground-light font-normal">
Ignored problems ({ignoredLints.length})
</div>
</AccordionTrigger>
<AccordionContent_Shadcn_>
<Table
body={
(lints ?? []).length === 0 ? (
<Table.tr>
<Table.td colSpan={6} className="p-3 py-12 text-center">
<p className="text-foreground-light">
{isLoading ? (
<>
<Loader className="animate-spin" size={12} />
Checking for project issues
</>
) : (
'No issues have been found for this project'
)}
</p>
</Table.td>
</Table.tr>
) : (
<>
{ignoredLints.map((lint) => {
return <ReportLintsTableRow key={lint.cache_key} lint={lint} />
})}
</>
)
}
/>
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
</div>
)}
</ScaffoldSection>
</ScaffoldContainer>
)
}
ProjectLints.getLayout = (page) => <ReportsLayout title="Linter">{page}</ReportsLayout>
export default ProjectLints