diff --git a/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx b/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx index 01c99fb0243..2e887f5f455 100644 --- a/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx @@ -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(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(false) + const [snippetToMakePublic, setSnippetToMakePublic] = useState< + { id: string; name: string } | undefined + >(undefined) const [editableReport, setEditableReport] = useState( 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, diff --git a/apps/studio/components/interfaces/ProjectHome/SnippetDropdown.tsx b/apps/studio/components/interfaces/ProjectHome/SnippetDropdown.tsx index d2300b24549..b528055631e 100644 --- a/apps/studio/components/interfaces/ProjectHome/SnippetDropdown.tsx +++ b/apps/studio/components/interfaces/ProjectHome/SnippetDropdown.tsx @@ -105,6 +105,10 @@ export const SnippetDropdown = ({

Loading...

) : search.length > 0 && snippets.length === 0 ? (

No snippets found

+ ) : search.length === 0 && snippets.length === 0 ? ( +

+ No snippets available +

) : ( 7 ? 'h-[210px]' : ''}> diff --git a/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx b/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx index 50b1ee2d27f..c066835d470 100644 --- a/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx +++ b/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx @@ -51,6 +51,7 @@ export const BlockViewConfiguration = ({
{ diff --git a/apps/studio/data/content/content-id-query.ts b/apps/studio/data/content/content-id-query.ts index 37547ab58fe..00ca2213fd4 100644 --- a/apps/studio/data/content/content-id-query.ts +++ b/apps/studio/data/content/content-id-query.ts @@ -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, }) diff --git a/apps/studio/data/content/content-query.ts b/apps/studio/data/content/content-query.ts index b34dc16ac1f..e65c862acbd 100644 --- a/apps/studio/data/content/content-query.ts +++ b/apps/studio/data/content/content-query.ts @@ -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, }) diff --git a/apps/studio/lib/ai/tool-filter.ts b/apps/studio/lib/ai/tool-filter.ts index 66cf66c5b3e..3969897cf71 100644 --- a/apps/studio/lib/ai/tool-filter.ts +++ b/apps/studio/lib/ai/tool-filter.ts @@ -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 = { 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, diff --git a/apps/studio/lib/ai/tools/index.ts b/apps/studio/lib/ai/tools/index.ts index 0e3bf9257a8..8dad88afdb6 100644 --- a/apps/studio/lib/ai/tools/index.ts +++ b/apps/studio/lib/ai/tools/index.ts @@ -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 }) : {}), } } diff --git a/apps/studio/lib/ai/tools/report-tools.test.ts b/apps/studio/lib/ai/tools/report-tools.test.ts new file mode 100644 index 00000000000..33cd0a6d202 --- /dev/null +++ b/apps/studio/lib/ai/tools/report-tools.test.ts @@ -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({ + 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({ + 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( + { 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({ + 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') + }) + }) +}) diff --git a/apps/studio/lib/ai/tools/report-tools.ts b/apps/studio/lib/ai/tools/report-tools.ts new file mode 100644 index 00000000000..414090f2275 --- /dev/null +++ b/apps/studio/lib/ai/tools/report-tools.ts @@ -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, + } + }, + }), + } +}