Reports.tsx505 lines · main
1// @ts-nocheck
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useQueryClient } from '@tanstack/react-query'
4import { useParams } from 'common'
5import { groupBy, isEqual, isNull } from 'lodash'
6import { Plus, RefreshCw, Save } from 'lucide-react'
7import { DragEvent, useEffect, useState } from 'react'
8import { toast } from 'sonner'
9import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, LogoLoader } from 'ui'
10
11import { createSqlSnippetSkeletonV2 } from '../SQLEditor/SQLEditor.utils'
12import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig'
13import { GridResize } from './GridResize'
14import { MetricOptions } from './MetricOptions'
15import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
16import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
17import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
18import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
19import { DateRangePicker } from '@/components/ui/DateRangePicker'
20import NoPermission from '@/components/ui/NoPermission'
21import { DEFAULT_CHART_CONFIG } from '@/components/ui/QueryBlock/QueryBlock'
22import { AnalyticsInterval } from '@/data/analytics/constants'
23import { analyticsKeys } from '@/data/analytics/keys'
24import { useContentQuery } from '@/data/content/content-query'
25import {
26 UpsertContentPayload,
27 useContentUpsertMutation,
28} from '@/data/content/content-upsert-mutation'
29import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
30import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
31import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
32import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
33import { Metric, TIME_PERIODS_REPORTS } from '@/lib/constants/metrics'
34import { uuidv4 } from '@/lib/helpers'
35import { useProfile } from '@/lib/profile'
36import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
37import type { Dashboards } from '@/types'
38
39const DEFAULT_CHART_COLUMN_COUNT = 1
40const DEFAULT_CHART_ROW_COUNT = 1
41
42const Reports = () => {
43 const { id: reportId, ref } = useParams()
44 const { profile } = useProfile()
45 const { data: project } = useSelectedProjectQuery()
46 const { data: selectedOrg } = useSelectedOrganizationQuery()
47 const queryClient = useQueryClient()
48 const state = useDatabaseSelectorStateSnapshot()
49
50 const [isDraggedOver, setIsDraggedOver] = useState(false)
51 const [config, setConfig] = useState<Dashboards.Content>()
52 const [startDate, setStartDate] = useState<string>()
53 const [endDate, setEndDate] = useState<string>()
54 const [hasEdits, setHasEdits] = useState<boolean>(false)
55 const [isRefreshing, setIsRefreshing] = useState<boolean>(false)
56
57 const {
58 data: userContents,
59 isPending: isLoading,
60 isSuccess,
61 } = useContentQuery({
62 projectRef: ref,
63 type: 'report',
64 })
65 const { mutate: upsertContent, isPending: isSaving } = useContentUpsertMutation({
66 onSuccess: (_, vars) => {
67 setHasEdits(false)
68 if (vars.payload.type === 'report') toast.success('Successfully saved report!')
69 },
70 onError: (error, vars) => {
71 if (vars.payload.type === 'report') toast.error(`Failed to update report: ${error.message}`)
72 },
73 })
74 const { mutate: sendEvent } = useSendEventMutation()
75
76 const currentReport = userContents?.content.find((report) => report.id === reportId)
77 const currentReportContent = currentReport?.content as Dashboards.Content
78
79 const { can: canReadReport, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
80 PermissionAction.READ,
81 'user_content',
82 {
83 resource: {
84 type: 'report',
85 visibility: currentReport?.visibility,
86 owner_id: currentReport?.owner_id,
87 },
88 subject: { id: profile?.id },
89 }
90 )
91 const { can: canUpdateReport } = useAsyncCheckPermissions(
92 PermissionAction.UPDATE,
93 'user_content',
94 {
95 resource: {
96 type: 'report',
97 visibility: currentReport?.visibility,
98 owner_id: currentReport?.owner_id,
99 },
100 subject: { id: profile?.id },
101 }
102 )
103
104 function handleDateRangePicker({ period_start, period_end }: any) {
105 setStartDate(period_start.date)
106 setEndDate(period_end.date)
107 }
108
109 function checkEditState() {
110 if (config === undefined) return
111 /*
112 * Shallow copying the config state variable maintains a reference
113 * Instead, we stringify it and parse it again to remove anything
114 * that can be mutated at component state level.
115 *
116 * This allows us to mutate these configs, like removing dates in case we do not
117 * want to compare fixed dates as possible differences from saved and edited versions of report.
118 */
119 let _config = JSON.parse(JSON.stringify(config))
120 let _original = JSON.parse(JSON.stringify(currentReportContent))
121
122 if (!_original || !_config) return
123
124 /*
125 * Check if the dates are a fixed custom date range
126 * if they are not, we remove the dates for the edit check comparison
127 *
128 * this feature is not yet in use, but if we did use custom fixed date ranges,
129 * the below would not need to be run
130 */
131 if (
132 _config.period_start.time_period !== 'custom' ||
133 _config.period_end.time_period !== 'custom'
134 ) {
135 _original.period_start.date = ''
136 _config.period_start.date = ''
137 _original.period_end.date = ''
138 _config.period_end.date = ''
139 }
140
141 // Runs comparison
142 if (isEqual(_config, _original)) {
143 setHasEdits(false)
144 } else {
145 setHasEdits(true)
146 }
147 }
148
149 const handleChartSelection = ({
150 metric,
151 isAddingChart,
152 }: {
153 metric: Metric
154 isAddingChart: boolean
155 }) => {
156 if (isAddingChart) pushChart({ metric })
157 else popChart({ metric })
158 }
159
160 const pushChart = ({ metric }: { metric: Metric }) => {
161 if (!config) return
162 const current = [...config.layout]
163
164 let x = 0
165 let y = null
166
167 const chartsByY = groupBy(config.layout, 'y')
168 const yValues = Object.keys(chartsByY)
169 const isSnippet = metric.key?.startsWith('snippet_')
170
171 if (yValues.length === 0) {
172 y = 0
173 } else {
174 // Find if any row has space to fit in a new chart
175 for (const yValue of yValues) {
176 const totalWidthTaken = chartsByY[yValue].reduce((a, b) => a + b.w, 0)
177 if (LAYOUT_COLUMN_COUNT - totalWidthTaken >= DEFAULT_CHART_COLUMN_COUNT) {
178 y = Number(yValue)
179
180 // Given that there can not be any gaps between charts, it's safe to
181 // assume that we can set x using the accumulative widths
182 x = totalWidthTaken
183 break
184 }
185 }
186
187 // If no rows have space to fit the new chart, bring it to a new row
188 if (isNull(y)) {
189 y = Number(yValues[yValues.length - 1]) + DEFAULT_CHART_ROW_COUNT
190 }
191 }
192
193 current.push({
194 x,
195 y,
196 w: DEFAULT_CHART_COLUMN_COUNT,
197 h: DEFAULT_CHART_ROW_COUNT,
198 id: metric?.id ?? uuidv4(),
199 label: metric.label,
200 attribute: metric.key as Dashboards.ChartType,
201 provider: metric.provider as any,
202 chart_type: 'bar',
203 ...(isSnippet ? { chartConfig: DEFAULT_CHART_CONFIG } : {}),
204 })
205
206 setConfig({
207 ...config,
208 layout: [...current],
209 })
210 }
211
212 const popChart = ({ metric }: { metric: Partial<Metric> }) => {
213 if (!config) return
214
215 const { key, id } = metric
216 const current = [...config.layout]
217
218 const foundIndex = current.findIndex((x) => {
219 if (x.attribute === key || x.id === id) return x
220 })
221 current.splice(foundIndex, 1)
222 setConfig({ ...config, layout: [...current] })
223 }
224
225 const updateChart = (
226 id: string,
227 {
228 chart,
229 chartConfig,
230 }: { chart?: Partial<Dashboards.Chart>; chartConfig?: Partial<ChartConfig> }
231 ) => {
232 const currentChart = config?.layout.find((x) => x.id === id)
233
234 if (currentChart) {
235 const updatedChart: Dashboards.Chart = {
236 ...currentChart,
237 ...(chart ?? {}),
238 }
239 if (chartConfig) {
240 updatedChart.chartConfig = { ...(currentChart?.chartConfig ?? {}), ...chartConfig }
241 }
242
243 const foundIndex = config?.layout.findIndex((x) => x.id === id)
244 if (config && foundIndex !== undefined && foundIndex >= 0) {
245 const updatedLayouts = [...config.layout]
246 updatedLayouts[foundIndex] = updatedChart
247 setConfig({ ...config, layout: updatedLayouts })
248 }
249 }
250 }
251
252 // Updates the report and reloads the report again
253 const onSaveReport = async () => {
254 if (ref === undefined) return console.error('Project ref is required')
255 if (currentReport === undefined) return console.error('Report is required')
256 if (config === undefined) return console.error('Config is required')
257 upsertContent({
258 projectRef: ref,
259 payload: { ...currentReport, content: config },
260 })
261 }
262
263 const onRefreshReport = () => {
264 // [Joshen] Since we can't track individual loading states for each chart
265 // so for now we mock a loading state that only lasts for a second
266 setIsRefreshing(true)
267 const monitoringCharts = config?.layout.filter(
268 (x) => x.provider === 'infra-monitoring' || x.provider === 'daily-stats'
269 )
270 monitoringCharts?.forEach((x) => {
271 queryClient.invalidateQueries({
272 queryKey: analyticsKeys.infraMonitoring(ref, {
273 attribute: x.attribute,
274 startDate,
275 endDate,
276 interval: config?.interval,
277 databaseIdentifier: state.selectedDatabaseId,
278 }),
279 })
280 })
281 setTimeout(() => setIsRefreshing(false), 1000)
282 }
283
284 const onDragOverEmptyState = (event: DragEvent<HTMLDivElement>) => {
285 if (event.type === 'dragover' && !isDraggedOver) {
286 setIsDraggedOver(true)
287 } else if (event.type === 'dragleave' || event.type === 'drop') {
288 setIsDraggedOver(false)
289 }
290 event.stopPropagation()
291 event.preventDefault()
292 }
293
294 const onDropSQLBlockEmptyState = (event: DragEvent<HTMLDivElement>) => {
295 onDragOverEmptyState(event)
296 if (!ref) return console.error('Project ref is required')
297 if (!profile) return console.error('Profile is required')
298 if (!project) return console.error('Project is required')
299 if (!config) return console.error('Chart configuration is required')
300
301 const data = event.dataTransfer.getData('application/json')
302 if (!data) return
303
304 const queryData = JSON.parse(data)
305 const { label, sql, config: sqlConfig } = queryData
306 if (!label || !sql) return console.error('SQL and Label required')
307
308 const toastId = toast.loading(`Creating new query: ${label}`)
309
310 const payload = createSqlSnippetSkeletonV2({
311 name: label,
312 sql,
313 owner_id: profile?.id,
314 project_id: project?.id,
315 }) as UpsertContentPayload
316
317 const updatedLayout = [...config.layout]
318 updatedLayout.push({
319 id: payload.id,
320 label,
321 x: 0,
322 y: 0,
323 chart_type: 'bar',
324 attribute: `new_snippet_${payload.id}` as Dashboards.ChartType,
325 w: DEFAULT_CHART_COLUMN_COUNT,
326 h: DEFAULT_CHART_ROW_COUNT,
327 chartConfig: { ...DEFAULT_CHART_CONFIG, ...(sqlConfig ?? {}) },
328 provider: undefined as any,
329 })
330
331 setConfig({ ...config, layout: [...updatedLayout] })
332
333 upsertContent(
334 { projectRef: ref, payload },
335 {
336 onSuccess: () => {
337 toast.success(`Successfully created new query: ${label}`, { id: toastId })
338 const finalLayout = updatedLayout.map((x) => {
339 if (x.id === payload.id) {
340 return { ...x, attribute: `snippet_${payload.id}` as Dashboards.ChartType }
341 } else return x
342 })
343 setConfig({ ...config, layout: finalLayout })
344 },
345 }
346 )
347 sendEvent({
348 action: 'custom_report_assistant_sql_block_added',
349 groups: { project: ref ?? 'Unknown', organization: selectedOrg?.slug ?? 'Unknown' },
350 })
351 }
352
353 useEffect(() => {
354 if (isSuccess && currentReportContent !== undefined) setConfig(currentReportContent)
355 }, [isSuccess, currentReportContent])
356
357 useEffect(() => {
358 checkEditState()
359 }, [config])
360
361 if (isLoading || isLoadingPermissions) {
362 return <LogoLoader />
363 }
364
365 if (!canReadReport) {
366 return <NoPermission isFullPage resourceText="access this custom report" />
367 }
368
369 return (
370 <>
371 <div className="flex flex-col space-y-4" style={{ maxHeight: '100%' }}>
372 <div className="flex items-center justify-between">
373 <div>
374 <h1>{currentReport?.name || 'Reports'}</h1>
375 <p className="text-foreground-light">{currentReport?.description}</p>
376 </div>
377 {hasEdits && (
378 <div className="flex items-center gap-x-2">
379 <Button
380 type="default"
381 disabled={isSaving}
382 onClick={() => setConfig(currentReportContent)}
383 >
384 Cancel
385 </Button>
386 <Button
387 type="primary"
388 icon={<Save />}
389 loading={isSaving}
390 onClick={() => onSaveReport()}
391 >
392 Save changes
393 </Button>
394 </div>
395 )}
396 </div>
397 <div className={cn('mb-4 flex items-center gap-x-3 justify-between')}>
398 <div className="flex items-center gap-x-2">
399 <ButtonTooltip
400 type="default"
401 icon={<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />}
402 className="w-7"
403 disabled={isRefreshing}
404 tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
405 onClick={onRefreshReport}
406 />
407 <div className="flex items-center gap-x-3">
408 <DateRangePicker
409 value="7d"
410 className="w-48"
411 onChange={handleDateRangePicker}
412 options={TIME_PERIODS_REPORTS}
413 loading={isLoading}
414 footer={
415 <div className="px-2 py-1">
416 <p className="text-xs text-foreground-lighter">
417 SQL blocks are independent of the selected date range
418 </p>
419 </div>
420 }
421 />
422 </div>
423 </div>
424
425 <div className="flex items-center gap-x-2">
426 {canUpdateReport ? (
427 <DropdownMenu>
428 <DropdownMenuTrigger asChild>
429 <Button type="default" icon={<Plus />}>
430 <span>Add block</span>
431 </Button>
432 </DropdownMenuTrigger>
433 <DropdownMenuContent side="bottom" align="center" className="w-44">
434 <MetricOptions config={config} handleChartSelection={handleChartSelection} />
435 </DropdownMenuContent>
436 </DropdownMenu>
437 ) : (
438 <ButtonTooltip
439 disabled
440 type="default"
441 icon={<Plus />}
442 tooltip={{
443 content: {
444 side: 'bottom',
445 className: 'w-56 text-center',
446 text: 'You need additional permissions to update custom reports',
447 },
448 }}
449 >
450 Add block
451 </ButtonTooltip>
452 )}
453 <DatabaseSelector />
454 </div>
455 </div>
456
457 {config?.layout !== undefined && config.layout.length === 0 ? (
458 <div
459 className={cn(
460 'flex min-h-full items-center justify-center rounded-sm border-2 border-dashed p-16 border-default transition duration-100',
461 isDraggedOver ? 'bg-surface-100' : ''
462 )}
463 onDragOver={onDragOverEmptyState}
464 onDragLeave={onDragOverEmptyState}
465 onDrop={onDropSQLBlockEmptyState}
466 >
467 {canUpdateReport ? (
468 <DropdownMenu>
469 <DropdownMenuTrigger asChild>
470 <Button type="default" iconRight={<Plus size={14} />}>
471 Add your first chart
472 </Button>
473 </DropdownMenuTrigger>
474 <DropdownMenuContent side="bottom" align="center">
475 <MetricOptions config={config} handleChartSelection={handleChartSelection} />
476 </DropdownMenuContent>
477 </DropdownMenu>
478 ) : (
479 <p className="text-sm text-foreground-light">No charts set up yet in report</p>
480 )}
481 </div>
482 ) : (
483 <div className="relative mb-16 grow">
484 {config && startDate && endDate && (
485 <GridResize
486 startDate={startDate}
487 endDate={endDate}
488 interval={config.interval as AnalyticsInterval}
489 editableReport={config}
490 disableUpdate={!canUpdateReport}
491 isRefreshing={isRefreshing}
492 onRemoveChart={popChart}
493 onUpdateChart={updateChart}
494 setEditableReport={setConfig}
495 />
496 )}
497 </div>
498 )}
499 </div>
500 <PreventNavigationOnUnsavedChanges hasChanges={hasEdits} />
501 </>
502 )
503}
504
505export default Reports