index.tsx441 lines · main
| 1 | import { useMonaco } from '@monaco-editor/react' |
| 2 | import { useLocalStorage } from '@uidotdev/usehooks' |
| 3 | import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' |
| 4 | import dayjs from 'dayjs' |
| 5 | import type { editor } from 'monaco-editor' |
| 6 | import { useRouter } from 'next/router' |
| 7 | import { useEffect, useMemo, useRef, useState } from 'react' |
| 8 | import { toast } from 'sonner' |
| 9 | import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' |
| 10 | |
| 11 | import { |
| 12 | EXPLORER_DATEPICKER_HELPERS, |
| 13 | getDefaultHelper, |
| 14 | LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD, |
| 15 | TEMPLATES, |
| 16 | } from '@/components/interfaces/Settings/Logs/Logs.constants' |
| 17 | import { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' |
| 18 | import { LogData, LogsWarning, LogTemplate } from '@/components/interfaces/Settings/Logs/Logs.types' |
| 19 | import { UpdateSavedQueryModal } from '@/components/interfaces/Settings/Logs/Logs.UpdateSavedQueryModal' |
| 20 | import { |
| 21 | maybeShowUpgradePromptIfNotEntitled, |
| 22 | useEditorHints, |
| 23 | } from '@/components/interfaces/Settings/Logs/Logs.utils' |
| 24 | import { |
| 25 | buildLogQueryParams, |
| 26 | resolveLogDateRange, |
| 27 | } from '@/components/interfaces/Settings/Logs/logsDateRange' |
| 28 | import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel' |
| 29 | import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable' |
| 30 | import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' |
| 31 | import DefaultLayout from '@/components/layouts/DefaultLayout' |
| 32 | import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' |
| 33 | import CodeEditor from '@/components/ui/CodeEditor/CodeEditor' |
| 34 | import LoadingOpacity from '@/components/ui/LoadingOpacity' |
| 35 | import ShimmerLine from '@/components/ui/ShimmerLine' |
| 36 | import { useContentQuery } from '@/data/content/content-query' |
| 37 | import { |
| 38 | UpsertContentPayload, |
| 39 | useContentUpsertMutation, |
| 40 | } from '@/data/content/content-upsert-mutation' |
| 41 | import useLogsQuery from '@/hooks/analytics/useLogsQuery' |
| 42 | import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState' |
| 43 | import { useCustomContent } from '@/hooks/custom-content/useCustomContent' |
| 44 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 45 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 46 | import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt' |
| 47 | import { uuidv4 } from '@/lib/helpers' |
| 48 | import { useProfile } from '@/lib/profile' |
| 49 | import { useTrack } from '@/lib/telemetry/track' |
| 50 | import type { LogSqlSnippets, NextPageWithLayout } from '@/types' |
| 51 | |
| 52 | const LOCAL_PLACEHOLDER_QUERY = |
| 53 | 'select\n timestamp, event_message, metadata\n from edge_logs limit 5' |
| 54 | |
| 55 | const PLATFORM_PLACEHOLDER_QUERY = |
| 56 | 'select\n cast(timestamp as datetime) as timestamp,\n event_message, metadata \nfrom edge_logs \nlimit 5' |
| 57 | |
| 58 | export 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 | |
| 435 | LogsExplorerPage.getLayout = (page) => ( |
| 436 | <DefaultLayout> |
| 437 | <LogsLayout title="Explorer">{page}</LogsLayout> |
| 438 | </DefaultLayout> |
| 439 | ) |
| 440 | |
| 441 | export default LogsExplorerPage |