mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Chore/linter v2 (#22790)
* Start mocking in v2 elements * wip * Cleanup header * Get side panel working * Style side panel * Cleanup * Add new lint * Start filters * Add lint filters * Nudge loading state * Fix empty state * Cleanup * Cleanup * Font nudges * Visual nudges * Fix filters * Fix shimmer * Cleanup
This commit is contained in:
1 parent
34d152ce78
commit
6b1a44524c
4 files changed
+551
-394
No files matched your search
@@ -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: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'auth_users_exposed',
|
||||
title: 'Exposed Auth Users',
|
||||
icon: <Lock className="text-foreground-muted" size={15} strokeWidth={1.5} />,
|
||||
},
|
||||
{
|
||||
name: 'auth_rls_initplan',
|
||||
title: 'Auth RLS Initialization Plan',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'no_primary_key',
|
||||
title: 'No Primary Key',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'unused_index',
|
||||
title: 'Unused Index',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'multiple_permissive_policies',
|
||||
title: 'Multiple Permissive Policies',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'function_search_path_mutable',
|
||||
title: 'Function Search Path Mutable',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'rls_enabled_no_policy',
|
||||
title: 'RLS Enabled No Policy',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'policy_exists_rls_disabled',
|
||||
title: 'Policy Exists RLS Disabled',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'rls_disabled_in_public',
|
||||
title: 'RLS Disabled in Public',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'security_definer_view',
|
||||
title: 'Security Definer View',
|
||||
icon: <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />,
|
||||
},
|
||||
{
|
||||
name: 'duplicate_index',
|
||||
title: 'Duplicate Index',
|
||||
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
{
|
||||
name: 'extension_in_public',
|
||||
title: 'Extension in Public',
|
||||
icon: <Unlock className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
</Link>
|
||||
@@ -66,7 +107,12 @@ export const LintCTA = ({
|
||||
case 'auth_users_exposed':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}/editor`} target="_blank" rel="noreferrer">
|
||||
<Link
|
||||
href={`/project/${projectRef}/editor`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
>
|
||||
View table
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -74,7 +120,12 @@ export const LintCTA = ({
|
||||
case 'auth_rls_initplan':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}/auth/policies`} target="_blank" rel="noreferrer">
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/policies`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
>
|
||||
View policies
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -82,7 +133,12 @@ export const LintCTA = ({
|
||||
case 'no_primary_key':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}/editor`} target="_blank" rel="noreferrer">
|
||||
<Link
|
||||
href={`/project/${projectRef}/editor`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
>
|
||||
View table
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -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
|
||||
</Link>
|
||||
@@ -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
|
||||
</Link>
|
||||
@@ -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
|
||||
</Link>
|
||||
@@ -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
|
||||
</Link>
|
||||
@@ -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
|
||||
</Link>
|
||||
@@ -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
|
||||
</Link>
|
||||
@@ -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
|
||||
</Link>
|
||||
@@ -181,6 +244,7 @@ export const LintCTA = ({
|
||||
href={`/project/${projectRef}/database/extensions?filter=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
>
|
||||
View extension
|
||||
</Link>
|
||||
@@ -191,3 +255,14 @@ export const LintCTA = ({
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
||||
export const entityTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'table':
|
||||
return <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />
|
||||
case 'view':
|
||||
return <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />
|
||||
default:
|
||||
return <Box className="text-foreground-muted" size={15} strokeWidth={1.5} />
|
||||
}
|
||||
}
|
||||
@@ -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<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)) {
|
||||
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 (
|
||||
<>
|
||||
<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 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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string[]>(
|
||||
// LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST,
|
||||
// []
|
||||
// )
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const gridRef = useRef<DataGridHandle>(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<LINTER_LEVELS>(
|
||||
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
|
||||
)
|
||||
const [selectedRow, setSelectedRow] = useState<number>()
|
||||
const [selectedLint, setSelectedLint] = useState<Lint | null>(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 ? (
|
||||
<ShimmeringLoader className="w-20 pt-1" />
|
||||
) : (
|
||||
<>
|
||||
{count} {label}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const lintCols = [
|
||||
{
|
||||
id: 'name',
|
||||
name: 'Issue type',
|
||||
description: undefined,
|
||||
minWidth: 240,
|
||||
value: (row: any) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="shrink-0">
|
||||
{lintInfoMap.find((item) => row.name === item.name)?.icon}
|
||||
</span>
|
||||
{<h3 className="text-xs">{lintInfoMap.find((item) => row.name === item.name)?.title}</h3>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'metadata.name',
|
||||
name: 'Entity/item',
|
||||
description: undefined,
|
||||
minWidth: 230,
|
||||
value: (row: any) => (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="shrink-0">{entityTypeIcon(row.metadata?.type)}</span>
|
||||
{`${row.metadata.schema}.${row.metadata.name}`}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
name: 'Description',
|
||||
description: undefined,
|
||||
minWidth: 400,
|
||||
value: (row: any) => <ReactMarkdown className="text-xs">{row.description}</ReactMarkdown>,
|
||||
},
|
||||
]
|
||||
|
||||
const columns = lintCols.map((col) => {
|
||||
const result: Column<any> = {
|
||||
key: col.id,
|
||||
name: col.name,
|
||||
resizable: true,
|
||||
minWidth: col.minWidth ?? 120,
|
||||
headerCellClass: 'first:pl-6 cursor-pointer',
|
||||
renderHeaderCell: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between font-mono font-normal text-xs w-full">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="!text-foreground">{col.name}</p>
|
||||
{col.description && <p className="text-foreground-lighter">{col.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
renderCell: (props) => {
|
||||
const value = col.value(props.row)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full flex flex-col justify-center font-mono text-xs',
|
||||
typeof value === 'number' ? 'text-right' : ''
|
||||
)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12">
|
||||
<FormHeader
|
||||
className="!mb-0"
|
||||
title="Database Linter"
|
||||
description="Identify common schema problems in your database."
|
||||
/>
|
||||
</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={filterOptions}
|
||||
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="https://supabase.github.io/splinter" target="_blank" rel="noreferrer">
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={isLoading || isRefetching}
|
||||
loading={isLoading || isRefetching}
|
||||
onClick={() => refetch()}
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader
|
||||
className="py-4 px-6 !mb-0"
|
||||
title="Suggestions"
|
||||
docsUrl="https://supabase.github.io/splinter/"
|
||||
/>
|
||||
<Tabs_Shadcn_
|
||||
defaultValue={currentTab}
|
||||
onValueChange={(value) => {
|
||||
setCurrentTab(value as LINTER_LEVELS)
|
||||
setSelectedLint(null)
|
||||
setSelectedRow(undefined)
|
||||
const { sort, search, ...rest } = router.query
|
||||
router.push({ ...router, query: { ...rest, preset: value } })
|
||||
}}
|
||||
>
|
||||
<TabsList_Shadcn_ className={cn('flex gap-0 border-0 items-end z-10 relative')}>
|
||||
{LINT_TABS.map((tab) => (
|
||||
<TabsTrigger_Shadcn_
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
'group relative',
|
||||
'px-6 py-3 border-b-0 flex flex-col items-start !shadow-none border-default border-t',
|
||||
'even:border-x last:border-r even:!border-x-strong last:!border-r-strong',
|
||||
tab.id === currentTab ? '!bg-surface-200' : '!bg-surface-200/[33%]',
|
||||
'hover:!bg-surface-100',
|
||||
'data-[state=active]:!bg-surface-200',
|
||||
'hover:text-foreground-light',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
Rerun linter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{tab.id === currentTab && (
|
||||
<div className="absolute top-0 left-0 w-full h-[1px] bg-foreground" />
|
||||
)}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span
|
||||
className={
|
||||
tab.id === LINTER_LEVELS.ERROR
|
||||
? 'text-destructive-600'
|
||||
: tab.id === LINTER_LEVELS.WARN
|
||||
? 'text-warning-600'
|
||||
: 'text-brand-500'
|
||||
}
|
||||
>
|
||||
<MessageSquareMore size={14} fill="currentColor" strokeWidth={0} />
|
||||
</span>
|
||||
|
||||
<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 database for issues...</p>
|
||||
<span className="">{tab.label}</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<InformationCircleIcon className="transition text-foreground-muted w-3 h-3 data-[state=delayed-open]:text-foreground-light" />
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="top">{tab.description}</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-muted group-hover:text-foreground-lighter group-data-[state=active]:text-foreground-lighter transition">
|
||||
{tab.id === LINTER_LEVELS.ERROR && lintCountLabel(errorLintsCount, 'errors')}
|
||||
{tab.id === LINTER_LEVELS.WARN && lintCountLabel(warnLintsCount, 'warnings')}
|
||||
{tab.id === LINTER_LEVELS.INFO && lintCountLabel(infoLintsCount, 'suggestions')}
|
||||
</span>
|
||||
</TabsTrigger_Shadcn_>
|
||||
))}
|
||||
</TabsList_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
|
||||
<div className="bg-surface-200 p-2 px-6 py-2 border-t -mt-px">
|
||||
{LINT_TABS.map((tab) => (
|
||||
<div key={tab.id} className={tab.id === currentTab ? '' : 'hidden'}>
|
||||
<FilterPopover
|
||||
name="Filter"
|
||||
options={filterOptions}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
activeOptions={filters.find((filter) => filter.level === currentTab)?.filters || []}
|
||||
onSaveFilters={(values) => updateFilters(currentTab, values)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4"></div>
|
||||
</div>
|
||||
<LoadingLine loading={isRefetching} />
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="relative flex flex-grow bg-alternative min-h-0"
|
||||
autoSaveId="linter-layout-v1"
|
||||
>
|
||||
<ResizablePanel defaultSize={1}>
|
||||
<DataGrid
|
||||
ref={gridRef}
|
||||
style={{ height: '100%' }}
|
||||
className={cn('flex-1 flex-grow h-full')}
|
||||
rowHeight={44}
|
||||
headerRowHeight={36}
|
||||
columns={columns}
|
||||
rows={filteredLints ?? []}
|
||||
rowClass={(_, idx) => {
|
||||
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 (
|
||||
<Row
|
||||
{...props}
|
||||
onClick={() => {
|
||||
if (typeof idx === 'number' && idx >= 0) {
|
||||
setSelectedRow(idx)
|
||||
setSelectedLint(props.row)
|
||||
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
noRowsFallback: isLoading ? (
|
||||
<div className="absolute top-14 px-6 w-full">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
|
||||
<TextSearch className="text-foreground-muted" strokeWidth={1} />
|
||||
<div className="text-center">
|
||||
<p className="text-foreground">No issues detected</p>
|
||||
<p className="text-foreground-light">
|
||||
Congrats! There are no suggestions available for this database
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
{selectedLint !== null && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel
|
||||
defaultSize={30}
|
||||
maxSize={45}
|
||||
minSize={30}
|
||||
className="bg-studio border-t"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="absolute top-3 right-3 px-1"
|
||||
icon={<X size={14} />}
|
||||
onClick={() => {
|
||||
setSelectedLint(null)
|
||||
setSelectedRow(undefined)
|
||||
}}
|
||||
/>
|
||||
<Tabs_Shadcn_
|
||||
value={view}
|
||||
className="flex flex-col h-full"
|
||||
onValueChange={(value: any) => {
|
||||
setView(value)
|
||||
}}
|
||||
>
|
||||
<TabsList_Shadcn_ className="px-5 flex gap-x-4 min-h-[46px]">
|
||||
<TabsTrigger_Shadcn_
|
||||
value="details"
|
||||
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
<TabsContent_Shadcn_
|
||||
value="details"
|
||||
className="mt-0 flex-grow min-h-0 overflow-y-auto prose"
|
||||
>
|
||||
{selectedLint && (
|
||||
<div className={cn('py-4 px-5')}>
|
||||
<h3 className="text-sm">
|
||||
{lintInfoMap.find((item) => item.name === selectedLint.name)?.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span>Entity</span>
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 bg-surface-200 border rounded-lg ">
|
||||
{selectedLint.metadata?.type === 'table' && (
|
||||
<Table2 className="text-foreground-muted" size={15} strokeWidth={1} />
|
||||
)}
|
||||
{selectedLint.metadata?.type === 'view' && (
|
||||
<Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />
|
||||
)}{' '}
|
||||
{`${selectedLint.metadata?.schema}.${selectedLint.metadata?.name}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid">
|
||||
<div>
|
||||
<h3 className="text-sm">Issue</h3>
|
||||
<ReactMarkdown className="leading-6 text-sm">
|
||||
{selectedLint.detail}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm">Description</h3>
|
||||
<ReactMarkdown className="text-sm">
|
||||
{selectedLint.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<h3 className="text-sm">Resolve</h3>
|
||||
<div>
|
||||
<LintCTA
|
||||
title={selectedLint.name}
|
||||
projectRef={ref!}
|
||||
metadata={selectedLint.metadata}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Check size={12} />
|
||||
<p className="text-foreground-light">
|
||||
No issues have been found for this database
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
<div className="px-6 py-6 flex gap-x-4 border-t ">
|
||||
<div className="w-[35%] flex flex-col gap-y-1 text-sm">
|
||||
<p>Reset suggestions</p>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Consider resetting the analysis making any changes
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
className="!mt-3 w-min"
|
||||
disabled={isLoading || isRefetching}
|
||||
loading={isLoading || isRefetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Rerun linter
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-[35%] flex flex-col gap-y-1 text-sm">
|
||||
<p>How are these suggestions generated?</p>
|
||||
<Markdown
|
||||
className="text-xs"
|
||||
content="These suggestions use [splinter (Supabase Postgres LINTER)](https://github.com/supabase/splinter)."
|
||||
/>
|
||||
</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 database issues
|
||||
</>
|
||||
) : (
|
||||
'No issues have been found for this database'
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user