diff --git a/apps/docs/synthetic/features/monitor.mdx b/apps/docs/synthetic/features/monitor.mdx index d343b6ce57..dbdfe6515b 100644 --- a/apps/docs/synthetic/features/monitor.mdx +++ b/apps/docs/synthetic/features/monitor.mdx @@ -82,3 +82,7 @@ We currently support the following assertions on the following fields: - **Status Code** - **Response Body** + +## Video Tutorial 📺 + + diff --git a/apps/docs/synthetic/features/status-page.mdx b/apps/docs/synthetic/features/status-page.mdx index 006497075b..ba63920e8a 100644 --- a/apps/docs/synthetic/features/status-page.mdx +++ b/apps/docs/synthetic/features/status-page.mdx @@ -38,3 +38,15 @@ If you want the dark version of the badge, you can use the following URL: ```html ``` + +You can also customize the size of the badge by adding a `size` parameter: + +- `sm +- `md` +- `lg` +- `xl` + + +```html + +``` \ No newline at end of file diff --git a/apps/ingest-worker/src/index.ts b/apps/ingest-worker/src/index.ts index f8bf9bab79..7930665c3b 100644 --- a/apps/ingest-worker/src/index.ts +++ b/apps/ingest-worker/src/index.ts @@ -119,6 +119,7 @@ app.post("/v1", async (c) => { const data = z.array(schemaV1).parse(JSON.parse(rawText)); const userAgent = c.req.header("user-agent") || ""; + const timestamp = Date.now(); const country = c.req.header("cf-ipcountry") || ""; const city = c.req.raw.cf?.city || ""; const region_code = c.req.raw.cf?.regionCode || ""; @@ -130,6 +131,7 @@ app.post("/v1", async (c) => { const device = getDevice(d.screen, os); return tbIngestWebVitals.parse({ ...d, + timestamp, device, ...d.data, browser, diff --git a/apps/ingest-worker/src/utils.ts b/apps/ingest-worker/src/utils.ts index 3689ec29a5..db36e8deb1 100644 --- a/apps/ingest-worker/src/utils.ts +++ b/apps/ingest-worker/src/utils.ts @@ -45,7 +45,8 @@ export function getDevice(screen: string, os: string) { return "laptop"; } return "desktop"; - } else if (MOBILE_OS.includes(os)) { + } + if (MOBILE_OS.includes(os)) { if (os === "Amazon OS" || +width > MOBILE_SCREEN_WIDTH) { return "tablet"; } @@ -54,11 +55,12 @@ export function getDevice(screen: string, os: string) { if (+width >= DESKTOP_SCREEN_WIDTH) { return "desktop"; - } else if (+width >= LAPTOP_SCREEN_WIDTH) { + } + if (+width >= LAPTOP_SCREEN_WIDTH) { return "laptop"; - } else if (+width >= MOBILE_SCREEN_WIDTH) { + } + if (+width >= MOBILE_SCREEN_WIDTH) { return "tablet"; - } else { - return "mobile"; } + return "mobile"; } diff --git a/apps/server/src/v1/statusReport.test.ts b/apps/server/src/v1/statusReport.test.ts index d0532523cb..5f1eab6231 100644 --- a/apps/server/src/v1/statusReport.test.ts +++ b/apps/server/src/v1/statusReport.test.ts @@ -1,7 +1,6 @@ import { expect, test } from "bun:test"; import { api } from "."; -import { iso8601Regex } from "./test-utils"; test("GET one status report", async () => { const res = await api.request("/status_report/1", { @@ -12,12 +11,47 @@ test("GET one status report", async () => { expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ id: 1, - // TODO: discuss if we should return `updates` instead of `status_report_updates` - status_report_updates: expect.any(Array), + title: "Test Status Report", + status: "monitoring", + status_report_updates: [1, 3, 4], + message: "test", + monitors_id: null, + pages_id: [1], }); }); -test("create one status report", async () => { +test("Get all status report", async () => { + const res = await api.request("/status_report", { + headers: { + "x-openstatus-key": "1", + }, + }); + expect(res.status).toBe(200); + expect({ data: await res.json() }).toMatchObject({ + data: [ + { + id: 1, + title: "Test Status Report", + status: "monitoring", + status_report_updates: [1, 3, 4], + message: "test", + monitors_id: null, + pages_id: [1], + }, + { + id: 2, + title: "Test Status Report", + status: "investigating", + status_report_updates: [2], + message: "Message", + monitors_id: [1, 2], + pages_id: [1], + }, + ], + }); +}); + +test("Create one status report including passing optional fields", async () => { const res = await api.request("/status_report", { method: "POST", headers: { @@ -26,13 +60,21 @@ test("create one status report", async () => { }, body: JSON.stringify({ status: "investigating", - title: "Test Status Report", + title: "New Status Report", + message: "Message", + monitors_id: [1], + pages_id: [1], }), }); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ - id: expect.any(Number), - status_report_updates: expect.any(Array), + id: 3, + title: "New Status Report", + status: "investigating", + status_report_updates: [5], + message: "Message", + monitors_id: [1], + pages_id: [1], }); }); @@ -72,21 +114,54 @@ test("Create one status report with invalid data should return 403", async () => }); }); -test("Get all status report", async () => { +test("Create status report with non existing monitor ids should return 400", async () => { const res = await api.request("/status_report", { + method: "POST", headers: { "x-openstatus-key": "1", + "content-type": "application/json", }, + body: JSON.stringify({ + status: "investigating", + title: "New Status Report", + message: "Message", + monitors_id: [100], + pages_id: [1], + }), }); - expect(res.status).toBe(200); - expect((await res.json())[0]).toMatchObject({ - id: expect.any(Number), - status_report_updates: expect.any(Array), + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 400, + message: "monitor(s) with id [100] doesn't exist ", + }); +}); + +test("Create status report with non existing page ids should return 400", async () => { + const res = await api.request("/status_report", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + title: "New Status Report", + message: "Message", + monitors_id: [1], + pages_id: [100], + }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 400, + message: "page(s) with id [100] doesn't exist ", }); }); test("Delete a status report", async () => { - const res = await api.request("/status_report/2", { + const res = await api.request("/status_report/3", { method: "DELETE", headers: { "x-openstatus-key": "1", @@ -97,8 +172,29 @@ test("Delete a status report", async () => { message: "Deleted", }); }); +test("create a status report update with empty body should return current report info", async () => { + const res = await api.request("/status_report/1/update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); -test("create a status report update", async () => { + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + id: 1, + title: "Test Status Report", + status: "investigating", + status_report_updates: [1, 3, 4], + message: "test", + monitors_id: null, + pages_id: [1], + }); +}); + +test("Create status report update with non existing monitor ids should return 400", async () => { const res = await api.request("/status_report/1/update", { method: "POST", headers: { @@ -107,16 +203,94 @@ test("create a status report update", async () => { }, body: JSON.stringify({ status: "investigating", - date: "2023-11-08T21:03:13.000Z", - message: "Test Status Report", + title: "New Status Report", + message: "Message", + monitors_id: [100], + pages_id: [1], + }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 400, + message: "monitor(s) with id [100] doesn't exist ", + }); +}); + +test("Create status report update with non existing page ids should return 400", async () => { + const res = await api.request("/status_report/1/update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + title: "New Status Report", + message: "Message", + monitors_id: [1], + pages_id: [100], }), }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 400, + message: "page(s) with id [100] doesn't exist ", + }); +}); + +test("Update with title, monitor & page should not create record in status_report_update table", async () => { + const res = await api.request("/status_report/1/update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "Doesn't add record", + monitors_id: [1], + pages_id: [], + }), + }); + expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ + id: 1, + title: "Doesn't add record", status: "investigating", - id: expect.any(String), - date: expect.stringMatching(iso8601Regex), - message: "Test Status Report", + status_report_updates: [1, 3, 4], + message: "test", + monitors_id: [1], + pages_id: null, + }); +}); + +test("create a status report update", async () => { + const res = await api.request("/status_report/1/update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "Updated Status Report", + status: "resolved", + message: "New Message", + monitors_id: [1, 2], + pages_id: [], + }), + }); + expect(res.status).toBe(200); + + expect(await res.json()).toMatchObject({ + id: 1, + title: "Updated Status Report", + status: "resolved", + status_report_updates: [1, 3, 4, 5], + message: "New Message", + monitors_id: [1, 2], + pages_id: null, }); }); @@ -129,7 +303,7 @@ test("Get Status Report should return current status of report", async () => { expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ id: 1, - status: "investigating", + status: "resolved", status_report_updates: expect.any(Array), }); }); @@ -150,7 +324,7 @@ test("Create a status report update not in db should return 404", async () => { expect(res.status).toBe(404); expect(await res.json()).toMatchObject({ code: 404, - message: "Not Found", + message: `status report with id 404 doesn't exist`, }); }); @@ -169,26 +343,3 @@ test("Create a status report update without auth key should return 401", async ( }); expect(res.status).toBe(401); }); - -test("Create a status report update with invalid data should return 403", async () => { - const res = await api.request("/status_report/1/update", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - //passing in incompelete body - date: "2023-11-08T21:03:13.000Z", - message: "Test Status Report", - }), - }); - expect(res.status).toBe(400); - expect(await res.json()).toMatchObject({ - error: { - issues: expect.any(Array), - name: "ZodError", - }, - success: false, - }); -}); diff --git a/apps/server/src/v1/statusReport.ts b/apps/server/src/v1/statusReport.ts index a3ccf44c60..343d4e4b3e 100644 --- a/apps/server/src/v1/statusReport.ts +++ b/apps/server/src/v1/statusReport.ts @@ -1,7 +1,8 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; -import { and, db, eq, isNotNull } from "@openstatus/db"; +import { and, asc, db, eq, isNotNull } from "@openstatus/db"; import { + monitorsToStatusReport, page, pageSubscriber, pagesToStatusReports, @@ -14,7 +15,6 @@ import { allPlans } from "@openstatus/plans"; import type { Variables } from "./index"; import { ErrorSchema } from "./shared"; -import { statusUpdateSchema } from "./statusReportUpdate"; import { isoDate } from "./utils"; const statusReportApi = new OpenAPIHono<{ Variables: Variables }>(); @@ -33,19 +33,39 @@ const ParamsSchema = z.object({ }), }); -const createStatusReportUpdateSchema = z.object({ +const reportSchema = z.object({ + id: z.number().openapi({ description: "The id of the status report" }), + title: z.string().openapi({ + example: "Documenso", + description: "The title of the status report", + }), status: z.enum(statusReportStatus).openapi({ - description: "The status of the update", + description: "The current status of the report", }), date: isoDate.openapi({ - description: "The date of the update in ISO8601 format", + description: "The date of the report in ISO8601 format", + }), + status_report_updates: z.array(z.number()).openapi({ + description: "The ids of the status report updates", }), message: z.string().openapi({ - description: "The message of the update", + description: "The message of the current status of incident", }), + monitors_id: z + .array(z.number()) + .openapi({ + description: "id of monitors this report needs to refer", + }) + .nullable(), + pages_id: z + .array(z.number()) + .openapi({ + description: "id of status pages this report needs to refer", + }) + .nullable(), }); -const statusSchema = z.object({ +const createStatusReportSchema = z.object({ title: z.string().openapi({ example: "Documenso", description: "The title of the status report", @@ -53,16 +73,67 @@ const statusSchema = z.object({ status: z.enum(statusReportStatus).openapi({ description: "The current status of the report", }), + message: z.string().openapi({ + description: "The message of the current status of incident", + }), + date: isoDate + .openapi({ + description: "The date of the report in ISO8601 format", + }) + .optional(), + monitors_id: z + .array(z.number()) + .openapi({ + description: "id of monitors this report needs to refer", + }) + .optional() + .default([]), + pages_id: z + .array(z.number()) + .openapi({ + description: "id of status pages this report needs to refer", + }) + .optional() + .default([]), }); -const statusReportExtendedSchema = statusSchema.extend({ - id: z.number().openapi({ description: "The id of the status report" }), - status_report_updates: z +const updateStatusReportSchema = z.object({ + title: z + .string() + .openapi({ + example: "Documenso", + description: "The title of the status report to update", + }) + .optional(), + status: z + .enum(statusReportStatus) + .openapi({ + description: "The status of the report to update", + }) + .optional(), + message: z + .string() + .openapi({ + description: "The message of the status of incident to update", + }) + .optional(), + date: isoDate + .openapi({ + description: "The date of the report in ISO8601 format to update", + }) + .optional(), + monitors_id: z .array(z.number()) .openapi({ - description: "The ids of the status report updates", + description: "id of monitors this report needs to refer when update", }) - .default([]), + .optional(), + pages_id: z + .array(z.number()) + .openapi({ + description: "id of status pages this report needs to refer when update", + }) + .optional(), }); const getAllRoute = createRoute({ @@ -75,7 +146,7 @@ const getAllRoute = createRoute({ 200: { content: { "application/json": { - schema: z.array(statusReportExtendedSchema), + schema: z.array(reportSchema), }, }, description: "Get all status reports", @@ -96,21 +167,36 @@ statusReportApi.openapi(getAllRoute, async (c) => { const _statusReports = await db.query.statusReport.findMany({ with: { statusReportUpdates: true, + monitorsToStatusReports: true, + pagesToStatusReports: true, }, where: eq(statusReport.workspaceId, workspaceId), }); if (!_statusReports) return c.json({ code: 404, message: "Not Found" }, 404); - const data = z.array(statusReportExtendedSchema).parse( - _statusReports.map((statusReport) => ({ - ...statusReport, - status_report_updates: statusReport.statusReportUpdates.map( - (statusReportUpdate) => { - return statusReportUpdate.id; - }, - ), - })), + const data = z.array(reportSchema).parse( + _statusReports.map((statusReport) => { + const { + statusReportUpdates, + monitorsToStatusReports, + pagesToStatusReports, + } = statusReport; + const { message, date } = + statusReportUpdates[statusReportUpdates.length - 1]; + return { + ...statusReport, + message, + date, + monitors_id: monitorsToStatusReports.length + ? monitorsToStatusReports.map((monitor) => monitor.monitorId) + : null, + pages_id: pagesToStatusReports.length + ? pagesToStatusReports.map((page) => page.pageId) + : null, + status_report_updates: statusReportUpdates.map((update) => update.id), + }; + }), ); return c.json(data); @@ -128,7 +214,7 @@ const getRoute = createRoute({ 200: { content: { "application/json": { - schema: statusReportExtendedSchema, + schema: reportSchema, }, }, description: "Get all status reports", @@ -152,6 +238,8 @@ statusReportApi.openapi(getRoute, async (c) => { const _statusUpdate = await db.query.statusReport.findFirst({ with: { statusReportUpdates: true, + monitorsToStatusReports: true, + pagesToStatusReports: true, }, where: and( eq(statusReport.workspaceId, workspaceId), @@ -160,11 +248,23 @@ statusReportApi.openapi(getRoute, async (c) => { }); if (!_statusUpdate) return c.json({ code: 404, message: "Not Found" }, 404); - const data = statusReportExtendedSchema.parse({ + const { statusReportUpdates, monitorsToStatusReports, pagesToStatusReports } = + _statusUpdate; + + // most recent report information + const { message, date } = statusReportUpdates[statusReportUpdates.length - 1]; + + const data = reportSchema.parse({ ..._statusUpdate, - status_report_updates: _statusUpdate.statusReportUpdates.map( - (update) => update.id, - ), + message, + date, + monitors_id: monitorsToStatusReports.length + ? monitorsToStatusReports.map((monitor) => monitor.monitorId) + : null, + pages_id: pagesToStatusReports.length + ? pagesToStatusReports.map((page) => page.pageId) + : null, + status_report_updates: statusReportUpdates.map((update) => update.id), }); return c.json(data); @@ -180,7 +280,7 @@ const postRoute = createRoute({ description: "The status report to create", content: { "application/json": { - schema: statusSchema, + schema: createStatusReportSchema, }, }, }, @@ -189,7 +289,7 @@ const postRoute = createRoute({ 200: { content: { "application/json": { - schema: statusReportExtendedSchema, + schema: reportSchema, }, }, description: "Status report created", @@ -209,6 +309,54 @@ statusReportApi.openapi(postRoute, async (c) => { const input = c.req.valid("json"); const workspaceId = Number(c.get("workspaceId")); + const { pages_id, monitors_id, date } = input; + + if (monitors_id.length) { + const monitors = ( + await db.query.monitor.findMany({ + columns: { + id: true, + }, + }) + ).map((m) => m.id); + + const nonExistingId = monitors_id.filter((m) => !monitors.includes(m)); + + if (nonExistingId.length) { + return c.json( + { + code: 400, + message: `monitor(s) with id [${nonExistingId}] doesn't exist `, + }, + 400, + ); + } + } + + // pages check + + if (pages_id.length) { + const pages = ( + await db.query.page.findMany({ + columns: { + id: true, + }, + }) + ).map((m) => m.id); + + const nonExistingId = pages_id.filter((m) => !pages.includes(m)); + + if (nonExistingId.length) { + return c.json( + { + code: 400, + message: `page(s) with id [${nonExistingId}] doesn't exist `, + }, + 400, + ); + } + } + const _newStatusReport = await db .insert(statusReport) .values({ @@ -221,16 +369,59 @@ statusReportApi.openapi(postRoute, async (c) => { const _statusReportHistory = await db .insert(statusReportUpdate) .values({ - status: input.status, - date: new Date(), - message: "", + ...input, + date: date ? new Date(date) : new Date(), statusReportId: _newStatusReport.id, }) .returning() .get(); - const data = statusReportExtendedSchema.parse({ + const pageToStatusIds: number[] = []; + const monitorToStatusIds: number[] = []; + + if (pages_id.length) { + pageToStatusIds.push( + ...( + await db + .insert(pagesToStatusReports) + .values( + pages_id.map((id) => { + return { + pageId: id, + statusReportId: _newStatusReport.id, + }; + }), + ) + .returning() + ).map((page) => page.pageId), + ); + } + + if (monitors_id.length) { + monitorToStatusIds.push( + ...( + await db + .insert(monitorsToStatusReport) + .values( + monitors_id.map((id) => { + return { + monitorId: id, + statusReportId: _newStatusReport.id, + }; + }), + ) + .returning() + ).map((monitor) => monitor.monitorId), + ); + } + + const { message: curMessage, date: curDate } = _statusReportHistory; + const data = reportSchema.parse({ ..._newStatusReport, + message: curMessage, + date: curDate, + monitors_id: monitorToStatusIds.length ? monitorToStatusIds : null, + pages_id: pageToStatusIds.length ? pageToStatusIds : null, status_report_updates: [_statusReportHistory.id], }); @@ -303,7 +494,7 @@ const postRouteUpdate = createRoute({ description: "the status report update", content: { "application/json": { - schema: createStatusReportUpdateSchema, + schema: updateStatusReportSchema, }, }, }, @@ -312,7 +503,7 @@ const postRouteUpdate = createRoute({ 200: { content: { "application/json": { - schema: statusUpdateSchema, + schema: reportSchema, }, }, description: "Status report updated", @@ -335,30 +526,198 @@ statusReportApi.openapi(postRouteUpdate, async (c) => { const statusReportId = Number(id); - const _updatedStatusReport = await db - .update(statusReport) - .set({ status: input.status }) - .where( - and( - eq(statusReport.id, statusReportId), - eq(statusReport.workspaceId, workspaceId), - ), - ) - .returning() - .get(); + const { title, date, message, monitors_id, pages_id, status } = input; + + // monitors check + const associatedMonitorsId: number[] = []; + const associatedPagesId: number[] = []; + + if (monitors_id) { + let monitors: number[]; + if (monitors_id.length) { + monitors = ( + await db.query.monitor.findMany({ + columns: { + id: true, + }, + }) + ).map((m) => m.id); + + const nonExistingId = monitors_id.filter((m) => !monitors.includes(m)); + + if (nonExistingId.length) { + return c.json( + { + code: 400, + message: `monitor(s) with id [${nonExistingId}] doesn't exist `, + }, + 400, + ); + } + } + } + + // pages check + + if (pages_id) { + let pages: number[]; + if (pages_id.length) { + pages = ( + await db.query.page.findMany({ + columns: { + id: true, + }, + }) + ).map((m) => m.id); + + const nonExistingId = pages_id.filter((m) => !pages.includes(m)); + + if (nonExistingId.length) { + return c.json( + { + code: 400, + message: `page(s) with id [${nonExistingId}] doesn't exist `, + }, + 400, + ); + } + } + } + + const [_updatedStatusReport, statusReportHistory] = await Promise.all([ + status || title + ? db + .update(statusReport) + .set({ ...(status && { status }), ...(title && { title }) }) + .where( + and( + eq(statusReport.id, statusReportId), + eq(statusReport.workspaceId, workspaceId), + ), + ) + .returning() + .get() + : db.query.statusReport.findFirst({ + where: and( + eq(statusReport.id, statusReportId), + eq(statusReport.workspaceId, workspaceId), + ), + }), + db.query.statusReportUpdate.findMany({ + where: eq(statusReportUpdate.statusReportId, statusReportId), + orderBy: asc(statusReportUpdate.createdAt), + }), + ]); if (!_updatedStatusReport) - return c.json({ code: 404, message: "Not Found" }, 404); + return c.json( + { + code: 404, + message: `status report with id ${statusReportId} doesn't exist`, + }, + 404, + ); + + if (!statusReportHistory) + return c.json( + { + code: 404, + message: `status reports history with id ${statusReportId} doesn't exist`, + }, + 404, + ); + + const _mostRecentUpdate = statusReportHistory[statusReportHistory.length - 1]; + + const { + status: prevStatus, + date: prevDate, + message: prevMessage, + } = _mostRecentUpdate; + + // only have status_report_update_changes in status_report_update_table + // if anyone status | date | message was intended to update + + const isUpdates = + status != undefined || date != undefined || message != undefined; + + const _statusReportUpdate = isUpdates + ? await db + .insert(statusReportUpdate) + .values({ + status: status ? status : prevStatus, + date: date ? new Date(date) : prevDate, + message: message ? message : prevMessage, + statusReportId: Number(id), + }) + .returning() + .get() + : _mostRecentUpdate; + + if (monitors_id) { + if (monitors_id.length) { + const updateToNew = monitors_id.map((id) => { + return { monitorId: id, statusReportId }; + }); + await db + .delete(monitorsToStatusReport) + .where(eq(monitorsToStatusReport.statusReportId, statusReportId)); + associatedMonitorsId.push( + ...( + await db + .insert(monitorsToStatusReport) + .values([...updateToNew]) + .returning() + ).map((m) => m.monitorId), + ); + } else { + await db + .delete(monitorsToStatusReport) + .where(eq(monitorsToStatusReport.statusReportId, statusReportId)); + } + } else { + associatedMonitorsId.push( + ...( + await db.query.monitorsToStatusReport.findMany({ + where: eq(monitorsToStatusReport.statusReportId, statusReportId), + }) + ).map((m) => m.monitorId), + ); + } + + if (pages_id) { + if (pages_id.length) { + const updateToNew = pages_id.map((id) => { + return { pageId: id, statusReportId }; + }); + + await db + .delete(pagesToStatusReports) + .where(eq(pagesToStatusReports.statusReportId, statusReportId)); + + associatedPagesId.push( + ...( + await db + .insert(pagesToStatusReports) + .values([...updateToNew]) + .returning() + ).map((m) => m.pageId), + ); + } else { + await db + .delete(pagesToStatusReports) + .where(eq(pagesToStatusReports.statusReportId, statusReportId)); + } + } else { + associatedPagesId.push( + ...( + await db.query.pagesToStatusReports.findMany({ + where: eq(pagesToStatusReports.statusReportId, statusReportId), + }) + ).map((p) => p.pageId), + ); + } - const _statusReportUpdate = await db - .insert(statusReportUpdate) - .values({ - ...input, - date: new Date(input.date), - statusReportId: Number(id), - }) - .returning() - .get(); // send email const workspacePlan = c.get("workspacePlan"); if (workspacePlan !== allPlans.free) { @@ -396,7 +755,33 @@ statusReportApi.openapi(postRouteUpdate, async (c) => { }); } } - const data = statusUpdateSchema.parse(_statusReportUpdate); + + const { + status: curStatus, + date: curDate, + message: curMessage, + } = _statusReportUpdate; + + const { title: curTitle } = _updatedStatusReport; + + const status_report_updates = [ + ...statusReportHistory.map((report) => report.id), + ]; + + if (isUpdates) { + status_report_updates.push(_statusReportUpdate.id); + } + + const data = reportSchema.parse({ + id: statusReportId, + title: curTitle, + status: curStatus, + date: curDate, + message: curMessage, + monitors_id: associatedMonitorsId.length ? associatedMonitorsId : null, + pages_id: associatedPagesId.length ? associatedPagesId : null, + status_report_updates, + }); return c.json({ ...data, diff --git a/apps/server/src/v1/statusReportUpdate.test.ts b/apps/server/src/v1/statusReportUpdate.test.ts index c2c0659fa2..90d3bfc93e 100644 --- a/apps/server/src/v1/statusReportUpdate.test.ts +++ b/apps/server/src/v1/statusReportUpdate.test.ts @@ -12,7 +12,7 @@ test("GET one status report update ", async () => { expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ status: "investigating", - message: "", + message: "Message", date: expect.stringMatching(iso8601Regex), }); }); diff --git a/apps/web/.env.example b/apps/web/.env.example index ac3e37c8f4..32fb8d4dde 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -55,6 +55,9 @@ CRON_SECRET= EXTERNAL_API_URL= +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST= + PLAYGROUND_UNKEY_API_KEY= # RUM server with separate clickhouse for self-host diff --git a/apps/web/next.config.js b/apps/web/next.config.js index c02db0b348..fe19b395d8 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -15,6 +15,8 @@ const nextConfig = { // "better-sqlite3" ], optimizePackageImports: ["@tremor/react"], + // FIXME: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout + missingSuspenseWithCSRBailout: false, }, logging: { fetches: { @@ -31,6 +33,10 @@ const nextConfig = { protocol: "https", hostname: "screenshot.openstat.us", }, + { + protocol: "https", + hostname: "www.openstatus.dev", + }, ], }, }; diff --git a/apps/web/package.json b/apps/web/package.json index 93a80f0262..38b2197f99 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,6 +65,8 @@ "next-contentlayer": "0.3.4", "next-plausible": "3.12.0", "next-themes": "0.2.1", + "posthog-js": "1.136.1", + "posthog-node": "4.0.1", "random-word-slugs": "0.1.7", "react": "18.2.0", "react-day-picker": "8.8.2", diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/data-table-wrapper.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/data-table-wrapper.tsx new file mode 100644 index 0000000000..fad9ba1fc2 --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/data-table-wrapper.tsx @@ -0,0 +1,12 @@ +import { columns } from "@/components/data-table/rum/columns"; +import { DataTable } from "@/components/data-table/rum/data-table"; +import type { responseRumPageQuery } from "@openstatus/tinybird/src/validation"; +import type { z } from "zod"; + +export const DataTableWrapper = ({ + data, +}: { + data: z.infer[]; +}) => { + return ; +}; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/action.ts b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/action.ts new file mode 100644 index 0000000000..233d255bfc --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/action.ts @@ -0,0 +1,18 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { analytics, trackAnalytics } from "@openstatus/analytics"; +import { Redis } from "@upstash/redis"; +const redis = Redis.fromEnv(); + +export const RequestAccessToRum = async () => { + const session = await auth(); + if (!session?.user) return; + + await redis.sadd("rum_access_requested", session.user.email); + await analytics.identify(session.user.id, { email: session.user.email }); + await trackAnalytics({ + event: "User RUM Beta Requested", + email: session.user.email || "", + }); +}; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/request-button.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/request-button.tsx new file mode 100644 index 0000000000..bffa0390dd --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/request-button.tsx @@ -0,0 +1,34 @@ +"use client"; +import { auth } from "@/lib/auth"; +import { Button } from "@openstatus/ui"; +import { Redis } from "@upstash/redis"; +import { useState } from "react"; +import { RequestAccessToRum } from "./action"; + +export const RequestButton = async ({ + hasRequestAccess, +}: { + hasRequestAccess: number; +}) => { + const [accessRequested, setAccessRequested] = useState(hasRequestAccess); + if (accessRequested) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/route-table.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/route-table.tsx index a1914a76c3..572dfd7109 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/route-table.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/route-table.tsx @@ -1,37 +1,17 @@ -import { - Table, - TableCaption, - TableHead, - TableHeader, - TableRow, -} from "@openstatus/ui"; - import { api } from "@/trpc/server"; +import { DataTableWrapper } from "./data-table-wrapper"; -const RouteTable = async () => { - const data = await api.rumRouter.GetAggregatedPerPage.query(); +const RouteTable = async ({ dsn }: { dsn: string }) => { + const data = await api.tinybird.rumMetricsForApplicationPerPage.query({ + dsn: dsn, + period: "24h", + }); if (!data) { return null; } return (
-

