mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user