Openstatus
www.openstatus.dev
1import { TRPCError } from "@trpc/server";
2import { z } from "zod";
3
4import {
5 type Assertion,
6 DnsRecordAssertion,
7 HeaderAssertion,
8 StatusAssertion,
9 TextBodyAssertion,
10 headerAssertion,
11 jsonBodyAssertion,
12 recordAssertion,
13 serialize,
14 statusAssertion,
15 textBodyAssertion,
16} from "@openstatus/assertions";
17import {
18 type SQL,
19 and,
20 count,
21 eq,
22 inArray,
23 isNull,
24 syncMaintenanceToMonitorDeleteByMonitors,
25 syncMonitorsToPageDeleteByMonitors,
26 syncStatusReportToMonitorDeleteByMonitors,
27} from "@openstatus/db";
28import {
29 insertMonitorSchema,
30 maintenancesToMonitors,
31 monitor,
32 monitorJobTypes,
33 monitorMethods,
34 monitorTag,
35 monitorTagsToMonitors,
36 monitorsToPages,
37 monitorsToStatusReport,
38 notification,
39 notificationsToMonitors,
40 privateLocationToMonitors,
41 selectIncidentSchema,
42 selectMonitorSchema,
43 selectMonitorTagSchema,
44 selectNotificationSchema,
45 selectPrivateLocationSchema,
46} from "@openstatus/db/src/schema";
47
48import { Events } from "@openstatus/analytics";
49import {
50 freeFlyRegions,
51 monitorPeriodicity,
52 monitorRegions,
53} from "@openstatus/db/src/schema/constants";
54import { regionDict } from "@openstatus/regions";
55import { createTRPCRouter, protectedProcedure } from "../trpc";
56import { testDns, testHttp, testTcp } from "./checker";
57
58export const monitorRouter = createTRPCRouter({
59 delete: protectedProcedure
60 .meta({ track: Events.DeleteMonitor })
61 .input(z.object({ id: z.number() }))
62 .mutation(async (opts) => {
63 const monitorToDelete = await opts.ctx.db
64 .select()
65 .from(monitor)
66 .where(
67 and(
68 eq(monitor.id, opts.input.id),
69 eq(monitor.workspaceId, opts.ctx.workspace.id),
70 ),
71 )
72 .get();
73 if (!monitorToDelete) return;
74
75 await opts.ctx.db
76 .update(monitor)
77 .set({ deletedAt: new Date(), active: false })
78 .where(eq(monitor.id, monitorToDelete.id))
79 .run();
80
81 await opts.ctx.db.transaction(async (tx) => {
82 await tx
83 .delete(monitorsToPages)
84 .where(eq(monitorsToPages.monitorId, monitorToDelete.id));
85 await tx
86 .delete(monitorTagsToMonitors)
87 .where(eq(monitorTagsToMonitors.monitorId, monitorToDelete.id));
88 await tx
89 .delete(monitorsToStatusReport)
90 .where(eq(monitorsToStatusReport.monitorId, monitorToDelete.id));
91 await tx
92 .delete(notificationsToMonitors)
93 .where(eq(notificationsToMonitors.monitorId, monitorToDelete.id));
94 await tx
95 .delete(maintenancesToMonitors)
96 .where(eq(maintenancesToMonitors.monitorId, monitorToDelete.id));
97 // Sync deletes to page components
98 await syncMonitorsToPageDeleteByMonitors(tx, [monitorToDelete.id]);
99 await syncStatusReportToMonitorDeleteByMonitors(tx, [
100 monitorToDelete.id,
101 ]);
102 await syncMaintenanceToMonitorDeleteByMonitors(tx, [
103 monitorToDelete.id,
104 ]);
105 });
106 }),
107
108 deleteMonitors: protectedProcedure
109 .input(z.object({ ids: z.number().array() }))
110 .mutation(async (opts) => {
111 const _monitors = await opts.ctx.db
112 .select()
113 .from(monitor)
114 .where(
115 and(
116 inArray(monitor.id, opts.input.ids),
117 eq(monitor.workspaceId, opts.ctx.workspace.id),
118 ),
119 )
120 .all();
121
122 if (_monitors.length !== opts.input.ids.length) {
123 throw new TRPCError({
124 code: "NOT_FOUND",
125 message: "Monitor not found.",
126 });
127 }
128
129 await opts.ctx.db
130 .update(monitor)
131 .set({ deletedAt: new Date(), active: false })
132 .where(inArray(monitor.id, opts.input.ids))
133 .run();
134
135 await opts.ctx.db.transaction(async (tx) => {
136 await tx
137 .delete(monitorsToPages)
138 .where(inArray(monitorsToPages.monitorId, opts.input.ids));
139 await tx
140 .delete(monitorTagsToMonitors)
141 .where(inArray(monitorTagsToMonitors.monitorId, opts.input.ids));
142 await tx
143 .delete(monitorsToStatusReport)
144 .where(inArray(monitorsToStatusReport.monitorId, opts.input.ids));
145 await tx
146 .delete(notificationsToMonitors)
147 .where(inArray(notificationsToMonitors.monitorId, opts.input.ids));
148 await tx
149 .delete(maintenancesToMonitors)
150 .where(inArray(maintenancesToMonitors.monitorId, opts.input.ids));
151 // Sync deletes to page components
152 await syncMonitorsToPageDeleteByMonitors(tx, opts.input.ids);
153 await syncStatusReportToMonitorDeleteByMonitors(tx, opts.input.ids);
154 await syncMaintenanceToMonitorDeleteByMonitors(tx, opts.input.ids);
155 });
156 }),
157
158 updateMonitors: protectedProcedure
159 .input(
160 insertMonitorSchema
161 .pick({ public: true, active: true })
162 .partial() // batched updates
163 .extend({ ids: z.number().array() }), // array of monitor ids to update
164 )
165 .mutation(async (opts) => {
166 await opts.ctx.db
167 .update(monitor)
168 .set(opts.input)
169 .where(
170 and(
171 inArray(monitor.id, opts.input.ids),
172 eq(monitor.workspaceId, opts.ctx.workspace.id),
173 isNull(monitor.deletedAt),
174 ),
175 );
176 }),
177
178 list: protectedProcedure
179 .input(
180 z
181 .object({
182 order: z.enum(["asc", "desc"]).optional(),
183 })
184 .optional(),
185 )
186 .query(async (opts) => {
187 const whereConditions: SQL[] = [
188 eq(monitor.workspaceId, opts.ctx.workspace.id),
189 isNull(monitor.deletedAt),
190 ];
191
192 const result = await opts.ctx.db.query.monitor.findMany({
193 where: and(...whereConditions),
194 with: {
195 monitorTagsToMonitors: {
196 with: { monitorTag: true },
197 },
198 incidents: {
199 orderBy: (incident, { desc }) => [desc(incident.createdAt)],
200 },
201 },
202 orderBy: (monitor, { asc, desc }) =>
203 opts.input?.order === "asc"
204 ? [asc(monitor.active), asc(monitor.createdAt)]
205 : [desc(monitor.active), desc(monitor.createdAt)],
206 });
207
208 return z
209 .array(
210 selectMonitorSchema.extend({
211 tags: z.array(selectMonitorTagSchema).prefault([]),
212 incidents: z.array(selectIncidentSchema).prefault([]),
213 }),
214 )
215 .parse(
216 result.map((data) => ({
217 ...data,
218 tags: data.monitorTagsToMonitors.map((t) => t.monitorTag),
219 })),
220 );
221 }),
222
223 get: protectedProcedure
224 .input(z.object({ id: z.coerce.number() }))
225 .query(async ({ ctx, input }) => {
226 const whereConditions: SQL[] = [
227 eq(monitor.id, input.id),
228 eq(monitor.workspaceId, ctx.workspace.id),
229 isNull(monitor.deletedAt),
230 ];
231
232 const data = await ctx.db.query.monitor.findFirst({
233 where: and(...whereConditions),
234 with: {
235 monitorsToNotifications: {
236 with: { notification: true },
237 },
238 monitorTagsToMonitors: {
239 with: { monitorTag: true },
240 },
241 incidents: true,
242 privateLocationToMonitors: {
243 with: { privateLocation: true },
244 },
245 },
246 });
247
248 if (!data) return null;
249
250 return selectMonitorSchema
251 .extend({
252 notifications: z.array(selectNotificationSchema).prefault([]),
253 tags: z.array(selectMonitorTagSchema).prefault([]),
254 incidents: z.array(selectIncidentSchema).prefault([]),
255 privateLocations: z.array(selectPrivateLocationSchema).prefault([]),
256 })
257 .parse({
258 ...data,
259 notifications: data.monitorsToNotifications.map(
260 (m) => m.notification,
261 ),
262 tags: data.monitorTagsToMonitors.map((t) => t.monitorTag),
263 incidents: data.incidents,
264 privateLocations: data.privateLocationToMonitors.map(
265 (p) => p.privateLocation,
266 ),
267 });
268 }),
269
270 clone: protectedProcedure
271 .meta({ track: Events.CloneMonitor })
272 .input(z.object({ id: z.number() }))
273 .mutation(async ({ ctx, input }) => {
274 const whereConditions: SQL[] = [
275 eq(monitor.id, input.id),
276 eq(monitor.workspaceId, ctx.workspace.id),
277 isNull(monitor.deletedAt),
278 ];
279
280 const _monitors = await ctx.db.query.monitor.findMany({
281 where: and(
282 eq(monitor.workspaceId, ctx.workspace.id),
283 isNull(monitor.deletedAt),
284 ),
285 });
286
287 if (_monitors.length >= ctx.workspace.limits.monitors) {
288 throw new TRPCError({
289 code: "FORBIDDEN",
290 message: "You have reached the maximum number of monitors.",
291 });
292 }
293
294 const data = await ctx.db.query.monitor.findFirst({
295 where: and(...whereConditions),
296 });
297
298 if (!data) {
299 throw new TRPCError({
300 code: "NOT_FOUND",
301 message: "Monitor not found.",
302 });
303 }
304
305 const [newMonitor] = await ctx.db
306 .insert(monitor)
307 .values({
308 ...data,
309 id: undefined, // let the db generate the id
310 name: `${data.name} (Copy)`,
311 createdAt: new Date(),
312 updatedAt: new Date(),
313 })
314 .returning();
315
316 if (!newMonitor) {
317 throw new TRPCError({
318 code: "INTERNAL_SERVER_ERROR",
319 message: "Failed to clone monitor.",
320 });
321 }
322
323 return newMonitor;
324 }),
325
326 updateRetry: protectedProcedure
327 .meta({ track: Events.UpdateMonitor })
328 .input(z.object({ id: z.number(), retry: z.number() }))
329 .mutation(async ({ ctx, input }) => {
330 const whereConditions: SQL[] = [
331 eq(monitor.id, input.id),
332 eq(monitor.workspaceId, ctx.workspace.id),
333 isNull(monitor.deletedAt),
334 ];
335
336 await ctx.db
337 .update(monitor)
338 .set({ retry: input.retry, updatedAt: new Date() })
339 .where(and(...whereConditions))
340 .run();
341 }),
342
343 updateFollowRedirects: protectedProcedure
344 .meta({ track: Events.UpdateMonitor })
345 .input(z.object({ id: z.number(), followRedirects: z.boolean() }))
346 .mutation(async ({ ctx, input }) => {
347 const whereConditions: SQL[] = [
348 eq(monitor.id, input.id),
349 eq(monitor.workspaceId, ctx.workspace.id),
350 isNull(monitor.deletedAt),
351 ];
352
353 await ctx.db
354 .update(monitor)
355 .set({
356 followRedirects: input.followRedirects,
357 updatedAt: new Date(),
358 })
359 .where(and(...whereConditions))
360 .run();
361 }),
362
363 updateOtel: protectedProcedure
364 .meta({ track: Events.UpdateMonitor })
365 .input(
366 z.object({
367 id: z.number(),
368 otelEndpoint: z.string(),
369 otelHeaders: z
370 .array(z.object({ key: z.string(), value: z.string() }))
371 .optional(),
372 }),
373 )
374 .mutation(async ({ ctx, input }) => {
375 const whereConditions: SQL[] = [
376 eq(monitor.id, input.id),
377 eq(monitor.workspaceId, ctx.workspace.id),
378 isNull(monitor.deletedAt),
379 ];
380
381 await ctx.db
382 .update(monitor)
383 .set({
384 otelEndpoint: input.otelEndpoint,
385 otelHeaders: input.otelHeaders
386 ? JSON.stringify(input.otelHeaders)
387 : undefined,
388 updatedAt: new Date(),
389 })
390 .where(and(...whereConditions))
391 .run();
392 }),
393
394 updatePublic: protectedProcedure
395 .meta({ track: Events.UpdateMonitor })
396 .input(z.object({ id: z.number(), public: z.boolean() }))
397 .mutation(async ({ ctx, input }) => {
398 const whereConditions: SQL[] = [
399 eq(monitor.id, input.id),
400 eq(monitor.workspaceId, ctx.workspace.id),
401 isNull(monitor.deletedAt),
402 ];
403
404 await ctx.db
405 .update(monitor)
406 .set({ public: input.public, updatedAt: new Date() })
407 .where(and(...whereConditions))
408 .run();
409 }),
410
411 updateSchedulingRegions: protectedProcedure
412 .meta({ track: Events.UpdateMonitor })
413 .input(
414 z.object({
415 id: z.number(),
416 regions: z.array(z.string()),
417 periodicity: z.enum(monitorPeriodicity),
418 privateLocations: z.array(z.number()),
419 }),
420 )
421 .mutation(async ({ ctx, input }) => {
422 const whereConditions: SQL[] = [
423 eq(monitor.id, input.id),
424 eq(monitor.workspaceId, ctx.workspace.id),
425 isNull(monitor.deletedAt),
426 ];
427
428 const limits = ctx.workspace.limits;
429
430 if (!limits.periodicity.includes(input.periodicity)) {
431 throw new TRPCError({
432 code: "FORBIDDEN",
433 message: "Upgrade to check more often.",
434 });
435 }
436
437 if (limits["max-regions"] < input.regions.length) {
438 throw new TRPCError({
439 code: "FORBIDDEN",
440 message: "You have reached the maximum number of regions.",
441 });
442 }
443
444 if (
445 input.regions.length > 0 &&
446 !input.regions.every((r) =>
447 limits.regions.includes(r as (typeof limits)["regions"][number]),
448 )
449 ) {
450 throw new TRPCError({
451 code: "FORBIDDEN",
452 message: "You don't have access to this region.",
453 });
454 }
455
456 await ctx.db.transaction(async (tx) => {
457 await tx
458 .update(monitor)
459 .set({
460 regions: input.regions.join(","),
461 periodicity: input.periodicity,
462 updatedAt: new Date(),
463 })
464 .where(and(...whereConditions))
465 .run();
466
467 await tx
468 .delete(privateLocationToMonitors)
469 .where(eq(privateLocationToMonitors.monitorId, input.id));
470
471 if (input.privateLocations && input.privateLocations.length > 0) {
472 await tx.insert(privateLocationToMonitors).values(
473 input.privateLocations.map((privateLocationId) => ({
474 monitorId: input.id,
475 privateLocationId,
476 })),
477 );
478 }
479 });
480 }),
481
482 updateResponseTime: protectedProcedure
483 .meta({ track: Events.UpdateMonitor })
484 .input(
485 z.object({
486 id: z.number(),
487 timeout: z.number(),
488 degradedAfter: z.number().nullish(),
489 }),
490 )
491 .mutation(async ({ ctx, input }) => {
492 const whereConditions: SQL[] = [
493 eq(monitor.id, input.id),
494 eq(monitor.workspaceId, ctx.workspace.id),
495 isNull(monitor.deletedAt),
496 ];
497
498 await ctx.db
499 .update(monitor)
500 .set({
501 timeout: input.timeout,
502 degradedAfter: input.degradedAfter,
503 updatedAt: new Date(),
504 })
505 .where(and(...whereConditions))
506 .run();
507 }),
508
509 updateTags: protectedProcedure
510 .meta({ track: Events.UpdateMonitor })
511 .input(z.object({ id: z.number(), tags: z.array(z.number()) }))
512 .mutation(async ({ ctx, input }) => {
513 const allTags = await ctx.db.query.monitorTag.findMany({
514 where: and(
515 eq(monitorTag.workspaceId, ctx.workspace.id),
516 inArray(monitorTag.id, input.tags),
517 ),
518 });
519
520 if (allTags.length !== input.tags.length) {
521 throw new TRPCError({
522 code: "FORBIDDEN",
523 message: "You don't have access to this tag.",
524 });
525 }
526
527 await ctx.db.transaction(async (tx) => {
528 await tx
529 .delete(monitorTagsToMonitors)
530 .where(and(eq(monitorTagsToMonitors.monitorId, input.id)));
531
532 if (input.tags.length > 0) {
533 await tx.insert(monitorTagsToMonitors).values(
534 input.tags.map((tagId) => ({
535 monitorId: input.id,
536 monitorTagId: tagId,
537 })),
538 );
539 }
540 });
541 }),
542
543 updateGeneral: protectedProcedure
544 .meta({ track: Events.UpdateMonitor })
545 .input(
546 z.object({
547 id: z.number(),
548 jobType: z.enum(monitorJobTypes),
549 url: z.string(),
550 method: z.enum(monitorMethods),
551 headers: z.array(z.object({ key: z.string(), value: z.string() })),
552 body: z.string().optional(),
553 name: z.string(),
554 assertions: z.array(
555 z.discriminatedUnion("type", [
556 statusAssertion,
557 headerAssertion,
558 textBodyAssertion,
559 jsonBodyAssertion,
560 recordAssertion,
561 ]),
562 ),
563 active: z.boolean().prefault(true),
564 // skip the test check if assertions are OK
565 skipCheck: z.boolean().prefault(true),
566 // save check in db (iff success? -> e.g. onboarding to get a first ping)
567 saveCheck: z.boolean().prefault(false),
568 }),
569 )
570 .mutation(async ({ ctx, input }) => {
571 const whereConditions: SQL[] = [
572 eq(monitor.id, input.id),
573 eq(monitor.workspaceId, ctx.workspace.id),
574 isNull(monitor.deletedAt),
575 ];
576
577 const assertions: Assertion[] = [];
578 for (const a of input.assertions ?? []) {
579 if (a.type === "status") {
580 assertions.push(new StatusAssertion(a));
581 }
582 if (a.type === "header") {
583 assertions.push(new HeaderAssertion(a));
584 }
585 if (a.type === "textBody") {
586 assertions.push(new TextBodyAssertion(a));
587 }
588 if (a.type === "dnsRecord") {
589 assertions.push(new DnsRecordAssertion(a));
590 }
591 }
592
593 // NOTE: we are checking the endpoint before saving
594 if (!input.skipCheck && input.active) {
595 if (input.jobType === "http") {
596 await testHttp({
597 url: input.url,
598 method: input.method,
599 headers: input.headers,
600 body: input.body,
601 // Filter out DNS record assertions as they can't be validated via HTTP
602 assertions: input.assertions.filter((a) => a.type !== "dnsRecord"),
603 region: "ams",
604 });
605 } else if (input.jobType === "tcp") {
606 await testTcp({
607 url: input.url,
608 region: "ams",
609 });
610 } else if (input.jobType === "dns") {
611 await testDns({
612 url: input.url,
613 region: "ams",
614 assertions: input.assertions.filter((a) => a.type === "dnsRecord"),
615 });
616 }
617 }
618
619 await ctx.db
620 .update(monitor)
621 .set({
622 name: input.name,
623 jobType: input.jobType,
624 url: input.url,
625 method: input.method,
626 headers: input.headers ? JSON.stringify(input.headers) : undefined,
627 body: input.body,
628 active: input.active,
629 assertions: serialize(assertions),
630 updatedAt: new Date(),
631 })
632 .where(and(...whereConditions))
633 .run();
634 }),
635
636 updateNotifiers: protectedProcedure
637 .meta({ track: Events.UpdateMonitor })
638 .input(z.object({ id: z.number(), notifiers: z.array(z.number()) }))
639 .mutation(async ({ ctx, input }) => {
640 const allNotifiers = await ctx.db.query.notification.findMany({
641 where: and(
642 eq(notification.workspaceId, ctx.workspace.id),
643 inArray(notification.id, input.notifiers),
644 ),
645 });
646
647 if (allNotifiers.length !== input.notifiers.length) {
648 throw new TRPCError({
649 code: "FORBIDDEN",
650 message: "You don't have access to this notifier.",
651 });
652 }
653
654 await ctx.db.transaction(async (tx) => {
655 await tx
656 .delete(notificationsToMonitors)
657 .where(and(eq(notificationsToMonitors.monitorId, input.id)));
658
659 if (input.notifiers.length > 0) {
660 await tx.insert(notificationsToMonitors).values(
661 input.notifiers.map((notifierId) => ({
662 monitorId: input.id,
663 notificationId: notifierId,
664 })),
665 );
666 }
667 });
668 }),
669
670 new: protectedProcedure
671 .meta({ track: Events.CreateMonitor, trackProps: ["url", "jobType"] })
672 .input(
673 z.object({
674 name: z.string(),
675 jobType: z.enum(monitorJobTypes),
676 url: z.string(),
677 method: z.enum(monitorMethods),
678 headers: z.array(z.object({ key: z.string(), value: z.string() })),
679 body: z.string().optional(),
680 assertions: z.array(
681 z.discriminatedUnion("type", [
682 statusAssertion,
683 headerAssertion,
684 textBodyAssertion,
685 jsonBodyAssertion,
686 recordAssertion,
687 ]),
688 ),
689 active: z.boolean().prefault(false),
690 saveCheck: z.boolean().prefault(false),
691 skipCheck: z.boolean().prefault(false),
692 }),
693 )
694 .mutation(async ({ ctx, input }) => {
695 const limits = ctx.workspace.limits;
696
697 const res = await ctx.db
698 .select({ count: count() })
699 .from(monitor)
700 .where(
701 and(
702 eq(monitor.workspaceId, ctx.workspace.id),
703 isNull(monitor.deletedAt),
704 ),
705 )
706 .get();
707
708 // the user has reached the limits
709 if (res && res.count >= limits.monitors) {
710 throw new TRPCError({
711 code: "FORBIDDEN",
712 message: "You reached your monitor limits.",
713 });
714 }
715
716 const assertions: Assertion[] = [];
717 for (const a of input.assertions ?? []) {
718 if (a.type === "status") {
719 assertions.push(new StatusAssertion(a));
720 }
721 if (a.type === "header") {
722 assertions.push(new HeaderAssertion(a));
723 }
724 if (a.type === "textBody") {
725 assertions.push(new TextBodyAssertion(a));
726 }
727 if (a.type === "dnsRecord") {
728 assertions.push(new DnsRecordAssertion(a));
729 }
730 }
731
732 // NOTE: we are checking the endpoint before saving
733 if (!input.skipCheck) {
734 if (input.jobType === "http") {
735 await testHttp({
736 url: input.url,
737 method: input.method,
738 headers: input.headers,
739 body: input.body,
740 // Filter out DNS record assertions as they can't be validated via HTTP
741 assertions: input.assertions.filter((a) => a.type !== "dnsRecord"),
742 region: "ams",
743 });
744 } else if (input.jobType === "tcp") {
745 await testTcp({
746 url: input.url,
747 region: "ams",
748 });
749 } else if (input.jobType === "dns") {
750 await testDns({
751 url: input.url,
752 region: "ams",
753 assertions: input.assertions.filter((a) => a.type === "dnsRecord"),
754 });
755 }
756 }
757
758 const selectableRegions =
759 ctx.workspace.plan === "free" ? freeFlyRegions : monitorRegions;
760 const randomRegions = ctx.workspace.plan === "free" ? 4 : 6;
761
762 const regions = [...selectableRegions]
763 // NOTE: make sure we don't use deprecated regions
764 .filter((r) => {
765 const deprecated = regionDict[r].deprecated;
766 if (!deprecated) return true;
767 return false;
768 })
769 .sort(() => 0.5 - Math.random())
770 .slice(0, randomRegions);
771
772 const newMonitor = await ctx.db
773 .insert(monitor)
774 .values({
775 name: input.name,
776 jobType: input.jobType,
777 url: input.url,
778 method: input.method,
779 headers: input.headers ? JSON.stringify(input.headers) : undefined,
780 body: input.body,
781 active: input.active,
782 workspaceId: ctx.workspace.id,
783 periodicity: ctx.workspace.plan === "free" ? "30m" : "1m",
784 regions: regions.join(","),
785 assertions: serialize(assertions),
786 updatedAt: new Date(),
787 })
788 .returning()
789 .get();
790
791 return newMonitor;
792 }),
793});