diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index 2ab39156d..d294df583 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -1,8 +1,12 @@ +import { differenceBy, uniq } from 'lodash'; +import { z } from 'zod'; + import type { ObjectId } from '@/models'; import Alert from '@/models/alert'; import Dashboard from '@/models/dashboard'; +import { chartSchema, tagsSchema } from '@/utils/zod'; -export default async function deleteDashboardAndAlerts( +export async function deleteDashboardAndAlerts( dashboardId: string, teamId: ObjectId, ) { @@ -14,3 +18,60 @@ export default async function deleteDashboardAndAlerts( await Alert.deleteMany({ dashboardId: dashboard._id }); } } + +export async function updateDashboardAndAlerts( + dashboardId: string, + teamId: ObjectId, + { + name, + charts, + query, + tags, + }: { + name: string; + charts: z.infer[]; + query: string; + tags: z.infer; + }, +) { + const oldDashboard = await Dashboard.findOne({ + _id: dashboardId, + team: teamId, + }); + if (oldDashboard == null) { + throw new Error('Dashboard not found'); + } + + const updatedDashboard = await Dashboard.findOneAndUpdate( + { + _id: dashboardId, + team: teamId, + }, + { + name, + charts, + query, + tags: tags && uniq(tags), + }, + { new: true }, + ); + if (updatedDashboard == null) { + throw new Error('Could not update dashboard'); + } + + // Delete related alerts + const deletedChartIds = differenceBy( + oldDashboard?.charts || [], + updatedDashboard?.charts || [], + 'id', + ).map(c => c.id); + + if (deletedChartIds?.length > 0) { + await Alert.deleteMany({ + dashboardId: dashboardId, + chartId: { $in: deletedChartIds }, + }); + } + + return updatedDashboard; +} diff --git a/packages/api/src/fixtures.ts b/packages/api/src/fixtures.ts index 25a504e62..5c98ee88b 100644 --- a/packages/api/src/fixtures.ts +++ b/packages/api/src/fixtures.ts @@ -325,6 +325,21 @@ export const makeChart = (opts?: { id?: string }) => ({ ], }); +export const makeExternalChart = (opts?: { id?: string }) => ({ + name: 'Test Chart', + x: 1, + y: 1, + w: 1, + h: 1, + series: [ + { + type: 'time', + data_source: 'events', + aggFn: 'count', + }, + ], +}); + export const makeAlert = ({ dashboardId, chartId, diff --git a/packages/api/src/routers/api/__tests__/dashboard.test.ts b/packages/api/src/routers/api/__tests__/dashboard.test.ts index 615a13abb..b7031fa5a 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -3,44 +3,10 @@ import { closeDB, getLoggedInAgent, getServer, + makeAlert, + makeChart, } from '@/fixtures'; -const randomId = () => Math.random().toString(36).substring(7); - -const makeChart = () => ({ - id: randomId(), - name: 'Test Chart', - x: 1, - y: 1, - w: 1, - h: 1, - series: [ - { - type: 'time', - table: 'metrics', - }, - ], -}); - -const makeAlert = ({ - dashboardId, - chartId, -}: { - dashboardId: string; - chartId: string; -}) => ({ - channel: { - type: 'webhook', - webhookId: 'test-webhook-id', - }, - interval: '15m', - threshold: 8, - type: 'presence', - source: 'CHART', - dashboardId, - chartId, -}); - const MOCK_DASHBOARD = { name: 'Test Dashboard', charts: [makeChart(), makeChart(), makeChart(), makeChart(), makeChart()], diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index a4570da5f..eea38f133 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -3,7 +3,10 @@ import { differenceBy, groupBy, uniq } from 'lodash'; import { z } from 'zod'; import { validateRequest } from 'zod-express-middleware'; -import deleteDashboardAndAlerts from '@/controllers/dashboard'; +import { + deleteDashboardAndAlerts, + updateDashboardAndAlerts, +} from '@/controllers/dashboard'; import Alert from '@/models/alert'; import Dashboard from '@/models/dashboard'; import { chartSchema, objectIdSchema, tagsSchema } from '@/utils/zod'; @@ -53,9 +56,9 @@ router.post( '/', validateRequest({ body: z.object({ - name: z.string(), + name: z.string().max(1024), charts: z.array(chartSchema), - query: z.string(), + query: z.string().max(2048), tags: tagsSchema, }), }), @@ -88,10 +91,13 @@ router.post( router.put( '/:id', validateRequest({ + params: z.object({ + id: objectIdSchema, + }), body: z.object({ - name: z.string(), + name: z.string().max(1024), charts: z.array(chartSchema), - query: z.string(), + query: z.string().max(2048), tags: tagsSchema, }), }), @@ -102,38 +108,20 @@ router.put( if (teamId == null) { return res.sendStatus(403); } - if (!dashboardId) { - return res.sendStatus(400); - } const { name, charts, query, tags } = req.body ?? {}; - // Update dashboard from name and charts - const oldDashboard = await Dashboard.findById(dashboardId); - const updatedDashboard = await Dashboard.findByIdAndUpdate( + const updatedDashboard = await updateDashboardAndAlerts( dashboardId, + teamId, { name, charts, query, - tags: tags && uniq(tags), + tags, }, - { new: true }, ); - // Delete related alerts - const deletedChartIds = differenceBy( - oldDashboard?.charts || [], - updatedDashboard?.charts || [], - 'id', - ).map(c => c.id); - - if (deletedChartIds?.length > 0) { - await Alert.deleteMany({ - dashboardId: dashboardId, - chartId: { $in: deletedChartIds }, - }); - } res.json({ data: updatedDashboard, }); diff --git a/packages/api/src/routers/external-api/__tests__/dashboard.test.ts b/packages/api/src/routers/external-api/__tests__/dashboard.test.ts new file mode 100644 index 000000000..02cb1453b --- /dev/null +++ b/packages/api/src/routers/external-api/__tests__/dashboard.test.ts @@ -0,0 +1,627 @@ +import _ from 'lodash'; + +import { + clearDBCollections, + closeDB, + getLoggedInAgent, + getServer, + makeExternalAlert, + makeExternalChart, +} from '@/fixtures'; + +const MOCK_DASHBOARD = { + name: 'Test Dashboard', + charts: [ + makeExternalChart(), + makeExternalChart(), + makeExternalChart(), + makeExternalChart(), + ], + query: 'test query', +}; + +function removeDashboardIds(dashboard: any) { + const dashboardWithoutIds = _.omit(dashboard, ['id']); + dashboardWithoutIds.charts = dashboardWithoutIds.charts.map(chart => { + return _.omit(chart, ['id']); + }); + + return dashboardWithoutIds; +} + +describe('dashboard router', () => { + const server = getServer(); + + beforeAll(async () => { + await server.start(); + }); + + afterEach(async () => { + await clearDBCollections(); + }); + + afterAll(async () => { + await server.closeHttpServer(); + await closeDB(); + }); + + it('CRUD /dashboards', async () => { + const { agent, user } = await getLoggedInAgent(server); + + await agent + .post('/api/v1/dashboards') + .set('Authorization', `Bearer ${user?.accessKey}`) + .send(MOCK_DASHBOARD) + .expect(200); + + const initialDashboards = await agent + .get('/api/v1/dashboards') + .set('Authorization', `Bearer ${user?.accessKey}`) + .expect(200); + + const singleDashboard = await agent + .get(`/api/v1/dashboards/${initialDashboards.body.data[0].id}`) + .set('Authorization', `Bearer ${user?.accessKey}`) + .send(MOCK_DASHBOARD) + .expect(200); + + expect(removeDashboardIds(singleDashboard.body.data)) + .toMatchInlineSnapshot(` +Object { + "charts": Array [ + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + ], + "name": "Test Dashboard", + "query": "test query", +} +`); + + const dashboardWithoutIds = removeDashboardIds( + initialDashboards.body.data[0], + ); + + expect(dashboardWithoutIds).toMatchInlineSnapshot(` +Object { + "charts": Array [ + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + ], + "name": "Test Dashboard", + "query": "test query", +} +`); + + // Create alerts for all charts + const dashboard = initialDashboards.body.data[0]; + await Promise.all( + dashboard.charts.map(chart => + agent + .post('/api/v1/alerts') + .set('Authorization', `Bearer ${user?.accessKey}`) + .send( + makeExternalAlert({ + dashboardId: dashboard.id, + chartId: chart.id, + }), + ) + .expect(200), + ), + ); + + // Delete the first chart + const dashboardPutWithoutFirstChart = await agent + .put(`/api/v1/dashboards/${dashboard.id}`) + .set('Authorization', `Bearer ${user?.accessKey}`) + .send({ + ...dashboard, + charts: dashboard.charts.slice(1), + }) + .expect(200); + + expect(removeDashboardIds(dashboardPutWithoutFirstChart.body.data)) + .toMatchInlineSnapshot(` +Object { + "charts": Array [ + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + Object { + "asRatio": false, + "h": 1, + "name": "Test Chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + ], + "w": 1, + "x": 1, + "y": 1, + }, + ], + "name": "Test Dashboard", + "query": "test query", + "tags": Array [], +} +`); + + await agent + .delete(`/api/v1/dashboards/${dashboard.id}`) + .set('Authorization', `Bearer ${user?.accessKey}`) + .expect(200); + + expect( + ( + await agent + .get('/api/v1/dashboards') + .set('Authorization', `Bearer ${user?.accessKey}`) + .expect(200) + ).body.data.length, + ).toBe(0); + }); + + it('can create all the chart types', async () => { + const { agent, user } = await getLoggedInAgent(server); + + await agent + .post('/api/v1/dashboards') + .set('Authorization', `Bearer ${user?.accessKey}`) + .send({ + id: '65adc1f516a4b2d24709c56d', + name: 'i dont break there', + charts: [ + { + id: '1mbgno', + name: 'two time series', + x: 0, + y: 0, + w: 7, + h: 3, + asRatio: false, + series: [ + { + type: 'time', + data_source: 'events', + aggFn: 'count', + where: '', + groupBy: [], + }, + { + type: 'time', + data_source: 'events', + aggFn: 'count', + where: 'level:err', + groupBy: [], + }, + ], + }, + { + id: 'va8j6', + name: 'ratio time series', + x: 4, + y: 3, + w: 4, + h: 2, + asRatio: true, + series: [ + { + type: 'time', + data_source: 'events', + aggFn: 'count', + where: '', + groupBy: [], + }, + { + type: 'time', + data_source: 'events', + aggFn: 'count', + where: 'level:err', + groupBy: [], + }, + ], + }, + { + id: 'q91iu', + name: 'table chart', + x: 7, + y: 0, + w: 5, + h: 3, + asRatio: false, + series: [ + { + type: 'table', + data_source: 'events', + aggFn: 'count', + where: '', + groupBy: [], + sortOrder: 'desc', + }, + ], + }, + { + id: '18efq2', + name: 'histogram chart', + x: 0, + y: 5, + w: 4, + h: 2, + asRatio: false, + series: [ + { + type: 'histogram', + data_source: 'events', + field: 'duration', + where: '', + }, + ], + }, + { + id: 't10am', + name: 'markdown chart', + x: 8, + y: 3, + w: 4, + h: 2, + asRatio: false, + series: [ + { + type: 'markdown', + data_source: 'events', + content: 'makedown', + }, + ], + }, + { + id: '1ip8he', + name: 'number chart', + x: 4, + y: 5, + w: 4, + h: 2, + asRatio: false, + series: [ + { + type: 'number', + data_source: 'events', + aggFn: 'count', + where: 'level:err OR level:warn', + }, + ], + }, + { + id: 'ipr35', + name: 'search chart', + x: 0, + y: 3, + w: 4, + h: 2, + asRatio: false, + series: [ + { + type: 'search', + data_source: 'events', + where: 'level:warn', + }, + ], + }, + ], + query: '', + }) + .expect(200); + + expect( + removeDashboardIds( + ( + await agent + .get('/api/v1/dashboards') + .set('Authorization', `Bearer ${user?.accessKey}`) + .expect(200) + ).body.data[0], + ), + ).toMatchInlineSnapshot(` +Object { + "charts": Array [ + Object { + "asRatio": false, + "h": 3, + "name": "two time series", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "level:err", + }, + ], + "w": 7, + "x": 0, + "y": 0, + }, + Object { + "asRatio": true, + "h": 2, + "name": "ratio time series", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "", + }, + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "type": "time", + "where": "level:err", + }, + ], + "w": 4, + "x": 4, + "y": 3, + }, + Object { + "asRatio": false, + "h": 3, + "name": "table chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "groupBy": Array [], + "sortOrder": "desc", + "type": "table", + "where": "", + }, + ], + "w": 5, + "x": 7, + "y": 0, + }, + Object { + "asRatio": false, + "h": 2, + "name": "histogram chart", + "series": Array [ + Object { + "data_source": "events", + "field": "duration", + "type": "histogram", + "where": "", + }, + ], + "w": 4, + "x": 0, + "y": 5, + }, + Object { + "asRatio": false, + "h": 2, + "name": "markdown chart", + "series": Array [ + Object { + "content": "makedown", + "data_source": "events", + "type": "markdown", + }, + ], + "w": 4, + "x": 8, + "y": 3, + }, + Object { + "asRatio": false, + "h": 2, + "name": "number chart", + "series": Array [ + Object { + "aggFn": "count", + "data_source": "events", + "type": "number", + "where": "level:err OR level:warn", + }, + ], + "w": 4, + "x": 4, + "y": 5, + }, + Object { + "asRatio": false, + "h": 2, + "name": "search chart", + "series": Array [ + Object { + "data_source": "events", + "type": "search", + "where": "level:warn", + }, + ], + "w": 4, + "x": 0, + "y": 3, + }, + ], + "name": "i dont break there", + "query": "", +} +`); + }); +}); diff --git a/packages/api/src/routers/external-api/v1/dashboards.ts b/packages/api/src/routers/external-api/v1/dashboards.ts new file mode 100644 index 000000000..b353976c6 --- /dev/null +++ b/packages/api/src/routers/external-api/v1/dashboards.ts @@ -0,0 +1,373 @@ +import express from 'express'; +import { uniq } from 'lodash'; +import { ObjectId } from 'mongodb'; +import { z } from 'zod'; +import { validateRequest } from 'zod-express-middleware'; + +import { + deleteDashboardAndAlerts, + updateDashboardAndAlerts, +} from '@/controllers/dashboard'; +import Dashboard, { IDashboard } from '@/models/dashboard'; +import { + chartSchema, + externalChartSchema, + externalChartSchemaWithId, + histogramChartSeriesSchema, + markdownChartSeriesSchema, + numberChartSeriesSchema, + objectIdSchema, + searchChartSeriesSchema, + tableChartSeriesSchema, + tagsSchema, + timeChartSeriesSchema, +} from '@/utils/zod'; + +const router = express.Router(); + +function translateExternalChartToInternalChart( + chartInput: z.infer, +): z.infer { + const { id, x, name, y, w, h, series, asRatio } = chartInput; + return { + id, + name, + x, + y, + w, + h, + seriesReturnType: asRatio ? 'ratio' : 'column', + series: series.map(s => { + const { + type, + data_source, + aggFn, + field, + fields, + where, + groupBy, + sortOrder, + content, + numberFormat, + metricDataType, + } = s; + + const table = data_source === 'metrics' ? 'metrics' : 'logs'; + + if (type === 'time') { + if (aggFn == null) { + throw new Error('aggFn must be set for time chart'); + } + + const series: z.infer = { + type: 'time', + table, + aggFn, + where: where ?? '', + groupBy: groupBy ?? [], + ...(field ? { field } : {}), + ...(numberFormat ? { numberFormat } : {}), + ...(metricDataType ? { metricDataType } : {}), + }; + + return series; + } else if (type === 'table') { + if (aggFn == null) { + throw new Error('aggFn must be set for table chart'); + } + + const series: z.infer = { + type: 'table', + table, + aggFn, + where: where ?? '', + groupBy: groupBy ?? [], + sortOrder: sortOrder ?? 'desc', + ...(field ? { field } : {}), + ...(numberFormat ? { numberFormat } : {}), + ...(metricDataType ? { metricDataType } : {}), + }; + + return series; + } else if (type === 'number') { + if (aggFn == null) { + throw new Error('aggFn must be set for number chart'); + } + + const series: z.infer = { + type: 'number', + table, + aggFn, + where: where ?? '', + ...(field ? { field } : {}), + ...(numberFormat ? { numberFormat } : {}), + ...(metricDataType ? { metricDataType } : {}), + }; + + return series; + } else if (type === 'histogram') { + const series: z.infer = { + type: 'histogram', + table, + where: where ?? '', + ...(field ? { field } : {}), + ...(metricDataType ? { metricDataType } : {}), + }; + + return series; + } else if (type === 'search') { + const series: z.infer = { + type: 'search', + fields: fields ?? [], + where: where ?? '', + }; + + return series; + } else if (type === 'markdown') { + const series: z.infer = { + type: 'markdown', + content: content ?? '', + }; + + return series; + } + + throw new Error(`Invalid chart type ${type}`); + }), + }; +} + +const translateChartDocumentToExternalChart = ( + chart: z.infer, +): z.infer => { + const { id, x, name, y, w, h, series, seriesReturnType } = chart; + return { + id, + name, + x, + y, + w, + h, + asRatio: seriesReturnType === 'ratio', + series: series.map(s => { + const { + type, + table, + aggFn, + field, + where, + groupBy, + sortOrder, + content, + numberFormat, + } = s; + + return { + type, + data_source: table === 'metrics' ? 'metrics' : 'events', + aggFn, + field, + where, + groupBy, + sortOrder, + content, + numberFormat, + }; + }), + }; +}; + +const translateDashboardDocumentToExternalDashboard = ( + dashboard: IDashboard, +): { + id: string; + name: string; + charts: z.infer[]; + query: string; + tags: string[]; +} => { + const { _id, name, charts, query, tags } = dashboard; + + return { + id: _id.toString(), + name, + charts: charts.map(translateChartDocumentToExternalChart), + query, + tags, + }; +}; + +router.get( + '/:id', + validateRequest({ + params: z.object({ + id: objectIdSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const dashboard = await Dashboard.findOne( + { team: teamId, _id: req.params.id }, + { _id: 1, name: 1, charts: 1, query: 1 }, + ); + + if (dashboard == null) { + return res.sendStatus(404); + } + + res.json({ + data: translateDashboardDocumentToExternalDashboard(dashboard), + }); + } catch (e) { + next(e); + } + }, +); + +router.get('/', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const dashboards = await Dashboard.find( + { team: teamId }, + { _id: 1, name: 1, charts: 1, query: 1 }, + ).sort({ name: -1 }); + + res.json({ + data: dashboards.map(d => + translateDashboardDocumentToExternalDashboard(d), + ), + }); + } catch (e) { + next(e); + } +}); + +router.post( + '/', + validateRequest({ + body: z.object({ + name: z.string().max(1024), + charts: z.array(externalChartSchema), + query: z.string().max(2048), + tags: tagsSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const { name, charts, query, tags } = req.body; + + const internalCharts = charts.map(chart => { + const chartId = new ObjectId().toString(); + return translateExternalChartToInternalChart({ + id: chartId, + ...chart, + }); + }); + + // Create new dashboard from name and charts + const newDashboard = await new Dashboard({ + name, + charts: internalCharts, + query, + tags: tags && uniq(tags), + team: teamId, + }).save(); + + res.json({ + data: translateDashboardDocumentToExternalDashboard(newDashboard), + }); + } catch (e) { + next(e); + } + }, +); + +router.put( + '/:id', + validateRequest({ + params: z.object({ + id: objectIdSchema, + }), + body: z.object({ + name: z.string().max(1024), + charts: z.array(externalChartSchemaWithId), + query: z.string().max(2048), + tags: tagsSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + const { id: dashboardId } = req.params; + if (teamId == null) { + return res.sendStatus(403); + } + if (!dashboardId) { + return res.sendStatus(400); + } + + const { name, charts, query, tags } = req.body ?? {}; + + const internalCharts = charts.map(chart => { + return translateExternalChartToInternalChart(chart); + }); + + const updatedDashboard = await updateDashboardAndAlerts( + dashboardId, + teamId, + { + name, + charts: internalCharts, + query, + tags, + }, + ); + + res.json({ + data: translateDashboardDocumentToExternalDashboard(updatedDashboard), + }); + } catch (e) { + next(e); + } + }, +); + +router.delete( + '/:id', + validateRequest({ + params: z.object({ + id: objectIdSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + const { id: dashboardId } = req.params; + if (teamId == null) { + return res.sendStatus(403); + } + + await deleteDashboardAndAlerts(dashboardId, teamId); + + res.json({}); + } catch (e) { + next(e); + } + }, +); + +export default router; diff --git a/packages/api/src/routers/external-api/v1/index.ts b/packages/api/src/routers/external-api/v1/index.ts index 2127c1caa..71dc8f7f5 100644 --- a/packages/api/src/routers/external-api/v1/index.ts +++ b/packages/api/src/routers/external-api/v1/index.ts @@ -8,13 +8,13 @@ import { validateRequest } from 'zod-express-middleware'; import * as clickhouse from '@/clickhouse'; import { getTeam } from '@/controllers/team'; import { validateUserAccessKey } from '@/middleware/auth'; +import alertsRouter from '@/routers/external-api/v1/alerts'; +import dashboardRouter from '@/routers/external-api/v1/dashboards'; import { Api400Error, Api403Error } from '@/utils/errors'; import logger from '@/utils/logger'; import rateLimiter from '@/utils/rateLimiter'; import { SimpleCache } from '@/utils/redis'; -import alertsRouter from './alerts'; - const router = express.Router(); const rateLimiterKeyGenerator = (req: express.Request) => { @@ -44,6 +44,13 @@ router.use( alertsRouter, ); +router.use( + '/dashboards', + getDefaultRateLimiter(), + validateUserAccessKey, + dashboardRouter, +); + router.get( '/logs/properties', getDefaultRateLimiter(), diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 411f6085f..b2e1f208b 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -63,36 +63,46 @@ export const tableChartSeriesSchema = z.object({ metricDataType: z.optional(z.nativeEnum(MetricsDataType)), }); +export const numberChartSeriesSchema = z.object({ + type: z.literal('number'), + table: z.optional(sourceTableSchema), + aggFn: aggFnSchema, + field: z.union([z.string(), z.undefined()]), + where: z.string(), + numberFormat: numberFormatSchema.optional(), + metricDataType: z.optional(z.nativeEnum(MetricsDataType)), +}); + +export const histogramChartSeriesSchema = z.object({ + table: z.optional(sourceTableSchema), + type: z.literal('histogram'), + field: z.union([z.string(), z.undefined()]), + where: z.string(), + metricDataType: z.optional(z.nativeEnum(MetricsDataType)), +}); + +export const searchChartSeriesSchema = z.object({ + type: z.literal('search'), + fields: z.array(z.string()), + where: z.string(), +}); + +export const markdownChartSeriesSchema = z.object({ + type: z.literal('markdown'), + content: z.string(), +}); + export const chartSeriesSchema = z.union([ timeChartSeriesSchema, tableChartSeriesSchema, - z.object({ - table: z.optional(sourceTableSchema), - type: z.literal('histogram'), - field: z.union([z.string(), z.undefined()]), - where: z.string(), - }), - z.object({ - type: z.literal('search'), - fields: z.array(z.string()), - where: z.string(), - }), - z.object({ - type: z.literal('number'), - table: z.optional(sourceTableSchema), - aggFn: aggFnSchema, - field: z.union([z.string(), z.undefined()]), - where: z.string(), - numberFormat: numberFormatSchema.optional(), - }), - z.object({ - type: z.literal('markdown'), - content: z.string(), - }), + histogramChartSeriesSchema, + searchChartSeriesSchema, + numberChartSeriesSchema, + markdownChartSeriesSchema, ]); export const chartSchema = z.object({ - id: z.string(), + id: z.string().max(32), name: z.string(), x: z.number(), y: z.number(), @@ -112,36 +122,57 @@ export const chartSchema = z.object({ 'markdown', ]), table: z.string().optional(), - aggFn: z.string().optional(), // TODO: Replace with the actual AggFn schema + aggFn: aggFnSchema.optional(), field: z.union([z.string(), z.undefined()]).optional(), + fields: z.array(z.string()).optional(), where: z.string().optional(), groupBy: z.array(z.string()).optional(), sortOrder: z.union([z.literal('desc'), z.literal('asc')]).optional(), content: z.string().optional(), - numberFormat: z - .object({ - output: z - .union([ - z.literal('currency'), - z.literal('percent'), - z.literal('byte'), - z.literal('time'), - z.literal('number'), - ]) - .optional(), - mantissa: z.number().optional(), - thousandSeparated: z.boolean().optional(), - average: z.boolean().optional(), - decimalBytes: z.boolean().optional(), - factor: z.number().optional(), - currencySymbol: z.string().optional(), - unit: z.string().optional(), - }) - .optional(), + numberFormat: numberFormatSchema.optional(), + metricDataType: z.optional(z.nativeEnum(MetricsDataType)), }), ), + seriesReturnType: z.enum(['ratio', 'column']).optional(), }); +export const externalChartSchema = z.object({ + name: z.string(), + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), + series: z.array( + z.object({ + type: z.enum([ + 'time', + 'histogram', + 'search', + 'number', + 'table', + 'markdown', + ]), + data_source: z.enum(['events', 'metrics']).optional(), + aggFn: aggFnSchema.optional(), + field: z.union([z.string(), z.undefined()]).optional(), + fields: z.array(z.string()).optional(), + where: z.string().optional(), + groupBy: z.array(z.string()).optional(), + sortOrder: z.union([z.literal('desc'), z.literal('asc')]).optional(), + content: z.string().optional(), + numberFormat: numberFormatSchema.optional(), + metricDataType: z.optional(z.nativeEnum(MetricsDataType)), + }), + ), + asRatio: z.boolean().optional(), +}); +export const externalChartSchemaWithId = externalChartSchema.and( + z.object({ + // This isn't always a Mongo ID + id: z.string().max(32), + }), +); + export const tagsSchema = z.array(z.string().max(32)).max(50).optional(); // ==============================