Skip to content

Commit 18264bd

Browse files
feat(enrichment): add enrichment details sidebar with cost + provider cascade
1 parent 63a3e6d commit 18264bd

21 files changed

Lines changed: 17639 additions & 44 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hybridAuthMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { EnrichmentRunDetail, TableDefinition } from '@/lib/table'
8+
9+
const { mockCheckAccess, mockLoadEnrichmentDetail } = vi.hoisted(() => ({
10+
mockCheckAccess: vi.fn(),
11+
mockLoadEnrichmentDetail: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/table/rows/executions', () => ({
15+
loadEnrichmentDetail: mockLoadEnrichmentDetail,
16+
}))
17+
vi.mock('@/app/api/table/utils', async () => {
18+
const { NextResponse } = await import('next/server')
19+
return {
20+
checkAccess: mockCheckAccess,
21+
accessError: (result: { status: number }) =>
22+
NextResponse.json({ error: 'denied' }, { status: result.status }),
23+
}
24+
})
25+
26+
import { GET } from '@/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route'
27+
28+
function buildTable(): TableDefinition {
29+
return {
30+
id: 'tbl_1',
31+
name: 'People',
32+
description: null,
33+
schema: { columns: [] },
34+
metadata: null,
35+
rowCount: 1,
36+
maxRows: 1_000_000,
37+
workspaceId: 'workspace-1',
38+
createdBy: 'user-1',
39+
archivedAt: null,
40+
createdAt: new Date(),
41+
updatedAt: new Date(),
42+
}
43+
}
44+
45+
function makeRequest(tableId = 'tbl_1', rowId = 'row_1', groupId = 'grp_1') {
46+
const req = new NextRequest(
47+
`http://localhost:3000/api/table/${tableId}/rows/${rowId}/enrichment/${groupId}`
48+
)
49+
return GET(req, { params: Promise.resolve({ tableId, rowId, groupId }) })
50+
}
51+
52+
const detail: EnrichmentRunDetail = {
53+
startedAt: '2026-06-18T00:00:00.000Z',
54+
completedAt: '2026-06-18T00:00:01.000Z',
55+
durationMs: 1000,
56+
totalCost: 0.05,
57+
matchedProvider: 'hunter',
58+
providers: [
59+
{
60+
id: 'hunter',
61+
label: 'Hunter',
62+
toolId: 'hunter_find_email',
63+
status: 'matched',
64+
cost: 0.05,
65+
durationMs: 1000,
66+
error: null,
67+
},
68+
],
69+
}
70+
71+
describe('GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]', () => {
72+
beforeEach(() => {
73+
vi.clearAllMocks()
74+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
75+
success: true,
76+
userId: 'user-1',
77+
authType: 'session',
78+
})
79+
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
80+
})
81+
82+
it('returns the enrichment detail', async () => {
83+
mockLoadEnrichmentDetail.mockResolvedValue(detail)
84+
const res = await makeRequest()
85+
expect(res.status).toBe(200)
86+
const json = await res.json()
87+
expect(json).toEqual({ success: true, data: { detail } })
88+
expect(mockLoadEnrichmentDetail).toHaveBeenCalledWith(
89+
expect.anything(),
90+
'tbl_1',
91+
'row_1',
92+
'grp_1'
93+
)
94+
})
95+
96+
it('returns null when there is no recorded run', async () => {
97+
mockLoadEnrichmentDetail.mockResolvedValue(null)
98+
const res = await makeRequest()
99+
expect(res.status).toBe(200)
100+
const json = await res.json()
101+
expect(json).toEqual({ success: true, data: { detail: null } })
102+
})
103+
104+
it('401s when unauthenticated', async () => {
105+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
106+
const res = await makeRequest()
107+
expect(res.status).toBe(401)
108+
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
109+
})
110+
111+
it('denies when access check fails', async () => {
112+
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
113+
const res = await makeRequest()
114+
expect(res.status).toBe(403)
115+
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
116+
})
117+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { db } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getEnrichmentDetailContract } from '@/lib/api/contracts/tables'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { loadEnrichmentDetail } from '@/lib/table/rows/executions'
10+
import { accessError, checkAccess } from '@/app/api/table/utils'
11+
12+
const logger = createLogger('EnrichmentDetailAPI')
13+
14+
interface RouteParams {
15+
params: Promise<{ tableId: string; rowId: string; groupId: string }>
16+
}
17+
18+
/**
19+
* GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]
20+
*
21+
* Returns the enrichment cascade breakdown (provider outcomes, cost, timing)
22+
* for one enrichment cell. Read on demand by the enrichment details panel —
23+
* this data is deliberately kept off the hot grid read. Returns `null` for
24+
* cells with no recorded run or runs that predate the feature.
25+
*/
26+
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
27+
const requestId = generateRequestId()
28+
29+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
30+
if (!authResult.success || !authResult.userId) {
31+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
32+
}
33+
34+
const parsed = await parseRequest(getEnrichmentDetailContract, request, { params })
35+
if (!parsed.success) return parsed.response
36+
const { tableId, rowId, groupId } = parsed.data.params
37+
38+
const result = await checkAccess(tableId, authResult.userId, 'read')
39+
if (!result.ok) return accessError(result, requestId, tableId)
40+
41+
const detail = await loadEnrichmentDetail(db, tableId, rowId, groupId)
42+
43+
logger.info(`[${requestId}] Loaded enrichment detail`, {
44+
tableId,
45+
rowId,
46+
groupId,
47+
hasDetail: detail !== null,
48+
})
49+
50+
return NextResponse.json({ success: true, data: { detail } })
51+
})

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
import { cn } from '@/lib/core/utils/cn'
3333
import type { TraceSpan } from '@/lib/logs/types'
3434
import {
35-
DEFAULT_BLOCK_COLOR,
35+
adjustBgForContrast,
3636
formatCostAmount,
3737
formatTokenCount,
3838
formatTps,
@@ -41,6 +41,7 @@ import {
4141
getDisplayName,
4242
hasErrorInTree,
4343
hasUnhandledErrorInTree,
44+
iconColorClass,
4445
isIterationType,
4546
parseTime,
4647
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
@@ -119,31 +120,6 @@ function getDisplayChildren(span: TraceSpan): TraceSpan[] {
119120
return kids
120121
}
121122

122-
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
123-
function iconColorClass(bgColor: string): string {
124-
const hex = bgColor.replace('#', '')
125-
if (hex.length !== 6) return 'text-white'
126-
const r = Number.parseInt(hex.slice(0, 2), 16)
127-
const g = Number.parseInt(hex.slice(2, 4), 16)
128-
const b = Number.parseInt(hex.slice(4, 6), 16)
129-
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
130-
}
131-
132-
/**
133-
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
134-
* Below the luminance threshold we fall back to the neutral block color used
135-
* for blocks with no distinct identity; everything brighter passes through.
136-
*/
137-
function adjustBgForContrast(bgColor: string): string {
138-
const hex = bgColor.replace('#', '')
139-
if (hex.length !== 6) return bgColor
140-
const r = Number.parseInt(hex.slice(0, 2), 16)
141-
const g = Number.parseInt(hex.slice(2, 4), 16)
142-
const b = Number.parseInt(hex.slice(4, 6), 16)
143-
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
144-
return bgColor
145-
}
146-
147123
/**
148124
* Flattens the visible (expanded) span tree into a linear list for keyboard
149125
* navigation, carrying depth, the chain of parent ids for indent drawing, and

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,31 @@ export function getBlockIconAndColor(
8181
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
8282
}
8383

84+
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
85+
export function iconColorClass(bgColor: string): string {
86+
const hex = bgColor.replace('#', '')
87+
if (hex.length !== 6) return 'text-white'
88+
const r = Number.parseInt(hex.slice(0, 2), 16)
89+
const g = Number.parseInt(hex.slice(2, 4), 16)
90+
const b = Number.parseInt(hex.slice(4, 6), 16)
91+
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
92+
}
93+
94+
/**
95+
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
96+
* Below the luminance threshold we fall back to the neutral block color used
97+
* for blocks with no distinct identity; everything brighter passes through.
98+
*/
99+
export function adjustBgForContrast(bgColor: string): string {
100+
const hex = bgColor.replace('#', '')
101+
if (hex.length !== 6) return bgColor
102+
const r = Number.parseInt(hex.slice(0, 2), 16)
103+
const g = Number.parseInt(hex.slice(2, 4), 16)
104+
const b = Number.parseInt(hex.slice(4, 6), 16)
105+
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
106+
return bgColor
107+
}
108+
84109
export function parseTime(value?: string | number | null): number {
85110
if (!value) return 0
86111
const ms = typeof value === 'number' ? value : new Date(value).getTime()

0 commit comments

Comments
 (0)