Skip to content

Commit 5ad0a3e

Browse files
committed
feat: global monitors
1 parent 8f7c976 commit 5ad0a3e

File tree

7 files changed

+272
-109
lines changed

7 files changed

+272
-109
lines changed

apps/dashboard/app/(main)/websites/[id]/pulse/_components/monitor-dialog.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
TooltipContent,
3030
TooltipTrigger,
3131
} from "@/components/ui/tooltip";
32+
import { useWebsite } from "@/hooks/use-websites";
3233
import { orpc } from "@/lib/orpc";
3334

3435
const granularityOptions = [
@@ -71,6 +72,7 @@ export function MonitorDialog({
7172
}: MonitorDialogProps) {
7273
const formRef = useRef<HTMLFormElement>(null);
7374
const isEditing = !!schedule;
75+
const { data: website } = useWebsite(websiteId);
7476

7577
const form = useForm<MonitorFormData>({
7678
resolver: zodResolver(monitorFormSchema),
@@ -136,8 +138,19 @@ export function MonitorDialog({
136138
});
137139
toast.success("Monitor updated successfully");
138140
} else {
141+
if (!website?.domain) {
142+
toast.error("Website domain not found");
143+
return;
144+
}
145+
146+
const url = website.domain.startsWith("http")
147+
? website.domain
148+
: `https://${website.domain}`;
149+
139150
await createMutation.mutateAsync({
140151
websiteId,
152+
url,
153+
name: website.name ?? undefined,
141154
granularity: data.granularity,
142155
});
143156
toast.success("Monitor created successfully");

apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
PauseIcon,
66
PencilIcon,
77
PlayIcon,
8+
TrashIcon,
89
} from "@phosphor-icons/react";
910
import { useMutation, useQuery } from "@tanstack/react-query";
1011
import dayjs from "dayjs";
@@ -13,6 +14,16 @@ import { useParams } from "next/navigation";
1314
import { useMemo, useState } from "react";
1415
import { toast } from "sonner";
1516
import { EmptyState } from "@/components/empty-state";
17+
import {
18+
AlertDialog,
19+
AlertDialogAction,
20+
AlertDialogCancel,
21+
AlertDialogContent,
22+
AlertDialogDescription,
23+
AlertDialogFooter,
24+
AlertDialogHeader,
25+
AlertDialogTitle,
26+
} from "@/components/ui/alert-dialog";
1627
import { Badge } from "@/components/ui/badge";
1728
import { Button } from "@/components/ui/button";
1829
import { useDateFilters } from "@/hooks/use-date-filters";
@@ -46,6 +57,7 @@ export default function PulsePage() {
4657
granularity: string;
4758
} | null>(null);
4859
const [isRefreshing, setIsRefreshing] = useState(false);
60+
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
4961

5062
const {
5163
data: schedule,
@@ -63,6 +75,9 @@ export default function PulsePage() {
6375
const resumeMutation = useMutation({
6476
...orpc.uptime.resumeSchedule.mutationOptions(),
6577
});
78+
const deleteMutation = useMutation({
79+
...orpc.uptime.deleteSchedule.mutationOptions(),
80+
});
6681

6782
const [isPausing, setIsPausing] = useState(false);
6883
const hasMonitor = !!schedule;
@@ -173,6 +188,23 @@ export default function PulsePage() {
173188
await refetchSchedule();
174189
};
175190

191+
const handleDeleteMonitor = async () => {
192+
if (!schedule) {
193+
return;
194+
}
195+
196+
try {
197+
await deleteMutation.mutateAsync({ scheduleId: schedule.id });
198+
toast.success("Monitor deleted successfully");
199+
await refetchSchedule();
200+
setIsDeleteDialogOpen(false);
201+
} catch (error) {
202+
const errorMessage =
203+
error instanceof Error ? error.message : "Failed to delete monitor";
204+
toast.error(errorMessage);
205+
}
206+
};
207+
176208
const handleRefresh = async () => {
177209
setIsRefreshing(true);
178210
try {
@@ -267,6 +299,15 @@ export default function PulsePage() {
267299
<PencilIcon size={16} weight="duotone" />
268300
Configure
269301
</Button>
302+
<Button
303+
disabled={deleteMutation.isPending}
304+
onClick={() => setIsDeleteDialogOpen(true)}
305+
size="sm"
306+
variant="outline"
307+
>
308+
<TrashIcon size={16} weight="duotone" />
309+
Delete
310+
</Button>
270311
</>
271312
) : undefined;
272313

@@ -337,6 +378,32 @@ export default function PulsePage() {
337378
schedule={editingSchedule}
338379
websiteId={websiteId as string}
339380
/>
381+
382+
<AlertDialog
383+
onOpenChange={setIsDeleteDialogOpen}
384+
open={isDeleteDialogOpen}
385+
>
386+
<AlertDialogContent>
387+
<AlertDialogHeader>
388+
<AlertDialogTitle>Delete Monitor</AlertDialogTitle>
389+
<AlertDialogDescription>
390+
Are you sure you want to delete this uptime monitor? This action
391+
cannot be undone and all historical data will be preserved but no
392+
new checks will be performed.
393+
</AlertDialogDescription>
394+
</AlertDialogHeader>
395+
<AlertDialogFooter>
396+
<AlertDialogCancel>Cancel</AlertDialogCancel>
397+
<AlertDialogAction
398+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
399+
disabled={deleteMutation.isPending}
400+
onClick={handleDeleteMonitor}
401+
>
402+
{deleteMutation.isPending ? "Deleting..." : "Delete Monitor"}
403+
</AlertDialogAction>
404+
</AlertDialogFooter>
405+
</AlertDialogContent>
406+
</AlertDialog>
340407
</div>
341408
);
342409
}

apps/uptime/src/actions.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createHash } from "node:crypto";
22
import { connect } from "node:tls";
3-
// import { chQuery } from "@databuddy/db";
4-
import { websiteService } from "@databuddy/services/websites";
3+
import { db, eq, uptimeSchedules } from "@databuddy/db";
54
import { captureError, record } from "./lib/tracing";
65
import type { ActionResult, UptimeData } from "./types";
76
import { MonitorStatus } from "./types";
@@ -41,20 +40,29 @@ type FetchFailure = {
4140
// streak: number;
4241
// };
4342

44-
export function lookupWebsite(
43+
export function lookupSchedule(
4544
id: string
46-
): Promise<ActionResult<{ id: string; domain: string }>> {
47-
return record("uptime.lookup_website", async () => {
45+
): Promise<ActionResult<{ id: string; url: string; websiteId: string | null }>> {
46+
return record("uptime.lookup_schedule", async () => {
4847
try {
49-
const site = await websiteService.getById(id);
48+
const schedule = await db.query.uptimeSchedules.findFirst({
49+
where: eq(uptimeSchedules.id, id),
50+
});
5051

51-
if (!site) {
52-
return { success: false, error: `Website ${id} not found` };
52+
if (!schedule) {
53+
return { success: false, error: `Schedule ${id} not found` };
5354
}
5455

55-
return { success: true, data: { id: site.id, domain: site.domain } };
56+
return {
57+
success: true,
58+
data: {
59+
id: schedule.id,
60+
url: schedule.url,
61+
websiteId: schedule.websiteId,
62+
},
63+
};
5664
} catch (error) {
57-
console.error("Database lookup failed:", error);
65+
console.error("Schedule lookup failed:", error);
5866
return {
5967
success: false,
6068
error: error instanceof Error ? error.message : "Database error",

apps/uptime/src/index.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Receiver } from "@upstash/qstash";
22
import Elysia from "elysia";
3-
import { checkUptime, lookupWebsite } from "./actions";
3+
import { z } from "zod";
4+
import { checkUptime, lookupSchedule } from "./actions";
45
import { sendUptimeEvent } from "./lib/producer";
56
import {
67
captureError,
@@ -84,13 +85,23 @@ const app = new Elysia()
8485
.get("/health", () => ({ status: "ok" }))
8586
.post("/", async ({ headers, body }) => {
8687
try {
87-
const siteId = headers["x-website-id"];
88-
const signature = headers["upstash-signature"];
88+
const headerSchema = z.object({
89+
"upstash-signature": z.string(),
90+
"x-schedule-id": z.string(),
91+
"x-max-retries": z.string().optional(),
92+
});
93+
94+
const parsed = headerSchema.safeParse(headers);
95+
if (!parsed.success) {
96+
return new Response("Missing required headers", { status: 400 });
97+
}
98+
99+
const { "upstash-signature": signature, "x-schedule-id": scheduleId } =
100+
parsed.data;
89101

90102
const isValid = await receiver.verify({
91103
// @ts-expect-error, this doesn't require type assertions
92104
body,
93-
// @ts-expect-error, these don't require type assertions
94105
signature,
95106
url: "https://uptime.databuddy.cc",
96107
});
@@ -99,39 +110,31 @@ const app = new Elysia()
99110
return new Response("Invalid signature", { status: 401 });
100111
}
101112

102-
if (!siteId || typeof siteId !== "string") {
103-
return new Response("Website ID is required", { status: 400 });
113+
const schedule = await lookupSchedule(scheduleId);
114+
if (!schedule.success) {
115+
captureError(schedule.error);
116+
return new Response("Schedule not found", { status: 404 });
104117
}
105118

106-
const site = await lookupWebsite(siteId);
119+
const monitorId = schedule.data.websiteId || scheduleId;
107120

108-
if (!site.success) {
109-
captureError(site.error);
110-
return new Response("Website not found", { status: 404 });
111-
}
112-
113-
const maxRetriesHeader = headers["x-max-retries"];
114-
const maxRetries = maxRetriesHeader
115-
? Number.parseInt(maxRetriesHeader as string, 10)
121+
const maxRetries = parsed.data["x-max-retries"]
122+
? Number.parseInt(parsed.data["x-max-retries"], 10)
116123
: 3;
117124

118-
const result = await checkUptime(siteId, site.data.domain, 1, maxRetries);
125+
const result = await checkUptime(monitorId, schedule.data.url, 1, maxRetries);
119126

120127
if (!result.success) {
121128
console.error("Uptime check failed:", result.error);
122129
captureError(result.error);
123130
return new Response("Failed to check uptime", { status: 500 });
124131
}
125132

126-
const { data } = result;
127-
128-
// Send event to Redpanda for ingestion via Vector
129133
try {
130-
await sendUptimeEvent(data, data.site_id);
134+
await sendUptimeEvent(result.data, monitorId);
131135
} catch (error) {
132-
console.error("Failed to send uptime event to Redpanda:", error);
136+
console.error("Failed to send uptime event:", error);
133137
captureError(error);
134-
// continue execution even if redpanda send fails
135138
}
136139

137140
return new Response("Uptime check complete", { status: 200 });

packages/db/src/drizzle/schema.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,10 @@ export const uptimeSchedules = pgTable(
782782
"uptime_schedules",
783783
{
784784
id: text().primaryKey().notNull(),
785-
websiteId: text("website_id").notNull(),
785+
websiteId: text("website_id"),
786+
userId: text("user_id").notNull(),
787+
url: text().notNull(),
788+
name: text(),
786789
granularity: text("granularity").notNull(),
787790
cron: text().notNull(),
788791
isPaused: boolean("is_paused").default(false).notNull(),
@@ -794,12 +797,27 @@ export const uptimeSchedules = pgTable(
794797
"btree",
795798
table.websiteId.asc().nullsLast().op("text_ops")
796799
),
800+
index("uptime_schedules_user_id_idx").using(
801+
"btree",
802+
table.userId.asc().nullsLast().op("text_ops")
803+
),
804+
index("uptime_schedules_url_idx").using(
805+
"btree",
806+
table.url.asc().nullsLast().op("text_ops")
807+
),
797808
foreignKey({
798809
columns: [table.websiteId],
799810
foreignColumns: [websites.id],
800811
name: "uptime_schedules_website_id_fkey",
801812
})
802813
.onUpdate("cascade")
803814
.onDelete("cascade"),
815+
foreignKey({
816+
columns: [table.userId],
817+
foreignColumns: [user.id],
818+
name: "uptime_schedules_user_id_fkey",
819+
})
820+
.onUpdate("cascade")
821+
.onDelete("cascade"),
804822
]
805823
);

0 commit comments

Comments
 (0)