Page Performance

-
- - An overview of your page performance. - - - Page - Total Events - CLS - FCP - INP - LCP - TTFB - - -
-
+
); }; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-card.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-card.tsx new file mode 100644 index 0000000000..71afb0d5a7 --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-card.tsx @@ -0,0 +1,35 @@ +import { Card } from "@tremor/react"; +import { getColorByType, webVitalsConfig } from "@openstatus/rum"; +import type { WebVitalEvents, WebVitalsValues } from "@openstatus/rum"; +import { CategoryBar } from "./category-bar"; + +export function prepareWebVitalValues(values: WebVitalsValues) { + return values.map((value) => ({ + ...value, + color: getColorByType(value.type), + })); +} + +export const RUMCard = async ({ + event, + value, +}: { + event: WebVitalEvents; + value: number; +}) => { + const eventConfig = webVitalsConfig[event]; + return ( + +

+ {eventConfig.label} ({event}) +

+

+ {event !== "CLS" ? value.toFixed(0) : value.toFixed(2) || 0} +

+ +
+ ); +}; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx index afecbdf9c3..9a9df9d43c 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx @@ -1,33 +1,22 @@ -import { Card } from "@tremor/react"; - -import { getColorByType, webVitalsConfig } from "@openstatus/rum"; -import type { WebVitalEvents, WebVitalsValues } from "@openstatus/rum"; +import { getColorByType } from "@openstatus/rum"; +import type { WebVitalsValues } from "@openstatus/rum"; import { api } from "@/trpc/server"; -import { CategoryBar } from "./category-bar"; - -function prepareWebVitalValues(values: WebVitalsValues) { - return values.map((value) => ({ - ...value, - color: getColorByType(value.type), - })); -} +import { RUMCard } from "./rum-card"; -export const RUMMetricCard = async ({ event }: { event: WebVitalEvents }) => { - const data = await api.rumRouter.GetEventMetricsForWorkspace.query({ event }); - const eventConfig = webVitalsConfig[event]; +export const RUMMetricCards = async ({ dsn }: { dsn: string }) => { + const data = await api.tinybird.totalRumMetricsForApplication.query({ + dsn: dsn, + period: "24h", + }); return ( - - {/*

- {eventConfig.label} ({event}) -

-

- {data?.median.toFixed(2) || 0} -

- */} -
+
+ + + + + + +
); }; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/util.ts b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/util.ts new file mode 100644 index 0000000000..3e5421f7bd --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/util.ts @@ -0,0 +1,6 @@ +export const timeFormater = (time: number) => { + if (time < 1000) { + return `${new Intl.NumberFormat("us").format(time).toString()}ms`; + } + return `${new Intl.NumberFormat("us").format(time / 1000).toString()}s`; +}; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/data-table-wrapper.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/data-table-wrapper.tsx new file mode 100644 index 0000000000..963b20d76a --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/data-table-wrapper.tsx @@ -0,0 +1,12 @@ +import { columns } from "@/components/data-table/session/columns"; +import { DataTable } from "@/components/data-table/session/data-table"; +import type { sessionRumPageQuery } from "@openstatus/tinybird/src/validation"; +import type { z } from "zod"; + +export const DataTableWrapper = ({ + data, +}: { + data: z.infer[]; +}) => { + return ; +}; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/path-card.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/path-card.tsx new file mode 100644 index 0000000000..93ae155322 --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/path-card.tsx @@ -0,0 +1,35 @@ +import { api } from "@/trpc/server"; +import { useSearchParams } from "next/navigation"; +import { use } from "react"; +import { RUMCard } from "../../_components/rum-card"; + +export const PathCard = async ({ + dsn, + path, +}: { + dsn: string; + path: string; +}) => { + if (!path) { + return null; + } + + const data = await api.tinybird.rumMetricsForPath.query({ + dsn, + path, + period: "24h", + }); + if (!data) { + return null; + } + return ( +
+ + + + + + +
+ ); +}; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/session-table.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/session-table.tsx new file mode 100644 index 0000000000..dc4e1c3f27 --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/_components/session-table.tsx @@ -0,0 +1,24 @@ +import { api } from "@/trpc/server"; +import { DataTableWrapper } from "./data-table-wrapper"; +import { useSearchParams } from "next/navigation"; +import { use } from "react"; + +const SessionTable = async ({ dsn, path }: { dsn: string; path: string }) => { + const data = await api.tinybird.sessionRumMetricsForPath.query({ + dsn: dsn, + period: "24h", + path: path, + }); + + if (!data) { + return null; + } + + return ( +
+ +
+ ); +}; + +export { SessionTable }; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/page.tsx new file mode 100644 index 0000000000..a09b08a4dc --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/page.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; + +import { PathCard } from "./_components/path-card"; +import { api } from "@/trpc/server"; +import { Suspense } from "react"; +import { Skeleton } from "@openstatus/ui/src/components/skeleton"; +import Loading from "../loading"; +import { auth } from "@/lib/auth"; +import { SessionTable } from "./_components/session-table"; +import { z } from "zod"; + +const searchParamsSchema = z.object({ + path: z.string(), +}); + +export default async function RUMPage({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined }; +}) { + const session = await auth(); + if (!session?.user) { + return ; + } + + const data = searchParamsSchema.parse(searchParams); + const applications = await api.workspace.getApplicationWorkspaces.query(); + if (applications.length === 0 || !applications[0].dsn) { + return null; + } + + return ( + <> + +
+ +
+ + ); +} diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx index bf976c18dd..879a55255e 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx @@ -1,55 +1,50 @@ -import Link from "next/link"; -import { notFound } from "next/navigation"; import * as React from "react"; -import { webVitalEvents } from "@openstatus/rum"; -import { Button } from "@openstatus/ui"; - import { EmptyState } from "@/components/dashboard/empty-state"; import { api } from "@/trpc/server"; import { RouteTable } from "./_components/route-table"; -import { RUMMetricCard } from "./_components/rum-metric-card"; +import { RUMMetricCards } from "./_components/rum-metric-card"; +import { Redis } from "@upstash/redis"; +import { auth } from "@/lib/auth"; +import { RequestButton } from "./_components/request-button/request-button"; export const dynamic = "force-dynamic"; +const redis = Redis.fromEnv(); + export default async function RUMPage() { - const workspace = await api.workspace.getWorkspace.query(); - if (!workspace) { - return notFound(); - } + const applications = await api.workspace.getApplicationWorkspaces.query(); + + const session = await auth(); + if (!session?.user) return null; - if (workspace.dsn === null) { + const accessRequested = await redis.sismember( + "rum_access_requested", + session.user.email + ); + + if (applications.length === 0) { return ( - - Contact Us - - - } + action={} /> ); } - + // ATM We can only have access to one application return ( <> -
- {webVitalEvents + + {/* {webVitalEvents // Remove FID from the list of events because it's deprecated by google .filter((v) => v !== "FID") .map((event) => ( - ))} -
+ ))} */}
- +
); diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/create-form.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/create-form.tsx index e018bbb7e0..29eb0a4682 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/create-form.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/create-form.tsx @@ -71,7 +71,7 @@ export function CreateForm({ ownerId }: { ownerId: number }) { setHasCopied(true); }} > - {rawKey} + {rawKey} {!hasCopied ? ( ) : ( diff --git a/apps/web/src/app/app/layout.tsx b/apps/web/src/app/app/layout.tsx index 4b5d121aa7..341f2f9e6d 100644 --- a/apps/web/src/app/app/layout.tsx +++ b/apps/web/src/app/app/layout.tsx @@ -1,9 +1,16 @@ import { SessionProvider } from "next-auth/react"; +import { PHProvider, PostHogPageview } from "@/providers/posthog"; + export default function AuthLayout({ children, // will be a page or nested layout }: { children: React.ReactNode; }) { - return {children}; + return ( + + + {children} + + ); } diff --git a/apps/web/src/app/shared-metadata.ts b/apps/web/src/app/shared-metadata.ts index f863ec299a..956d6a058d 100644 --- a/apps/web/src/app/shared-metadata.ts +++ b/apps/web/src/app/shared-metadata.ts @@ -2,7 +2,7 @@ import type { Metadata } from "next"; export const TITLE = "OpenStatus"; export const DESCRIPTION = - "A better way to monitor your services. Don't let your downtime ruin your day."; + "A better way to monitor your API and your frontend performance. Don't let downtime or slow page loading ruin your user experience. Speed Matters âš¡."; export const defaultMetadata: Metadata = { title: { diff --git a/apps/web/src/app/status-page/[domain]/badge/route.tsx b/apps/web/src/app/status-page/[domain]/badge/route.tsx index 934479bc36..c9e389c796 100644 --- a/apps/web/src/app/status-page/[domain]/badge/route.tsx +++ b/apps/web/src/app/status-page/[domain]/badge/route.tsx @@ -36,30 +36,45 @@ const statusDictionary: Record = { }, } as const; -const SIZE = { width: 120, height: 34 }; - +// const SIZE = { width: 120, height: 34 }; +const SIZE: Record = { + sm: { width: 120, height: 34 }, + md: { width: 160, height: 46 }, + lg: { width: 200, height: 56 }, + xl: { width: 240, height: 68 }, +}; export async function GET( req: NextRequest, - { params }: { params: { domain: string } }, + { params }: { params: { domain: string } } ) { const { status } = await getStatus(params.domain); const theme = req.nextUrl.searchParams.get("theme"); - + const size = req.nextUrl.searchParams.get("size"); + let s = SIZE.sm; + if (size) { + if (SIZE[size]) { + s = SIZE[size]; + } + } const { label, color } = statusDictionary[status]; - const light = "border-gray-200 text-gray-700 bg-white"; const dark = "border-gray-800 text-gray-300 bg-gray-900"; return new ImageResponse( -
- {label} -
-
, - { ...SIZE }, + ( +
+ {label} +
+
+ ), + { ...s } ); } diff --git a/apps/web/src/components/data-table/rum/columns.tsx b/apps/web/src/components/data-table/rum/columns.tsx new file mode 100644 index 0000000000..4c85c55361 --- /dev/null +++ b/apps/web/src/components/data-table/rum/columns.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import Link from "next/link"; + +import type { responseRumPageQuery } from "@openstatus/tinybird/src/validation"; +import type { z } from "zod"; + +export const columns: ColumnDef>[] = [ + { + accessorKey: "path", + header: "Page", + cell: ({ row }) => { + return ( + + {row.getValue("path")} + + ); + }, + }, + { + accessorKey: "totalSession", + header: "Total Session", + cell: ({ row }) => { + return <>{row.original.totalSession}; + }, + }, + { + accessorKey: "cls", + header: "CLS", + cell: ({ row }) => { + return ( + {row.original.cls ? row.original.cls.toFixed(2) : "-"} + ); + }, + }, + { + accessorKey: "fcp", + header: "FCP", + cell: ({ row }) => { + return ( + {row.original.fcp ? row.original.fcp.toFixed(0) : "-"} + ); + }, + }, + { + accessorKey: "inp", + header: "INP", + cell: ({ row }) => { + return ( + {row.original.inp ? row.original.inp.toFixed(0) : "-"} + ); + }, + }, + { + accessorKey: "lcp", + header: "LCP", + cell: ({ row }) => { + return ( + {row.original.lcp ? row.original.lcp.toFixed(0) : "-"} + ); + }, + }, + { + accessorKey: "ttfb", + header: "TTFB", + cell: ({ row }) => { + return ( + {row.original.ttfb ? row.original.ttfb.toFixed(0) : "-"} + ); + }, + }, + // { + // accessorKey: "updatedAt", + // header: "Last Updated", + // cell: ({ row }) => { + // return {formatDate(row.getValue("updatedAt"))}; + // }, + // }, +]; diff --git a/apps/web/src/components/data-table/rum/data-table.tsx b/apps/web/src/components/data-table/rum/data-table.tsx new file mode 100644 index 0000000000..c8cdc7e34f --- /dev/null +++ b/apps/web/src/components/data-table/rum/data-table.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import * as React from "react"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@openstatus/ui"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/apps/web/src/components/data-table/session/columns.tsx b/apps/web/src/components/data-table/session/columns.tsx new file mode 100644 index 0000000000..757310f394 --- /dev/null +++ b/apps/web/src/components/data-table/session/columns.tsx @@ -0,0 +1,69 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import Link from "next/link"; + +import type { sessionRumPageQuery } from "@openstatus/tinybird/src/validation"; +import type { z } from "zod"; + +export const columns: ColumnDef>[] = [ + { + accessorKey: "session", + header: "Session", + cell: ({ row }) => { + return <>{row.original.session_id}; + }, + }, + { + accessorKey: "cls", + header: "CLS", + cell: ({ row }) => { + return ( + {row.original.cls ? row.original.cls.toFixed(2) : "-"} + ); + }, + }, + { + accessorKey: "fcp", + header: "FCP", + cell: ({ row }) => { + return ( + {row.original.fcp ? row.original.fcp.toFixed(0) : "-"} + ); + }, + }, + { + accessorKey: "inp", + header: "INP", + cell: ({ row }) => { + return ( + {row.original.inp ? row.original.inp.toFixed(0) : "-"} + ); + }, + }, + { + accessorKey: "lcp", + header: "LCP", + cell: ({ row }) => { + return ( + {row.original.lcp ? row.original.lcp.toFixed(0) : "-"} + ); + }, + }, + { + accessorKey: "ttfb", + header: "TTFB", + cell: ({ row }) => { + return ( + {row.original.ttfb ? row.original.ttfb.toFixed(0) : "-"} + ); + }, + }, + // { + // accessorKey: "updatedAt", + // header: "Last Updated", + // cell: ({ row }) => { + // return {formatDate(row.getValue("updatedAt"))}; + // }, + // }, +]; diff --git a/apps/web/src/components/data-table/session/data-table.tsx b/apps/web/src/components/data-table/session/data-table.tsx new file mode 100644 index 0000000000..c8cdc7e34f --- /dev/null +++ b/apps/web/src/components/data-table/session/data-table.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import * as React from "react"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@openstatus/ui"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 2be1d6d225..bdf01fab61 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -28,6 +28,8 @@ export const env = createEnv({ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(), NEXT_PUBLIC_URL: z.string(), NEXT_PUBLIC_SENTRY_DSN: z.string(), + NEXT_PUBLIC_POSTHOG_KEY: z.string(), + NEXT_PUBLIC_POSTHOG_HOST: z.string(), }, runtimeEnv: { TINY_BIRD_API_KEY: process.env.TINY_BIRD_API_KEY, @@ -40,6 +42,8 @@ export const env = createEnv({ STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY, NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, UNKEY_TOKEN: process.env.UNKEY_TOKEN, UNKEY_API_ID: process.env.UNKEY_API_ID, GCP_PROJECT_ID: process.env.GCP_PROJECT_ID, diff --git a/apps/web/src/lib/auth/index.ts b/apps/web/src/lib/auth/index.ts index 39bf14636f..d38e7f6164 100644 --- a/apps/web/src/lib/auth/index.ts +++ b/apps/web/src/lib/auth/index.ts @@ -8,6 +8,7 @@ import { WelcomeEmail, sendEmail } from "@openstatus/emails"; import { adapter } from "./adapter"; import { GitHubProvider, GoogleProvider, ResendProvider } from "./providers"; +import { identifyUser } from "@/providers/posthog"; export type { DefaultSession }; @@ -80,6 +81,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (process.env.NODE_ENV !== "development") { await analytics.identify(userId, { email, userId }); await trackAnalytics({ event: "User Created", userId, email }); + await identifyUser({ user: params.user }); } }, @@ -91,6 +93,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (process.env.NODE_ENV !== "development") { await analytics.identify(userId, { userId, email }); + await identifyUser({ user: params.user }); await trackAnalytics({ event: "User Signed In" }); } }, diff --git a/apps/web/src/lib/posthog/client.ts b/apps/web/src/lib/posthog/client.ts new file mode 100644 index 0000000000..dc1866f18d --- /dev/null +++ b/apps/web/src/lib/posthog/client.ts @@ -0,0 +1,14 @@ +import { PostHog } from "posthog-node"; + +import { env } from "@/env"; + +// REMINDER: SSR actions + +export default function PostHogClient() { + const posthogClient = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { + host: env.NEXT_PUBLIC_POSTHOG_HOST, + flushAt: 1, + flushInterval: 0, + }); + return posthogClient; +} diff --git a/apps/web/src/providers/posthog.tsx b/apps/web/src/providers/posthog.tsx new file mode 100644 index 0000000000..f8a8446471 --- /dev/null +++ b/apps/web/src/providers/posthog.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import type { User } from "next-auth"; +import type { CaptureOptions, Properties } from "posthog-js"; +import posthog from "posthog-js"; +import { PostHogProvider } from "posthog-js/react"; + +import { env } from "@/env"; + +if (typeof window !== "undefined") { + posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: env.NEXT_PUBLIC_POSTHOG_HOST, + capture_pageview: false, // Disable automatic pageview capture, as we capture manually + disable_session_recording: false, // Enable automatic session recording + }); +} + +export function PostHogPageview(): JSX.Element { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + if (pathname) { + let url = window.origin + pathname; + if (searchParams?.toString()) { + url = `${url}?${searchParams.toString()}`; + } + posthog.capture("$pageview", { + $current_url: url, + }); + } + }, [pathname, searchParams]); + + return <>; +} + +export function PHProvider({ children }: { children: React.ReactNode }) { + if (process.env.NODE_ENV !== "production") { + return <>{children}; + } + return {children}; +} + +export function trackEvent({ + name, + props, + opts, +}: { + name: string; + props: Properties; + opts: CaptureOptions; +}) { + posthog.capture(name, props, opts); +} + +export function identifyUser({ user }: { user: User }) { + posthog.identify(user.id, { email: user.email }); +} diff --git a/package.json b/package.json index fbb1ea44af..d4d308743e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "turbo": "1.13.3", "typescript": "5.4.5" }, - "packageManager": "pnpm@9.1.2", + "packageManager": "pnpm@9.1.4", "name": "openstatus", "workspaces": [ "apps/*", diff --git a/packages/analytics/src/type.ts b/packages/analytics/src/type.ts index 1859afc87c..03630e7170 100644 --- a/packages/analytics/src/type.ts +++ b/packages/analytics/src/type.ts @@ -18,7 +18,7 @@ export type AnalyticsEvents = } | { event: "User Upgraded"; email: string } | { event: "User Signed In" } - | { event: "User Vercel Beta" } + | { event: "User RUM Beta Requested"; email: string } | { event: "Notification Created"; provider: string } | { event: "Subscribe to Status Page"; slug: string } | { event: "Invitation Created"; emailTo: string; workspaceId: number }; diff --git a/packages/api/src/router/invitation.ts b/packages/api/src/router/invitation.ts index 96d26ae02f..340dd51b21 100644 --- a/packages/api/src/router/invitation.ts +++ b/packages/api/src/router/invitation.ts @@ -72,10 +72,16 @@ export const invitationRouter = createTRPCRouter({ body: JSON.stringify({ to: email, from: "OpenStatus ", - subject: `You have been invited to join OpenStatus.dev`, - html: `

You have been invited by ${opts.ctx.user.email} ${!!opts.ctx.workspace.name ? `to join the workspace '${opts.ctx.workspace.name}'.` : "to join a workspace."}

+ subject: "You have been invited to join OpenStatus.dev", + html: `

You have been invited by ${opts.ctx.user.email} ${ + opts.ctx.workspace.name + ? `to join the workspace '${opts.ctx.workspace.name}'.` + : "to join a workspace." + }


-

Click here to access the workspace: accept invitation.

+

Click here to access the workspace: accept invitation.

If you don't have an account yet, it will require you to create one.

`, }), diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index 275e1e5522..13ec400ed7 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -41,7 +41,7 @@ export const monitorRouter = createTRPCRouter({ await opts.ctx.db.query.monitor.findMany({ where: and( eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), + isNull(monitor.deletedAt) ), }) ).length; @@ -104,7 +104,7 @@ export const monitorRouter = createTRPCRouter({ const allNotifications = await opts.ctx.db.query.notification.findMany({ where: and( eq(notification.workspaceId, opts.ctx.workspace.id), - inArray(notification.id, notifications), + inArray(notification.id, notifications) ), }); @@ -120,7 +120,7 @@ export const monitorRouter = createTRPCRouter({ const allTags = await opts.ctx.db.query.monitorTag.findMany({ where: and( eq(monitorTag.workspaceId, opts.ctx.workspace.id), - inArray(monitorTag.id, tags), + inArray(monitorTag.id, tags) ), }); @@ -136,7 +136,7 @@ export const monitorRouter = createTRPCRouter({ const allPages = await opts.ctx.db.query.page.findMany({ where: and( eq(page.workspaceId, opts.ctx.workspace.id), - inArray(page.id, pages), + inArray(page.id, pages) ), }); @@ -163,7 +163,7 @@ export const monitorRouter = createTRPCRouter({ where: and( eq(monitor.id, opts.input.id), eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), + isNull(monitor.deletedAt) ), with: { monitorTagsToMonitors: { with: { monitorTag: true } }, @@ -199,7 +199,7 @@ export const monitorRouter = createTRPCRouter({ where: and( eq(monitor.id, opts.input.id), isNull(monitor.deletedAt), - eq(monitor.public, true), + eq(monitor.public, true) ), }); if (!_monitor) return undefined; @@ -211,7 +211,7 @@ export const monitorRouter = createTRPCRouter({ }); const hasPageRelation = _page?.monitorsToPages.find( - ({ monitorId }) => _monitor.id === monitorId, + ({ monitorId }) => _monitor.id === monitorId ); if (!hasPageRelation) return undefined; @@ -271,8 +271,8 @@ export const monitorRouter = createTRPCRouter({ and( eq(monitor.id, opts.input.id), eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), - ), + isNull(monitor.deletedAt) + ) ) .returning() .get(); @@ -287,7 +287,7 @@ export const monitorRouter = createTRPCRouter({ (x) => !currentMonitorNotifications .map(({ notificationId }) => notificationId) - ?.includes(x), + ?.includes(x) ); if (addedNotifications.length > 0) { @@ -311,9 +311,9 @@ export const monitorRouter = createTRPCRouter({ eq(notificationsToMonitors.monitorId, currentMonitor.id), inArray( notificationsToMonitors.notificationId, - removedNotifications, - ), - ), + removedNotifications + ) + ) ) .run(); } @@ -328,7 +328,7 @@ export const monitorRouter = createTRPCRouter({ (x) => !currentMonitorTags .map(({ monitorTagId }) => monitorTagId) - ?.includes(x), + ?.includes(x) ); if (addedTags.length > 0) { @@ -350,8 +350,8 @@ export const monitorRouter = createTRPCRouter({ .where( and( eq(monitorTagsToMonitors.monitorId, currentMonitor.id), - inArray(monitorTagsToMonitors.monitorTagId, removedTags), - ), + inArray(monitorTagsToMonitors.monitorTagId, removedTags) + ) ) .run(); } @@ -363,7 +363,7 @@ export const monitorRouter = createTRPCRouter({ .all(); const addedPages = pages.filter( - (x) => !currentMonitorPages.map(({ pageId }) => pageId)?.includes(x), + (x) => !currentMonitorPages.map(({ pageId }) => pageId)?.includes(x) ); if (addedPages.length > 0) { @@ -385,8 +385,8 @@ export const monitorRouter = createTRPCRouter({ .where( and( eq(monitorsToPages.monitorId, currentMonitor.id), - inArray(monitorsToPages.pageId, removedPages), - ), + inArray(monitorsToPages.pageId, removedPages) + ) ) .run(); } @@ -401,8 +401,8 @@ export const monitorRouter = createTRPCRouter({ .where( and( eq(monitor.id, opts.input.id), - eq(monitor.workspaceId, opts.ctx.workspace.id), - ), + eq(monitor.workspaceId, opts.ctx.workspace.id) + ) ) .get(); if (!monitorToDelete) return; @@ -433,7 +433,7 @@ export const monitorRouter = createTRPCRouter({ const monitors = await opts.ctx.db.query.monitor.findMany({ where: and( eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), + isNull(monitor.deletedAt) ), with: { monitorTagsToMonitors: { with: { monitorTag: true } }, @@ -446,7 +446,7 @@ export const monitorRouter = createTRPCRouter({ monitorTagsToMonitors: z .array(z.object({ monitorTag: selectMonitorTagSchema })) .default([]), - }), + }) ) .parse(monitors); }), @@ -461,8 +461,8 @@ export const monitorRouter = createTRPCRouter({ and( eq(monitor.id, opts.input.id), eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), - ), + isNull(monitor.deletedAt) + ) ) .get(); @@ -481,8 +481,8 @@ export const monitorRouter = createTRPCRouter({ .where( and( eq(monitor.id, opts.input.id), - eq(monitor.workspaceId, opts.ctx.workspace.id), - ), + eq(monitor.workspaceId, opts.ctx.workspace.id) + ) ) .run(); }), @@ -510,8 +510,8 @@ export const monitorRouter = createTRPCRouter({ notification, and( eq(notificationsToMonitors.notificationId, notification.id), - eq(notification.workspaceId, opts.ctx.workspace.id), - ), + eq(notification.workspaceId, opts.ctx.workspace.id) + ) ) .where(eq(notificationsToMonitors.monitorId, opts.input.id)) .all(); @@ -524,7 +524,7 @@ export const monitorRouter = createTRPCRouter({ await opts.ctx.db.query.monitor.findMany({ where: and( eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), + isNull(monitor.deletedAt) ), }) ).length; diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts index 4d5dcc0a9d..6f89afb8a7 100644 --- a/packages/api/src/router/tinybird/index.ts +++ b/packages/api/src/router/tinybird/index.ts @@ -29,9 +29,43 @@ export const tinybirdRouter = createTRPCRouter({ url: z.string().url().optional(), region: z.enum(flyRegions).optional(), cronTimestamp: z.number().int().optional(), - }), + }) ) .query(async (opts) => { return await tb.endpointResponseDetails("7d")(opts.input); }), + + totalRumMetricsForApplication: protectedProcedure + .input(z.object({ dsn: z.string(), period: z.enum(["24h", "7d", "30d"]) })) + .query(async (opts) => { + return await tb.applicationRUMMetrics()(opts.input); + }), + rumMetricsForApplicationPerPage: protectedProcedure + .input(z.object({ dsn: z.string(), period: z.enum(["24h", "7d", "30d"]) })) + .query(async (opts) => { + return await tb.applicationRUMMetricsPerPage()(opts.input); + }), + + rumMetricsForPath: protectedProcedure + .input( + z.object({ + dsn: z.string(), + path: z.string(), + period: z.enum(["24h", "7d", "30d"]), + }) + ) + .query(async (opts) => { + return await tb.applicationRUMMetricsForPath()(opts.input); + }), + sessionRumMetricsForPath: protectedProcedure + .input( + z.object({ + dsn: z.string(), + path: z.string(), + period: z.enum(["24h", "7d", "30d"]), + }) + ) + .query(async (opts) => { + return await tb.applicationSessionMetricsPerPath()(opts.input); + }), }); diff --git a/packages/api/src/router/workspace.ts b/packages/api/src/router/workspace.ts index 5e1afce599..2e0d43dda6 100644 --- a/packages/api/src/router/workspace.ts +++ b/packages/api/src/router/workspace.ts @@ -5,9 +5,11 @@ import { z } from "zod"; import { and, eq, sql } from "@openstatus/db"; import { + application, monitor, notification, page, + selectApplicationSchema, selectWorkspaceSchema, user, usersToWorkspaces, @@ -75,6 +77,14 @@ export const workspaceRouter = createTRPCRouter({ return selectWorkspaceSchema.parse(result); }), + getApplicationWorkspaces: protectedProcedure.query(async (opts) => { + const result = await opts.ctx.db.query.application.findMany({ + where: eq(application.workspaceId, opts.ctx.workspace.id), + }); + + return selectApplicationSchema.array().parse(result); + }), + getUserWorkspaces: protectedProcedure.query(async (opts) => { const result = await opts.ctx.db.query.usersToWorkspaces.findMany({ where: eq(usersToWorkspaces.userId, opts.ctx.user.id), @@ -114,7 +124,7 @@ export const workspaceRouter = createTRPCRouter({ await opts.ctx.db.query.usersToWorkspaces.findFirst({ where: and( eq(usersToWorkspaces.userId, opts.ctx.user.id), - eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), + eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id) ), }); @@ -131,8 +141,8 @@ export const workspaceRouter = createTRPCRouter({ .where( and( eq(usersToWorkspaces.userId, opts.input.id), - eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), - ), + eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id) + ) ) .run(); }), @@ -144,7 +154,7 @@ export const workspaceRouter = createTRPCRouter({ await opts.ctx.db.query.usersToWorkspaces.findFirst({ where: and( eq(usersToWorkspaces.userId, opts.ctx.user.id), - eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), + eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id) ), }); diff --git a/packages/db/.env.example b/packages/db/.env.example index dbbe1b6054..32cdbf2382 100644 --- a/packages/db/.env.example +++ b/packages/db/.env.example @@ -1,6 +1,2 @@ DATABASE_URL=file:./../../openstatus-dev.db DATABASE_AUTH_TOKEN=any-token - -CLICKHOUSE_URL=http://localhost:8123 -CLICKHOUSE_USERNAME=default -CLICKHOUSE_PASSWORD= diff --git a/packages/db/src/schema/applications/index.ts b/packages/db/src/schema/applications/index.ts index f4fe0545f5..e654ec5da9 100644 --- a/packages/db/src/schema/applications/index.ts +++ b/packages/db/src/schema/applications/index.ts @@ -1 +1,2 @@ export * from "./application"; +export * from "./validation"; diff --git a/packages/db/src/schema/applications/validation.ts b/packages/db/src/schema/applications/validation.ts new file mode 100644 index 0000000000..eefc9fc754 --- /dev/null +++ b/packages/db/src/schema/applications/validation.ts @@ -0,0 +1,4 @@ +import { createSelectSchema } from "drizzle-zod"; +import { application } from "./application"; + +export const selectApplicationSchema = createSelectSchema(application); diff --git a/packages/db/src/seed.mts b/packages/db/src/seed.mts index f906028c29..5cb03940da 100644 --- a/packages/db/src/seed.mts +++ b/packages/db/src/seed.mts @@ -9,9 +9,11 @@ import { incidentTable, monitor, monitorsToPages, + monitorsToStatusReport, notification, notificationsToMonitors, page, + pagesToStatusReports, statusReport, statusReportUpdate, user, @@ -155,7 +157,7 @@ async function main() { id: 1, statusReportId: 1, status: "investigating", - message: "", + message: "Message", date: new Date(), }) .run(); @@ -177,11 +179,33 @@ async function main() { id: 2, statusReportId: 2, status: "investigating", - message: "", + message: "Message", date: new Date(), }) .run(); + await db.insert(monitorsToStatusReport).values([ + { + monitorId: 1, + statusReportId: 2, + }, + { + monitorId: 2, + statusReportId: 2, + }, + ]); + + await db.insert(pagesToStatusReports).values([ + { + pageId: 1, + statusReportId: 2, + }, + { + pageId: 1, + statusReportId: 1, + }, + ]); + await db .insert(incidentTable) .values({ diff --git a/packages/tinybird/src/os-client.ts b/packages/tinybird/src/os-client.ts index 82d4b9f007..bc15dc1f13 100644 --- a/packages/tinybird/src/os-client.ts +++ b/packages/tinybird/src/os-client.ts @@ -4,7 +4,11 @@ import { z } from "zod"; import { flyRegions } from "@openstatus/utils"; import type { tbIngestWebVitalsArray } from "./validation"; -import { tbIngestWebVitals } from "./validation"; +import { + responseRumPageQuery, + sessionRumPageQuery, + tbIngestWebVitals, +} from "./validation"; const isProd = process.env.NODE_ENV === "production"; @@ -43,7 +47,7 @@ export class OSTinybird { // FIXME: use Tinybird instead with super(args) maybe // how about passing here the `opts: {revalidate}` to access it within the functions? constructor(private args: { token: string; baseUrl?: string | undefined }) { - if (process.env.NODE_ENV === "development") { + if (process.env.NODE_ENV !== "development") { this.tb = new NoopTinybird(); } else { this.tb = new Tinybird(args); @@ -112,7 +116,7 @@ export class OSTinybird { opts?: { cache?: RequestCache | undefined; revalidate: number | undefined; - }, // RETHINK: not the best way to handle it + } // RETHINK: not the best way to handle it ) => { try { const res = await this.tb.buildPipe({ @@ -171,7 +175,7 @@ export class OSTinybird { endpointStatusPeriod( period: "7d" | "45d", - timezone: "UTC" = "UTC", // "EST" | "PST" | "CET" + timezone: "UTC" = "UTC" // "EST" | "PST" | "CET" ) { const parameters = z.object({ monitorId: z.string() }); @@ -180,7 +184,7 @@ export class OSTinybird { opts?: { cache?: RequestCache | undefined; revalidate: number | undefined; - }, // RETHINK: not the best way to handle it + } // RETHINK: not the best way to handle it ) => { try { const res = await this.tb.buildPipe({ @@ -336,6 +340,116 @@ export class OSTinybird { event: tbIngestWebVitals, })(data); } + + applicationRUMMetrics() { + const parameters = z.object({ + dsn: z.string(), + period: z.enum(["24h", "7d", "30d"]), + }); + + return async (props: z.infer) => { + try { + const res = await this.tb.buildPipe({ + pipe: "rum_total_query", + parameters, + data: z.object({ + cls: z.number(), + fcp: z.number(), + // fid: z.number(), + lcp: z.number(), + inp: z.number(), + ttfb: z.number(), + }), + opts: { + next: { + revalidate: MIN_CACHE, + }, + }, + })(props); + return res.data[0]; + } catch (e) { + console.error(e); + } + }; + } + applicationRUMMetricsPerPage() { + const parameters = z.object({ + dsn: z.string(), + period: z.enum(["24h", "7d", "30d"]), + }); + return async (props: z.infer) => { + try { + const res = await this.tb.buildPipe({ + pipe: "rum_page_query", + parameters, + data: responseRumPageQuery, + opts: { + next: { + revalidate: MIN_CACHE, + }, + }, + })(props); + return res.data; + } catch (e) { + console.error(e); + } + }; + } + applicationSessionMetricsPerPath() { + const parameters = z.object({ + dsn: z.string(), + period: z.enum(["24h", "7d", "30d"]), + path: z.string(), + }); + return async (props: z.infer) => { + try { + const res = await this.tb.buildPipe({ + pipe: "rum_page_query_per_path", + parameters, + data: sessionRumPageQuery, + opts: { + next: { + revalidate: MIN_CACHE, + }, + }, + })(props); + return res.data; + } catch (e) { + console.error(e); + } + }; + } + applicationRUMMetricsForPath() { + const parameters = z.object({ + dsn: z.string(), + path: z.string(), + period: z.enum(["24h", "7d", "30d"]), + }); + return async (props: z.infer) => { + try { + const res = await this.tb.buildPipe({ + pipe: "rum_total_query_per_path", + parameters, + data: z.object({ + cls: z.number(), + fcp: z.number(), + // fid: z.number(), + lcp: z.number(), + inp: z.number(), + ttfb: z.number(), + }), + opts: { + next: { + revalidate: MIN_CACHE, + }, + }, + })(props); + return res.data[0]; + } catch (e) { + console.error(e); + } + }; + } } /** diff --git a/packages/tinybird/src/validation.ts b/packages/tinybird/src/validation.ts index e274946f51..f229a41b55 100644 --- a/packages/tinybird/src/validation.ts +++ b/packages/tinybird/src/validation.ts @@ -21,6 +21,28 @@ export const tbIngestWebVitals = z.object({ region_code: z.string().default(""), timezone: z.string().default(""), os: z.string(), + timestamp: z.number().int(), +}); + +export const responseRumPageQuery = z.object({ + path: z.string(), + totalSession: z.number(), + cls: z.number(), + fcp: z.number(), + // fid: z.number(), + inp: z.number(), + lcp: z.number(), + ttfb: z.number(), +}); + +export const sessionRumPageQuery = z.object({ + session_id: z.string(), + cls: z.number(), + fcp: z.number(), + // fid: z.number(), + inp: z.number(), + lcp: z.number(), + ttfb: z.number(), }); export const tbIngestWebVitalsArray = z.array(tbIngestWebVitals); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 008e08f0ea..861ee9d251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,6 +386,12 @@ importers: next-themes: specifier: 0.2.1 version: 0.2.1(next@14.2.3(@opentelemetry/api@1.4.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + posthog-js: + specifier: 1.136.1 + version: 1.136.1 + posthog-node: + specifier: 4.0.1 + version: 4.0.1 random-word-slugs: specifier: 0.1.7 version: 0.1.7 @@ -429,8 +435,8 @@ importers: specifier: 0.14.4 version: 0.14.4 sonner: - specifier: 1.4.41 - version: 1.4.41(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: 1.3.1 + version: 1.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) stripe: specifier: 13.8.0 version: 13.8.0 @@ -864,7 +870,7 @@ importers: version: link:../../tinybird '@react-email/components': specifier: 0.0.7 - version: 0.0.7(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.4.5)) + version: 0.0.7 '@react-email/render': specifier: 0.0.7 version: 0.0.7 @@ -892,7 +898,7 @@ importers: version: 18.2.21 next: specifier: 14.2.3 - version: 14.2.3(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.2.0))(react@18.2.0) + version: 14.2.3(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) typescript: specifier: 5.4.5 version: 5.4.5 @@ -1164,8 +1170,8 @@ importers: specifier: 7.47.0 version: 7.47.0(react@18.2.0) sonner: - specifier: 1.4.41 - version: 1.4.41(react-dom@18.3.1(react@18.2.0))(react@18.2.0) + specifier: 1.3.1 + version: 1.3.1(react-dom@18.3.1(react@18.2.0))(react@18.2.0) tailwind-merge: specifier: 1.14.0 version: 1.14.0 @@ -4831,6 +4837,9 @@ packages: resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==} engines: {node: '>=4'} + axios@1.7.2: + resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} + axobject-query@3.2.1: resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} @@ -6023,6 +6032,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -6072,6 +6084,15 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -7882,6 +7903,13 @@ packages: resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.136.1: + resolution: {integrity: sha512-hM3PCDtPdyzO52l0FXEFAw1sI6PJm1U9U3MVanAcrOY3QgeJ+z241OnYm5XMrTyDF5ImCTWzq4p23moLQSZvDA==} + + posthog-node@4.0.1: + resolution: {integrity: sha512-rtqm2h22QxLGBrW2bLYzbRhliIrqgZ0k+gF0LkQ1SNdeD06YE5eilV0MxZppFSxC8TfH0+B0cWCuebEnreIDgQ==} + engines: {node: '>=15.0.0'} + preact-render-to-string@5.2.3: resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} peerDependencies: @@ -7890,6 +7918,9 @@ packages: preact@10.11.3: resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} + preact@10.22.0: + resolution: {integrity: sha512-RRurnSjJPj4rp5K6XoP45Ui33ncb7e4H7WiOHVpjbkvqvA3U+N8Z6Qbo0AE6leGYBV66n8EhEaFixvIu3SkxFw==} + prebuild-install@7.1.1: resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} engines: {node: '>=10'} @@ -8309,6 +8340,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rusha@0.8.14: + resolution: {integrity: sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==} + rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} engines: {npm: '>=2.0.0'} @@ -8448,8 +8482,8 @@ packages: resolution: {integrity: sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==} engines: {node: '>= 10.13.0', npm: '>= 3.0.0'} - sonner@1.4.41: - resolution: {integrity: sha512-uG511ggnnsw6gcn/X+YKkWPo5ep9il9wYi3QJxHsYe7yTZ4+cOd1wuodOUmOpFuXL+/RE3R04LczdNCDygTDgQ==} + sonner@1.3.1: + resolution: {integrity: sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==} peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -12399,6 +12433,29 @@ snapshots: dependencies: react: 18.2.0 + '@react-email/components@0.0.7': + dependencies: + '@react-email/body': 0.0.2 + '@react-email/button': 0.0.9 + '@react-email/column': 0.0.7 + '@react-email/container': 0.0.8 + '@react-email/font': 0.0.2 + '@react-email/head': 0.0.5 + '@react-email/heading': 0.0.8 + '@react-email/hr': 0.0.5 + '@react-email/html': 0.0.4 + '@react-email/img': 0.0.5 + '@react-email/link': 0.0.5 + '@react-email/preview': 0.0.6 + '@react-email/render': 0.0.7 + '@react-email/row': 0.0.5 + '@react-email/section': 0.0.9 + '@react-email/tailwind': 0.0.8 + '@react-email/text': 0.0.5 + react: 18.2.0 + transitivePeerDependencies: + - ts-node + '@react-email/components@0.0.7(ts-node@10.9.2(@types/node@18.11.9)(typescript@4.9.3))': dependencies: '@react-email/body': 0.0.2 @@ -12507,6 +12564,15 @@ snapshots: dependencies: react: 18.2.0 + '@react-email/tailwind@0.0.8': + dependencies: + html-react-parser: 3.0.9(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tw-to-css: 0.0.11 + transitivePeerDependencies: + - ts-node + '@react-email/tailwind@0.0.8(ts-node@10.9.2(@types/node@18.11.9)(typescript@4.9.3))': dependencies: html-react-parser: 3.0.9(react@18.2.0) @@ -13738,6 +13804,14 @@ snapshots: axe-core@4.7.0: {} + axios@1.7.2: + dependencies: + follow-redirects: 1.15.6 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@3.2.1: dependencies: dequal: 2.0.3 @@ -14788,8 +14862,8 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@8.36.0)(typescript@4.9.3) eslint: 8.36.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.36.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.36.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0))(eslint@8.36.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.36.0) eslint-plugin-react: 7.34.1(eslint@8.36.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.36.0) @@ -14811,13 +14885,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.36.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0): dependencies: debug: 4.3.4 enhanced-resolve: 5.16.1 eslint: 8.36.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.36.0))(eslint@8.36.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.36.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0))(eslint@8.36.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0))(eslint@8.36.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 is-core-module: 2.13.0 @@ -14828,18 +14902,18 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.36.0))(eslint@8.36.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0))(eslint@8.36.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 5.62.0(eslint@8.36.0)(typescript@4.9.3) eslint: 8.36.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.36.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.36.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0))(eslint@8.36.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -14849,7 +14923,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.36.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.36.0))(eslint@8.36.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.36.0)(typescript@4.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.36.0))(eslint@8.36.0))(eslint@8.36.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -15116,6 +15190,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.2.1 + fflate@0.4.8: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -15165,6 +15241,8 @@ snapshots: flatted@3.3.1: {} + follow-redirects@1.15.6: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -17594,6 +17672,18 @@ snapshots: picocolors: 1.0.0 source-map-js: 1.2.0 + posthog-js@1.136.1: + dependencies: + fflate: 0.4.8 + preact: 10.22.0 + + posthog-node@4.0.1: + dependencies: + axios: 1.7.2 + rusha: 0.8.14 + transitivePeerDependencies: + - debug + preact-render-to-string@5.2.3(preact@10.11.3): dependencies: preact: 10.11.3 @@ -17601,6 +17691,8 @@ snapshots: preact@10.11.3: {} + preact@10.22.0: {} + prebuild-install@7.1.1: dependencies: detect-libc: 2.0.2 @@ -18159,6 +18251,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rusha@0.8.14: {} + rxjs@6.6.7: dependencies: tslib: 1.14.1 @@ -18321,12 +18415,12 @@ snapshots: ip: 2.0.0 smart-buffer: 4.2.0 - sonner@1.4.41(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + sonner@1.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - sonner@1.4.41(react-dom@18.3.1(react@18.2.0))(react@18.2.0): + sonner@1.3.1(react-dom@18.3.1(react@18.2.0))(react@18.2.0): dependencies: react: 18.2.0 react-dom: 18.3.1(react@18.2.0) @@ -18592,6 +18686,34 @@ snapshots: transitivePeerDependencies: - ts-node + tailwindcss@3.2.7(postcss@8.4.21): + dependencies: + arg: 5.0.2 + chokidar: 3.6.0 + color-name: 1.1.4 + detective: 5.2.1 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.21 + postcss-import: 14.1.0(postcss@8.4.21) + postcss-js: 4.0.1(postcss@8.4.21) + postcss-load-config: 3.1.4(postcss@8.4.21)(ts-node@10.9.2(@types/node@18.11.9)(typescript@4.9.3)) + postcss-nested: 6.0.0(postcss@8.4.21) + postcss-selector-parser: 6.0.13 + postcss-value-parser: 4.2.0 + quick-lru: 5.1.1 + resolve: 1.22.8 + transitivePeerDependencies: + - ts-node + tailwindcss@3.2.7(postcss@8.4.21)(ts-node@10.9.2(@types/node@18.11.9)(typescript@4.9.3)): dependencies: arg: 5.0.2 @@ -18906,6 +19028,14 @@ snapshots: turbo-windows-64: 1.13.3 turbo-windows-arm64: 1.13.3 + tw-to-css@0.0.11: + dependencies: + postcss: 8.4.21 + postcss-css-variables: 0.18.0(postcss@8.4.21) + tailwindcss: 3.2.7(postcss@8.4.21) + transitivePeerDependencies: + - ts-node + tw-to-css@0.0.11(ts-node@10.9.2(@types/node@18.11.9)(typescript@4.9.3)): dependencies: postcss: 8.4.21