index.tsx441 lines · main
1import { useMonaco } from '@monaco-editor/react'
2import { useLocalStorage } from '@uidotdev/usehooks'
3import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
4import dayjs from 'dayjs'
5import type { editor } from 'monaco-editor'
6import { useRouter } from 'next/router'
7import { useEffect, useMemo, useRef, useState } from 'react'
8import { toast } from 'sonner'
9import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
10
11import {
12 EXPLORER_DATEPICKER_HELPERS,
13 getDefaultHelper,
14 LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD,
15 TEMPLATES,
16} from '@/components/interfaces/Settings/Logs/Logs.constants'
17import { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
18import { LogData, LogsWarning, LogTemplate } from '@/components/interfaces/Settings/Logs/Logs.types'
19import { UpdateSavedQueryModal } from '@/components/interfaces/Settings/Logs/Logs.UpdateSavedQueryModal'
20import {
21 maybeShowUpgradePromptIfNotEntitled,
22 useEditorHints,
23} from '@/components/interfaces/Settings/Logs/Logs.utils'
24import {
25 buildLogQueryParams,
26 resolveLogDateRange,
27} from '@/components/interfaces/Settings/Logs/logsDateRange'
28import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel'
29import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
30import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
31import DefaultLayout from '@/components/layouts/DefaultLayout'
32import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout'
33import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
34import LoadingOpacity from '@/components/ui/LoadingOpacity'
35import ShimmerLine from '@/components/ui/ShimmerLine'
36import { useContentQuery } from '@/data/content/content-query'
37import {
38 UpsertContentPayload,
39 useContentUpsertMutation,
40} from '@/data/content/content-upsert-mutation'
41import useLogsQuery from '@/hooks/analytics/useLogsQuery'
42import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
43import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
44import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
45import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
46import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
47import { uuidv4 } from '@/lib/helpers'
48import { useProfile } from '@/lib/profile'
49import { useTrack } from '@/lib/telemetry/track'
50import type { LogSqlSnippets, NextPageWithLayout } from '@/types'
51
52const LOCAL_PLACEHOLDER_QUERY =
53 'select\n timestamp, event_message, metadata\n from edge_logs limit 5'
54
55const PLATFORM_PLACEHOLDER_QUERY =
56 'select\n cast(timestamp as datetime) as timestamp,\n event_message, metadata \nfrom edge_logs \nlimit 5'
57
58export const LogsExplorerPage: NextPageWithLayout = () => {
59 useEditorHints()
60 const monaco = useMonaco()
61 const router = useRouter()
62 const { profile } = useProfile()
63 const { ref, q, queryId } = useParams()
64 const track = useTrack()
65 const projectRef = ref as string
66 const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template'])
67
68 const allTemplates = useMemo(() => {
69 if (logsShowMetadataIpTemplate) return TEMPLATES
70 else return TEMPLATES.filter((x) => x.label !== 'Metadata IP')
71 }, [logsShowMetadataIpTemplate])
72
73 const editorRef = useRef<editor.IStandaloneCodeEditor>(null)
74 const [editorId] = useState<string>(uuidv4())
75 const { search, setSearch, timestampStart, timestampEnd, setTimeRange } = useLogsUrlState()
76 const defaultHelper = useMemo(() => getDefaultHelper(EXPLORER_DATEPICKER_HELPERS), [])
77 const initialDatePickerValue = useMemo<DatePickerValue>(() => {
78 if (timestampStart && timestampEnd) {
79 return { from: timestampStart, to: timestampEnd, isHelper: false }
80 }
81 if (timestampStart) {
82 return { from: timestampStart, to: timestampEnd || '', isHelper: false }
83 }
84 return {
85 from: defaultHelper.calcFrom(),
86 to: defaultHelper.calcTo(),
87 isHelper: true,
88 text: defaultHelper.text,
89 }
90 }, [timestampStart, timestampEnd, defaultHelper])
91 const [datePickerValue, setDatePickerValue] = useState<DatePickerValue>(initialDatePickerValue)
92
93 const { logsDefaultQuery } = useCustomContent(['logs:default_query'])
94 const PLACEHOLDER_QUERY = IS_PLATFORM
95 ? (logsDefaultQuery ?? PLATFORM_PLACEHOLDER_QUERY)
96 : LOCAL_PLACEHOLDER_QUERY
97
98 const [editorValue, setEditorValue] = useState<string>(PLACEHOLDER_QUERY)
99 const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
100 const [warnings, setWarnings] = useState<LogsWarning[]>([])
101 const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
102
103 const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>(
104 `project-content-${projectRef}-recent-log-sql`,
105 []
106 )
107
108 const [useOtelEndpoint, setUseOtelEndpoint] = useLocalStorage<boolean>(
109 `logs-explorer-use-otel-endpoint-${projectRef}`,
110 false
111 )
112
113 const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
114 const entitledToAuditLogDays = getEntitlementNumericValue()
115
116 const { data: content } = useContentQuery({
117 projectRef: ref,
118 type: 'log_sql',
119 })
120 const query = content?.content.find((x) => x.id === queryId)
121
122 const resolvedRange = useMemo(() => {
123 if (datePickerValue.isHelper) {
124 return resolveLogDateRange(datePickerValue)
125 }
126 if (timestampStart && timestampEnd) {
127 return { from: timestampStart, to: timestampEnd }
128 }
129 return resolveLogDateRange(datePickerValue)
130 }, [timestampStart, timestampEnd, datePickerValue])
131
132 const {
133 params,
134 logData,
135 error,
136 isLoading: logsLoading,
137 setParams,
138 } = useLogsQuery(
139 projectRef,
140 {
141 iso_timestamp_start: resolvedRange.from,
142 iso_timestamp_end: resolvedRange.to,
143 },
144 true,
145 { useOtel: useOtelEndpoint }
146 )
147
148 const results = logData
149 const isLoading = logsLoading
150
151 const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({
152 onError: (e) => {
153 const error = e as { message: string }
154 console.error(error)
155 setSaveModalOpen(false)
156 if (queryId) {
157 toast.error(`Failed to update query: ${error.message}`)
158 } else {
159 toast.error(`Failed to save query: ${error.message}`)
160 }
161 },
162 onSuccess: (_data, vars) => {
163 setSaveModalOpen(false)
164 if (queryId) {
165 toast.success(`Updated "${vars.payload.name}" log query`)
166 } else {
167 toast.success(`Saved "${vars.payload.name}" log query`)
168 }
169 },
170 })
171
172 const addRecentLogSqlSnippet = (snippet: Partial<LogSqlSnippets.Content>) => {
173 const defaults: LogSqlSnippets.Content = {
174 schema_version: '1',
175 sql: '',
176 content_id: '',
177 }
178 setRecentLogs([...recentLogs, { ...defaults, ...snippet }])
179 }
180
181 const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(
182 params.iso_timestamp_start as string
183 )
184
185 const onSelectTemplate = (template: LogTemplate) => {
186 if (editorRef.current && monaco) {
187 const editorModel = editorRef.current?.getModel()
188
189 editorRef.current.pushUndoStop()
190 editorRef.current.executeEdits(`insert-identifier`, [
191 {
192 text: template.searchString,
193 range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
194 },
195 ])
196 editorRef.current.pushUndoStop()
197 editorRef.current.focus()
198 }
199
200 addRecentLogSqlSnippet({ sql: template.searchString })
201 }
202
203 const handleRun = (value?: string | React.MouseEvent) => {
204 track('log_explorer_query_run_button_clicked', { is_saved_query: !!queryId })
205
206 const query = typeof value === 'string' ? value || editorValue : editorValue
207 const resolvedParams = buildLogQueryParams(datePickerValue, query)
208
209 setParams((prev) => ({
210 ...prev,
211 sql: resolvedParams.sql,
212 iso_timestamp_start: resolvedParams.from,
213 iso_timestamp_end: resolvedParams.to,
214 }))
215 if (!datePickerValue.isHelper) {
216 setTimeRange(resolvedParams.from, resolvedParams.to)
217 } else {
218 setTimeRange('', '')
219 }
220 setSearch(query)
221 addRecentLogSqlSnippet({ sql: query })
222 }
223
224 const handleInsertSource = (source: string) => {
225 if (editorRef.current && monaco) {
226 const editorModel = editorRef.current?.getModel()
227 const currentValue = editorRef.current.getValue()
228 const index = currentValue.indexOf('from')
229
230 const updatedValue =
231 index < 0
232 ? `${currentValue}${source}`
233 : `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`
234
235 editorRef.current.pushUndoStop()
236 editorRef.current.executeEdits(`insert-identifier`, [
237 {
238 text: updatedValue,
239 range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
240 },
241 ])
242 editorRef.current.pushUndoStop()
243 editorRef.current.focus()
244 }
245 }
246
247 type SaveQueryFormValues = { name: string; description?: string }
248
249 const handleCreateQuery = async (values: SaveQueryFormValues) => {
250 if (!projectRef) return console.error('Project ref is required')
251 if (!profile) return console.error('Profile is required')
252
253 const id = uuidv4()
254 const payload: UpsertContentPayload = {
255 id,
256 name: values.name,
257 description: values.description || '',
258 type: 'log_sql' as const,
259 content: {
260 content_id: editorId,
261 sql: editorValue,
262 schema_version: '1',
263 favorite: false,
264 } as LogSqlSnippets.Content,
265 owner_id: profile.id,
266 visibility: 'user' as const,
267 }
268 await upsertContent(
269 { projectRef, payload },
270 {
271 onSuccess: () => router.push(`/project/${projectRef}/logs/explorer?queryId=${id}`),
272 }
273 )
274 }
275
276 async function handleOnSave() {
277 if (!projectRef) return console.error('Project ref is required')
278
279 // if we have a queryId, we are editing a saved query
280 if (queryId && query) {
281 await upsertContent({
282 projectRef: projectRef!,
283 payload: {
284 ...query,
285 content: { ...(query.content as LogSqlSnippets.Content), sql: editorValue },
286 },
287 })
288
289 return
290 }
291
292 setSaveModalOpen(!saveModalOpen)
293 }
294
295 const handleDateChange = (value: DatePickerValue) => {
296 setDatePickerValue(value)
297 const resolvedRange = resolveLogDateRange(value)
298 const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
299 resolvedRange.from,
300 entitledToAuditLogDays
301 )
302
303 if (shouldShowUpgradePrompt) {
304 setShowUpgradePrompt(true)
305 return
306 }
307
308 if (value.isHelper) {
309 setTimeRange('', '')
310 } else {
311 setTimeRange(resolvedRange.from || '', resolvedRange.to || '')
312 }
313 setParams((prev) => ({
314 ...prev,
315 iso_timestamp_start: resolvedRange.from,
316 iso_timestamp_end: resolvedRange.to,
317 }))
318 }
319
320 useEffect(() => {
321 if (search) {
322 setEditorValue(search)
323 } else if (q) {
324 setEditorValue(q)
325 setSearch(q)
326 }
327 }, [q, search, setSearch])
328
329 useEffect(() => {
330 // prevents overwriting when the user selects a helper.
331 // without this, if the user selects "last 3 days" it would overwrite it with "last hour"
332 // its the simplest solution I could come up with - jordi
333 if (!initialDatePickerValue.isHelper) {
334 setDatePickerValue(initialDatePickerValue)
335 }
336 }, [initialDatePickerValue])
337
338 useEffect(() => {
339 let newWarnings = []
340 const start = timestampStart ? dayjs(timestampStart) : dayjs()
341 const end = timestampEnd ? dayjs(timestampEnd) : dayjs()
342 const daysDiff = Math.abs(start.diff(end, 'days'))
343
344 if (daysDiff >= LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD) {
345 newWarnings.push({
346 text: 'Querying large date ranges can be slow. Consider selecting a smaller date range.',
347 })
348 }
349 if (editorValue && !editorValue.toLowerCase().includes('limit')) {
350 newWarnings.push({ text: 'When querying large date ranges, include a LIMIT clause.' })
351 }
352 setWarnings(newWarnings)
353 }, [editorValue, timestampStart, timestampEnd])
354
355 // Show the prompt on page load based on query params
356 useEffect(() => {
357 if (timestampStart) {
358 const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
359 timestampStart,
360 entitledToAuditLogDays
361 )
362 if (shouldShowUpgradePrompt) {
363 setShowUpgradePrompt(true)
364 }
365 }
366 }, [timestampStart, entitledToAuditLogDays, setShowUpgradePrompt])
367
368 return (
369 <div className="w-full h-full mx-auto">
370 <ResizablePanelGroup
371 className="w-full h-full max-h-screen"
372 orientation="vertical"
373 autoSaveId={LOCAL_STORAGE_KEYS.LOG_EXPLORER_SPLIT_SIZE}
374 >
375 <ResizablePanel collapsible minSize="5">
376 <LogsQueryPanel
377 value={datePickerValue}
378 onDateChange={handleDateChange}
379 onSelectSource={handleInsertSource}
380 templates={allTemplates.filter((template) => template.mode === 'custom')}
381 onSelectTemplate={onSelectTemplate}
382 warnings={warnings}
383 useOtel={useOtelEndpoint}
384 onUseOtelChange={setUseOtelEndpoint}
385 />
386 <ShimmerLine active={isLoading} />
387 <CodeEditor
388 // Ensure we reset the editor to the query content whenever the selected query changes
389 key={queryId}
390 id={editorId}
391 editorRef={editorRef}
392 language="pgsql"
393 defaultValue={editorValue}
394 onInputChange={(v) => setEditorValue(v || '')}
395 actions={{ runQuery: { enabled: true, callback: handleRun } }}
396 />
397 </ResizablePanel>
398 <ResizableHandle withHandle />
399 <ResizablePanel collapsible minSize="5" className="overflow-auto">
400 <LoadingOpacity active={isLoading}>
401 <LogTable
402 isSaving={isUpsertingContent}
403 showHistogramToggle={false}
404 onRun={handleRun}
405 onSave={handleOnSave}
406 hasEditorValue={Boolean(editorValue)}
407 data={results}
408 error={error}
409 projectRef={projectRef}
410 onSelectedLogChange={setSelectedLog}
411 selectedLog={selectedLog || undefined}
412 sqlQuery={editorValue}
413 />
414
415 <div className="flex flex-row justify-end mt-2">
416 <UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
417 </div>
418 </LoadingOpacity>
419 </ResizablePanel>
420 </ResizablePanelGroup>
421
422 <UpdateSavedQueryModal
423 header="Save log query"
424 visible={saveModalOpen}
425 initialValues={{ name: '', description: '' }}
426 onCancel={() => {
427 setSaveModalOpen(false)
428 }}
429 onSubmit={handleCreateQuery}
430 />
431 </div>
432 )
433}
434
435LogsExplorerPage.getLayout = (page) => (
436 <DefaultLayout>
437 <LogsLayout title="Explorer">{page}</LogsLayout>
438 </DefaultLayout>
439)
440
441export default LogsExplorerPage