Chore/reports read replicas filter (#23711)

* Scaffold for API reports

* Add support for filtering database reports by replicas

* midway adding read replica support for custom reports

* Refactor reports page to use react query and deprecate project content mobxstore

* Reports add chart labels if metric is read replica specific

* Give an easier way to remove charts from reports

* make reports layout non-blocking

* Update apps/studio/components/interfaces/Reports/Reports.tsx

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>

---------

Co-authored-by: Alaister Young <a@alaisteryoung.com>
Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-05-07 16:44:23 +08:00
1 parent 02c0d75dfb
commit 1afaae6447
26 files changed
+647 -734

No files matched your search

@@ -1,8 +1,8 @@
import { InformationCircleIcon } from '@heroicons/react/16/solid'
import { X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { X } from 'lucide-react'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -10,6 +10,7 @@ import { executeSql } from 'data/sql/execute-sql-query'
import { useLocalStorageQuery } from 'hooks'
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import {
Button,
TabsList_Shadcn_,
@@ -28,7 +29,6 @@ import { PresetHookResult } from '../Reports/Reports.utils'
import { QUERY_PERFORMANCE_REPORT_TYPES } from './QueryPerformance.constants'
import { QueryPerformanceFilterBar } from './QueryPerformanceFilterBar'
import { QueryPerformanceGrid } from './QueryPerformanceGrid'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
interface QueryPerformanceProps {
queryHitRate: PresetHookResult
@@ -1,11 +1,33 @@
import { X } from 'lucide-react'
import RGL, { WidthProvider } from 'react-grid-layout'
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
import { useParams } from 'common'
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
const ReactGridLayout = WidthProvider(RGL)
const GridResize = ({ startDate, endDate, interval, editableReport, setEditableReport }: any) => {
if (!editableReport) return null
interface GridResizeProps {
startDate: string
endDate: string
interval: string
editableReport: any
onRemoveChart: ({ metric }: { metric: { key: string } }) => void
setEditableReport: (payload: any) => void
}
const GridResize = ({
startDate,
endDate,
interval,
editableReport,
onRemoveChart,
setEditableReport,
}: GridResizeProps) => {
const { ref } = useParams()
const state = useDatabaseSelectorStateSnapshot()
function onLayoutChange(layout: any) {
let updatedLayout = editableReport.layout
@@ -23,6 +45,8 @@ const GridResize = ({ startDate, endDate, interval, editableReport, setEditableR
setEditableReport(payload)
}
if (!editableReport) return null
return (
<>
<ReactGridLayout
@@ -47,13 +71,26 @@ const GridResize = ({ startDate, endDate, interval, editableReport, setEditableR
interval={interval}
attribute={x.attribute}
provider={x.provider}
label={x.label}
label={`${x.label}${ref !== state.selectedDatabaseId ? (x.provider === 'infra-monitoring' ? ' of replica' : ' on project') : ''}`}
customDateFormat={'MMM D, YYYY'}
/>
>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="text"
icon={<X />}
className="ml-2 px-1"
onClick={() => onRemoveChart({ metric: { key: x.attribute } })}
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">Remove chart</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</ChartHandler>
<div className="absolute inset-x-0 top-3 ">
<div className="flex justify-around">
<div className="flex h-3 w-24 cursor-move flex-col space-y-2">
<div className="hidden h-3 w-full border-4 border-dotted border-green-900 opacity-50 transition-all hover:opacity-100 group-hover:block"></div>
<div className="hidden h-3 w-full border-4 border-dotted border-green-900 opacity-50 transition-all hover:opacity-100 group-hover:block" />
</div>
</div>
</div>
@@ -0,0 +1,57 @@
import { Home } from 'lucide-react'
import { useIsFeatureEnabled } from 'hooks'
import { METRICS, METRIC_CATEGORIES } from 'lib/constants/metrics'
import {
DropdownMenuCheckboxItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from 'ui'
interface MetricOptionsProps {
config: any
handleChartSelection: any
}
export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsProps) => {
const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([
'project_auth:all',
'project_storage:all',
])
const metricCategories = Object.values(METRIC_CATEGORIES).filter(({ key }) => {
if (key === 'api_auth') return authEnabled
if (key === 'api_storage') return storageEnabled
return true
})
return metricCategories.map((cat) => {
return (
<DropdownMenuSub key={cat.key}>
<DropdownMenuSubTrigger className="space-x-2">
{cat.icon ? cat.icon : <Home size={14} />}
<p>{cat.label}</p>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{METRICS.filter((metric) => metric?.category?.key === cat.key).map((metric) => {
return (
<DropdownMenuCheckboxItem
key={metric.key}
checked={config.layout?.some((x: any) => x.attribute === metric.key)}
onCheckedChange={(e) => handleChartSelection({ metric, value: e })}
>
<div className="flex flex-col space-y-0">
<span>{metric.label}</span>
</div>
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)
})
}
@@ -1,25 +1,23 @@
import { ChevronDown, Database, Plus, X } from 'lucide-react'
import { ComponentProps, useState } from 'react'
import SVG from 'react-inlinesvg'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import { Auth, Realtime, Storage } from 'icons'
import { BASE_PATH } from 'lib/constants'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
IconBox,
IconChevronDown,
IconCode,
IconDatabase,
IconKey,
IconPlus,
IconX,
IconZap,
Input,
Popover,
Select,
cn,
} from 'ui'
import DatePickers from '../Settings/Logs/Logs.DatePickers'
import { REPORTS_DATEPICKER_HELPERS } from './Reports.constants'
import type { ReportFilterItem } from './Reports.types'
@@ -41,7 +39,7 @@ const PRODUCT_FILTERS = [
filterValue: '/rest',
label: 'REST',
description: 'Requests made to PostgREST',
icon: IconDatabase,
icon: Database,
},
{
key: 'auth',
@@ -49,7 +47,7 @@ const PRODUCT_FILTERS = [
filterValue: '/auth',
label: 'Auth',
description: 'Auth and authorization requests',
icon: IconKey,
icon: Auth,
},
{
key: 'storage',
@@ -57,7 +55,7 @@ const PRODUCT_FILTERS = [
filterValue: '/storage',
label: 'Storage',
description: 'Storage asset requests',
icon: IconBox,
icon: Storage,
},
{
key: 'realtime',
@@ -65,7 +63,7 @@ const PRODUCT_FILTERS = [
filterValue: '/realtime',
label: 'Realtime',
description: 'Realtime connection requests',
icon: IconZap,
icon: Realtime,
},
{
key: 'graphql',
@@ -73,7 +71,7 @@ const PRODUCT_FILTERS = [
filterValue: '/graphql',
label: 'GraphQL',
description: 'Requests made to pg_graphql',
icon: IconCode,
icon: null,
},
]
@@ -86,6 +84,10 @@ const ReportFilterBar = ({
onRemoveFilters,
datepickerHelpers,
}: ReportFilterBarProps) => {
const { project } = useProjectContext()
// [Joshen] TODO: Once API support is out
const showReadReplicasUI = false // project?.is_read_replicas_enabled
const filterKeys = [
'request.path',
'request.method',
@@ -135,7 +137,7 @@ const ReportFilterBar = ({
}
return (
<div>
<div className="flex items-center justify-between">
<div className="flex flex-row justify-start items-center flex-wrap gap-2">
<DatePickers
onChange={onDatepickerChange}
@@ -148,7 +150,7 @@ const ReportFilterBar = ({
<Button
type="default"
className="inline-flex flex-row gap-2"
iconRight={<IconChevronDown size={14} />}
iconRight={<ChevronDown size={14} />}
>
<span>
{currentProductFilter === null ? 'All Requests' : currentProductFilter.label}
@@ -162,6 +164,7 @@ const ReportFilterBar = ({
<DropdownMenuSeparator />
{PRODUCT_FILTERS.map((productFilter) => {
const Icon = productFilter.icon
return (
<DropdownMenuItem
key={productFilter.key}
@@ -169,7 +172,17 @@ const ReportFilterBar = ({
disabled={productFilter.key === currentProductFilter?.key}
onClick={() => handleProductFilterChange(productFilter)}
>
<Icon size={20} className="mr-2" />
{productFilter.key === 'graphql' ? (
<SVG
src={`${BASE_PATH}/img/graphql.svg`}
className="w-[20px] h-[20px] mr-2"
preProcessor={(code) =>
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
}
/>
) : Icon !== null ? (
<Icon size={20} strokeWidth={1.5} className="mr-2" />
) : null}
<div className="flex flex-col">
<p
className={cn(
@@ -205,7 +218,7 @@ const ReportFilterBar = ({
size="tiny"
className="!p-0 !space-x-0"
onClick={() => onRemoveFilters([filter])}
icon={<IconX size="tiny" className="text-foreground-light" />}
icon={<X size={14} className="text-foreground-light" />}
>
<span className="sr-only">Remove</span>
</Button>
@@ -285,12 +298,13 @@ const ReportFilterBar = ({
asChild
type="default"
size="tiny"
icon={<IconPlus size="tiny" className={`text-foreground-light `} />}
icon={<Plus size={14} className={`text-foreground-light `} />}
>
<span>Add filter</span>
</Button>
</Popover>
</div>
{showReadReplicasUI && <DatabaseSelector />}
</div>
)
}
@@ -1,7 +1,10 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import Panel from 'components/ui/Panel'
import { ExternalLink, HelpCircle } from 'lucide-react'
import { NextRouter, useRouter } from 'next/router'
import { Button, IconExternalLink, IconHelpCircle, Loading } from 'ui'
import { useParams } from 'common'
import Panel from 'components/ui/Panel'
import { Button, Loading, cn } from 'ui'
import { LogsEndpointParams } from '../Settings/Logs'
import type { BaseReportParams, ReportQueryType } from './Reports.types'
@@ -27,15 +30,16 @@ export interface ReportWidgetRendererProps<T = any> extends ReportWidgetProps<T>
projectRef: string
}
const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
const ReportWidget = (props: ReportWidgetProps) => {
const router = useRouter()
const { ref } = router.query
const { ref } = useParams()
const projectRef = ref as string
return (
<Panel
noMargin
noHideOverflow
className={'pb-0 ' + props.className}
className={cn('pb-0', props.className)}
bodyClassName="h-full"
wrapWithLoading={false}
>
@@ -47,11 +51,7 @@ const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
{props?.tooltip && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<IconHelpCircle
className="text-foreground-light"
size="tiny"
strokeWidth={1.5}
/>
<HelpCircle className="text-foreground-light" size={14} />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
@@ -76,7 +76,7 @@ const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
<Tooltip.Trigger asChild>
<Button
type="default"
icon={<IconExternalLink strokeWidth={1.5} />}
icon={<ExternalLink />}
className="px-1"
onClick={() => {
const isDbQueryType = props.queryType === 'db'
@@ -95,10 +95,7 @@ const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
query.ite = props.params!.iso_timestamp_end
}
router.push({
pathname,
query,
})
router.push({ pathname, query })
}}
/>
</Tooltip.Trigger>
@@ -1,39 +1,34 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import dayjs from 'dayjs'
import { groupBy, isNull } from 'lodash'
import { toJS } from 'mobx'
import { useEffect, useState } from 'react'
import {
Badge,
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuSub,
DropdownMenuTrigger,
IconArrowRight,
IconHome,
IconPlus,
IconSave,
IconSettings,
} from 'ui'
import toast from 'react-hot-toast'
import { useParams } from 'common/hooks'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import { Loading } from 'components/ui/Loading'
import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, useIsFeatureEnabled } from 'hooks'
import { METRICS, METRIC_CATEGORIES, TIME_PERIODS_REPORTS } from 'lib/constants/metrics'
import { useContentQuery } from 'data/content/content-query'
import { useContentUpdateMutation } from 'data/content/content-update-mutation'
import { useCheckPermissions } from 'hooks'
import { TIME_PERIODS_REPORTS } from 'lib/constants/metrics'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useProjectContentStore } from 'stores/projectContentStore'
import { ArrowRight, Plus, Save, Settings } from 'lucide-react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
Tooltip_Shadcn_,
} from 'ui'
import GridResize from './GridResize'
import { MetricOptions } from './MetricOptions'
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
import { useContentQuery } from 'data/content/content-query'
const DEFAULT_CHART_COLUMN_COUNT = 12
const DEFAULT_CHART_ROW_COUNT = 4
@@ -41,87 +36,46 @@ const DEFAULT_CHART_ROW_COUNT = 4
const Reports = () => {
const { id, ref } = useParams()
const { profile } = useProfile()
const { project } = useProjectContext()
const hasReadReplicasEnabled = project?.is_read_replicas_enabled
const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([
'project_auth:all',
'project_storage:all',
])
const reportQuery = useContentQuery(ref)
const currentReport =
reportQuery.data && reportQuery.data?.content.find((report) => report.id === id)
const [report, setReport] = useState<any>()
const [loading, setLoading] = useState<any>(false)
const [config, setConfig] = useState<any>(null)
const [originalConfig, setOriginalConfig] = useState<any>(null)
const [hasEdits, setHasEdits] = useState<any>(false)
const [saving, setSaving] = useState<any>(false)
const [config, setConfig] = useState<any>(undefined)
const [startDate, setStartDate] = useState<any>(null)
const [endDate, setEndDate] = useState<any>(null)
const [hasEdits, setHasEdits] = useState<any>(false)
const { data: userContents, isLoading } = useContentQuery(ref)
const { mutateAsync: saveReport, isLoading: isSaving } = useContentUpdateMutation({
onSuccess: () => {
toast.success('Successfully saved report!')
},
})
const currentReport = userContents?.content.find((report) => report.id === id)
const contentStore = useProjectContentStore(ref)
const canReadReport = useCheckPermissions(PermissionAction.READ, 'user_content', {
resource: {
type: 'report',
visibility: report?.visibility,
owner_id: report?.owner_id,
visibility: currentReport?.visibility,
owner_id: currentReport?.owner_id,
},
subject: { id: profile?.id },
})
const canUpdateReport = useCheckPermissions(PermissionAction.UPDATE, 'user_content', {
resource: {
type: 'report',
visibility: report?.visibility,
owner_id: report?.owner_id,
visibility: currentReport?.visibility,
owner_id: currentReport?.owner_id,
},
subject: { id: profile?.id },
})
/*
* fetchReport()
*
* Fetches the report and sets the main states of the page
* Also sets an 'original' report for comaprison.
*
* toJS() is used to deepcopy mobx to js
*/
const fetchReport = async () => {
setLoading(true)
await contentStore.load()
const reportData = contentStore.byId(id)
if (reportData) {
setReport(reportData)
// [Joshen TODO] Worth refactoring - no need for so many states
setConfig(toJS(reportData.content))
setOriginalConfig(toJS(reportData.content))
}
setLoading(false)
return reportData
}
/*
* handleDateRangePicker()
*
* Sets date range of reports, using the DateRangePicker component
*/
function handleDateRangePicker({ period_start, period_end }: any) {
setStartDate(period_start.date)
setEndDate(period_end.date)
}
/*
* checkEditState()
*
* Makes a comparison of report changes vs the original report
*
* returns setHasEdits(bool)
*/
function checkEditState() {
if (config === undefined) return
/*
* Shallow copying the config state variable maintains a mobx reference
* Instead, we stringify it and parse it again to remove anything
@@ -131,7 +85,7 @@ const Reports = () => {
* want to compare fixed dates as possible differences from saved and edited versions of report.
*/
let _config = JSON.parse(JSON.stringify(config))
let _original = JSON.parse(JSON.stringify(originalConfig))
let _original = JSON.parse(JSON.stringify(currentReport?.content))
if (!_original || !_config) return
@@ -159,58 +113,23 @@ const Reports = () => {
setHasEdits(true)
}
}
// Reloads the entire report when id changes
useEffect(() => {
fetchReport()
}, [id])
// Runs when any changes are made to report
useEffect(() => {
checkEditState()
}, [config])
if (!config) {
return <Loading />
}
if (!canReadReport) {
return <NoPermission isFullPage resourceText="access this project's report" />
}
// Updates the report and reloads the report again
const saveReport = async () => {
setSaving(true)
const payload = {
content: config,
}
await contentStore.update(id, payload, 'report')
await fetchReport()
const onSaveReport = async () => {
if (ref === undefined) return console.error('Project ref is required')
if (id === undefined) return console.error('Report ID is required')
await saveReport({ projectRef: ref, id, type: 'report', content: config })
setHasEdits(false)
setSaving(false)
}
/*
* handleChartSelection()
*
* Handler for the chart behavior
*/
function handleChartSelection({ metric, value }: any) {
if (value) {
pushChart({ metric })
} else {
popChart({ metric })
}
if (value) pushChart({ metric })
else popChart({ metric })
}
/*
* pushChart()
*
* Adds a new chart to the report
*/
function pushChart({ metric }: any) {
let current = config.layout
const index = current.length + 1
const current = [...config.layout]
let x = 0
let y = null
@@ -257,14 +176,9 @@ const Reports = () => {
})
}
/*
* popChart()
*
* Removes a chart from the report based on its attribute
*/
function popChart({ metric }: any) {
const { key } = metric
let current = config.layout
const current = [...config.layout]
const foundIndex = current.findIndex((x: any, i: number) => {
if (x.attribute === key) {
@@ -278,51 +192,48 @@ const Reports = () => {
})
}
const metricCategories = Object.values(METRIC_CATEGORIES).filter(({ key }) => {
if (key === 'api_auth') return authEnabled
if (key === 'api_storage') return storageEnabled
return true
})
useEffect(() => {
if (currentReport !== undefined) setConfig(currentReport?.content)
}, [currentReport])
const MetricOptions = () => {
return (
<>
{metricCategories.map((cat) => {
return (
<DropdownMenuSub key={cat.key}>
<DropdownMenuSubTrigger className="space-x-2">
{cat.icon ? cat.icon : <IconHome size="tiny" />}
<p>{cat.label}</p>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{METRICS.filter((metric) => metric?.category?.key === cat.key).map((metric) => {
return (
<DropdownMenuCheckboxItem
key={metric.key}
checked={config.layout?.some((x: any) => x.attribute === metric.key)}
onCheckedChange={(e) => handleChartSelection({ metric, value: e })}
>
<div className="flex flex-col space-y-0">
<span>{metric.label}</span>
</div>
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)
})}
</>
)
useEffect(() => {
checkEditState()
}, [config])
if (isLoading) {
return <Loading />
}
if (!canReadReport) {
return <NoPermission isFullPage resourceText="access this project's report" />
}
return (
<div className="flex flex-col space-y-4" style={{ maxHeight: '100%' }}>
<div className="space-y-0.5">
<h1 className="text-xl text-foreground">{currentReport?.name || 'Reports'}</h1>
<p className="text-foreground-light">{currentReport?.description}</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl text-foreground">{currentReport?.name || 'Reports'}</h1>
<p className="text-foreground-light">{currentReport?.description}</p>
</div>
{hasEdits && (
<div className="flex items-center gap-x-2">
<Button
type="default"
onClick={() => setConfig(currentReport?.content)}
disabled={isSaving}
>
Cancel
</Button>
<Button
type="primary"
icon={<Save />}
onClick={() => onSaveReport()}
loading={isSaving}
>
Save changes
</Button>
</div>
)}
</div>
<div className="mb-4 flex items-center justify-between space-x-3">
<div className="flex items-center space-x-3">
@@ -330,7 +241,7 @@ const Reports = () => {
onChange={handleDateRangePicker}
value="7d"
options={TIME_PERIODS_REPORTS}
loading={loading}
loading={isLoading}
/>
{startDate && endDate && (
@@ -339,7 +250,7 @@ const Reports = () => {
{dayjs(startDate).format('MMM D, YYYY')}
</span>
<span className="text-foreground-lighter">
<IconArrowRight size={12} />
<ArrowRight size={12} />
</span>
<span className="text-sm text-foreground-light">
{dayjs(endDate).format('MMM D, YYYY')}
@@ -348,75 +259,48 @@ const Reports = () => {
)}
</div>
<div className="flex items-center space-x-2">
{hasEdits && (
<div className="hidden xl:inline-block">
<Badge variant="brand">There are unsaved changes</Badge>
</div>
)}
{hasEdits && (
<Button
type={!hasEdits ? 'default' : 'primary'}
disabled={!hasEdits}
icon={<IconSave />}
onClick={() => saveReport()}
loading={saving}
>
{hasEdits && 'Save changes'}
</Button>
)}
<div className="flex items-center gap-x-2">
{canUpdateReport ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<IconSettings />}>
<Button type="default" iconRight={<Settings size={14} />}>
<span>Add / Remove charts</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<MetricOptions />
<MetricOptions config={config} handleChartSelection={handleChartSelection} />
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button disabled type="default" iconRight={<IconSettings />}>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button disabled type="default" iconRight={<Settings size={14} />}>
Add / Remove charts
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to update this project's report
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" className="w-56 text-center" align="end">
You need additional permissions to update this project's report
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
)}
{hasReadReplicasEnabled && <DatabaseSelector />}
</div>
</div>
{config.layout.length <= 0 ? (
{config?.layout !== undefined && config.layout.length <= 0 ? (
<div className="flex min-h-full items-center justify-center rounded border-2 border-dashed p-16 border-default">
{canUpdateReport ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<IconPlus />}>
<Button type="default" iconRight={<Plus size={14} />}>
<span>
{config.layout.length <= 0 ? 'Add your first chart' : 'Add another chart'}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="center">
<MetricOptions />
<MetricOptions config={config} handleChartSelection={handleChartSelection} />
</DropdownMenuContent>
</DropdownMenu>
) : (
@@ -431,6 +315,7 @@ const Reports = () => {
endDate={endDate}
interval={config.interval}
editableReport={config}
onRemoveChart={popChart}
setEditableReport={setConfig}
/>
)}
@@ -1,3 +1,8 @@
import sumBy from 'lodash/sumBy'
import { Fragment, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { useParams } from 'common'
import {
jsonSyntaxHighlight,
TextFormatter,
@@ -5,12 +10,9 @@ import {
import Table from 'components/to-be-cleaned/Table'
import BarChart from 'components/ui/Charts/BarChart'
import useFillTimeseriesSorted from 'hooks/analytics/useFillTimeseriesSorted'
import sumBy from 'lodash/sumBy'
import { Fragment, useState } from 'react'
import { Button, Collapsible, IconChevronRight } from 'ui'
import { Button, Collapsible } from 'ui'
import { queryParamsToObject } from '../Reports.utils'
import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget'
import { useParams } from 'common/hooks'
export const NetworkTrafficRenderer = (
props: ReportWidgetProps<{
@@ -63,6 +65,7 @@ export const NetworkTrafficRenderer = (
</div>
)
}
export const TotalRequestsChartRenderer = (
props: ReportWidgetProps<{
timestamp: string
@@ -107,15 +110,17 @@ export const TopApiRoutesRenderer = (
}>
) => {
const { ref: projectRef } = useParams()
const [showMore, setShowMore] = useState(false)
const headerClasses = '!text-xs !py-2 p-0 font-bold !bg-surface-200 !border-x-0 !rounded-none'
const cellClasses = '!text-xs !py-2 !border-x-0 !rounded-none align-middle'
if (props.data.length === 0) return null
const headerClasses = '!text-xs !py-2 p-0 font-bold !bg-surface-200'
const cellClasses = '!text-xs !py-2'
return (
<Collapsible>
<Table
className="rounded-t-none"
head={
<>
<Table.th className={headerClasses}>Request</Table.th>
@@ -128,10 +133,10 @@ export const TopApiRoutesRenderer = (
body={
<>
{props.data.map((datum, index) => (
<Fragment key={datum.path + (datum.search || '')}>
<Fragment key={datum.method + datum.path + (datum.search || '')}>
<Table.tr
className={[
'p-0 transition transform duration-700',
'p-0 transition transform cursor-pointer hover:bg-surface-200',
showMore && index >= 3 ? 'w-full h-full opacity-100' : '',
!showMore && index >= 3 ? ' w-0 h-0 translate-y-10 opacity-0' : '',
].join(' ')}
@@ -270,10 +275,10 @@ interface RouteTdContentProps {
const RouteTdContent = (datum: RouteTdContentProps) => (
<Collapsible>
<Collapsible.Trigger asChild>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
<Button asChild type="text" className=" !py-0 !p-1" title="Show more route details">
<span>
<IconChevronRight
<ChevronRight
size={14}
className="transition data-open-parent:rotate-90 data-closed-parent:rotate-0"
/>
@@ -9,7 +9,7 @@ interface ReportsLayoutProps {
const ReportsLayout = ({ title, children }: PropsWithChildren<ReportsLayoutProps>) => {
return (
<ProjectLayout title={title} product="Reports" productMenu={<ReportsMenu />}>
<ProjectLayout title={title} product="Reports" productMenu={<ReportsMenu />} isBlocking={false}>
<main style={{ maxHeight: '100vh' }} className="flex-1 overflow-y-auto">
{children}
</main>
@@ -1,19 +1,26 @@
import type { Dictionary } from 'types'
import { isUndefined } from 'lodash'
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect, useState } from 'react'
import { Button, IconActivity, IconAlertCircle, IconBarChart, IconLoader } from 'ui'
import { PropsWithChildren, useState } from 'react'
import { Button } from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AreaChart from 'components/ui/Charts/AreaChart'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { AnalyticsInterval } from 'data/analytics/constants'
import {
InfraMonitoringAttribute,
useInfraMonitoringQuery,
} from 'data/analytics/infra-monitoring-query'
import { useProjectDailyStatsQuery } from 'data/analytics/project-daily-stats-query'
import { Activity, BarChartIcon, Loader2 } from 'lucide-react'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { WarningIcon } from 'ui-patterns/Icons/StatusIcons'
import type { ChartData } from './ChartHandler.types'
import { BarChart } from './ChartRenderer'
interface ChartHandlerProps {
label: string
attribute: string
provider: string
provider: 'infra-monitoring' | 'daily-stats'
startDate: string
endDate: string
interval: string
@@ -56,72 +63,77 @@ const ChartHandler = ({
const router = useRouter()
const { ref } = router.query
// internal state is overridden by isLoading prop
const [fetching, setFetching] = useState<boolean>(false)
const [fetchedData, setFetchedData] = useState<any>(undefined)
const { project } = useProjectContext()
const isReadReplicasEnabled = project?.is_read_replicas_enabled
const state = useDatabaseSelectorStateSnapshot()
const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle)
const chartData = data || fetchedData
const loading = isLoading || fetching
useEffect(() => {
let cancel = false
const databaseIdentifier = isReadReplicasEnabled ? state.selectedDatabaseId : undefined
const fetchChartData = async () => {
setFetching(true)
const { data: dailyStatsData, isLoading: isFetchingDailyStats } = useProjectDailyStatsQuery(
{
projectRef: ref as string,
attribute: attribute as InfraMonitoringAttribute,
startDate,
endDate,
interval: interval as AnalyticsInterval,
databaseIdentifier,
},
{ enabled: provider === 'daily-stats' && data === undefined }
)
const url = `${API_URL}/projects/${ref}/${provider}?attribute=${attribute}&startDate=${encodeURIComponent(
startDate
)}&endDate=${encodeURIComponent(endDate)}&interval=${interval ? interval : '1d'}`
const { data: infraMonitoringData, isLoading: isFetchingInfraMonitoring } =
useInfraMonitoringQuery(
{
projectRef: ref as string,
attribute: attribute as InfraMonitoringAttribute,
startDate,
endDate,
interval: interval as AnalyticsInterval,
databaseIdentifier,
},
{ enabled: provider === 'infra-monitoring' && data === undefined }
)
const { error, ...res } = await get(url)
if ((error || isUndefined(res)) && !cancel) {
setFetching(false)
setFetchedData(undefined)
return console.error('Chart error:', error)
}
// Convert null values to 0
// TODO: Chart endpoint should handle this data formatting to 0 instead of client.
const formattedChartData = (res?.data ?? []).map(
(dataPoint: Dictionary<any>, idx: number) => {
return {
...dataPoint,
[attribute]: dataPoint[attribute] ? Number(dataPoint[attribute]) : 0,
}
}
)
if (!cancel) {
setFetchedData({ ...res, data: formattedChartData })
setFetching(false)
}
}
// only fetch if data prop is not provided
if (data === undefined && !isLoading) {
fetchChartData()
}
return () => {
cancel = true
}
}, [startDate])
highlightedValue = highlightedValue
? highlightedValue
: provider === 'daily-stats' && !attribute.includes('ingress') && !attribute.includes('egress')
? chartData?.maximum
const chartData =
data ||
(provider === 'infra-monitoring'
? infraMonitoringData
: provider === 'daily-stats'
? chartData?.total
: provider === 'log-stats'
? chartData?.totalGrouped?.[attribute]
: chartData?.data[chartData?.data.length - 1]?.[attribute]
? dailyStatsData
: undefined)
const loading =
isLoading ||
(provider === 'infra-monitoring'
? isFetchingInfraMonitoring
: provider === 'daily-stats'
? isFetchingDailyStats
: isLoading)
const shouldHighlightMaxValue =
provider === 'daily-stats' &&
!attribute.includes('ingress') &&
!attribute.includes('egress') &&
chartData !== undefined &&
'maximum' in chartData
const shouldHighlightTotalGroupedValue = chartData !== undefined && 'totalGrouped' in chartData
const _highlightedValue =
highlightedValue !== undefined
? highlightedValue
: shouldHighlightMaxValue
? chartData?.maximum
: provider === 'daily-stats'
? chartData?.total
: shouldHighlightTotalGroupedValue
? chartData?.totalGrouped?.[attribute]
: (chartData?.data[chartData?.data.length - 1] as any)?.[attribute as any]
if (loading) {
return (
<div className="flex h-52 w-full flex-col items-center justify-center space-y-4">
<IconLoader className="animate-spin text-border-strong" />
<div className="flex h-52 w-full flex-col items-center justify-center gap-y-2">
<Loader2 size={18} className="animate-spin text-border-strong" />
<p className="text-xs text-foreground-lighter">Loading data for {label}</p>
</div>
)
@@ -129,8 +141,8 @@ const ChartHandler = ({
if (isUndefined(chartData)) {
return (
<div className="flex h-52 w-full flex-col items-center justify-center space-y-4">
<IconAlertCircle className="text-border-strong" />
<div className="flex h-52 w-full flex-col items-center justify-center gap-y-2">
<WarningIcon />
<p className="text-xs text-foreground-lighter">Unable to load data for {label}</p>
</div>
)
@@ -139,37 +151,39 @@ const ChartHandler = ({
return (
<div className="h-full w-full">
<div className="absolute right-6 z-50 flex justify-between">
<div className="space-y-3">{children}</div>
{!hideChartType && (
<div>
<div className="flex w-full space-x-3">
<Button
type="default"
icon={chartStyle === 'bar' ? <IconActivity /> : <IconBarChart />}
className="px-1.5"
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
onClick={() => setChartStyle(chartStyle === 'bar' ? 'line' : 'bar')}
/>
</div>
</div>
)}
{children}
</div>
{chartStyle === 'bar' ? (
<BarChart
data={chartData?.data ?? []}
attribute={attribute}
yAxisLimit={chartData?.yAxisLimit}
format={format || chartData?.format}
highlightedValue={highlightedValue}
highlightedValue={_highlightedValue}
label={label}
customDateFormat={customDateFormat}
onBarClick={onBarClick}
/>
) : (
<AreaChart
data={chartData?.data ?? []}
data={(chartData?.data ?? []) as any}
format={format || chartData?.format}
xAxisKey="period_start"
yAxisKey={attribute}
highlightedValue={highlightedValue}
highlightedValue={_highlightedValue}
title={label}
customDateFormat={customDateFormat}
/>
@@ -1,95 +0,0 @@
import { toJS } from 'mobx'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { useProjectContentStore } from 'stores/projectContentStore'
import { Input, Modal } from 'ui'
const EditModal = () => {
const router = useRouter()
const { id, ref } = router.query
const [visible, setVisible] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [name, setName] = useState<string>()
const [description, setDescription] = useState<string>()
const contentStore = useProjectContentStore(ref)
/*
* fetchReport()
*
* Fetches the report and sets the Name and description
*
* toJS() is used to deepcopy mobx to js
*/
const fetchReport = async () => {
setLoading(true)
await contentStore.load()
let reportData = contentStore.byId(id)
setName(toJS(reportData?.name))
setDescription(toJS(reportData?.description))
setLoading(false)
return reportData
}
useEffect(() => {
fetchReport()
setVisible(false)
}, [])
async function handleSubmit() {
try {
setLoading(true)
const { data, error } = await contentStore.update(id, {
name,
description,
})
if (error) throw error
setLoading(false)
await contentStore.load()
setVisible(!visible)
return data
} catch (error: any) {
toast.error(error.message)
setLoading(false)
}
}
return (
<Modal
title="Edit report"
visible={visible}
onCancel={() => setVisible(false)}
size="medium"
onConfirm={handleSubmit}
loading={loading}
>
<div className="w-full space-y-4">
<Input
layout="horizontal"
label="Name"
descriptionText="Name cannot be longer than 128 char"
value={name}
onChange={(e) => setName(e.target.value)}
>
Name
</Input>
<Input
layout="horizontal"
label="Description"
descriptionText="description cannot be longer than 256 char"
value={description}
onChange={(e) => setDescription(e.target.value)}
>
Description
</Input>
</div>
</Modal>
)
}
export default observer(EditModal)
@@ -1,4 +1,4 @@
import { IconBarChart2 } from 'ui'
import { BarChart2 } from 'lucide-react'
interface EmptyStateProps {
title?: string
@@ -17,7 +17,7 @@ const EmptyState = ({
border-dashed border-control py-4 text-center
"
>
<IconBarChart2 className="text-foreground-light" />
<BarChart2 size={20} className="text-foreground-light" />
<div>
<p className="text-xs text-foreground-light">{title}</p>
<p className="text-xs text-foreground-light">{message}</p>
@@ -1,4 +1,4 @@
import { IconBarChart2 } from 'ui'
import { BarChart2 } from 'lucide-react'
import { useChartSize } from './Charts.utils'
interface Props {
@@ -23,7 +23,7 @@ const NoDataPlaceholder: React.FC<Props> = ({
// extra 20 px for the x ticks
style={{ minHeight: minHeight + 20 }}
>
<IconBarChart2 className="text-border-stronger" />
<BarChart2 size={20} className="text-border-stronger" />
<div>
<p className="text-foreground-light text-xs">{title}</p>
{message && <p className="text-foreground-lighter text-xs">{message}</p>}
+2
View File
@@ -15,3 +15,5 @@ export interface AnalyticsData {
yAxisLimit: number
hasNoData?: boolean
}
export type AnalyticsInterval = '1m' | '5m' | '10m' | '30m' | '1h' | '1d'
@@ -1,28 +1,38 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import type { AnalyticsData } from './constants'
import { get, handleError } from 'data/fetchers'
import type { AnalyticsData, AnalyticsInterval } from './constants'
import { analyticsKeys } from './keys'
export type InfraMonitoringAttribute =
| 'max_cpu_usage'
| 'avg_cpu_usage'
| 'disk_io_budget'
| 'ram_usage'
| 'disk_io_consumption'
| 'swap_usage'
export type InfraMonitoringVariables = {
projectRef?: string
attribute:
| 'max_cpu_usage'
| 'avg_cpu_usage'
| 'disk_io_budget'
| 'ram_usage'
| 'disk_io_consumption'
| 'swap_usage'
attribute: InfraMonitoringAttribute
startDate?: string
endDate?: string
interval?: '1m' | '5m' | '10m' | '30m' | '1h' | '1d'
interval?: AnalyticsInterval
dateFormat?: string
databaseIdentifier?: string
modifier?: (x: number) => number
}
export async function getInfraMonitoring(
{ projectRef, attribute, startDate, endDate, interval = '1d' }: InfraMonitoringVariables,
{
projectRef,
attribute,
startDate,
endDate,
interval = '1d',
databaseIdentifier,
}: InfraMonitoringVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('Project ref is required')
@@ -30,15 +40,22 @@ export async function getInfraMonitoring(
if (!startDate) throw new Error('Start date is required')
if (!endDate) throw new Error('End date is required')
const data = await get(
`${API_URL}/projects/${projectRef}/infra-monitoring?attribute=${attribute}&startDate=${encodeURIComponent(
startDate
)}&endDate=${encodeURIComponent(endDate)}&interval=${interval}`,
{ signal }
)
const { data, error } = await get('/platform/projects/{ref}/infra-monitoring', {
params: {
path: { ref: projectRef },
query: {
attribute,
startDate,
endDate,
interval,
databaseIdentifier,
},
},
signal,
})
if (data.error) throw data.error
return data as AnalyticsData
if (error) throw handleError(error)
return data as unknown as AnalyticsData
}
export type InfraMonitoringData = Awaited<ReturnType<typeof getInfraMonitoring>>
@@ -52,6 +69,7 @@ export const useInfraMonitoringQuery = <TData = InfraMonitoringData>(
endDate,
interval = '1d',
dateFormat = 'DD MMM',
databaseIdentifier,
modifier,
}: InfraMonitoringVariables,
{
@@ -60,9 +78,18 @@ export const useInfraMonitoringQuery = <TData = InfraMonitoringData>(
}: UseQueryOptions<InfraMonitoringData, InfraMonitoringError, TData> = {}
) =>
useQuery<InfraMonitoringData, InfraMonitoringError, TData>(
analyticsKeys.infraMonitoring(projectRef, { attribute, startDate, endDate, interval }),
analyticsKeys.infraMonitoring(projectRef, {
attribute,
startDate,
endDate,
interval,
databaseIdentifier,
}),
({ signal }) =>
getInfraMonitoring({ projectRef, attribute, startDate, endDate, interval }, signal),
getInfraMonitoring(
{ projectRef, attribute, startDate, endDate, interval, databaseIdentifier },
signal
),
{
enabled:
enabled &&
+3 -1
View File
@@ -115,18 +115,20 @@ export const analyticsKeys = {
startDate,
endDate,
interval,
databaseIdentifier,
}: {
attribute?: string
startDate?: string
endDate?: string
interval?: string
databaseIdentifier?: string
}
) =>
[
'projects',
projectRef,
'infra-monitoring',
{ attribute, startDate, endDate, interval },
{ attribute, startDate, endDate, interval, databaseIdentifier },
] as const,
usageApiCounts: (projectRef: string | undefined, interval: string | undefined) =>
['projects', projectRef, 'usage.api-counts', interval] as const,
@@ -0,0 +1,119 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { get, handleError } from 'data/fetchers'
import type { AnalyticsData } from './constants'
import { analyticsKeys } from './keys'
export type ProjectDailyStatsAttribute =
| 'max_cpu_usage'
| 'avg_cpu_usage'
| 'disk_io_budget'
| 'ram_usage'
| 'disk_io_consumption'
| 'swap_usage'
export type ProjectDailyStatsInterval = '1m' | '5m' | '10m' | '30m' | '1h' | '1d'
export type ProjectDailyStatsVariables = {
projectRef?: string
attribute: ProjectDailyStatsAttribute
startDate?: string
endDate?: string
interval?: ProjectDailyStatsInterval
dateFormat?: string
databaseIdentifier?: string
modifier?: (x: number) => number
}
export async function getProjectDailyStats(
{
projectRef,
attribute,
startDate,
endDate,
interval = '1d',
databaseIdentifier,
}: ProjectDailyStatsVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('Project ref is required')
if (!attribute) throw new Error('Attribute is required')
if (!startDate) throw new Error('Start date is required')
if (!endDate) throw new Error('End date is required')
const { data, error } = await get('/platform/projects/{ref}/daily-stats', {
params: {
path: { ref: projectRef },
query: {
attribute,
startDate,
endDate,
interval,
// [Joshen] TODO: Once API support is ready
// databaseIdentifier,
},
},
signal,
})
if (error) throw handleError(error)
return data as unknown as AnalyticsData
}
export type ProjectDailyStatsData = Awaited<ReturnType<typeof getProjectDailyStats>>
export type ProjectDailyStatsError = unknown
export const useProjectDailyStatsQuery = <TData = ProjectDailyStatsData>(
{
projectRef,
attribute,
startDate,
endDate,
interval = '1d',
dateFormat = 'DD MMM',
databaseIdentifier,
modifier,
}: ProjectDailyStatsVariables,
{
enabled = true,
...options
}: UseQueryOptions<ProjectDailyStatsData, ProjectDailyStatsError, TData> = {}
) =>
useQuery<ProjectDailyStatsData, ProjectDailyStatsError, TData>(
analyticsKeys.infraMonitoring(projectRef, {
attribute,
startDate,
endDate,
interval,
databaseIdentifier,
}),
({ signal }) =>
getProjectDailyStats(
{ projectRef, attribute, startDate, endDate, interval, databaseIdentifier },
signal
),
{
enabled:
enabled &&
typeof projectRef !== 'undefined' &&
typeof attribute !== 'undefined' &&
typeof startDate !== 'undefined' &&
typeof endDate !== 'undefined',
select(data) {
return {
...data,
data: data.data.map((x) => {
return {
...x,
[attribute]:
modifier !== undefined ? modifier(Number(x[attribute])) : Number(x[attribute]),
periodStartFormatted: dayjs(x.period_start).format(dateFormat),
}
}),
} as TData
},
staleTime: 1000 * 60, // default good for a minute
...options,
}
)
-12
View File
@@ -72,15 +72,3 @@ export const useContentQuery = <TData = ContentData>(
({ signal }) => getContent(projectRef, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
export const useContentPrefetch = (projectRef: string | undefined) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef) {
client.prefetchQuery(contentKeys.list(projectRef), ({ signal }) =>
getContent(projectRef, signal)
)
}
}, [projectRef])
}
@@ -0,0 +1,58 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { patch } from 'data/fetchers'
import type { ResponseError } from 'types'
import { contentKeys } from './keys'
type UpdateContentVariables = { projectRef: string; id: string; type: 'report'; content: any }
export async function updateContent(
{ projectRef, id, type, content }: UpdateContentVariables,
signal?: AbortSignal
) {
const { data, error } = await patch('/platform/projects/{ref}/content', {
params: {
// @ts-ignore API codegen issue
path: { ref: projectRef },
query: { id },
},
body: { id, type, content },
signal,
})
if (error) throw error
return data
}
type UpdateContentData = Awaited<ReturnType<typeof updateContent>>
export const useContentUpdateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<UpdateContentData, ResponseError, UpdateContentVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<UpdateContentData, ResponseError, UpdateContentVariables>(
(args) => updateContent(args),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(contentKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update content: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
+1 -1
View File
@@ -36,8 +36,8 @@ const useDbQuery = ({
orderBy?: string
}): DbQueryHook => {
const { project } = useProjectContext()
const state = useDatabaseSelectorStateSnapshot()
const { data: databases } = useReadReplicasQuery({ projectRef: project?.ref })
const connectionString = project?.is_read_replicas_enabled
? (databases || []).find((db) => db.identifier === state.selectedDatabaseId)?.connectionString
+12 -4
View File
@@ -1,17 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { Dispatch, SetStateAction, useState } from 'react'
import {
EXPLORER_DATEPICKER_HELPERS,
genQueryParams,
getDefaultHelper,
} from 'components/interfaces/Settings/Logs'
import { Dispatch, SetStateAction, useState } from 'react'
import type {
LogsEndpointParams,
Logs,
LogData,
Logs,
LogsEndpointParams,
} from 'components/interfaces/Settings/Logs/Logs.types'
import { API_URL } from 'lib/constants'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { get, isResponseOk } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
export interface LogsQueryHook {
params: LogsEndpointParams
isLoading: boolean
@@ -27,6 +31,10 @@ const useLogsQuery = (
projectRef: string,
initialParams: Partial<LogsEndpointParams> = {}
): LogsQueryHook => {
// [Joshen] TODO once API is unblocked
const state = useDatabaseSelectorStateSnapshot()
const { data: databases } = useReadReplicasQuery({ projectRef })
const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS)
const [params, setParams] = useState<LogsEndpointParams>({
sql: initialParams?.sql || '',
@@ -1,15 +1,11 @@
import Reports from 'components/interfaces/Reports/Reports'
import { ReportsLayout } from 'components/layouts'
import EditReportModal from 'components/to-be-cleaned/Reports/EditReportModal'
import type { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => (
<>
<div className="mx-auto flex flex-col gap-4 p-3">
<Reports />
</div>
<EditReportModal />
</>
<div className="mx-auto flex flex-col gap-4 p-3">
<Reports />
</div>
)
PageLayout.getLayout = (page) => <ReportsLayout>{page}</ReportsLayout>
@@ -25,6 +25,8 @@ import { ReportsLayout } from 'components/layouts'
import ShimmerLine from 'components/ui/ShimmerLine'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
export const ApiReport: NextPageWithLayout = () => {
const report = useApiReport()
@@ -108,7 +110,12 @@ export const ApiReport: NextPageWithLayout = () => {
// hook to fetch data
const useApiReport = () => {
const { project } = useProjectContext()
const { ref: projectRef } = useParams()
const state = useDatabaseSelectorStateSnapshot()
// [Joshen] TODO: Once API support is out
const showReadReplicasUI = false // project?.is_read_replicas_enabled
const queryHooks = queriesFactory<keyof typeof PRESET_CONFIG.api.queries>(
PRESET_CONFIG.api.queries,
@@ -1,14 +1,9 @@
import { useParams } from 'common/hooks'
import dayjs from 'dayjs'
import { ArrowRight } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import {
AlertDescription_Shadcn_,
Alert_Shadcn_,
Button,
IconArrowRight,
IconExternalLink,
} from 'ui'
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, IconExternalLink } from 'ui'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
@@ -18,6 +13,7 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import Table from 'components/to-be-cleaned/Table'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import Panel from 'components/ui/Panel'
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
import type { DbQueryHook } from 'hooks/analytics/useDbQuery'
@@ -45,19 +41,27 @@ const DatabaseUsage = () => {
const { project } = useProjectContext()
const [dateRange, setDateRange] = useState<any>(undefined)
const showReadReplicasUI = project?.is_read_replicas_enabled
const report = useDatabaseReport()
const { data } = useDatabaseSizeQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const databaseSizeBytes = data?.result[0].db_size ?? 0
const report = useDatabaseReport()
return (
<>
<div>
<section>
<Panel title={<h2>Database health</h2>}>
<Panel
title={
<div className="w-full flex items-center justify-between">
<h2>Database health</h2>
{showReadReplicasUI && <DatabaseSelector />}
</div>
}
>
<Panel.Content>
<div className="mb-4 flex items-center space-x-3">
<DateRangePicker
@@ -68,12 +72,12 @@ const DatabaseUsage = () => {
onChange={setDateRange}
/>
{dateRange && (
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<p className="text-foreground-light">
{dayjs(dateRange.period_start.date).format('MMMM D, hh:mma')}
</p>
<p className="text-foreground-light">
<IconArrowRight size={12} />
<ArrowRight size={12} />
</p>
<p className="text-foreground-light">
{dayjs(dateRange.period_end.date).format('MMMM D, hh:mma')}
@@ -1,50 +1,41 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useParams } from 'common/hooks'
import { useParams } from 'common'
import { CreateReportModal } from 'components/interfaces/Reports/Reports.CreateReportModal'
import { ReportsLayout } from 'components/layouts'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { Loading } from 'components/ui/Loading'
import { useContentQuery } from 'data/content/content-query'
import { useCheckPermissions } from 'hooks'
import { useProfile } from 'lib/profile'
import { useProjectContentStore } from 'stores/projectContentStore'
import type { NextPageWithLayout } from 'types'
export const UserReportPage: NextPageWithLayout = () => {
const [loading, setLoading] = useState(true)
const router = useRouter()
const { ref } = useParams()
const { profile } = useProfile()
const [showCreateReportModal, setShowCreateReportModal] = useState(false)
const contentStore = useProjectContentStore(ref)
const { isLoading } = useContentQuery(ref, {
onSuccess: (data) => {
const reports = data.content
.filter((x) => x.type === 'report')
.sort((a, b) => a.name.localeCompare(b.name))
if (reports.length > 1) router.push(`/project/${ref}/reports/${reports[0].id}`)
},
})
const canCreateReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'report', owner_id: profile?.id },
subject: { id: profile?.id },
})
async function loadReports() {
await contentStore.load()
const reports = contentStore.reports()
if (reports.length >= 1) {
router.push(`/project/${ref}/reports/${reports[0].id}`)
} else {
setLoading(false)
}
}
useEffect(() => {
loadReports()
}, [ref])
return (
<div className="mx-auto my-32 w-full max-w-7xl flex-grow space-y-16">
{loading ? (
{isLoading ? (
<Loading />
) : (
<>
@@ -67,12 +58,8 @@ export const UserReportPage: NextPageWithLayout = () => {
</ProductEmptyState>
<CreateReportModal
visible={showCreateReportModal}
onCancel={() => {
setShowCreateReportModal(false)
}}
afterSubmit={() => {
setShowCreateReportModal(false)
}}
onCancel={() => setShowCreateReportModal(false)}
afterSubmit={() => setShowCreateReportModal(false)}
/>
</>
)}
-199
View File
@@ -1,199 +0,0 @@
import { makeObservable, observable, computed } from 'mobx'
import { get, patch, post, delete_ } from 'lib/common/fetch'
import { keyBy } from 'lodash'
import type { UserContent } from 'types'
import { API_URL } from 'lib/constants'
/**
* Singleton
* Usage:
* import { useProjectContentStore } from 'stores/userContent/projectContentStore'
* const contentStore = useProjectContentStore(projectRef)
* contentStore.load() // inside usEffect()
* @deprecated use content store on RootStore instead.
*/
let store: any = null
let projectRef: any = null
export function useProjectContentStore(ref: any) {
if (store === null || ref !== projectRef) {
// set the current ref
projectRef = ref
const baseUrl = `${API_URL}/projects/${ref}/content`
store = new ProjectContent(baseUrl)
}
return store
}
/**
* Local type declarations
*/
interface UserContentMap {
[key: string]: UserContent
}
/**
* MobX Store
* @deprecated
*/
export default class ProjectContent {
STATES = {
INITIAL: 'initial',
LOADING: 'loading',
ERROR: 'error',
LOADED: 'loaded',
}
rootStore = null
url = ''
state = this.STATES.INITIAL
data: UserContentMap = {}
error = null
/**
* @param connectionString And encrypted database connection string to pass to the API
*/
constructor(dataUrl: any) {
this.url = dataUrl
makeObservable(this, {
state: observable,
data: observable,
list: observable,
hasError: computed,
isLoading: computed,
})
}
async fetchData() {
const headers = {
'Content-Type': 'application/json',
}
const res = await get(this.url, { headers })
if (res.error) {
throw res.error
}
this.data = res.data.length > 0 ? keyBy(res.data, 'id') : res.data
return res
}
async load() {
let { LOADING, ERROR, LOADED } = this.STATES
try {
this.error = null
this.state = LOADING
await this.fetchData()
this.state = LOADED
return this.data
} catch (e: any) {
console.error('e.message', e.message)
this.error = e
this.state = ERROR
}
}
get hasError() {
return this.state === this.STATES.ERROR
}
get isLoading() {
return this.state === this.STATES.INITIAL || this.state === this.STATES.LOADING
}
list(filter: any) {
let arr = Object.values(this.data)
if (!!filter) {
return arr.filter(filter).sort((a, b) => a.name.localeCompare(b.name))
} else {
return arr.sort((a, b) => a.name.localeCompare(b.name))
}
}
reports(filter: any) {
let arr = Object.values(this.data)
if (!!filter) {
return arr
.filter((x) => x.type == 'report')
.filter(filter)
.sort((a, b) => a.name.localeCompare(b.name))
} else {
return arr.filter((x) => x.type == 'report').sort((a, b) => a.name.localeCompare(b.name))
}
}
sqlSnippets(filter: any) {
let arr = Object.values(this.data)
if (!!filter) {
const arr_filtered = arr
.filter((x) => x.type == 'sql')
.filter(filter)
.sort((a, b) => a.name.localeCompare(b.name))
return arr_filtered
} else {
const arr_filtered = arr
.filter((x) => x.type == 'sql')
.sort((a, b) => a.name.localeCompare(b.name))
return arr_filtered
}
}
byId(id: any) {
return this.data[id]
}
async create(payload: any) {
try {
const headers = {
'Content-Type': 'application/json',
}
const created = await post(this.url, payload, { headers })
if (created.error) throw created.error
this.data[created['id']] = created
return { data: created, error: null }
} catch (error) {
return { data: null, error }
}
}
async update(id: any, updates: any, type: any) {
try {
const headers = {
'Content-Type': 'application/json',
}
let payload = {
...updates,
type,
id,
}
const url = `${this.url}?id=${id}`
const updated = await patch(url, payload, { headers })
if (updated.error) throw updated.error
this.data[id] = updated
return { data: updated, error: null }
} catch (error) {
return { data: null, error }
}
}
async updateSql(id: any, updates: any) {
return this.update(id, updates, 'sql')
}
async updateReport(id: any, updates: any) {
return this.update(id, updates, 'report')
}
async del(id: any) {
try {
const headers = {
'Content-Type': 'application/json',
}
const url = `${this.url}?id=${id}`
const deleted = await delete_(url, {}, { headers })
if (deleted.error) throw deleted.error
delete this.data[id]
return { data: true, error: null }
} catch (error) {
return { data: false, error }
}
}
}
+6 -6
View File
@@ -796,11 +796,11 @@ export interface paths {
post: operations['TelemetryPageController_sendServerPage']
}
'/platform/telemetry/activity': {
/** Sends mixpanel server activity */
/** Sends server activity */
post: operations['TelemetryActivityController_sendServerActivity']
}
'/platform/telemetry/pageview': {
/** Send mixpanel page event */
/** Send pageview event */
post: operations['TelemetryPageviewController_sendServerPageViewed']
}
'/platform/vercel/token': {
@@ -11404,7 +11404,7 @@ export interface operations {
}
}
}
/** Sends mixpanel server activity */
/** Sends server activity */
TelemetryActivityController_sendServerActivity: {
requestBody: {
content: {
@@ -11415,13 +11415,13 @@ export interface operations {
201: {
content: never
}
/** @description Failed to send mixpanel server activity */
/** @description Failed to send server activity */
500: {
content: never
}
}
}
/** Send mixpanel page event */
/** Send pageview event */
TelemetryPageviewController_sendServerPageViewed: {
requestBody: {
content: {
@@ -11432,7 +11432,7 @@ export interface operations {
201: {
content: never
}
/** @description Failed to send mixpanel page event */
/** @description Failed to send pageview event */
500: {
content: never
}