AdvisorWidget.tsx358 lines · main
1import { useParams } from 'common'
2import { Activity, ExternalLink, Shield } from 'lucide-react'
3import Link from 'next/link'
4import { useCallback, useMemo, useState } from 'react'
5import {
6 Card,
7 CardContent,
8 CardHeader,
9 CardTitle,
10 Table,
11 TableBody,
12 TableCell,
13 TableHead,
14 TableHeader,
15 TableRow,
16 Tabs_Shadcn_ as Tabs,
17 TabsContent_Shadcn_ as TabsContent,
18 TabsList_Shadcn_ as TabsList,
19 TabsTrigger_Shadcn_ as TabsTrigger,
20} from 'ui'
21import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
22
23import { useQueryPerformanceQuery } from '../QueryPerformance/useQueryPerformanceQuery'
24import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants'
25import {
26 createLintSummaryPrompt,
27 EntityTypeIcon,
28} from '@/components/interfaces/Linter/Linter.utils'
29import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
30import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
31import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
32import { Lint, useProjectLintsQuery } from '@/data/lint/lint-query'
33import { useTrack } from '@/lib/telemetry/track'
34import { useAdvisorStateSnapshot } from '@/state/advisor-state'
35import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
36import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
37
38interface SlowQuery {
39 rolname: string
40 mean_time: number
41 calls: number
42 query: string
43}
44
45export const AdvisorWidget = () => {
46 const { ref: projectRef } = useParams()
47 const [selectedTab, setSelectedTab] = useState<'security' | 'performance'>('security')
48 const { data: lints, isPending: isLoadingLints } = useProjectLintsQuery({ projectRef })
49 const { data: slowestQueriesData, isLoading: isLoadingSlowestQueries } = useQueryPerformanceQuery(
50 { preset: 'slowestExecutionTime' }
51 )
52 const snap = useAiAssistantStateSnapshot()
53 const { openSidebar } = useSidebarManagerSnapshot()
54 const { setSelectedItem } = useAdvisorStateSnapshot()
55 const track = useTrack()
56
57 const securityLints = useMemo(
58 () => (lints ?? []).filter((lint: Lint) => lint.categories.includes('SECURITY')),
59 [lints]
60 )
61 const performanceLints = useMemo(
62 () => (lints ?? []).filter((lint: Lint) => lint.categories.includes('PERFORMANCE')),
63 [lints]
64 )
65
66 const securityErrorCount = securityLints.filter(
67 (lint: Lint) => lint.level === LINTER_LEVELS.ERROR
68 ).length
69 const securityWarningCount = securityLints.filter(
70 (lint: Lint) => lint.level === LINTER_LEVELS.WARN
71 ).length
72 const performanceErrorCount = performanceLints.filter(
73 (lint: Lint) => lint.level === LINTER_LEVELS.ERROR
74 ).length
75 const performanceWarningCount = performanceLints.filter(
76 (lint: Lint) => lint.level === LINTER_LEVELS.WARN
77 ).length
78
79 const top5SlowestQueries = useMemo(
80 () => ((slowestQueriesData ?? []) as SlowQuery[]).slice(0, 5),
81 [slowestQueriesData]
82 )
83
84 const handleLintClick = useCallback(
85 (lint: Lint) => {
86 setSelectedItem(lint.cache_key, 'lint')
87 openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
88 },
89 [setSelectedItem, openSidebar]
90 )
91
92 const totalIssues =
93 securityErrorCount + securityWarningCount + performanceErrorCount + performanceWarningCount
94 const hasErrors = securityErrorCount > 0 || performanceErrorCount > 0
95 const hasWarnings = securityWarningCount > 0 || performanceWarningCount > 0
96
97 let titleContent: React.ReactNode
98
99 if (totalIssues === 0) {
100 titleContent = <h2>No issues available</h2>
101 } else {
102 const issuesText = totalIssues === 1 ? 'issue' : 'issues'
103 const numberDisplay = totalIssues.toString()
104
105 let attentionClassName = ''
106 if (hasErrors) {
107 attentionClassName = 'text-destructive'
108 } else if (hasWarnings) {
109 attentionClassName = 'text-warning'
110 }
111
112 titleContent = (
113 <h2>
114 {numberDisplay} {issuesText} need
115 {totalIssues === 1 ? 's' : ''} <span className={attentionClassName}>attention</span>
116 </h2>
117 )
118 }
119
120 const renderLintTabContent = (
121 title: string,
122 lints: Lint[],
123 errorCount: number,
124 warningCount: number,
125 isLoading: boolean
126 ) => {
127 const topIssues = lints
128 .filter((lint) => lint.level === LINTER_LEVELS.ERROR || lint.level === LINTER_LEVELS.WARN)
129 .sort((a, _b) => (a.level === LINTER_LEVELS.ERROR ? -1 : 1))
130
131 return (
132 <div className="h-full">
133 {isLoading && (
134 <div className="flex flex-col p-4 gap-2">
135 <ShimmeringLoader />
136 <ShimmeringLoader className="w-3/4" />
137 <ShimmeringLoader className="w-1/2" />
138 </div>
139 )}
140 {!isLoading && (errorCount > 0 || warningCount > 0) && (
141 <ul>
142 {topIssues.map((lint) => {
143 const lintText = lint.detail ? lint.detail : lint.title
144 return (
145 <li
146 key={lint.cache_key}
147 className="text-sm w-full border-b my-0 last:border-b-0 group px-4 "
148 >
149 <div className="flex items-center justify-between w-full group">
150 <button
151 onClick={() => handleLintClick(lint)}
152 className="flex items-center gap-2 transition truncate flex-1 min-w-0 py-3 text-left"
153 >
154 <EntityTypeIcon type={lint.metadata?.type} />
155 <p className="flex-1 font-mono text-xs leading-6 text-xs text-foreground-light group-hover:text-foreground truncate">
156 {lintText.replace(/\\`/g, '`')}
157 </p>
158 </button>
159 <div
160 onClick={(e) => {
161 e.stopPropagation()
162 e.preventDefault()
163 }}
164 className="opacity-0 group-hover:opacity-100"
165 >
166 <AiAssistantDropdown
167 label="Ask Assistant"
168 iconOnly
169 tooltip="Help me fix this issue"
170 buildPrompt={() => createLintSummaryPrompt(lint)}
171 onOpenAssistant={() => {
172 openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
173 snap.newChat({
174 name: 'Summarize lint',
175 initialInput: createLintSummaryPrompt(lint),
176 })
177 track('advisor_assistant_button_clicked', {
178 origin: 'homepage',
179 advisorCategory: lint.categories[0],
180 advisorType: lint.name,
181 advisorLevel: lint.level,
182 })
183 }}
184 telemetrySource="advisor_widget"
185 type="text"
186 className="px-1 w-7"
187 />
188 </div>
189 </div>
190 </li>
191 )
192 })}
193 </ul>
194 )}
195 {!isLoading && errorCount === 0 && warningCount === 0 && (
196 <div className="flex-1 flex flex-col h-full items-center justify-center gap-2">
197 <Shield size={20} strokeWidth={1.5} className="text-foreground-muted" />
198 <p className="text-sm text-foreground-light">No {title.toLowerCase()} issues found</p>
199 </div>
200 )}
201 </div>
202 )
203 }
204
205 return (
206 <div className="@container">
207 {isLoadingLints ? (
208 <ShimmeringLoader className="w-96 mb-6" />
209 ) : (
210 <div className="flex justify-between items-center mb-6">{titleContent}</div>
211 )}
212 <div className="grid grid-cols-1 @xl:grid-cols-2 gap-4">
213 <Card className="h-80">
214 <Tabs value={selectedTab} className="h-full flex flex-col">
215 <CardHeader className="h-10 py-0 pl-4 pr-2 flex flex-row items-center justify-between flex-0">
216 <TabsList className="flex justify-start rounded-none gap-x-4 border-b-0 mt-0! pt-0">
217 <TabsTrigger
218 value="security"
219 onClick={() => setSelectedTab('security')}
220 className="flex items-center gap-2 text-xs py-3 border-b font-mono uppercase"
221 >
222 Security{' '}
223 {securityErrorCount + securityWarningCount > 0 && (
224 <div className="rounded-sm bg-warning text-warning-100 px-1">
225 {securityErrorCount + securityWarningCount}
226 </div>
227 )}
228 </TabsTrigger>
229 <TabsTrigger
230 value="performance"
231 onClick={() => setSelectedTab('performance')}
232 className="flex items-center gap-2 text-xs py-3 border-b font-mono uppercase"
233 >
234 Performance{' '}
235 {performanceErrorCount + performanceWarningCount > 0 && (
236 <div className="rounded-sm bg-warning text-warning-100 px-1">
237 {performanceErrorCount + performanceWarningCount}
238 </div>
239 )}
240 </TabsTrigger>
241 </TabsList>
242 <ButtonTooltip
243 asChild
244 type="text"
245 className="mt-0! w-7"
246 icon={<ExternalLink />}
247 tooltip={{
248 content: {
249 side: 'bottom',
250 text: `Open ${selectedTab} Advisor`,
251 className: 'capitalize',
252 },
253 }}
254 >
255 <Link
256 href={`/project/${projectRef}/advisors/${selectedTab}`}
257 aria-label={`Open ${selectedTab} advisor`}
258 />
259 </ButtonTooltip>
260 </CardHeader>
261 <CardContent className="p-0! mt-0 flex-1 overflow-y-auto">
262 <TabsContent value="security" className="p-0 mt-0 h-full">
263 {renderLintTabContent(
264 'Security',
265 securityLints,
266 securityErrorCount,
267 securityWarningCount,
268 isLoadingLints
269 )}
270 </TabsContent>
271 <TabsContent value="performance" className="p-0 mt-0 h-full">
272 {renderLintTabContent(
273 'Performance',
274 performanceLints,
275 performanceErrorCount,
276 performanceWarningCount,
277 isLoadingLints
278 )}
279 </TabsContent>
280 </CardContent>
281 </Tabs>
282 </Card>
283
284 <Card className="h-80 flex flex-col">
285 <CardHeader className="h-10 flex-row items-center justify-between py-0 pl-4 pr-2">
286 <CardTitle>Slow Queries</CardTitle>
287 <ButtonTooltip
288 asChild
289 type="text"
290 className="mt-0! w-7"
291 icon={<ExternalLink />}
292 tooltip={{
293 content: {
294 side: 'bottom',
295 text: `Open Query Performance Advisor`,
296 },
297 }}
298 >
299 <Link
300 href={`/project/${projectRef}/reports/query-performance`}
301 aria-label="Open Query Performance Advisor"
302 />
303 </ButtonTooltip>
304 </CardHeader>
305 <CardContent className="p-0! flex-1 overflow-y-auto">
306 {isLoadingSlowestQueries ? (
307 <div className="space-y-2 p-4">
308 <ShimmeringLoader />
309 <ShimmeringLoader className="w-3/4" />
310 <ShimmeringLoader className="w-1/2" />
311 <ShimmeringLoader className="w-3/4" />
312 <ShimmeringLoader className="w-1/2" />
313 </div>
314 ) : top5SlowestQueries.length === 0 ? (
315 <div className="flex-1 flex flex-col h-full items-center justify-center gap-2">
316 <Activity strokeWidth={1.5} size={20} className="text-foreground-muted" />
317 <p className="text-sm text-foreground-light">
318 No slow queries found in the selected period
319 </p>
320 </div>
321 ) : (
322 <Table className="text-xs font-mono max-w-full">
323 <TableHeader>
324 <TableRow>
325 <TableHead className="text-foreground-lighter truncate py-2 h-auto">
326 Query
327 </TableHead>
328 <TableHead className="text-foreground-lighter truncate py-2 h-auto">
329 Avg time
330 </TableHead>
331 <TableHead className="text-foreground-lighter truncate py-2 h-auto">
332 Calls
333 </TableHead>
334 </TableRow>
335 </TableHeader>
336 <TableBody>
337 {/* Added explicit types for map parameters */}
338 {top5SlowestQueries.map((query: SlowQuery, i: number) => (
339 <TableRow key={i} className="py-2">
340 <TableCell className="font-mono truncate max-w-xs">{query.query}</TableCell>
341
342 <TableCell className="font-mono truncate max-w-xs">
343 {typeof query.mean_time === 'number'
344 ? `${(query.mean_time / 1000).toFixed(2)}s`
345 : 'N/A'}
346 </TableCell>
347 <TableCell className="font-mono truncate max-w-xs">{query.calls}</TableCell>
348 </TableRow>
349 ))}
350 </TableBody>
351 </Table>
352 )}
353 </CardContent>
354 </Card>
355 </div>
356 </div>
357 )
358}