diff --git a/prisma/migrations/20260708113434_add_dashboard_model/migration.sql b/prisma/migrations/20260708113434_add_dashboard_model/migration.sql new file mode 100644 index 0000000..7a47c74 --- /dev/null +++ b/prisma/migrations/20260708113434_add_dashboard_model/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "Dashboard" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "tags" TEXT[], + "ownerId" TEXT NOT NULL, + "panels" JSONB NOT NULL DEFAULT '[]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Dashboard_ownerId_idx" ON "Dashboard"("ownerId"); + +-- CreateIndex +CREATE INDEX "Dashboard_createdAt_idx" ON "Dashboard"("createdAt"); + +-- CreateIndex +CREATE INDEX "Dashboard_title_idx" ON "Dashboard"("title"); + +-- AddForeignKey +ALTER TABLE "Dashboard" ADD CONSTRAINT "Dashboard_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "Analyst"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b7b4e6d..59b97d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,9 +26,11 @@ enum Status { IGNORED OTHER } + enum crdb_internal_region { aws_eu_central_1 @map("aws-eu-central-1") } + model Analyst { id String @id @default(uuid()) firstName String @@ -41,6 +43,7 @@ model Analyst { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt refreshTokens RefreshToken[] + dashboards Dashboard[] } model RefreshToken { @@ -111,12 +114,12 @@ model Rule { name String @unique description String type String - mitreTactics String[] + mitreTactics String[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - logSourceId String? - logSource LogSource? @relation(fields: [logSourceId], references: [id], onDelete: SetNull) + + logSourceId String? + logSource LogSource? @relation(fields: [logSourceId], references: [id], onDelete: SetNull) alerts Alert[] @@ -134,26 +137,42 @@ model Alert { updatedAt DateTime @updatedAt rule Rule @relation(fields: [ruleId], references: [id], onDelete: Cascade) ruleId String - device Device? @relation(fields: [deviceId], references: [id]) + device Device? @relation(fields: [deviceId], references: [id]) deviceId String? confidence Float severity Severity status Status scope String? source String? - mitre String[] + mitre String[] +} + +model Dashboard { + id String @id @default(uuid()) + title String + description String? + tags String[] + ownerId String + owner Analyst @relation(fields: [ownerId], references: [id], onDelete: Cascade) + panels Json @default("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ownerId]) + @@index([createdAt]) + @@index([title]) } model LogSource { id String @id @default(uuid()) name String @unique - category String + category String vendor String product String description String dataset String type String @default("logs") - index String // The Elasticsearch index pattern to query + index String // The Elasticsearch index pattern to query agent String agentVersion String @default("9.2.1") pipeline String @@ -167,5 +186,5 @@ model LogSource { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - rules Rule[] + rules Rule[] } diff --git a/src/docs/openapi.docs.ts b/src/docs/openapi.docs.ts index 8838e43..c5faa41 100644 --- a/src/docs/openapi.docs.ts +++ b/src/docs/openapi.docs.ts @@ -809,4 +809,120 @@ * 404: { $ref: '#/components/responses/NotFound' } */ +/** + * @swagger + * components: + * schemas: + * DashboardPanel: + * type: object + * required: [id, type, title, spec] + * properties: + * id: { type: string, example: p1 } + * type: { type: string, enum: [metric, histogram, breakdown] } + * title: { type: string, example: Total events } + * spec: + * type: object + * description: "metric: { index, aggType: count|cardinality|ratio, field?, filter?, numeratorFilter? } | histogram: { index, interval?, filter? } | breakdown: { index, field, size?, filter? }" + * example: { index: "logs-auditbeat.auditd-*", aggType: cardinality, field: host.name } + * Dashboard: + * type: object + * properties: + * id: { type: string, format: uuid } + * title: { type: string, example: Auditd Overview } + * description: { type: string, nullable: true, example: Kernel audit events } + * tags: { type: array, items: { type: string }, example: [auditd] } + * ownerId: { type: string, format: uuid } + * panels: { type: array, items: { $ref: '#/components/schemas/DashboardPanel' } } + * createdAt: { type: string, format: date-time } + * updatedAt: { type: string, format: date-time } + * DashboardListItem: + * type: object + * properties: + * id: { type: string, format: uuid } + * title: { type: string } + * desc: { type: string } + * tags: { type: array, items: { type: string } } + * DashboardInput: + * type: object + * required: [title] + * properties: + * title: { type: string, example: Auditd Overview } + * description: { type: string, example: Kernel audit events } + * tags: { type: array, items: { type: string }, example: [auditd] } + * panels: { type: array, items: { $ref: '#/components/schemas/DashboardPanel' } } + */ + +/** + * @swagger + * /api/v1/dashboards: + * get: + * tags: [Dashboards] + * summary: List dashboards + * security: [{ bearerAuth: [] }] + * parameters: + * - { in: query, name: search, schema: { type: string } } + * - { in: query, name: page, schema: { type: integer, default: 1 } } + * - { in: query, name: limit, schema: { type: integer, default: 10 } } + * responses: + * 200: { description: Paginated dashboard list } + * 401: { $ref: '#/components/responses/Unauthorized' } + * post: + * tags: [Dashboards] + * summary: Create dashboard + * security: [{ bearerAuth: [] }] + * requestBody: + * required: true + * content: + * application/json: + * schema: { $ref: '#/components/schemas/DashboardInput' } + * responses: + * 201: { description: Dashboard created } + * 401: { $ref: '#/components/responses/Unauthorized' } + * /api/v1/dashboards/{id}: + * get: + * tags: [Dashboards] + * summary: Get dashboard definition + * security: [{ bearerAuth: [] }] + * parameters: + * - { in: path, name: id, required: true, schema: { type: string, format: uuid } } + * responses: + * 200: { description: Dashboard details } + * 404: { $ref: '#/components/responses/NotFound' } + * patch: + * tags: [Dashboards] + * summary: Update dashboard + * security: [{ bearerAuth: [] }] + * parameters: + * - { in: path, name: id, required: true, schema: { type: string, format: uuid } } + * requestBody: + * required: true + * content: + * application/json: + * schema: { $ref: '#/components/schemas/DashboardInput' } + * responses: + * 200: { description: Dashboard updated } + * 404: { $ref: '#/components/responses/NotFound' } + * delete: + * tags: [Dashboards] + * summary: Delete dashboard + * security: [{ bearerAuth: [] }] + * parameters: + * - { in: path, name: id, required: true, schema: { type: string, format: uuid } } + * responses: + * 204: { description: Dashboard deleted } + * 404: { $ref: '#/components/responses/NotFound' } + * /api/v1/dashboards/{id}/data: + * get: + * tags: [Dashboards] + * summary: Get live panel data over a time range + * security: [{ bearerAuth: [] }] + * parameters: + * - { in: path, name: id, required: true, schema: { type: string, format: uuid } } + * - { in: query, name: from, schema: { type: string, format: date-time } } + * - { in: query, name: to, schema: { type: string, format: date-time } } + * responses: + * 200: { description: Dashboard panel data } + * 404: { $ref: '#/components/responses/NotFound' } + */ + export {}; diff --git a/src/docs/swagger.config.ts b/src/docs/swagger.config.ts index 949d472..0c2b3c0 100644 --- a/src/docs/swagger.config.ts +++ b/src/docs/swagger.config.ts @@ -87,6 +87,10 @@ const swaggerDefinition: SwaggerDefinition = { name: 'Alerts', description: 'Security alert queue management endpoints', }, + { + name: 'Dashboards', + description: 'Dashboard management endpoints', + }, { name: 'LogSources', description: 'Log source management endpoints', diff --git a/src/modules/Dashboards/controllers/dashboard.controller.ts b/src/modules/Dashboards/controllers/dashboard.controller.ts new file mode 100644 index 0000000..9c3da5a --- /dev/null +++ b/src/modules/Dashboards/controllers/dashboard.controller.ts @@ -0,0 +1,83 @@ +import { Request, Response } from 'express'; +import catchAsync from '../../../common/utils/catchAsync'; +import { STATUS_CODE } from '../../../common/constants/responseCode'; +import { STATUS } from '../../../common/constants/responseStatus'; +import { + createDashboardData, + DashboardDataQuery, + ListDashboardsQuery, + updateDashboardData, +} from '../types/types'; +import { + createDashboardService, + updateDashboardService, + deleteDashboardService, + getDashboardService, + getAllDashboardsService, + getDashboardDataService, +} from '../services/dashboard.service'; +import { IRequest } from '../../../common/interfaces/types'; + +export const createDashboard = catchAsync(async (req: IRequest, res: Response) => { + const data: createDashboardData = req.body as createDashboardData; + const dashboard = await createDashboardService(req.user!.id, data); + + res.status(STATUS_CODE.CREATED).json({ + status: STATUS.SUCCESS, + data: dashboard, + message: 'Dashboard created successfully', + }); +}); + +export const updateDashboard = catchAsync(async (req: Request, res: Response) => { + const id = req.params.id as string; + const data: updateDashboardData = req.body as updateDashboardData; + const dashboard = await updateDashboardService(id, data); + + res.status(STATUS_CODE.SUCCESS).json({ + status: STATUS.SUCCESS, + data: dashboard, + message: 'Dashboard updated successfully', + }); +}); + +export const deleteDashboard = catchAsync(async (req: Request, res: Response) => { + const id = req.params.id as string; + await deleteDashboardService(id); + + res.status(STATUS_CODE.NO_CONTENT).send(); +}); + +export const getDashboardById = catchAsync(async (req: Request, res: Response) => { + const id = req.params.id as string; + const dashboard = await getDashboardService(id); + + res.status(STATUS_CODE.SUCCESS).json({ + status: STATUS.SUCCESS, + data: dashboard, + message: 'Dashboard retrieved successfully', + }); +}); + +export const getAllDashboards = catchAsync(async (req: Request, res: Response) => { + const query: ListDashboardsQuery = req.query as unknown as ListDashboardsQuery; + const dashboards = await getAllDashboardsService(query); + + res.status(STATUS_CODE.SUCCESS).json({ + status: STATUS.SUCCESS, + data: dashboards.data, + meta: dashboards.meta, + }); +}); + +export const getDashboardData = catchAsync(async (req: Request, res: Response) => { + const id = req.params.id as string; + const query: DashboardDataQuery = req.query as unknown as DashboardDataQuery; + const data = await getDashboardDataService(id, query); + + res.status(STATUS_CODE.SUCCESS).json({ + status: STATUS.SUCCESS, + data, + message: 'Dashboard data retrieved successfully', + }); +}); diff --git a/src/modules/Dashboards/routes/dashboard.routes.ts b/src/modules/Dashboards/routes/dashboard.routes.ts new file mode 100644 index 0000000..1bdc5f1 --- /dev/null +++ b/src/modules/Dashboards/routes/dashboard.routes.ts @@ -0,0 +1,439 @@ +import express from 'express'; +import { + createDashboard, + updateDashboard, + deleteDashboard, + getDashboardById, + getAllDashboards, + getDashboardData, +} from '../controllers/dashboard.controller'; +import { authenticate } from '../../../common/middlewares'; +import validationMiddleware from '../../../common/middlewares/validation.middleware'; +import { + createDashboardValidation, + updateDashboardValidation, + getDashboardValidation, + deleteDashboardValidation, + queryDashboardsValidation, + dashboardDataQueryValidation, +} from '../validation/dashboard.validation'; + +const router = express.Router(); + +router.use(authenticate); + +/** + * @swagger + * tags: + * name: Dashboards + * description: Saved dashboards (panel definitions + live Elasticsearch data) + * + * components: + * schemas: + * DashboardPanel: + * type: object + * required: [id, type, title, spec] + * properties: + * id: + * type: string + * example: p1 + * type: + * type: string + * enum: [metric, histogram, breakdown] + * title: + * type: string + * example: Total events + * spec: + * type: object + * description: > + * Query/aggregation spec, shape depends on "type". + * metric: { index, aggType: count|cardinality|ratio, field?, filter?, numeratorFilter? } + * histogram: { index, interval?, filter? } + * breakdown: { index, field, size?, filter? } + * example: + * index: logs-auditbeat.auditd-* + * aggType: cardinality + * field: host.name + * Dashboard: + * type: object + * properties: + * id: + * type: string + * format: uuid + * title: + * type: string + * example: Auditd Overview + * description: + * type: string + * nullable: true + * example: Kernel audit events + * tags: + * type: array + * items: + * type: string + * example: [auditd] + * ownerId: + * type: string + * format: uuid + * panels: + * type: array + * items: + * $ref: '#/components/schemas/DashboardPanel' + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * DashboardListItem: + * type: object + * properties: + * id: + * type: string + * format: uuid + * title: + * type: string + * desc: + * type: string + * tags: + * type: array + * items: + * type: string + * PaginationMeta: + * type: object + * properties: + * total: + * type: integer + * page: + * type: integer + * limit: + * type: integer + * totalPages: + * type: integer + * hasNextPage: + * type: boolean + * hasPreviousPage: + * type: boolean + */ + +/** + * @swagger + * /api/v1/dashboards: + * get: + * summary: List dashboards (metadata rows for the dashboard list page) + * tags: [Dashboards] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: search + * schema: + * type: string + * description: Search across title and description + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * responses: + * 200: + * description: Paginated list of dashboards + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * type: array + * items: + * $ref: '#/components/schemas/DashboardListItem' + * meta: + * $ref: '#/components/schemas/PaginationMeta' + * 401: + * description: Unauthorized + */ +router.get('/', validationMiddleware({ query: queryDashboardsValidation }), getAllDashboards); + +/** + * @swagger + * /dashboards/{id}/data: + * get: + * summary: Get live panel data for a dashboard (metric cards, histogram, breakdown) + * description: > + * Runs each panel's Elasticsearch aggregation at request time over the given + * time range. Kept separate from the dashboard definition so changing the + * time range does not refetch the layout/panels config. + * tags: [Dashboards] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * - in: query + * name: from + * schema: + * type: string + * format: date-time + * description: Start of time range (ISO 8601). Defaults to 24h before "to". + * - in: query + * name: to + * schema: + * type: string + * format: date-time + * description: End of time range (ISO 8601). Defaults to now. + * responses: + * 200: + * description: Dashboard panel data retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * type: object + * properties: + * stats: + * type: array + * items: + * type: object + * properties: + * label: + * type: string + * example: Total events + * value: + * oneOf: + * - type: string + * - type: number + * example: 128340 + * histogram: + * type: array + * items: + * type: object + * properties: + * time: + * type: string + * example: "14:00" + * count: + * type: integer + * example: 342 + * breakdown: + * type: array + * items: + * type: object + * properties: + * label: + * type: string + * example: success + * value: + * type: integer + * example: 87 + * message: + * type: string + * example: Dashboard data retrieved successfully + * 401: + * description: Unauthorized + * 404: + * description: Dashboard not found + */ + +router.get( + '/:id/data', + validationMiddleware({ params: getDashboardValidation, query: dashboardDataQueryValidation }), + getDashboardData, +); + +/** + * @swagger + * /dashboards: + * post: + * summary: Create a dashboard + * tags: [Dashboards] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [title] + * properties: + * title: + * type: string + * example: Auditd Overview + * description: + * type: string + * example: Kernel audit events + * tags: + * type: array + * items: + * type: string + * example: [auditd] + * panels: + * type: array + * items: + * $ref: '#/components/schemas/DashboardPanel' + * responses: + * 201: + * description: Dashboard created successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * $ref: '#/components/schemas/Dashboard' + * message: + * type: string + * example: Dashboard created successfully + * 400: + * description: Validation error + * 401: + * description: Unauthorized + */ +router.post('/', validationMiddleware({ body: createDashboardValidation }), createDashboard); + +/** + * @swagger + * /dashboards/{id}: + * get: + * summary: Get a dashboard's full definition (metadata + panels config) + * tags: [Dashboards] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Dashboard retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * $ref: '#/components/schemas/Dashboard' + * message: + * type: string + * example: Dashboard retrieved successfully + * 401: + * description: Unauthorized + * 404: + * description: Dashboard not found + */ +router.get('/:id', validationMiddleware({ params: getDashboardValidation }), getDashboardById); + +/** + * @swagger + * /dashboards/{id}: + * patch: + * summary: Update a dashboard's metadata and/or panels + * tags: [Dashboards] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * description: + * type: string + * tags: + * type: array + * items: + * type: string + * panels: + * type: array + * items: + * $ref: '#/components/schemas/DashboardPanel' + * responses: + * 200: + * description: Dashboard updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * $ref: '#/components/schemas/Dashboard' + * message: + * type: string + * example: Dashboard updated successfully + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: Dashboard not found + */ +router.patch( + '/:id', + validationMiddleware({ params: getDashboardValidation, body: updateDashboardValidation }), + updateDashboard, +); + +/** + * @swagger + * /dashboards/{id}: + * delete: + * summary: Delete a dashboard + * tags: [Dashboards] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 204: + * description: Dashboard deleted successfully (no content) + * 401: + * description: Unauthorized + * 404: + * description: Dashboard not found + */ +router.delete('/:id', validationMiddleware({ params: deleteDashboardValidation }), deleteDashboard); +export default router; diff --git a/src/modules/Dashboards/services/dashboard.service.ts b/src/modules/Dashboards/services/dashboard.service.ts new file mode 100644 index 0000000..f46a481 --- /dev/null +++ b/src/modules/Dashboards/services/dashboard.service.ts @@ -0,0 +1,229 @@ +import ApiErrorHandler from '../../../common/utils/ApiErrorHandler'; +import logger from '../../../common/utils/logger'; +import { Prisma } from '@prisma/client'; +import { prisma } from '../../../config/postgres'; +import { elasticClient } from '../../../config/elasticsearch'; +import { + createDashboardData, + ListDashboardsQuery, + updateDashboardData, + DashboardDataQuery, + DashboardPanelDefinition, + MetricPanelDefinition, + HistogramPanelDefinition, + BreakdownPanelDefinition, + DashboardPanelsData, + PanelStat, + HistogramPoint, + BreakdownItem, +} from '../types/types'; +import { buildDashboardFilter } from './dashboard.utils'; + +export const createDashboardService = async (ownerId: string, data: createDashboardData) => { + const dashboard = await prisma.dashboard.create({ + data: { + title: data.title, + description: data.description, + tags: data.tags ?? [], + panels: data.panels ? (data.panels as unknown as Prisma.InputJsonValue) : undefined, + ownerId, + }, + }); + logger.info(`dashboard created successfully: title ${dashboard.title}`); + return dashboard; +}; + +export const updateDashboardService = async (id: string, data: updateDashboardData) => { + const dashboard = await prisma.dashboard.update({ + where: { id }, + data: { + ...data, + panels: data.panels ? (data.panels as unknown as Prisma.InputJsonValue) : undefined, + }, + }); + + logger.info(`dashboard updated successfully: id ${id}`); + return dashboard; +}; + +export const deleteDashboardService = async (id: string) => { + await prisma.dashboard.delete({ where: { id } }); + logger.info(`dashboard deleted successfully: id ${id}`); +}; + +export const getDashboardService = async (id: string) => { + const dashboard = await prisma.dashboard.findUnique({ where: { id } }); + if (!dashboard) throw new ApiErrorHandler(404, 'Dashboard Not Found'); + + logger.info(`dashboard retrieved successfully: id ${id}`); + return dashboard; +}; + +export const getAllDashboardsService = async (query: ListDashboardsQuery) => { + const where = buildDashboardFilter({ search: query.search }); + + const page = query.page || 1; + const limit = query.limit || 10; + const skip = (page - 1) * limit; + + const [data, total] = await Promise.all([ + prisma.dashboard.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + prisma.dashboard.count({ where }), + ]); + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }; +}; + +const DEFAULT_RANGE_HOURS = 24; + +const buildTimeRange = (query: DashboardDataQuery) => { + const to = query.to ? new Date(query.to) : new Date(); + const from = query.from + ? new Date(query.from) + : new Date(to.getTime() - DEFAULT_RANGE_HOURS * 60 * 60 * 1000); + return { gte: from.toISOString(), lte: to.toISOString() }; +}; + +const buildFilterClauses = (filter?: Record) => + filter ? Object.entries(filter).map(([field, value]) => ({ term: { [field]: value } })) : []; + +const buildBoolQuery = ( + timeRange: { gte: string; lte: string }, + filter?: Record, +) => ({ + bool: { + filter: [{ range: { '@timestamp': timeRange } }, ...buildFilterClauses(filter)], + }, +}); + +const runMetricPanel = async ( + panel: MetricPanelDefinition, + timeRange: { gte: string; lte: string }, +): Promise => { + const { spec } = panel; + const query = buildBoolQuery(timeRange, spec.filter); + + if (spec.aggType === 'count') { + const res = await elasticClient.count({ index: spec.index, query }); + return { label: panel.title, value: res.count }; + } + + if (spec.aggType === 'cardinality') { + if (!spec.field) throw new Error(`Panel "${panel.title}": cardinality metric requires "field"`); + const res = await elasticClient.search({ + index: spec.index, + size: 0, + query, + aggs: { unique: { cardinality: { field: spec.field } } }, + }); + const value = (res.aggregations as Record)?.unique?.value ?? 0; + return { label: panel.title, value }; + } + + const [totalRes, numeratorRes] = await Promise.all([ + elasticClient.count({ index: spec.index, query }), + elasticClient.count({ + index: spec.index, + query: buildBoolQuery(timeRange, { ...spec.filter, ...spec.numeratorFilter }), + }), + ]); + const total = totalRes.count; + const numerator = numeratorRes.count; + const value = total > 0 ? `${((numerator / total) * 100).toFixed(1)}%` : '0.0%'; + return { label: panel.title, value }; +}; + +const runHistogramPanel = async ( + panel: HistogramPanelDefinition, + timeRange: { gte: string; lte: string }, +): Promise => { + const { spec } = panel; + const res = await elasticClient.search({ + index: spec.index, + size: 0, + query: buildBoolQuery(timeRange, spec.filter), + aggs: { + over_time: { date_histogram: { field: '@timestamp', fixed_interval: spec.interval ?? '1h' } }, + }, + }); + + const buckets = ((res.aggregations as Record)?.over_time?.buckets ?? []) as { + key: number; + doc_count: number; + }[]; + + return buckets.map((b) => ({ + time: new Date(b.key).toISOString().slice(11, 16), + count: b.doc_count, + })); +}; + +const runBreakdownPanel = async ( + panel: BreakdownPanelDefinition, + timeRange: { gte: string; lte: string }, +): Promise => { + const { spec } = panel; + const res = await elasticClient.search({ + index: spec.index, + size: 0, + query: buildBoolQuery(timeRange, spec.filter), + aggs: { by_field: { terms: { field: spec.field, size: spec.size ?? 5 } } }, + }); + + const buckets = ((res.aggregations as Record)?.by_field?.buckets ?? []) as { + key: string; + doc_count: number; + }[]; + + return buckets.map((b) => ({ label: String(b.key), value: b.doc_count })); +}; + +export const getDashboardDataService = async ( + id: string, + query: DashboardDataQuery, +): Promise => { + const dashboard = await prisma.dashboard.findUnique({ where: { id } }); + if (!dashboard) throw new ApiErrorHandler(404, 'Dashboard Not Found'); + + const panels = (dashboard.panels as unknown as DashboardPanelDefinition[]) ?? []; + const timeRange = buildTimeRange(query); + + const stats: PanelStat[] = []; + let histogram: HistogramPoint[] = []; + let breakdown: BreakdownItem[] = []; + + await Promise.all( + panels.map(async (panel) => { + try { + if (panel.type === 'metric') { + stats.push(await runMetricPanel(panel, timeRange)); + } else if (panel.type === 'histogram') { + histogram = await runHistogramPanel(panel, timeRange); + } else if (panel.type === 'breakdown') { + breakdown = await runBreakdownPanel(panel, timeRange); + } + } catch (error) { + logger.error(`Failed to compute panel "${panel.title}" for dashboard ${id}`, { error }); + } + }), + ); + + return { stats, histogram, breakdown }; +}; diff --git a/src/modules/Dashboards/services/dashboard.utils.ts b/src/modules/Dashboards/services/dashboard.utils.ts new file mode 100644 index 0000000..2dcb94a --- /dev/null +++ b/src/modules/Dashboards/services/dashboard.utils.ts @@ -0,0 +1,40 @@ +import { Prisma } from '@prisma/client'; + +export const buildDashboardFilter = (query: { + search?: string; + ownerId?: string; + createdAt?: string; +}): Prisma.DashboardWhereInput => { + const { search, ownerId, createdAt } = query; + const where: Prisma.DashboardWhereInput = {}; + + if (ownerId) where.ownerId = ownerId; + + if (search) { + where.OR = [ + { + title: { + contains: search, + mode: 'insensitive', + }, + }, + { + description: { + contains: search, + mode: 'insensitive', + }, + }, + ]; + } + + if (createdAt) { + if (createdAt.includes('to')) { + const [start, end] = createdAt.split('to').map((d) => new Date(d.trim())); + where.createdAt = { gte: start, lte: end }; + } else { + where.createdAt = { gte: new Date(createdAt) }; + } + } + + return where; +}; diff --git a/src/modules/Dashboards/types/types.ts b/src/modules/Dashboards/types/types.ts new file mode 100644 index 0000000..b66d713 --- /dev/null +++ b/src/modules/Dashboards/types/types.ts @@ -0,0 +1,81 @@ +export interface MetricPanelDefinition { + id: string; + type: 'metric'; + title: string; + spec: { + index: string; + aggType: 'count' | 'cardinality' | 'ratio'; + field?: string; + filter?: Record; + numeratorFilter?: Record; + }; +} + +export interface HistogramPanelDefinition { + id: string; + type: 'histogram'; + title: string; + spec: { + index: string; + interval?: string; + filter?: Record; + }; +} + +export interface BreakdownPanelDefinition { + id: string; + type: 'breakdown'; + title: string; + spec: { + index: string; + field: string; + size?: number; + filter?: Record; + }; +} + +export type DashboardPanelDefinition = + | MetricPanelDefinition + | HistogramPanelDefinition + | BreakdownPanelDefinition; + +export interface createDashboardData { + title: string; + description?: string; + tags?: string[]; + panels?: DashboardPanelDefinition[]; +} + +export type updateDashboardData = Partial; + +export interface ListDashboardsQuery { + search?: string; + page: number; + limit: number; +} + +export interface DashboardDataQuery { + from?: string; + to?: string; +} + +export interface PanelStat { + label: string; + value: string | number; +} + +export interface HistogramPoint { + time: string; + count: number; +} + +export interface BreakdownItem { + label: string; + value: number; +} + +export interface DashboardPanelsData { + stats: PanelStat[]; + histogram: HistogramPoint[]; + breakdown: BreakdownItem[]; +} diff --git a/src/modules/Dashboards/validation/dashboard.validation.ts b/src/modules/Dashboards/validation/dashboard.validation.ts new file mode 100644 index 0000000..4f18e6c --- /dev/null +++ b/src/modules/Dashboards/validation/dashboard.validation.ts @@ -0,0 +1,73 @@ +import * as z from 'zod'; + +const filterValidation = z.record(z.string(), z.string()).optional(); + +const metricPanelValidation = z.object({ + id: z.string().min(1), + type: z.literal('metric'), + title: z.string().min(1).max(150), + spec: z.object({ + index: z.string().min(1), + aggType: z.enum(['count', 'cardinality', 'ratio']), + field: z.string().optional(), + filter: filterValidation, + numeratorFilter: filterValidation, + }), +}); + +const histogramPanelValidation = z.object({ + id: z.string().min(1), + type: z.literal('histogram'), + title: z.string().min(1).max(150), + spec: z.object({ + index: z.string().min(1), + interval: z.string().optional(), + filter: filterValidation, + }), +}); + +const breakdownPanelValidation = z.object({ + id: z.string().min(1), + type: z.literal('breakdown'), + title: z.string().min(1).max(150), + spec: z.object({ + index: z.string().min(1), + field: z.string().min(1), + size: z.number().int().min(1).max(50).optional(), + filter: filterValidation, + }), +}); + +const panelValidation = z.discriminatedUnion('type', [ + metricPanelValidation, + histogramPanelValidation, + breakdownPanelValidation, +]); + +export const createDashboardValidation = z.object({ + title: z.string().min(3).max(150).trim(), + description: z.string().max(500).trim().optional(), + tags: z.array(z.string().trim()).optional(), + panels: z.array(panelValidation).optional(), +}); + +export const updateDashboardValidation = createDashboardValidation.partial(); + +export const getDashboardValidation = z.object({ + id: z.uuid(), +}); + +export const deleteDashboardValidation = z.object({ + id: z.uuid(), +}); + +export const queryDashboardsValidation = z.object({ + search: z.string().min(1).optional(), + page: z.coerce.number().int().min(1).default(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(10).optional(), +}); + +export const dashboardDataQueryValidation = z.object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), +}); diff --git a/src/routes/index.ts b/src/routes/index.ts index 0f4c007..cd0ae89 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,6 +6,7 @@ import ruleRoutes from '../modules/Rule/routes/rule.routes'; import serviceRouter from '../modules/service/routes/service.routes'; import logSourceRouter from '../modules/logSource/routes/logSource.routes'; import alertRoutes from '../modules/Alerts/routes/alert.routes'; +import dashboardRoutes from '../modules/Dashboards/routes/dashboard.routes'; const router = express.Router(); @@ -16,5 +17,5 @@ router.use('/rules', ruleRoutes); router.use('/services', serviceRouter); router.use('/log-sources', logSourceRouter); router.use('/alerts', alertRoutes); - +router.use('/dashboards', dashboardRoutes); export default router;