mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
feat: try real data
This commit is contained in:
1 parent
39de6061e2
commit
cc8c4dcab2
3 files changed
+519
-87
No files matched your search
@@ -1,89 +1,49 @@
|
||||
/**
|
||||
* UserJourney — PROTOTYPE
|
||||
*
|
||||
* This is a demo-quality prototype of the "User Journey" tab. It renders a
|
||||
* chronological timeline reconstructed from Auth and PostgREST logs using
|
||||
* HARDCODED MOCK DATA — there are no real log queries wired up yet.
|
||||
* Renders a chronological timeline of everything one user did, reconstructed
|
||||
* from Auth and PostgREST logs: signed up, authenticated, API reads/writes,
|
||||
* and where a request was denied.
|
||||
*
|
||||
* This wires up REAL log queries (auth_logs + edge_logs filtered by the user)
|
||||
* via useLogsQuery. Where the logs can't tell the full story — most notably the
|
||||
* exact RLS policy that denied a write — it degrades gracefully (see the
|
||||
* data-availability note in UserJourney.utils.ts). A "Sample data" toggle shows
|
||||
* a hardcoded reference scenario so the tab stays demoable on projects with no
|
||||
* matching logs.
|
||||
*
|
||||
* See PRFAQ: https://www.notion.so/supabase/User-Journey-PRFAQ (placeholder link)
|
||||
*/
|
||||
import { useParams } from 'common'
|
||||
import { AlertTriangle, Database, ExternalLink, LogIn, RefreshCw, UserPlus } from 'lucide-react'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
LogIn,
|
||||
RefreshCw,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, cn, Separator } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import { UserHeader } from './UserHeader'
|
||||
import {
|
||||
buildAuthLogsSql,
|
||||
buildEdgeLogsSql,
|
||||
buildJourney,
|
||||
isValidUserId,
|
||||
SAMPLE_JOURNEY_EVENTS,
|
||||
type JourneyEvent,
|
||||
} from './UserJourney.utils'
|
||||
import { PANEL_PADDING } from './Users.constants'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { User } from '@/data/auth/users-infinite-query'
|
||||
|
||||
type JourneyEventStatus = 'success' | 'neutral' | 'error'
|
||||
type JourneyEventSource = 'auth' | 'postgrest'
|
||||
|
||||
interface JourneyEvent {
|
||||
id: string
|
||||
timestamp: string // ISO, ms precision
|
||||
source: JourneyEventSource
|
||||
status: JourneyEventStatus
|
||||
title: string
|
||||
description: string
|
||||
request?: { method: string; path: string; statusCode: number }
|
||||
error?: { message: string; policy?: string; table?: string }
|
||||
}
|
||||
|
||||
const MOCK_JOURNEY_EVENTS: JourneyEvent[] = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2026-07-13T09:41:02.118Z',
|
||||
source: 'auth',
|
||||
status: 'success',
|
||||
title: 'Signed up',
|
||||
description: 'Auth · new user created via email signup',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2026-07-13T09:41:02.421Z',
|
||||
source: 'auth',
|
||||
status: 'success',
|
||||
title: 'Authenticated',
|
||||
description: 'Auth · session issued, JWT minted',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2026-07-13T09:41:03.905Z',
|
||||
source: 'postgrest',
|
||||
status: 'neutral',
|
||||
title: 'Read profile',
|
||||
description: 'PostgREST · fetched the current user profile',
|
||||
request: { method: 'GET', path: '/rest/v1/profiles', statusCode: 200 },
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2026-07-13T09:41:05.332Z',
|
||||
source: 'postgrest',
|
||||
status: 'neutral',
|
||||
title: 'Created order',
|
||||
description: 'PostgREST · inserted a new order row',
|
||||
request: { method: 'POST', path: '/rest/v1/orders', statusCode: 201 },
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
timestamp: '2026-07-13T09:41:06.744Z',
|
||||
source: 'postgrest',
|
||||
status: 'error',
|
||||
title: 'Write blocked',
|
||||
description: 'PostgREST · insert rejected before reaching the table',
|
||||
request: { method: 'POST', path: '/rest/v1/payments', statusCode: 403 },
|
||||
error: {
|
||||
message: 'Denied by RLS policy',
|
||||
policy: 'payments_insert_owner',
|
||||
table: 'payments',
|
||||
},
|
||||
},
|
||||
]
|
||||
import { useLogsQuery } from '@/hooks/analytics/useLogsQuery'
|
||||
|
||||
const getEventIcon = (event: JourneyEvent) => {
|
||||
if (event.status === 'error') return AlertTriangle
|
||||
@@ -103,11 +63,58 @@ interface UserJourneyProps {
|
||||
|
||||
export const UserJourney = ({ user }: UserJourneyProps) => {
|
||||
const { ref } = useParams()
|
||||
const [errorsOnly, setErrorsOnly] = useState(false)
|
||||
const useOtel = useFlag('otelLegacyLogs')
|
||||
|
||||
const events = errorsOnly
|
||||
? MOCK_JOURNEY_EVENTS.filter((event) => event.status === 'error')
|
||||
: MOCK_JOURNEY_EVENTS
|
||||
const [errorsOnly, setErrorsOnly] = useState(false)
|
||||
const [showSample, setShowSample] = useState(false)
|
||||
|
||||
const userId = user.id
|
||||
const canQuery = isValidUserId(userId) && !showSample
|
||||
|
||||
const authSql = useMemo(
|
||||
() => (isValidUserId(userId) ? buildAuthLogsSql(userId, useOtel) : ''),
|
||||
[userId, useOtel]
|
||||
)
|
||||
const edgeSql = useMemo(
|
||||
() => (isValidUserId(userId) ? buildEdgeLogsSql(userId, useOtel) : ''),
|
||||
[userId, useOtel]
|
||||
)
|
||||
|
||||
const {
|
||||
logData: authLogs,
|
||||
isLoading: isLoadingAuth,
|
||||
error: authError,
|
||||
runQuery: runAuthQuery,
|
||||
} = useLogsQuery({
|
||||
projectRef: ref,
|
||||
initialParams: { sql: authSql },
|
||||
enabled: canQuery,
|
||||
options: { useOtel },
|
||||
})
|
||||
|
||||
const {
|
||||
logData: edgeLogs,
|
||||
isLoading: isLoadingEdge,
|
||||
error: edgeError,
|
||||
runQuery: runEdgeQuery,
|
||||
} = useLogsQuery({
|
||||
projectRef: ref,
|
||||
initialParams: { sql: edgeSql },
|
||||
enabled: canQuery,
|
||||
options: { useOtel },
|
||||
})
|
||||
|
||||
const liveEvents = useMemo(() => buildJourney(authLogs, edgeLogs), [authLogs, edgeLogs])
|
||||
|
||||
const isLoading = canQuery && (isLoadingAuth || isLoadingEdge)
|
||||
const error = authError || edgeError
|
||||
const allEvents = showSample ? SAMPLE_JOURNEY_EVENTS : liveEvents
|
||||
const events = errorsOnly ? allEvents.filter((event) => event.status === 'error') : allEvents
|
||||
|
||||
const refresh = () => {
|
||||
runAuthQuery()
|
||||
runEdgeQuery()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -144,6 +151,15 @@ export const UserJourney = ({ user }: UserJourneyProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
variant={showSample ? 'secondary' : 'default'}
|
||||
className="px-2"
|
||||
icon={<FlaskConical />}
|
||||
onClick={() => setShowSample((value) => !value)}
|
||||
tooltip={{
|
||||
content: { text: showSample ? 'Showing sample data' : 'Show sample data' },
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
asChild
|
||||
variant="default"
|
||||
@@ -158,13 +174,23 @@ export const UserJourney = ({ user }: UserJourneyProps) => {
|
||||
variant="default"
|
||||
className="px-2"
|
||||
icon={<RefreshCw />}
|
||||
onClick={() => setErrorsOnly(false)}
|
||||
loading={isLoading}
|
||||
disabled={isLoading || showSample}
|
||||
onClick={refresh}
|
||||
tooltip={{ content: { text: 'Refresh' } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
{isLoading && events.length === 0 ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : error && events.length === 0 && !showSample ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Unable to load this user's journey"
|
||||
description={typeof error === 'string' ? error : 'Failed to query auth and API logs'}
|
||||
/>
|
||||
) : events.length === 0 ? (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="No journey available for this user"
|
||||
@@ -219,16 +245,30 @@ export const UserJourney = ({ user }: UserJourneyProps) => {
|
||||
<Separator className="bg-destructive-400" />
|
||||
<div className="px-3 py-2.5">
|
||||
<p className="text-xs text-foreground-light">{event.error.message}</p>
|
||||
<p className="text-xs mt-1">
|
||||
<span className="text-foreground-light">policy </span>
|
||||
<span className="font-mono text-foreground">
|
||||
{event.error.policy}
|
||||
</span>
|
||||
<span className="text-foreground-light"> on table </span>
|
||||
<span className="font-mono text-foreground">
|
||||
{event.error.table}
|
||||
</span>
|
||||
</p>
|
||||
{(event.error.policy || event.error.table) && (
|
||||
<p className="text-xs mt-1">
|
||||
{event.error.policy ? (
|
||||
<>
|
||||
<span className="text-foreground-light">policy </span>
|
||||
<span className="font-mono text-foreground">
|
||||
{event.error.policy}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-foreground-light">
|
||||
policy name unavailable from logs
|
||||
</span>
|
||||
)}
|
||||
{event.error.table && (
|
||||
<>
|
||||
<span className="text-foreground-light"> on table </span>
|
||||
<span className="font-mono text-foreground">
|
||||
{event.error.table}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildAuthLogsSql,
|
||||
buildEdgeLogsSql,
|
||||
buildJourney,
|
||||
classifyAuthLog,
|
||||
classifyEdgeLog,
|
||||
isValidUserId,
|
||||
} from './UserJourney.utils'
|
||||
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
|
||||
const USER_ID = '00000000-0000-4000-8000-000000000000'
|
||||
|
||||
const authRow = (overrides: Partial<LogData> = {}): LogData =>
|
||||
({ id: 'a1', timestamp: 1_700_000_000_000_000, event_message: '', ...overrides }) as LogData
|
||||
|
||||
const edgeRow = (overrides: Partial<LogData> = {}): LogData =>
|
||||
({ id: 'e1', timestamp: 1_700_000_000_000_000, event_message: '', ...overrides }) as LogData
|
||||
|
||||
describe('isValidUserId', () => {
|
||||
it('accepts a uuid', () => {
|
||||
expect(isValidUserId(USER_ID)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty, null, and non-uuid values', () => {
|
||||
expect(isValidUserId(undefined)).toBe(false)
|
||||
expect(isValidUserId(null)).toBe(false)
|
||||
expect(isValidUserId('')).toBe(false)
|
||||
expect(isValidUserId('not-a-uuid')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAuthLogsSql', () => {
|
||||
it('targets auth_logs and filters by user in BigQuery dialect', () => {
|
||||
const sql = buildAuthLogsSql(USER_ID, false)
|
||||
expect(sql).toContain('from auth_logs')
|
||||
expect(sql).toContain(`regexp_contains(event_message, '${USER_ID}')`)
|
||||
})
|
||||
|
||||
it('targets the ClickHouse logs table with a source filter in OTEL dialect', () => {
|
||||
const sql = buildAuthLogsSql(USER_ID, true)
|
||||
expect(sql).toContain("where source = 'auth_logs'")
|
||||
expect(sql).toContain(`ilike '%${USER_ID}%'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEdgeLogsSql', () => {
|
||||
it('filters edge logs on auth_user in BigQuery dialect', () => {
|
||||
const sql = buildEdgeLogsSql(USER_ID, false)
|
||||
expect(sql).toContain('from edge_logs')
|
||||
expect(sql).toContain(`sb.auth_user = '${USER_ID}'`)
|
||||
})
|
||||
|
||||
it('filters edge logs on the auth_user attribute in OTEL dialect', () => {
|
||||
const sql = buildEdgeLogsSql(USER_ID, true)
|
||||
expect(sql).toContain("where source = 'edge_logs'")
|
||||
expect(sql).toContain(`log_attributes['request.sb.auth_user'] = '${USER_ID}'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyAuthLog', () => {
|
||||
it('labels a signup event as a success', () => {
|
||||
const event = classifyAuthLog(authRow({ path: '/signup', msg: 'user signed up' }))
|
||||
expect(event).toMatchObject({ source: 'auth', status: 'success', title: 'Signed up' })
|
||||
})
|
||||
|
||||
it('labels a token/login event as Authenticated', () => {
|
||||
const event = classifyAuthLog(authRow({ path: '/token', msg: 'login' }))
|
||||
expect(event.title).toBe('Authenticated')
|
||||
})
|
||||
|
||||
it('marks error/fatal levels as errors and attaches an error object', () => {
|
||||
const event = classifyAuthLog(authRow({ level: 'error', msg: 'invalid password' }))
|
||||
expect(event.status).toBe('error')
|
||||
expect(event.error?.message).toBe('invalid password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyEdgeLog', () => {
|
||||
it('labels a successful GET as a neutral read', () => {
|
||||
const event = classifyEdgeLog(
|
||||
edgeRow({ method: 'GET', path: '/rest/v1/profiles', status_code: '200' })
|
||||
)
|
||||
expect(event).toMatchObject({
|
||||
source: 'postgrest',
|
||||
status: 'neutral',
|
||||
title: 'Read profiles',
|
||||
})
|
||||
expect(event.request).toEqual({ method: 'GET', path: '/rest/v1/profiles', statusCode: 200 })
|
||||
})
|
||||
|
||||
it('labels a successful POST as a neutral create', () => {
|
||||
const event = classifyEdgeLog(
|
||||
edgeRow({ method: 'POST', path: '/rest/v1/orders', status_code: '201' })
|
||||
)
|
||||
expect(event.title).toBe('Created orders')
|
||||
expect(event.status).toBe('neutral')
|
||||
})
|
||||
|
||||
it('treats a 403 write as a blocked error and infers the table but not the policy', () => {
|
||||
const event = classifyEdgeLog(
|
||||
edgeRow({ method: 'POST', path: '/rest/v1/payments', status_code: '403' })
|
||||
)
|
||||
expect(event.status).toBe('error')
|
||||
expect(event.title).toBe('Write blocked')
|
||||
expect(event.error?.table).toBe('payments')
|
||||
expect(event.error?.policy).toBeUndefined()
|
||||
expect(event.error?.policyUnknown).toBe(true)
|
||||
})
|
||||
|
||||
it('handles a query string in the path when inferring the resource', () => {
|
||||
const event = classifyEdgeLog(
|
||||
edgeRow({ method: 'GET', path: '/rest/v1/orders?select=*&id=eq.1', status_code: '200' })
|
||||
)
|
||||
expect(event.title).toBe('Read orders')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildJourney', () => {
|
||||
it('merges auth and edge events into a single oldest-to-newest timeline', () => {
|
||||
const authLogs = [
|
||||
authRow({ id: 'signup', timestamp: 100, path: '/signup', msg: 'signed up' }),
|
||||
authRow({ id: 'login', timestamp: 200, path: '/token', msg: 'login' }),
|
||||
]
|
||||
const edgeLogs = [
|
||||
edgeRow({
|
||||
id: 'read',
|
||||
timestamp: 300,
|
||||
method: 'GET',
|
||||
path: '/rest/v1/p',
|
||||
status_code: '200',
|
||||
}),
|
||||
edgeRow({
|
||||
id: 'blocked',
|
||||
timestamp: 400,
|
||||
method: 'POST',
|
||||
path: '/rest/v1/payments',
|
||||
status_code: '403',
|
||||
}),
|
||||
]
|
||||
|
||||
const journey = buildJourney(authLogs, edgeLogs)
|
||||
|
||||
expect(journey.map((event) => event.id)).toEqual(['signup', 'login', 'read', 'blocked'])
|
||||
expect(journey[journey.length - 1].status).toBe('error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* UserJourney.utils — pure helpers for the User Journey tab.
|
||||
*
|
||||
* These build the logs SQL, classify raw auth/edge log rows into journey
|
||||
* events, and merge them chronologically. Kept free of React so they can be
|
||||
* unit-tested in isolation (see studio-testing conventions).
|
||||
*
|
||||
* DATA-AVAILABILITY NOTE: the logs give us the auth timeline (auth_logs) and
|
||||
* per-user API activity with method/path/status (edge_logs, filtered on the
|
||||
* caller's JWT `auth_user`). What they do NOT give us is the exact RLS policy
|
||||
* that denied a write — Postgres' RLS error names the table, never the policy,
|
||||
* and there's no request id joining an edge_logs row to a postgres_logs row.
|
||||
* So a 403 on a `/rest/v1/*` write is surfaced as "likely blocked by RLS" with
|
||||
* the table inferred from the path, and the policy name is left unknown.
|
||||
*/
|
||||
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
|
||||
export type JourneyEventStatus = 'success' | 'neutral' | 'error'
|
||||
export type JourneyEventSource = 'auth' | 'postgrest'
|
||||
|
||||
export interface JourneyEvent {
|
||||
id: string
|
||||
/** ISO string (sample data) or unix microseconds (live logs) — both render via TimestampInfo. */
|
||||
timestamp: string | number
|
||||
source: JourneyEventSource
|
||||
status: JourneyEventStatus
|
||||
title: string
|
||||
description: string
|
||||
request?: { method: string; path: string; statusCode: number }
|
||||
error?: { message: string; policy?: string; table?: string; policyUnknown?: boolean }
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
export const isValidUserId = (userId?: string | null): userId is string =>
|
||||
!!userId && UUID_RE.test(userId)
|
||||
|
||||
/** Last meaningful path segment, e.g. `/rest/v1/orders?select=*` → `orders`. */
|
||||
const resourceFromPath = (path: string) => {
|
||||
const clean = path.split('?')[0].replace(/\/+$/, '')
|
||||
const segments = clean.split('/').filter(Boolean)
|
||||
return segments[segments.length - 1] || clean || 'resource'
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL to pull this user's auth events. Matched on the user id appearing in the
|
||||
* raw event message — the same heuristic the Logs tab uses (`search_query`).
|
||||
*/
|
||||
export const buildAuthLogsSql = (userId: string, useOtel: boolean, limit = 50) => {
|
||||
if (useOtel) {
|
||||
return `select
|
||||
id,
|
||||
timestamp,
|
||||
event_message,
|
||||
log_attributes['level'] as level,
|
||||
log_attributes['status'] as status,
|
||||
log_attributes['path'] as path,
|
||||
log_attributes['msg'] as msg
|
||||
from logs
|
||||
where source = 'auth_logs'
|
||||
and event_message ilike '%${userId}%'
|
||||
order by timestamp desc
|
||||
limit ${limit}`
|
||||
}
|
||||
return `select
|
||||
id,
|
||||
timestamp,
|
||||
event_message,
|
||||
metadata.level as level,
|
||||
metadata.status as status,
|
||||
metadata.path as path,
|
||||
metadata.msg as msg
|
||||
from auth_logs
|
||||
cross join unnest(metadata) as metadata
|
||||
where regexp_contains(event_message, '${userId}')
|
||||
order by timestamp desc
|
||||
limit ${limit}`
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL to pull this user's API activity from the edge gateway, filtered on the
|
||||
* JWT `auth_user` (the acting user's id) carried on each request.
|
||||
*
|
||||
* The OTEL/ClickHouse `log_attributes` key for auth_user is the BigQuery path
|
||||
* with the `metadata` root dropped; if it ever returns nothing on a ClickHouse
|
||||
* project, confirm the key via the Field Reference drawer / `mapKeys`.
|
||||
*/
|
||||
export const buildEdgeLogsSql = (userId: string, useOtel: boolean, limit = 50) => {
|
||||
if (useOtel) {
|
||||
return `select
|
||||
id,
|
||||
timestamp,
|
||||
log_attributes['request.method'] as method,
|
||||
log_attributes['request.path'] as path,
|
||||
log_attributes['response.status_code'] as status_code
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
and log_attributes['request.sb.auth_user'] = '${userId}'
|
||||
order by timestamp desc
|
||||
limit ${limit}`
|
||||
}
|
||||
return `select
|
||||
id,
|
||||
timestamp,
|
||||
request.method as method,
|
||||
request.path as path,
|
||||
response.status_code as status_code
|
||||
from edge_logs
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.request) as request
|
||||
cross join unnest(m.response) as response
|
||||
cross join unnest(request.sb) as sb
|
||||
where sb.auth_user = '${userId}'
|
||||
order by timestamp desc
|
||||
limit ${limit}`
|
||||
}
|
||||
|
||||
const toSortableTimestamp = (timestamp: string | number) =>
|
||||
typeof timestamp === 'number' ? timestamp : Date.parse(timestamp)
|
||||
|
||||
export const classifyAuthLog = (log: LogData): JourneyEvent => {
|
||||
const msg = String(log.msg ?? log.event_message ?? '')
|
||||
const path = String(log.path ?? '')
|
||||
const level = String(log.level ?? '').toLowerCase()
|
||||
const haystack = `${msg} ${path}`.toLowerCase()
|
||||
const isError = level === 'error' || level === 'fatal'
|
||||
|
||||
let title = 'Authentication event'
|
||||
if (haystack.includes('signup') || haystack.includes('sign up')) title = 'Signed up'
|
||||
else if (haystack.includes('token') || haystack.includes('login')) title = 'Authenticated'
|
||||
else if (haystack.includes('logout')) title = 'Signed out'
|
||||
else if (haystack.includes('recover')) title = 'Password recovery'
|
||||
|
||||
return {
|
||||
id: String(log.id),
|
||||
timestamp: log.timestamp,
|
||||
source: 'auth',
|
||||
status: isError ? 'error' : 'success',
|
||||
title,
|
||||
description: `Auth · ${msg || path || 'event'}`,
|
||||
...(isError && { error: { message: msg || 'Authentication error' } }),
|
||||
}
|
||||
}
|
||||
|
||||
export const classifyEdgeLog = (log: LogData): JourneyEvent => {
|
||||
const method = String(log.method ?? '').toUpperCase()
|
||||
const path = String(log.path ?? '')
|
||||
const statusCode = Number(log.status_code ?? 0)
|
||||
const resource = resourceFromPath(path)
|
||||
const isError = statusCode >= 400
|
||||
const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
|
||||
|
||||
let title: string
|
||||
if (isError) title = isWrite ? 'Write blocked' : 'Request failed'
|
||||
else if (method === 'GET') title = `Read ${resource}`
|
||||
else if (method === 'POST') title = `Created ${resource}`
|
||||
else if (method === 'PATCH' || method === 'PUT') title = `Updated ${resource}`
|
||||
else if (method === 'DELETE') title = `Deleted ${resource}`
|
||||
else title = `${method || 'Request'} ${resource}`.trim()
|
||||
|
||||
const event: JourneyEvent = {
|
||||
id: String(log.id),
|
||||
timestamp: log.timestamp,
|
||||
source: 'postgrest',
|
||||
status: isError ? 'error' : 'neutral',
|
||||
title,
|
||||
description: `PostgREST · ${method || 'request'}`,
|
||||
...(path && { request: { method, path, statusCode } }),
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const likelyRls = statusCode === 403 && path.includes('/rest/')
|
||||
event.error = {
|
||||
message: likelyRls
|
||||
? 'Request denied — likely blocked by a row-level security policy'
|
||||
: `Request failed with status ${statusCode}`,
|
||||
...(likelyRls && { table: resource, policyUnknown: true }),
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
/** Merge auth + edge rows into a single chronological (oldest → newest) timeline. */
|
||||
export const buildJourney = (authLogs: LogData[], edgeLogs: LogData[]): JourneyEvent[] => {
|
||||
return [...authLogs.map(classifyAuthLog), ...edgeLogs.map(classifyEdgeLog)].sort(
|
||||
(a, b) => toSortableTimestamp(a.timestamp) - toSortableTimestamp(b.timestamp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded reference scenario (mirrors a real support case). Used by the
|
||||
* "Sample data" toggle so the tab is demoable on projects with no matching logs.
|
||||
*/
|
||||
export const SAMPLE_JOURNEY_EVENTS: JourneyEvent[] = [
|
||||
{
|
||||
id: 'sample-1',
|
||||
timestamp: '2026-07-13T09:41:02.118Z',
|
||||
source: 'auth',
|
||||
status: 'success',
|
||||
title: 'Signed up',
|
||||
description: 'Auth · new user created via email signup',
|
||||
},
|
||||
{
|
||||
id: 'sample-2',
|
||||
timestamp: '2026-07-13T09:41:02.421Z',
|
||||
source: 'auth',
|
||||
status: 'success',
|
||||
title: 'Authenticated',
|
||||
description: 'Auth · session issued, JWT minted',
|
||||
},
|
||||
{
|
||||
id: 'sample-3',
|
||||
timestamp: '2026-07-13T09:41:03.905Z',
|
||||
source: 'postgrest',
|
||||
status: 'neutral',
|
||||
title: 'Read profile',
|
||||
description: 'PostgREST · fetched the current user profile',
|
||||
request: { method: 'GET', path: '/rest/v1/profiles', statusCode: 200 },
|
||||
},
|
||||
{
|
||||
id: 'sample-4',
|
||||
timestamp: '2026-07-13T09:41:05.332Z',
|
||||
source: 'postgrest',
|
||||
status: 'neutral',
|
||||
title: 'Created order',
|
||||
description: 'PostgREST · inserted a new order row',
|
||||
request: { method: 'POST', path: '/rest/v1/orders', statusCode: 201 },
|
||||
},
|
||||
{
|
||||
id: 'sample-5',
|
||||
timestamp: '2026-07-13T09:41:06.744Z',
|
||||
source: 'postgrest',
|
||||
status: 'error',
|
||||
title: 'Write blocked',
|
||||
description: 'PostgREST · insert rejected before reaching the table',
|
||||
request: { method: 'POST', path: '/rest/v1/payments', statusCode: 403 },
|
||||
error: {
|
||||
message: 'Denied by RLS policy',
|
||||
policy: 'payments_insert_owner',
|
||||
table: 'payments',
|
||||
},
|
||||
},
|
||||
]
|
||||
Reference in new issue
Block a user