EdgeFunctionRecentErrors.tsx371 lines · main
| 1 | import { BookOpen, Check, ExternalLink, Eye } from 'lucide-react' |
| 2 | import { useRouter } from 'next/router' |
| 3 | import { Fragment, useMemo } from 'react' |
| 4 | import { |
| 5 | Badge, |
| 6 | Button, |
| 7 | Card, |
| 8 | cn, |
| 9 | Table, |
| 10 | TableBody, |
| 11 | TableCell, |
| 12 | TableHead, |
| 13 | TableHeader, |
| 14 | TableRow, |
| 15 | } from 'ui' |
| 16 | import { PageContainer } from 'ui-patterns/PageContainer' |
| 17 | import { |
| 18 | PageSection, |
| 19 | PageSectionAside, |
| 20 | PageSectionContent, |
| 21 | PageSectionMeta, |
| 22 | PageSectionSummary, |
| 23 | PageSectionTitle, |
| 24 | } from 'ui-patterns/PageSection' |
| 25 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 26 | |
| 27 | import { |
| 28 | buildGroupAssistantPrompt, |
| 29 | buildTroubleshootingDocsUrl, |
| 30 | formatLogTimestamp, |
| 31 | formatSingleLineMessage, |
| 32 | getDisplayErrorMessage, |
| 33 | getFunctionRuntimeLogsSql, |
| 34 | getRecentErrorGroups, |
| 35 | getRecentErrorGroupsBase, |
| 36 | getRecentErrorInvocationsSql, |
| 37 | getRelatedExecutionIds, |
| 38 | getSinceLastDeployInvocationCount, |
| 39 | getSinceLastDeployInvocationCountSql, |
| 40 | getSinceLastDeployInvocationPhrase, |
| 41 | getSinceLastDeployLogRange, |
| 42 | getStatusBadgeVariant, |
| 43 | toAlertError, |
| 44 | type RecentErrorGroup, |
| 45 | } from './EdgeFunctionRecentErrors.utils' |
| 46 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 47 | import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' |
| 48 | import AlertError from '@/components/ui/AlertError' |
| 49 | import useLogsQuery from '@/hooks/analytics/useLogsQuery' |
| 50 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 51 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 52 | |
| 53 | interface EdgeFunctionRecentErrorsProps { |
| 54 | functionId?: string |
| 55 | functionSlug?: string |
| 56 | projectRef?: string |
| 57 | updatedAt?: string | number |
| 58 | } |
| 59 | |
| 60 | export const EdgeFunctionRecentErrors = ({ |
| 61 | functionId, |
| 62 | functionSlug, |
| 63 | projectRef, |
| 64 | updatedAt, |
| 65 | }: EdgeFunctionRecentErrorsProps) => { |
| 66 | const router = useRouter() |
| 67 | const { openSidebar } = useSidebarManagerSnapshot() |
| 68 | const aiAssistant = useAiAssistantStateSnapshot() |
| 69 | const { isoTimestampStart, isoTimestampEnd } = useMemo( |
| 70 | () => getSinceLastDeployLogRange(updatedAt), |
| 71 | [updatedAt] |
| 72 | ) |
| 73 | const emptyStateFallback = |
| 74 | 'Runtime errors since the last deploy will appear here when this function returns a 5xx response.' |
| 75 | |
| 76 | const isQueryEnabled = Boolean(projectRef && functionId && isoTimestampStart) |
| 77 | const recentErrorInvocationsSql = useMemo( |
| 78 | () => getRecentErrorInvocationsSql(functionId), |
| 79 | [functionId] |
| 80 | ) |
| 81 | const sinceLastDeployInvocationCountSql = useMemo( |
| 82 | () => getSinceLastDeployInvocationCountSql(functionId), |
| 83 | [functionId] |
| 84 | ) |
| 85 | |
| 86 | const { |
| 87 | logData: recentErrorInvocations, |
| 88 | isLoading: isLoadingRecentErrorInvocations, |
| 89 | error: recentErrorInvocationsError, |
| 90 | } = useLogsQuery( |
| 91 | projectRef as string, |
| 92 | { |
| 93 | sql: recentErrorInvocationsSql, |
| 94 | iso_timestamp_start: isoTimestampStart, |
| 95 | iso_timestamp_end: isoTimestampEnd, |
| 96 | }, |
| 97 | isQueryEnabled |
| 98 | ) |
| 99 | |
| 100 | const recentErrorGroupsBase = useMemo( |
| 101 | () => getRecentErrorGroupsBase(recentErrorInvocations), |
| 102 | [recentErrorInvocations] |
| 103 | ) |
| 104 | const { |
| 105 | logData: sinceLastDeployInvocationCountRows, |
| 106 | isLoading: isLoadingSinceLastDeployInvocationCount, |
| 107 | error: sinceLastDeployInvocationCountError, |
| 108 | } = useLogsQuery( |
| 109 | projectRef as string, |
| 110 | { |
| 111 | sql: sinceLastDeployInvocationCountSql, |
| 112 | iso_timestamp_start: isoTimestampStart, |
| 113 | iso_timestamp_end: isoTimestampEnd, |
| 114 | }, |
| 115 | Boolean(projectRef && sinceLastDeployInvocationCountSql && isoTimestampStart) |
| 116 | ) |
| 117 | |
| 118 | const relatedExecutionIds = useMemo( |
| 119 | () => getRelatedExecutionIds(recentErrorGroupsBase), |
| 120 | [recentErrorGroupsBase] |
| 121 | ) |
| 122 | |
| 123 | const functionRuntimeLogsSql = useMemo( |
| 124 | () => getFunctionRuntimeLogsSql({ functionId, executionIds: relatedExecutionIds }), |
| 125 | [functionId, relatedExecutionIds] |
| 126 | ) |
| 127 | |
| 128 | const { |
| 129 | logData: functionRuntimeLogs, |
| 130 | isLoading: isLoadingFunctionRuntimeLogs, |
| 131 | error: functionRuntimeLogsError, |
| 132 | } = useLogsQuery( |
| 133 | projectRef as string, |
| 134 | { |
| 135 | sql: functionRuntimeLogsSql, |
| 136 | iso_timestamp_start: isoTimestampStart, |
| 137 | iso_timestamp_end: isoTimestampEnd, |
| 138 | }, |
| 139 | Boolean(projectRef && functionRuntimeLogsSql && isoTimestampStart) |
| 140 | ) |
| 141 | const queryError = |
| 142 | toAlertError(recentErrorInvocationsError) ?? toAlertError(functionRuntimeLogsError) |
| 143 | |
| 144 | const recentErrorGroups = useMemo( |
| 145 | () => getRecentErrorGroups({ recentErrorGroupsBase, functionRuntimeLogs }), |
| 146 | [functionRuntimeLogs, recentErrorGroupsBase] |
| 147 | ) |
| 148 | const sinceLastDeployInvocationCount = useMemo( |
| 149 | () => getSinceLastDeployInvocationCount(sinceLastDeployInvocationCountRows), |
| 150 | [sinceLastDeployInvocationCountRows] |
| 151 | ) |
| 152 | const emptyStateMessage = useMemo(() => { |
| 153 | if (!isoTimestampStart || sinceLastDeployInvocationCountError) return emptyStateFallback |
| 154 | |
| 155 | const verb = sinceLastDeployInvocationCount === 1 ? 'has' : 'have' |
| 156 | const invocationPhrase = getSinceLastDeployInvocationPhrase(sinceLastDeployInvocationCount) |
| 157 | |
| 158 | return ( |
| 159 | <> |
| 160 | There {verb} been <span className="text-foreground">{invocationPhrase}</span> since last |
| 161 | deploy and no errors. |
| 162 | </> |
| 163 | ) |
| 164 | }, [ |
| 165 | emptyStateFallback, |
| 166 | isoTimestampStart, |
| 167 | sinceLastDeployInvocationCount, |
| 168 | sinceLastDeployInvocationCountError, |
| 169 | ]) |
| 170 | const emptyStateIcon = |
| 171 | isoTimestampStart && !sinceLastDeployInvocationCountError ? ( |
| 172 | sinceLastDeployInvocationCount > 0 ? ( |
| 173 | <Check |
| 174 | size={16} |
| 175 | strokeWidth={1.5} |
| 176 | className="mt-0.5 shrink-0 text-brand" |
| 177 | aria-hidden="true" |
| 178 | /> |
| 179 | ) : ( |
| 180 | <Eye |
| 181 | size={16} |
| 182 | strokeWidth={1.5} |
| 183 | className="mt-0.5 shrink-0 text-foreground-muted" |
| 184 | aria-hidden="true" |
| 185 | /> |
| 186 | ) |
| 187 | ) : null |
| 188 | |
| 189 | const handleOpenAssistant = (group: RecentErrorGroup) => { |
| 190 | openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) |
| 191 | aiAssistant.newChat({ |
| 192 | name: `Investigate ${functionSlug ?? 'error'}`, |
| 193 | initialMessage: buildGroupAssistantPrompt(group, functionSlug), |
| 194 | }) |
| 195 | } |
| 196 | |
| 197 | return ( |
| 198 | <PageSection> |
| 199 | <PageSectionContent> |
| 200 | <PageContainer size="full"> |
| 201 | <div className="flex flex-col gap-6"> |
| 202 | <PageSectionMeta> |
| 203 | <PageSectionSummary> |
| 204 | <PageSectionTitle>Errors since last deploy</PageSectionTitle> |
| 205 | </PageSectionSummary> |
| 206 | <PageSectionAside> |
| 207 | <Button |
| 208 | type="default" |
| 209 | size="tiny" |
| 210 | icon={<ExternalLink size={14} />} |
| 211 | onClick={() => |
| 212 | router.push(`/project/${projectRef}/functions/${functionSlug}/logs`) |
| 213 | } |
| 214 | > |
| 215 | View logs |
| 216 | </Button> |
| 217 | </PageSectionAside> |
| 218 | </PageSectionMeta> |
| 219 | |
| 220 | {recentErrorInvocationsError || functionRuntimeLogsError ? ( |
| 221 | <AlertError |
| 222 | error={queryError} |
| 223 | subject="Failed to retrieve edge function errors since the last deploy" |
| 224 | /> |
| 225 | ) : isLoadingRecentErrorInvocations || |
| 226 | isLoadingFunctionRuntimeLogs || |
| 227 | isLoadingSinceLastDeployInvocationCount ? ( |
| 228 | <GenericSkeletonLoader /> |
| 229 | ) : recentErrorGroups.length === 0 ? ( |
| 230 | <div className="rounded-md border border-dashed px-5 py-6 text-sm text-foreground-light"> |
| 231 | <div className="flex items-start gap-3"> |
| 232 | {emptyStateIcon} |
| 233 | <div>{emptyStateMessage}</div> |
| 234 | </div> |
| 235 | </div> |
| 236 | ) : ( |
| 237 | <Card className="p-0 overflow-hidden"> |
| 238 | <Table> |
| 239 | <TableHeader> |
| 240 | <TableRow> |
| 241 | <TableHead>Error</TableHead> |
| 242 | <TableHead>Count</TableHead> |
| 243 | <TableHead>Last Seen</TableHead> |
| 244 | <TableHead>Method</TableHead> |
| 245 | <TableHead>Status</TableHead> |
| 246 | <TableHead>Duration</TableHead> |
| 247 | <TableHead className="text-right">Troubleshoot</TableHead> |
| 248 | </TableRow> |
| 249 | </TableHeader> |
| 250 | <TableBody> |
| 251 | {recentErrorGroups.map((group) => { |
| 252 | const displayMessage = getDisplayErrorMessage(group) |
| 253 | const docsUrl = buildTroubleshootingDocsUrl({ |
| 254 | statusCode: group.lastStatusCode, |
| 255 | }) |
| 256 | |
| 257 | return ( |
| 258 | <Fragment key={group.message}> |
| 259 | <TableRow key={`${group.message}-summary`}> |
| 260 | <TableCell className="max-w-[420px]"> |
| 261 | <span |
| 262 | className="block truncate whitespace-nowrap text-foreground" |
| 263 | title={displayMessage} |
| 264 | > |
| 265 | {displayMessage} |
| 266 | </span> |
| 267 | </TableCell> |
| 268 | <TableCell className="text-foreground-light">{group.count}</TableCell> |
| 269 | <TableCell className="text-foreground-light"> |
| 270 | {formatLogTimestamp(group.lastSeen, 'relative')} |
| 271 | </TableCell> |
| 272 | <TableCell className="text-foreground-light"> |
| 273 | {group.lastMethod ?? '-'} |
| 274 | </TableCell> |
| 275 | <TableCell> |
| 276 | {group.lastStatusCode ? ( |
| 277 | <Badge |
| 278 | variant={getStatusBadgeVariant(group.lastStatusCode)} |
| 279 | className="font-mono" |
| 280 | > |
| 281 | {group.lastStatusCode} |
| 282 | </Badge> |
| 283 | ) : ( |
| 284 | <Badge variant="destructive" className="font-mono"> |
| 285 | Error |
| 286 | </Badge> |
| 287 | )} |
| 288 | </TableCell> |
| 289 | <TableCell className="text-foreground-light"> |
| 290 | {group.executionTime ?? '-'} |
| 291 | </TableCell> |
| 292 | <TableCell className="text-right"> |
| 293 | <div className="flex justify-end"> |
| 294 | <AiAssistantDropdown |
| 295 | label="Ask Assistant" |
| 296 | size="tiny" |
| 297 | buildPrompt={() => buildGroupAssistantPrompt(group, functionSlug)} |
| 298 | onOpenAssistant={() => handleOpenAssistant(group)} |
| 299 | additionalDropdownItems={[ |
| 300 | { |
| 301 | label: 'View troubleshooting guide', |
| 302 | icon: <BookOpen size={14} />, |
| 303 | onClick: () => |
| 304 | window.open(docsUrl, '_blank', 'noopener,noreferrer'), |
| 305 | }, |
| 306 | ]} |
| 307 | /> |
| 308 | </div> |
| 309 | </TableCell> |
| 310 | </TableRow> |
| 311 | <TableRow key={`${group.message}-logs`} className="hover:bg-transparent"> |
| 312 | <TableCell colSpan={7} className="p-0"> |
| 313 | <div className="max-h-64 overflow-auto bg-surface-75 font-mono text-xs"> |
| 314 | {group.logs.length === 0 ? ( |
| 315 | <div className="px-4 py-3 text-foreground-lighter"> |
| 316 | No related runtime logs found for this error group. |
| 317 | </div> |
| 318 | ) : ( |
| 319 | group.logs.map((log, index) => { |
| 320 | const isError = log.level === 'error' |
| 321 | return ( |
| 322 | <div |
| 323 | key={log.key} |
| 324 | className={cn( |
| 325 | 'flex items-start gap-3 px-4 py-2', |
| 326 | index !== 0 && 'border-t border-default', |
| 327 | isError && 'bg-destructive-200/40' |
| 328 | )} |
| 329 | > |
| 330 | <span className="shrink-0 tabular-nums text-foreground-muted"> |
| 331 | {formatLogTimestamp(log.lastSeen, 'time')} |
| 332 | </span> |
| 333 | <Badge |
| 334 | variant={isError ? 'destructive' : 'default'} |
| 335 | className="shrink-0" |
| 336 | > |
| 337 | {log.level} |
| 338 | </Badge> |
| 339 | {log.count > 1 && ( |
| 340 | <span className="shrink-0 text-foreground-muted tabular-nums"> |
| 341 | ×{log.count} |
| 342 | </span> |
| 343 | )} |
| 344 | <span |
| 345 | className={cn( |
| 346 | 'flex-1 wrap-break-word whitespace-pre-wrap', |
| 347 | isError ? 'text-destructive' : 'text-foreground-light' |
| 348 | )} |
| 349 | > |
| 350 | {formatSingleLineMessage(log.message)} |
| 351 | </span> |
| 352 | </div> |
| 353 | ) |
| 354 | }) |
| 355 | )} |
| 356 | </div> |
| 357 | </TableCell> |
| 358 | </TableRow> |
| 359 | </Fragment> |
| 360 | ) |
| 361 | })} |
| 362 | </TableBody> |
| 363 | </Table> |
| 364 | </Card> |
| 365 | )} |
| 366 | </div> |
| 367 | </PageContainer> |
| 368 | </PageSectionContent> |
| 369 | </PageSection> |
| 370 | ) |
| 371 | } |