Reports.tsx505 lines · main
| 1 | // @ts-nocheck |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { useQueryClient } from '@tanstack/react-query' |
| 4 | import { useParams } from 'common' |
| 5 | import { groupBy, isEqual, isNull } from 'lodash' |
| 6 | import { Plus, RefreshCw, Save } from 'lucide-react' |
| 7 | import { DragEvent, useEffect, useState } from 'react' |
| 8 | import { toast } from 'sonner' |
| 9 | import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, LogoLoader } from 'ui' |
| 10 | |
| 11 | import { createSqlSnippetSkeletonV2 } from '../SQLEditor/SQLEditor.utils' |
| 12 | import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig' |
| 13 | import { GridResize } from './GridResize' |
| 14 | import { MetricOptions } from './MetricOptions' |
| 15 | import { LAYOUT_COLUMN_COUNT } from './Reports.constants' |
| 16 | import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' |
| 17 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 18 | import { DatabaseSelector } from '@/components/ui/DatabaseSelector' |
| 19 | import { DateRangePicker } from '@/components/ui/DateRangePicker' |
| 20 | import NoPermission from '@/components/ui/NoPermission' |
| 21 | import { DEFAULT_CHART_CONFIG } from '@/components/ui/QueryBlock/QueryBlock' |
| 22 | import { AnalyticsInterval } from '@/data/analytics/constants' |
| 23 | import { analyticsKeys } from '@/data/analytics/keys' |
| 24 | import { useContentQuery } from '@/data/content/content-query' |
| 25 | import { |
| 26 | UpsertContentPayload, |
| 27 | useContentUpsertMutation, |
| 28 | } from '@/data/content/content-upsert-mutation' |
| 29 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 30 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 31 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 32 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 33 | import { Metric, TIME_PERIODS_REPORTS } from '@/lib/constants/metrics' |
| 34 | import { uuidv4 } from '@/lib/helpers' |
| 35 | import { useProfile } from '@/lib/profile' |
| 36 | import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' |
| 37 | import type { Dashboards } from '@/types' |
| 38 | |
| 39 | const DEFAULT_CHART_COLUMN_COUNT = 1 |
| 40 | const DEFAULT_CHART_ROW_COUNT = 1 |
| 41 | |
| 42 | const 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 | |
| 505 | export default Reports |