Reports.tsx 17 KB

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