ObservabilityMenu.tsx271 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useFlag, useParams } from 'common'
3import { Plus } from 'lucide-react'
4import { useRouter } from 'next/router'
5import { parseAsBoolean, useQueryState } from 'nuqs'
6import { useMemo, useState } from 'react'
7import { toast } from 'sonner'
8import { Menu } from 'ui'
9import { InnerSideBarEmptyPanel } from 'ui-patterns'
10import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
11import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
12
13import { generateObservabilityMenuItems } from './ObservabilityMenu.utils'
14import { ObservabilityMenuItem } from './ObservabilityMenuItem'
15import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus'
16import { CreateReportModal } from '@/components/interfaces/Reports/CreateReportModal'
17import { UpdateCustomReportModal } from '@/components/interfaces/Reports/UpdateModal'
18import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
19import { ProductMenu } from '@/components/ui/ProductMenu'
20import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
21import { Content, ContentBase, useContentQuery } from '@/data/content/content-query'
22import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
23import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
24import { IS_PLATFORM } from '@/lib/constants'
25import { useProfile } from '@/lib/profile'
26import type { Dashboards } from '@/types'
27
28const ObservabilityMenu = () => {
29 const router = useRouter()
30 const { profile } = useProfile()
31 const { ref, id } = useParams()
32 const pageKey = (id || router.pathname.split('/')[4] || 'observability') as string
33 const showOverview = useFlag('observabilityOverview')
34 const { isSupamonitorEnabled } = useSupamonitorStatus()
35
36 const storageSupported = useIsFeatureEnabled('project_storage:all')
37
38 const { can: canCreateCustomReport } = useAsyncCheckPermissions(
39 PermissionAction.CREATE,
40 'user_content',
41 {
42 resource: { type: 'report', owner_id: profile?.id },
43 subject: { id: profile?.id },
44 }
45 )
46
47 // Preserve date range query parameters when navigating
48 const preservedQueryParams = useMemo(() => {
49 const { its, ite, isHelper, helperText } = router.query
50 const params = new URLSearchParams()
51
52 if (its && typeof its === 'string') params.set('its', its)
53 if (ite && typeof ite === 'string') params.set('ite', ite)
54 if (isHelper && typeof isHelper === 'string') params.set('isHelper', isHelper)
55 if (helperText && typeof helperText === 'string') params.set('helperText', helperText)
56
57 const queryString = params.toString()
58 return queryString ? `?${queryString}` : ''
59 }, [router.query])
60
61 const { data: content, isPending: isLoading } = useContentQuery({
62 projectRef: ref,
63 type: 'report',
64 })
65 const { mutate: deleteReport, isPending: isDeleting } = useContentDeleteMutation({
66 onSuccess: () => {
67 setDeleteModalOpen(false)
68 toast.success('Successfully deleted report')
69 router.push(`/project/${ref}/observability`)
70 },
71 onError: (error) => {
72 toast.error(`Failed to delete report: ${error.message}`)
73 },
74 })
75
76 const [deleteModalOpen, setDeleteModalOpen] = useState(false)
77 const [showNewReportModal, setShowNewReportModal] = useQueryState(
78 'newReport',
79 parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
80 )
81 const [selectedReportToDelete, setSelectedReportToDelete] = useState<Content>()
82 const [selectedReportToUpdate, setSelectedReportToUpdate] = useState<Content>()
83
84 const onConfirmDeleteReport = () => {
85 if (ref === undefined) return console.error('Project ref is required')
86 if (selectedReportToDelete?.id === undefined) return console.error('Report ID is required')
87 deleteReport({ projectRef: ref, ids: [selectedReportToDelete.id] })
88 }
89
90 function isReportContent(c: Content): c is ContentBase & {
91 type: 'report'
92 content: Dashboards.Content
93 } {
94 return c.type === 'report'
95 }
96
97 function getReportMenuItems() {
98 if (!content) return []
99
100 const reports = content?.content.filter(isReportContent)
101
102 const sortedReports = reports?.sort((a, b) => {
103 if (a.name < b.name) {
104 return -1
105 }
106 if (a.name > b.name) {
107 return 1
108 }
109 return 0
110 })
111
112 const reportMenuItems = sortedReports.map((r, idx) => ({
113 id: r.id,
114 name: r.name,
115 description: r.description || '',
116 key: r.id || idx + '-report',
117 url: `/project/${ref}/observability/${r.id}${preservedQueryParams}`,
118 hasDropdownActions: true,
119 report: r,
120 }))
121
122 return reportMenuItems
123 }
124
125 const reportMenuItems = getReportMenuItems()
126
127 const menuItems = generateObservabilityMenuItems({
128 ref,
129 preservedQueryParams,
130 showOverview,
131 isSupamonitorEnabled,
132 storageSupported,
133 isPlatform: IS_PLATFORM,
134 })
135
136 return (
137 <div>
138 {isLoading ? (
139 <div className="px-5 my-4 space-y-2">
140 <ShimmeringLoader />
141 <ShimmeringLoader className="w-3/4" />
142 <ShimmeringLoader className="w-1/2" />
143 </div>
144 ) : (
145 <div className="flex flex-col gap-y-6">
146 <ProductMenu
147 page={pageKey}
148 menu={menuItems.map((item) => ({
149 ...item,
150 items: item.items.map((subItem) => ({ ...subItem, items: [] })),
151 }))}
152 />
153
154 {IS_PLATFORM && (
155 <>
156 <div className="h-px w-full bg-border-overlay" />
157 <div className="mx-2">
158 <Menu type="pills">
159 <Menu.Group
160 title={
161 <span className="flex w-full items-center justify-between relative h-6">
162 <span className="uppercase font-mono">Custom Reports</span>
163 {reportMenuItems.length > 0 && (
164 <ButtonTooltip
165 type="default"
166 size="tiny"
167 icon={<Plus />}
168 disabled={!canCreateCustomReport}
169 className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1"
170 onClick={() => {
171 setShowNewReportModal(true)
172 }}
173 tooltip={{
174 content: {
175 side: 'bottom',
176 text: !canCreateCustomReport
177 ? 'You need additional permissions to create custom reports'
178 : undefined,
179 },
180 }}
181 />
182 )}
183 </span>
184 }
185 />
186 {reportMenuItems.length > 0 &&
187 reportMenuItems.map((item) => (
188 <ObservabilityMenuItem
189 key={item.id}
190 item={item}
191 pageKey={pageKey}
192 onSelectEdit={() => {
193 setSelectedReportToUpdate(item.report)
194 }}
195 onSelectDelete={() => {
196 setSelectedReportToDelete(item.report)
197 setDeleteModalOpen(true)
198 }}
199 />
200 ))}
201 </Menu>
202 {reportMenuItems.length === 0 ? (
203 <div className="px-2">
204 <InnerSideBarEmptyPanel
205 title="No custom reports yet"
206 description="Create and save custom reports to track your project metrics"
207 actions={
208 <ButtonTooltip
209 type="default"
210 icon={<Plus />}
211 disabled={!canCreateCustomReport}
212 onClick={() => {
213 setShowNewReportModal(true)
214 }}
215 tooltip={{
216 content: {
217 side: 'bottom',
218 text: !canCreateCustomReport
219 ? 'You need additional permissions to create custom reports'
220 : undefined,
221 },
222 }}
223 >
224 New custom report
225 </ButtonTooltip>
226 }
227 />
228 </div>
229 ) : null}
230 </div>
231 </>
232 )}
233
234 <UpdateCustomReportModal
235 onCancel={() => setSelectedReportToUpdate(undefined)}
236 selectedReport={selectedReportToUpdate}
237 initialValues={{
238 name: selectedReportToUpdate?.name || '',
239 description: selectedReportToUpdate?.description || '',
240 }}
241 />
242
243 <ConfirmationModal
244 title="Delete custom report"
245 confirmLabel="Delete report"
246 confirmLabelLoading="Deleting report"
247 size="medium"
248 loading={isDeleting}
249 visible={deleteModalOpen}
250 onCancel={() => setDeleteModalOpen(false)}
251 onConfirm={onConfirmDeleteReport}
252 >
253 <div className="text-sm text-foreground-light grid gap-4">
254 <div className="grid gap-1">
255 <p>Are you sure you want to delete '{selectedReportToDelete?.name}'?</p>
256 </div>
257 </div>
258 </ConfirmationModal>
259
260 <CreateReportModal
261 visible={showNewReportModal}
262 onCancel={() => setShowNewReportModal(false)}
263 afterSubmit={() => setShowNewReportModal(false)}
264 />
265 </div>
266 )}
267 </div>
268 )
269}
270
271export default ObservabilityMenu