Support listing and reading custom reports from Assistant (#48530)

## Context

This is pre-requisite work for adding support to managing custom reports
from the Assistant. Planning to break this into a number of PRs, briefly
- Adding read support for custom reports
- Adding write support for custom reports
- Adding run support for custom reports
  - Should be able to infer data from the results then

This PR starts with adding support for listing and reading custom
reports from the Assistant

## Other changes involved
- Updates setting up of the home page report to have better title and
description
- Swaps the variant of the ToggleGroup in the SQL block for custom
reports as the default variant blends into the background color of the
PopoverContent

## To test
- [ ] Assistant should be able to list custom reports + read its
contents
<img width="428" height="755" alt="image"
src="https://github.com/user-attachments/assets/6a15b660-c0ee-4a06-984c-87eff3943eec"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added AI-assisted tools to list reports and retrieve report details,
including chart counts, layouts, configurations, and SQL-backed chart
information.
  - Added clearer empty-state messaging when no snippets are available.

- **Improvements**
- New homepage reports now use the name “Homepage Report” and include a
descriptive project-home summary.
  - Updated query controls with refreshed visual styling.
  - Improved content requests to support additional request context.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-03 13:59:50 +07:00
1 parent 022b374f2d
commit 2c53ca4a79
9 files changed
+333 -10

No files matched your search

@@ -46,24 +46,25 @@ export function CustomReportSection() {
const startDate = dayjs().subtract(7, 'day').toISOString()
const endDate = dayjs().toISOString()
const track = useTrack()
const { ref } = useParams()
const { profile } = useProfile()
const state = useDatabaseSelectorStateSnapshot()
const track = useTrack()
const { invalidateInfraMonitoringQuery } = useInvalidateAnalyticsQuery()
const { data: project } = useSelectedProjectQuery()
const [isRefreshing, setIsRefreshing] = useState<boolean>(false)
const [snippetToMakePublic, setSnippetToMakePublic] = useState<
{ id: string; name: string } | undefined
>(undefined)
const { data: reportsData } = useContentInfiniteQuery(
{ projectRef: ref, type: 'report', name: 'Home', limit: 1 },
{ placeholderData: keepPreviousData }
)
const homeReport = reportsData?.pages?.[0]?.content?.[0] as Content | undefined
const reportContent = homeReport?.content as Dashboards.Content | undefined
const [isRefreshing, setIsRefreshing] = useState<boolean>(false)
const [snippetToMakePublic, setSnippetToMakePublic] = useState<
{ id: string; name: string } | undefined
>(undefined)
const [editableReport, setEditableReport] = useState<Dashboards.Content | undefined>(
reportContent
)
@@ -201,8 +202,8 @@ export function CustomReportSection() {
payload: {
id: uuidv4(),
type: 'report',
name: 'Home',
description: '',
name: 'Homepage Report',
description: "Report displayed on the project's home page",
visibility: 'project',
owner_id: profile.id,
content: newReport,
@@ -105,6 +105,10 @@ export const SnippetDropdown = ({
<p className="text-xs text-center text-foreground-lighter py-3">Loading...</p>
) : search.length > 0 && snippets.length === 0 ? (
<p className="text-xs text-center text-foreground-lighter py-3">No snippets found</p>
) : search.length === 0 && snippets.length === 0 ? (
<p className="text-xs text-center text-foreground-lighter py-3">
No snippets available
</p>
) : (
<CommandGroup>
<ScrollArea className={snippets.length > 7 ? 'h-[210px]' : ''}>
@@ -51,6 +51,7 @@ export const BlockViewConfiguration = ({
<form className="grid gap-2">
<ToggleGroup
type="single"
variant="outline"
value={view}
className="w-full"
onValueChange={(view: 'chart' | 'table') => {
+3 -1
View File
@@ -17,13 +17,15 @@ export type GetUserContentByIdResponse = Omit<
export async function getContentById(
{ projectRef, id }: { projectRef?: string; id?: string },
signal?: AbortSignal
signal?: AbortSignal,
headers?: HeadersInit
) {
if (typeof projectRef === 'undefined') throw new Error('projectRef is required')
if (typeof id === 'undefined') throw new Error('Content ID is required')
const { data, error } = await get('/platform/projects/{ref}/content/item/{id}', {
params: { path: { ref: projectRef, id } },
headers,
signal,
})
+3 -1
View File
@@ -35,7 +35,8 @@ interface GetContentVariables {
export async function getContent(
{ projectRef, type, name, limit = 10 }: GetContentVariables,
signal?: AbortSignal
signal?: AbortSignal,
headers?: HeadersInit
) {
if (typeof projectRef === 'undefined') {
throw new Error('projectRef is required for getContent')
@@ -43,6 +44,7 @@ export async function getContent(
const { data, error } = await get('/platform/projects/{ref}/content', {
params: { path: { ref: projectRef }, query: { type, name, limit: limit.toString() } },
headers,
signal,
})
+4
View File
@@ -35,6 +35,8 @@ export const toolSetValidationSchema = z.record(
'escalate_to_human',
'resolve_support_conversation',
'list_policies',
'list_reports',
'get_report',
// Fallback tools for self-hosted
'getSchemaTables',
@@ -84,6 +86,8 @@ export const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
list_edge_functions: TOOL_CATEGORIES.SCHEMA,
list_branches: TOOL_CATEGORIES.SCHEMA,
list_policies: TOOL_CATEGORIES.SCHEMA,
list_reports: TOOL_CATEGORIES.SCHEMA,
get_report: TOOL_CATEGORIES.SCHEMA,
getSchemaTables: TOOL_CATEGORIES.SCHEMA,
getRlsKnowledge: TOOL_CATEGORIES.SCHEMA,
getFunctions: TOOL_CATEGORIES.SCHEMA,
+2
View File
@@ -5,6 +5,7 @@ import { filterToolsByOptInLevel } from '../tool-filter'
import { getFallbackTools } from './fallback-tools'
import { getIncidentTools } from './incident-tools'
import { getMcpTools } from './mcp-tools'
import { getReportTools } from './report-tools'
import { getSchemaTools } from './schema-tools'
import { getStudioTools } from './studio-tools'
import { getSupportLifecycleTools } from './support-tools'
@@ -70,6 +71,7 @@ export const getTools = async ({
projectRef,
connectionString,
}),
...getReportTools({ projectRef, authorization }),
...(baseUrl ? getIncidentTools({ baseUrl }) : {}),
}
}
@@ -0,0 +1,215 @@
import { components } from 'api-types'
import { HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { getReportTools } from './report-tools'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
type GetUserContentByIdResponse = components['schemas']['GetUserContentByIdResponse']
describe('ai/tools/report-tools', () => {
describe('getReportTools', () => {
it('should return list_reports and get_report tools', () => {
const tools = getReportTools()
expect(Object.keys(tools)).toEqual(['list_reports', 'get_report'])
})
it('should not require approval to read reports', () => {
const tools = getReportTools()
expect(tools.list_reports.needsApproval).toBeUndefined()
expect(tools.get_report.needsApproval).toBeUndefined()
})
})
describe('list_reports', () => {
it('should list reports with summary fields, forwarding the authorization header', async () => {
let capturedRequest: Request | undefined
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content',
response: ({ request }) => {
capturedRequest = request
return HttpResponse.json({
data: [
{
id: 'report-1',
name: 'Home',
description: undefined,
visibility: 'project',
favorite: false,
folder_id: null,
inserted_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
owner_id: 1,
owner: { id: 1, username: 'test' },
updated_by: { id: 1, username: 'test' },
project_id: 1,
type: 'report',
content: {
schema_version: 1,
period_start: { time_period: '7d' },
period_end: { time_period: 'today' },
interval: '1d',
layout: [{ id: 'a' }, { id: 'b' }],
},
},
],
})
},
})
const tools = getReportTools({ projectRef: 'test-project', authorization: 'Bearer token' })
if (!tools.list_reports.execute) throw new Error('execute is undefined')
const result = await tools.list_reports.execute(
{ limit: 20 },
{ toolCallId: 'test', messages: [] }
)
expect(capturedRequest?.headers.get('authorization')).toBe('Bearer token')
const url = new URL(capturedRequest!.url)
expect(url.pathname).toContain('/projects/test-project/content')
expect(url.searchParams.get('type')).toBe('report')
expect(url.searchParams.get('limit')).toBe('20')
expect(result).toEqual([
{
id: 'report-1',
name: 'Home',
description: undefined,
visibility: 'project',
updated_at: '2026-01-01T00:00:00.000Z',
chart_count: 2,
},
])
})
})
describe('get_report', () => {
it('should resolve snippet_ chart blocks to their SQL', async () => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content/item/:id',
response: ({ params }) => {
if (params.id === 'report-1') {
return HttpResponse.json<GetUserContentByIdResponse>({
id: 'report-1',
name: 'My report',
description: undefined,
visibility: 'project',
favorite: false,
folder_id: null,
inserted_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
owner_id: 1,
project_id: 1,
type: 'report',
content: {
schema_version: 1,
period_start: { time_period: '7d' },
period_end: { time_period: 'today' },
interval: '1d',
layout: [
{
id: 'snippet-1',
attribute: 'snippet_snippet-1',
x: 0,
y: 0,
w: 1,
h: 1,
label: 'My query',
provider: 'daily-stats',
chart_type: 'bar',
},
{
id: 'total_egress',
attribute: 'total_egress',
x: 1,
y: 0,
w: 1,
h: 1,
label: 'Egress',
provider: 'daily-stats',
chart_type: 'bar',
},
],
},
})
}
if (params.id === 'snippet-1') {
return HttpResponse.json<GetUserContentByIdResponse>({
id: 'snippet-1',
name: 'My query',
description: undefined,
visibility: 'user',
favorite: false,
folder_id: null,
inserted_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
owner_id: 1,
project_id: 1,
type: 'sql',
content: { content_id: 'snippet-1', sql: 'select 1', schema_version: '1' },
})
}
return HttpResponse.json<APIErrorBody>(
{ message: `Unexpected id: ${params.id}` },
{ status: 404 }
)
},
})
const tools = getReportTools({ projectRef: 'test-project' })
if (!tools.get_report.execute) throw new Error('execute is undefined')
const result = (await tools.get_report.execute(
{ id: 'report-1' },
{ toolCallId: 'test', messages: [] }
)) as { layout: Array<{ id: string; attribute: string; sql?: string }> }
expect(result.layout).toEqual([
expect.objectContaining({
id: 'snippet-1',
attribute: 'snippet_snippet-1',
sql: 'select 1',
}),
expect.objectContaining({ id: 'total_egress', attribute: 'total_egress' }),
])
expect(result.layout[1].sql).toBeUndefined()
})
it('should throw when the content id is not a report', async () => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content/item/:id',
response: () =>
HttpResponse.json<GetUserContentByIdResponse>({
id: 'snippet-1',
name: 'My query',
description: undefined,
visibility: 'user',
favorite: false,
folder_id: null,
inserted_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
owner_id: 1,
project_id: 1,
type: 'sql',
content: { content_id: 'snippet-1', sql: 'select 1', schema_version: '1' },
}),
})
const tools = getReportTools({ projectRef: 'test-project' })
if (!tools.get_report.execute) throw new Error('execute is undefined')
await expect(
tools.get_report.execute({ id: 'snippet-1' }, { toolCallId: 'test', messages: [] })
).rejects.toThrow('is not a report')
})
})
})
+92
View File
@@ -0,0 +1,92 @@
import { tool } from 'ai'
import { z } from 'zod'
import { getContentById } from '@/data/content/content-id-query'
import { getContent } from '@/data/content/content-query'
import type { Dashboards, SqlSnippets } from '@/types'
export type ReportToolsContext = {
projectRef?: string
authorization?: string
}
export const getReportTools = (ctx: ReportToolsContext = {}) => {
const { projectRef, authorization } = ctx
const authHeaders = authorization ? { Authorization: authorization } : undefined
return {
list_reports: tool({
description: 'List the custom reports saved for this project',
inputSchema: z.object({
limit: z
.number()
.int()
.positive()
.max(100)
.default(20)
.describe('Max number of reports to return.'),
}),
execute: async ({ limit }) => {
const { content } = await getContent(
{ projectRef, type: 'report', limit },
undefined,
authHeaders
)
return content.map((report) => ({
id: report.id,
name: report.name,
description: report.description,
visibility: report.visibility,
updated_at: report.updated_at,
chart_count: (report.content as Dashboards.Content).layout?.length ?? 0,
}))
},
}),
get_report: tool({
description:
'Get a single custom report by id, including the resolved SQL for any SQL-based chart blocks it contains.',
inputSchema: z.object({
id: z.string().describe('The id of the report to fetch.'),
}),
execute: async ({ id }) => {
const report = await getContentById({ projectRef, id }, undefined, authHeaders)
if (report.type !== 'report') {
throw new Error(`Content ${id} is not a report (type: ${report.type})`)
}
const content = report.content as Dashboards.Content
const layout = await Promise.all(
content.layout.map(async (chart) => {
if (!chart.attribute.startsWith('snippet_')) return chart
// A SQL-block chart's `id` is the id of its linked `type: 'sql'` content row.
const snippet = await getContentById(
{ projectRef, id: chart.id },
undefined,
authHeaders
).catch(() => null)
const sql =
snippet?.type === 'sql'
? (snippet.content as SqlSnippets.Content).unchecked_sql
: undefined
return { ...chart, sql }
})
)
return {
id: report.id,
name: report.name,
description: report.description,
visibility: report.visibility,
period_start: content.period_start,
period_end: content.period_end,
interval: content.interval,
layout,
}
},
}),
}
}