Openstatus www.openstatus.dev

feat: page-components status-page feed (#1784)

* feat: page-components status-page feed

* fix: wrong zod schema field

* fix: relation

* chore: add tests

authored by

Maximilian Kaske and committed by
GitHub
1871de92 78015fc0

+288 -37
+10
apps/docs/src/content/docs/reference/status-page.mdx
··· 60 60 61 61 **Example:** `https://status.openstatus.dev/feed/json` 62 62 63 + **Deprecation Notice:** 64 + 65 + The following fields are deprecated and will be removed in a future version: 66 + 67 + - **`monitors`** (top-level): Use `pageComponents` instead, which provides a more flexible component-based structure that supports both monitors and external services. 68 + - **`maintenances[].monitors`**: Use `maintenances[].pageComponents` instead, which references page component IDs rather than monitor IDs. 69 + - **`statusReports[].monitors`**: Use `statusReports[].pageComponents` instead, which references page component IDs rather than monitor IDs. 70 + 71 + These deprecated fields are currently maintained for backward compatibility but may be removed in future versions. 72 + 63 73 ### SSH Command 64 74 65 75 **Type:** Command-line utility
+12 -7
apps/status-page/src/app/(status-page)/[domain]/(public)/feed/[type]/route.ts
··· 24 24 25 25 if (!["rss", "atom"].includes(type)) return notFound(); 26 26 27 - const page = await queryClient.fetchQuery( 27 + const _page = await queryClient.fetchQuery( 28 28 trpc.page.getPageBySlug.queryOptions({ slug: domain }), 29 29 ); 30 - if (!page) return notFound(); 30 + if (!_page) return notFound(); 31 31 32 - if (page.accessType === "password") { 32 + if (_page.accessType === "password") { 33 33 const url = new URL(_request.url); 34 34 const password = url.searchParams.get("pw"); 35 - console.log({ url, page, password }); 36 - if (password !== page.password) return unauthorized(); 35 + console.log({ url, _page, password }); 36 + if (password !== _page.password) return unauthorized(); 37 37 } 38 38 39 - if (page.accessType === "email-domain") { 39 + if (_page.accessType === "email-domain") { 40 40 const session = await auth(); 41 41 const user = session?.user; 42 - const allowedDomains = page.authEmailDomains ?? []; 42 + const allowedDomains = _page.authEmailDomains ?? []; 43 43 if (!user || !user.email) return unauthorized(); 44 44 if (!allowedDomains.includes(user.email.split("@")[1])) 45 45 return unauthorized(); 46 46 } 47 + 48 + const page = await queryClient.fetchQuery( 49 + trpc.statusPage.get.queryOptions({ slug: domain }), 50 + ); 51 + if (!page) return notFound(); 47 52 48 53 const baseUrl = getBaseUrl({ 49 54 slug: page.slug,
+26 -9
apps/status-page/src/app/(status-page)/[domain]/(public)/feed/json/route.ts
··· 2 2 import { getQueryClient, trpc } from "@/lib/trpc/server"; 3 3 import { notFound, unauthorized } from "next/navigation"; 4 4 5 - const _STATUS_LABELS = { 6 - investigating: "Investigating", 7 - identified: "Identified", 8 - monitoring: "Monitoring", 9 - resolved: "Resolved", 10 - maintenance: "Maintenance", 11 - } as const; 12 - 13 5 export const revalidate = 60; 14 6 15 7 export async function GET( ··· 53 45 description: page.description, 54 46 status: page.status, 55 47 updatedAt: new Date(), 48 + // @deprecated Use pageComponents instead 56 49 monitors: page.monitors.map((monitor) => ({ 57 50 id: monitor.id, 58 51 name: monitor.name, 59 52 description: monitor.description, 60 53 status: monitor.status, 61 54 })), 55 + // New field - exposes the page component structure 56 + pageComponents: page.pageComponents.map((component) => ({ 57 + id: component.id, 58 + name: component.name, 59 + description: component.description, 60 + monitorId: component.monitorId, 61 + order: component.order, 62 + groupId: component.groupId, 63 + groupOrder: component.groupOrder, 64 + })), 65 + pageComponentGroups: page.pageComponentGroups.map((group) => ({ 66 + id: group.id, 67 + name: group.name, 68 + })), 62 69 maintenances: page.maintenances.map((maintenance) => ({ 63 70 id: maintenance.id, 64 71 name: maintenance.title, ··· 66 73 from: maintenance.from, 67 74 to: maintenance.to, 68 75 updatedAt: maintenance.updatedAt, 76 + // @deprecated Use components instead 69 77 monitors: maintenance.maintenancesToMonitors.map( 70 78 (item) => item.monitor.id, 71 79 ), 80 + // New field - references page component IDs 81 + pageComponents: maintenance.maintenancesToPageComponents.map( 82 + (item) => item.pageComponentId, 83 + ), 72 84 })), 73 85 statusReports: page.statusReports.map((report) => ({ 74 86 id: report.id, 75 87 title: report.title, 76 - updateAt: report.updatedAt, 88 + updatedAt: report.updatedAt, 77 89 status: report.status, 90 + // @deprecated Use components instead 78 91 monitors: report.monitorsToStatusReports.map((item) => item.monitor.id), 92 + // New field - references page component IDs 93 + pageComponents: report.statusReportsToPageComponents.map( 94 + (item) => item.pageComponentId, 95 + ), 79 96 statusReportUpdates: report.statusReportUpdates.map((update) => ({ 80 97 id: update.id, 81 98 status: update.status,
+202 -3
packages/api/src/router/statusPage.e2e.test.ts
··· 576 576 where: eq(pageSubscriber.email, "resubscribe-test@example.com"), 577 577 }); 578 578 579 + if (!subscriber) { 580 + throw new Error("Subscriber ID is undefined"); 581 + } 582 + 579 583 expect(subscriber?.acceptedAt).not.toBeNull(); 580 584 expect(subscriber?.unsubscribedAt).toBeNull(); 581 585 ··· 583 587 await db 584 588 .update(pageSubscriber) 585 589 .set({ unsubscribedAt: new Date() }) 586 - .where(eq(pageSubscriber.id, subscriber?.id)); 590 + .where(eq(pageSubscriber.id, subscriber.id)); 587 591 588 592 subscriber = await db.query.pageSubscriber.findFirst({ 589 - where: eq(pageSubscriber.id, subscriber?.id), 593 + where: eq(pageSubscriber.id, subscriber.id), 590 594 }); 591 595 592 596 expect(subscriber?.unsubscribedAt).not.toBeNull(); ··· 607 611 608 612 expect(subscribersAfterUnsub.length).toBe(0); 609 613 614 + if (!subscriber) { 615 + throw new Error("Subscriber is undefined"); 616 + } 617 + 610 618 // Step 4: User re-subscribes (simulating the re-subscription flow) 611 619 const newToken = crypto.randomUUID(); 612 620 await db ··· 617 625 token: newToken, 618 626 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 619 627 }) 620 - .where(eq(pageSubscriber.id, subscriber?.id)); 628 + .where(eq(pageSubscriber.id, subscriber.id)); 621 629 622 630 // Step 5: User is still excluded (not yet verified) 623 631 const subscribersPendingVerify = await db ··· 720 728 .where(eq(pageSubscriber.email, "already-unsub@example.com")); 721 729 }); 722 730 }); 731 + 732 + describe("statusPage.get endpoint validation", () => { 733 + test("Returns all required output fields with correct types", async () => { 734 + // Use the edgeRouter to call the statusPage.get endpoint 735 + const { edgeRouter } = await import("../edge"); 736 + const { createInnerTRPCContext } = await import("../trpc"); 737 + 738 + const ctx = createInnerTRPCContext({ 739 + req: undefined, 740 + // @ts-expect-error - auth not required for public procedure 741 + auth: undefined, 742 + }); 743 + 744 + const caller = edgeRouter.createCaller(ctx); 745 + const result = await caller.statusPage.get({ slug: testSlug }); 746 + 747 + // Validate that result is not null 748 + expect(result).toBeDefined(); 749 + expect(result).not.toBeNull(); 750 + 751 + if (!result) { 752 + throw new Error("Result should not be null"); 753 + } 754 + 755 + // Validate core page fields with specific types 756 + expect(typeof result.slug).toBe("string"); 757 + expect(typeof result.title).toBe("string"); 758 + expect(typeof result.description).toBe("string"); 759 + expect(result.createdAt).toBeInstanceOf(Date); 760 + expect(result.updatedAt).toBeInstanceOf(Date); 761 + 762 + // Validate slug matches what we requested 763 + expect(result.slug).toBe(testSlug); 764 + 765 + // Validate all array fields exist and are arrays 766 + expect(Array.isArray(result.monitors)).toBe(true); 767 + expect(Array.isArray(result.monitorGroups)).toBe(true); 768 + expect(Array.isArray(result.pageComponents)).toBe(true); 769 + expect(Array.isArray(result.pageComponentGroups)).toBe(true); 770 + expect(Array.isArray(result.trackers)).toBe(true); 771 + expect(Array.isArray(result.lastEvents)).toBe(true); 772 + expect(Array.isArray(result.openEvents)).toBe(true); 773 + expect(Array.isArray(result.statusReports)).toBe(true); 774 + expect(Array.isArray(result.incidents)).toBe(true); 775 + expect(Array.isArray(result.maintenances)).toBe(true); 776 + 777 + // Validate status field is one of the allowed values 778 + expect(["success", "degraded", "error", "info"]).toContain(result.status); 779 + 780 + // Validate workspacePlan field 781 + expect(result.workspacePlan).toBeDefined(); 782 + expect(typeof result.workspacePlan).toBe("string"); 783 + 784 + // Validate whiteLabel field 785 + expect(typeof result.whiteLabel).toBe("boolean"); 786 + }); 787 + 788 + test("Returns null for non-existent slug", async () => { 789 + const { edgeRouter } = await import("../edge"); 790 + const { createInnerTRPCContext } = await import("../trpc"); 791 + 792 + const ctx = createInnerTRPCContext({ 793 + req: undefined, 794 + // @ts-expect-error - auth not required for public procedure 795 + auth: undefined, 796 + }); 797 + 798 + const caller = edgeRouter.createCaller(ctx); 799 + const result = await caller.statusPage.get({ 800 + slug: "non-existent-slug-12345", 801 + }); 802 + 803 + expect(result).toBeNull(); 804 + }); 805 + 806 + test("Tracker objects have correct discriminated union types", async () => { 807 + const { edgeRouter } = await import("../edge"); 808 + const { createInnerTRPCContext } = await import("../trpc"); 809 + 810 + const ctx = createInnerTRPCContext({ 811 + req: undefined, 812 + // @ts-expect-error - auth not required for public procedure 813 + auth: undefined, 814 + }); 815 + 816 + const caller = edgeRouter.createCaller(ctx); 817 + const result = await caller.statusPage.get({ slug: testSlug }); 818 + 819 + if (!result) { 820 + // If no result, skip this test as there are no trackers to validate 821 + return; 822 + } 823 + 824 + // Validate each tracker has the correct structure 825 + for (const tracker of result.trackers) { 826 + expect(tracker).toHaveProperty("type"); 827 + expect(tracker).toHaveProperty("order"); 828 + 829 + if (tracker.type === "monitor") { 830 + expect(tracker).toHaveProperty("monitor"); 831 + expect(tracker.monitor).toHaveProperty("id"); 832 + expect(tracker.monitor).toHaveProperty("name"); 833 + expect(tracker.monitor).toHaveProperty("status"); 834 + expect(["success", "degraded", "error", "info"]).toContain( 835 + tracker.monitor.status, 836 + ); 837 + } else if (tracker.type === "group") { 838 + expect(tracker).toHaveProperty("groupId"); 839 + expect(tracker).toHaveProperty("groupName"); 840 + expect(tracker).toHaveProperty("monitors"); 841 + expect(tracker).toHaveProperty("status"); 842 + expect(Array.isArray(tracker.monitors)).toBe(true); 843 + expect(["success", "degraded", "error", "info"]).toContain( 844 + tracker.status, 845 + ); 846 + } 847 + } 848 + }); 849 + 850 + test("Event objects have required fields", async () => { 851 + const { edgeRouter } = await import("../edge"); 852 + const { createInnerTRPCContext } = await import("../trpc"); 853 + 854 + const ctx = createInnerTRPCContext({ 855 + req: undefined, 856 + // @ts-expect-error - auth not required for public procedure 857 + auth: undefined, 858 + }); 859 + 860 + const caller = edgeRouter.createCaller(ctx); 861 + const result = await caller.statusPage.get({ slug: testSlug }); 862 + 863 + if (!result) { 864 + return; 865 + } 866 + 867 + // Validate lastEvents structure 868 + for (const event of result.lastEvents) { 869 + expect(event).toMatchObject({ 870 + id: expect.any(Number), 871 + name: expect.any(String), 872 + from: expect.any(Date), 873 + status: expect.any(String), 874 + type: expect.any(String), 875 + }); 876 + expect(["maintenance", "incident", "report"]).toContain(event.type); 877 + expect(["success", "degraded", "error", "info"]).toContain(event.status); 878 + } 879 + 880 + // Validate openEvents structure 881 + for (const event of result.openEvents) { 882 + expect(event).toMatchObject({ 883 + id: expect.any(Number), 884 + name: expect.any(String), 885 + from: expect.any(Date), 886 + status: expect.any(String), 887 + type: expect.any(String), 888 + }); 889 + expect(["maintenance", "incident", "report"]).toContain(event.type); 890 + expect(["success", "degraded", "error", "info"]).toContain(event.status); 891 + } 892 + }); 893 + 894 + test("Monitor objects contain status field", async () => { 895 + const { edgeRouter } = await import("../edge"); 896 + const { createInnerTRPCContext } = await import("../trpc"); 897 + 898 + const ctx = createInnerTRPCContext({ 899 + req: undefined, 900 + // @ts-expect-error - auth not required for public procedure 901 + auth: undefined, 902 + }); 903 + 904 + const caller = edgeRouter.createCaller(ctx); 905 + const result = await caller.statusPage.get({ slug: testSlug }); 906 + 907 + if (!result || result.monitors.length === 0) { 908 + return; 909 + } 910 + 911 + // Validate each monitor has status field 912 + for (const monitor of result.monitors) { 913 + expect(monitor).toHaveProperty("status"); 914 + expect(["success", "degraded", "error", "info"]).toContain( 915 + monitor.status, 916 + ); 917 + expect(monitor).toHaveProperty("id"); 918 + expect(monitor).toHaveProperty("name"); 919 + } 920 + }); 921 + });
+2
packages/api/src/router/statusPage.ts
··· 345 345 status, 346 346 lastEvents, 347 347 openEvents, 348 + pageComponents, 349 + pageComponentGroups: _page.pageComponentGroups, 348 350 whiteLabel, 349 351 }); 350 352 }),
+36 -18
packages/db/src/schema/shared.ts
··· 32 32 33 33 export const selectStatusReportPageSchema = selectStatusReportSchema.extend({ 34 34 statusReportUpdates: z.array(selectStatusReportUpdateSchema).prefault([]), 35 + statusReportsToPageComponents: z 36 + .array( 37 + z.object({ 38 + pageComponentId: z.number(), 39 + statusReportId: z.number(), 40 + }), 41 + ) 42 + .prefault([]), 35 43 monitorsToStatusReports: z 36 44 .array( 37 45 z.object({ ··· 44 52 }); 45 53 46 54 export const selectMaintenancePageSchema = selectMaintenanceSchema.extend({ 55 + maintenancesToPageComponents: z 56 + .array( 57 + z.object({ 58 + pageComponentId: z.number(), 59 + maintenanceId: z.number(), 60 + }), 61 + ) 62 + .prefault([]), 47 63 maintenancesToMonitors: z 48 64 .array( 49 65 z.object({ ··· 139 155 type: z.enum(["maintenance", "incident", "report"]), 140 156 }); 141 157 142 - export const selectPublicPageSchemaWithRelation = selectPageSchema.extend({ 143 - monitorGroups: selectMonitorGroupSchema.array().prefault([]), 144 - // TODO: include status of the monitor 145 - monitors: selectPublicMonitorWithStatusSchema.array(), 146 - trackers: trackersSchema, 147 - lastEvents: z.array(statusPageEventSchema), 148 - openEvents: z.array(statusPageEventSchema), 149 - statusReports: z.array(selectStatusReportPageSchema), 150 - incidents: z.array(selectIncidentSchema), 151 - maintenances: z.array(selectMaintenancePageSchema), 152 - status: z.enum(["success", "degraded", "error", "info"]).prefault("success"), 153 - workspacePlan: workspacePlanSchema 154 - .nullable() 155 - .prefault("free") 156 - .transform((val) => val ?? "free"), 157 - whiteLabel: z.boolean().prefault(false), 158 - }); 159 - 160 158 export const selectPageComponentWithMonitorRelation = 161 159 selectPageComponentSchema.extend({ 162 160 monitor: selectPublicMonitorBaseSchema ··· 174 172 export type PageComponentWithMonitorRelation = z.infer< 175 173 typeof selectPageComponentWithMonitorRelation 176 174 >; 175 + 176 + export const selectPublicPageSchemaWithRelation = selectPageSchema.extend({ 177 + monitorGroups: selectMonitorGroupSchema.array().prefault([]), 178 + // TODO: include status of the monitor 179 + monitors: selectPublicMonitorWithStatusSchema.array(), 180 + pageComponents: selectPageComponentWithMonitorRelation.array().prefault([]), 181 + pageComponentGroups: selectPageComponentGroupSchema.array().prefault([]), 182 + trackers: trackersSchema, 183 + lastEvents: z.array(statusPageEventSchema), 184 + openEvents: z.array(statusPageEventSchema), 185 + statusReports: z.array(selectStatusReportPageSchema), 186 + incidents: z.array(selectIncidentSchema), 187 + maintenances: z.array(selectMaintenancePageSchema), 188 + status: z.enum(["success", "degraded", "error", "info"]).prefault("success"), 189 + workspacePlan: workspacePlanSchema 190 + .nullable() 191 + .prefault("free") 192 + .transform((val) => val ?? "free"), 193 + whiteLabel: z.boolean().prefault(false), 194 + }); 177 195 178 196 export const selectPublicStatusReportSchemaWithRelation = 179 197 selectStatusReportSchema.extend({