mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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') => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user