Skip to content

Commit 76e68b1

Browse files
aggmoulikclaudemxkaske
authored
Migration to OpenStatus from Atlassian StatusPage (#1933)
* feat(importers): scaffold package with Statuspage API client, schemas, and fixtures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(importers): add data mapper and import provider with tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(api): add import service layer and tRPC router for Statuspage migration Add DB write service with phased transactions and per-resource error handling, tRPC router with preview/run endpoints, and register on the lambda router. Also enrich provider output with sourceGroupId on components and sourceComponentIds on maintenances for proper relational linking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(api): add integration tests for Statuspage import router Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(importers): finalize package exports and provider registry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Formatted code * Formatted and linted the code Signed-off-by: Moulik Aggarwal <qwertymoulik@gmail.com> * Updated new test cases for statuspage client Signed-off-by: Moulik Aggarwal <qwertymoulik@gmail.com> * refactor: composition over inheritance * refactor: register edge router * chore: dashboard importer * chore: fine-tune importer * fix: page subscribers to component * fix: handle pagination in import router test mock and adjust warning assertion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: skip already-truncated resources in writeComponentsPhase Resources marked as "skipped" by the component limit truncation were being processed and overwritten by writeComponentsPhase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use dedicated test page for component limit truncation test Create a fresh page instead of relying on seeded pageId: 1 to ensure predictable DB state for the component limit enforcement test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use dedicated test page for component limit truncation test Create a fresh page instead of relying on seeded pageId: 1 to ensure predictable DB state for the component limit enforcement test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: claude review * fix: lock file --------- Signed-off-by: Moulik Aggarwal <qwertymoulik@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Maximilian Kaske <maximilian@kaske.org>
1 parent 370fd5e commit 76e68b1

File tree

25 files changed

+3665
-0
lines changed

25 files changed

+3665
-0
lines changed

apps/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@openstatus/error": "workspace:*",
3131
"@openstatus/header-analysis": "workspace:*",
3232
"@openstatus/icons": "workspace:*",
33+
"@openstatus/importers": "workspace:*",
3334
"@openstatus/notification-discord": "workspace:*",
3435
"@openstatus/notification-emails": "workspace:*",
3536
"@openstatus/notification-google-chat": "workspace:*",
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
"use client";
2+
3+
import { Note } from "@/components/common/note";
4+
import {
5+
FormCard,
6+
FormCardContent,
7+
FormCardDescription,
8+
FormCardFooter,
9+
FormCardHeader,
10+
FormCardSeparator,
11+
FormCardTitle,
12+
} from "@/components/forms/form-card";
13+
import { useTRPC } from "@/lib/trpc/client";
14+
import { zodResolver } from "@hookform/resolvers/zod";
15+
import { StatuspageIcon } from "@openstatus/icons";
16+
import type { ImportSummary } from "@openstatus/importers/types";
17+
import { Badge } from "@openstatus/ui/components/ui/badge";
18+
import { Button } from "@openstatus/ui/components/ui/button";
19+
import {
20+
Form,
21+
FormControl,
22+
FormDescription,
23+
FormField,
24+
FormItem,
25+
FormLabel,
26+
FormMessage,
27+
} from "@openstatus/ui/components/ui/form";
28+
import { Input } from "@openstatus/ui/components/ui/input";
29+
import {
30+
RadioGroup,
31+
RadioGroupItem,
32+
} from "@openstatus/ui/components/ui/radio-group";
33+
import { Switch } from "@openstatus/ui/components/ui/switch";
34+
import { useMutation } from "@tanstack/react-query";
35+
import { isTRPCClientError } from "@trpc/client";
36+
import { AlertTriangle } from "lucide-react";
37+
import { useTransition } from "react";
38+
import { useForm } from "react-hook-form";
39+
import { toast } from "sonner";
40+
import { z } from "zod";
41+
42+
const schema = z.object({
43+
provider: z.enum(["statuspage"]),
44+
apiKey: z.string().min(1, "API key is required"),
45+
statuspagePageId: z.string().optional(),
46+
includeStatusReports: z.boolean(),
47+
includeSubscribers: z.boolean(),
48+
includeComponents: z.boolean(),
49+
});
50+
51+
export type ImportFormValues = z.input<typeof schema>;
52+
53+
function getPhaseCount(preview: ImportSummary, phase: string): number {
54+
return preview.phases.find((p) => p.phase === phase)?.resources.length ?? 0;
55+
}
56+
57+
const PHASE_LABELS: Record<string, string> = {
58+
componentGroups: "Component Groups",
59+
components: "Components",
60+
incidents: "Status Reports",
61+
maintenances: "Maintenances",
62+
subscribers: "Subscribers",
63+
};
64+
65+
export function FormImport({
66+
pageId,
67+
onSubmit,
68+
}: {
69+
pageId: number;
70+
onSubmit: (values: ImportFormValues) => Promise<ImportSummary>;
71+
}) {
72+
const form = useForm<ImportFormValues>({
73+
resolver: zodResolver(schema),
74+
defaultValues: {
75+
provider: undefined,
76+
apiKey: "",
77+
statuspagePageId: "",
78+
includeStatusReports: true,
79+
includeSubscribers: false,
80+
includeComponents: true,
81+
},
82+
});
83+
const [isPending, startTransition] = useTransition();
84+
const trpc = useTRPC();
85+
const watchProvider = form.watch("provider");
86+
const watchApiKey = form.watch("apiKey");
87+
const watchStatuspagePageId = form.watch("statuspagePageId");
88+
89+
const previewMutation = useMutation(
90+
trpc.import.preview.mutationOptions({
91+
onError: (error) => {
92+
if (isTRPCClientError(error)) {
93+
toast.error(error.message);
94+
} else {
95+
toast.error("Failed to preview import");
96+
}
97+
},
98+
}),
99+
);
100+
101+
async function runPreview() {
102+
const apiKey = form.getValues("apiKey");
103+
if (!apiKey) {
104+
form.setError("apiKey", { message: "API key is required" });
105+
return;
106+
}
107+
previewMutation.mutate({
108+
provider: "statuspage",
109+
apiKey: watchApiKey,
110+
statuspagePageId: watchStatuspagePageId || undefined,
111+
pageId,
112+
});
113+
}
114+
115+
function submitAction(values: ImportFormValues) {
116+
if (isPending || !previewMutation.data) return;
117+
118+
startTransition(async () => {
119+
try {
120+
const promise = onSubmit(values);
121+
toast.promise(promise, {
122+
loading: "Importing...",
123+
success: (result) => {
124+
if (result.status === "partial")
125+
return "Import completed with warnings";
126+
return "Import completed";
127+
},
128+
error: (error) => {
129+
if (isTRPCClientError(error)) {
130+
return error.message;
131+
}
132+
return "Import failed";
133+
},
134+
});
135+
await promise;
136+
} catch (error) {
137+
console.error(error);
138+
}
139+
});
140+
}
141+
return (
142+
<Form {...form}>
143+
<form onSubmit={form.handleSubmit(submitAction)}>
144+
<FormCard>
145+
<FormCardHeader>
146+
<FormCardTitle>Import</FormCardTitle>
147+
<FormCardDescription>
148+
Import components, incidents, and subscribers from an external
149+
status page provider.
150+
</FormCardDescription>
151+
</FormCardHeader>
152+
<FormCardSeparator />
153+
<FormCardContent>
154+
<FormField
155+
control={form.control}
156+
name="provider"
157+
render={({ field }) => (
158+
<FormItem>
159+
<FormLabel>Provider</FormLabel>
160+
<FormControl>
161+
<RadioGroup
162+
onValueChange={field.onChange}
163+
defaultValue={field.value}
164+
className="grid grid-cols-2 gap-4 sm:grid-cols-4"
165+
>
166+
<FormItem className="relative flex cursor-pointer flex-row items-center gap-3 rounded-md border border-input px-2 py-3 text-center shadow-xs outline-none transition-[color,box-shadow] has-data-[state=checked]:border-primary/50 has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50">
167+
<FormControl>
168+
<RadioGroupItem
169+
value="statuspage"
170+
className="sr-only"
171+
/>
172+
</FormControl>
173+
<StatuspageIcon
174+
className="size-4 shrink-0 text-foreground"
175+
aria-hidden="true"
176+
/>
177+
<FormLabel className="cursor-pointer font-medium text-foreground text-xs leading-none after:absolute after:inset-0">
178+
Atlassian Statuspage
179+
</FormLabel>
180+
</FormItem>
181+
<div className="col-span-1 self-end text-muted-foreground text-xs sm:place-self-end">
182+
Missing a provider?{" "}
183+
<a href="mailto:ping@openstatus.dev">Contact us</a>
184+
</div>
185+
</RadioGroup>
186+
</FormControl>
187+
<FormMessage />
188+
</FormItem>
189+
)}
190+
/>
191+
</FormCardContent>
192+
{watchProvider ? (
193+
<>
194+
<FormCardSeparator />
195+
<FormCardContent className="grid gap-4">
196+
<FormField
197+
control={form.control}
198+
name="apiKey"
199+
render={({ field }) => (
200+
<FormItem>
201+
<FormLabel>API Key</FormLabel>
202+
<FormControl>
203+
<Input
204+
type="password"
205+
placeholder="OAuth API key"
206+
{...field}
207+
/>
208+
</FormControl>
209+
<FormMessage />
210+
<FormDescription>
211+
Your Statuspage API key. Found in your Statuspage
212+
account under Manage Account &gt; API.
213+
</FormDescription>
214+
</FormItem>
215+
)}
216+
/>
217+
<FormField
218+
control={form.control}
219+
name="statuspagePageId"
220+
render={({ field }) => (
221+
<FormItem>
222+
<FormLabel>Page ID (optional)</FormLabel>
223+
<FormControl>
224+
<Input placeholder="e.g. abc123def456" {...field} />
225+
</FormControl>
226+
<FormDescription>
227+
Import a specific page. Leave empty to import across
228+
pages.
229+
</FormDescription>
230+
</FormItem>
231+
)}
232+
/>
233+
<Button
234+
type="button"
235+
variant="secondary"
236+
onClick={runPreview}
237+
disabled={previewMutation.isPending}
238+
>
239+
{previewMutation.isPending
240+
? "Loading preview..."
241+
: "Preview Import"}
242+
</Button>
243+
</FormCardContent>
244+
</>
245+
) : null}
246+
{previewMutation.data ? (
247+
<>
248+
<FormCardSeparator />
249+
<FormCardContent className="grid gap-4">
250+
<div>
251+
<FormLabel>Preview</FormLabel>
252+
<div className="mt-2 flex flex-wrap gap-2">
253+
{Object.entries(PHASE_LABELS).map(([key, label]) => {
254+
const count = getPhaseCount(previewMutation.data, key);
255+
if (count === 0) return null;
256+
return (
257+
<Badge key={key} variant="secondary">
258+
{label}: {count}
259+
</Badge>
260+
);
261+
})}
262+
</div>
263+
</div>
264+
{previewMutation.data.errors.length > 0 ? (
265+
<Note color="error" size="sm">
266+
<AlertTriangle />
267+
<p className="text-sm">
268+
{previewMutation.data.errors.join(" ")}
269+
</p>
270+
</Note>
271+
) : null}
272+
<FormField
273+
control={form.control}
274+
name="includeStatusReports"
275+
render={({ field }) => (
276+
<FormItem className="flex flex-row items-center justify-between">
277+
<div className="space-y-0.5">
278+
<FormLabel>Status Reports & Maintenances</FormLabel>
279+
<FormDescription>
280+
Import incidents as status reports and scheduled
281+
maintenances.
282+
</FormDescription>
283+
</div>
284+
<FormControl>
285+
<Switch
286+
checked={field.value}
287+
onCheckedChange={field.onChange}
288+
/>
289+
</FormControl>
290+
</FormItem>
291+
)}
292+
/>
293+
<FormField
294+
control={form.control}
295+
name="includeComponents"
296+
render={({ field }) => (
297+
<FormItem className="flex flex-row items-center justify-between">
298+
<div className="space-y-0.5">
299+
<FormLabel>Components</FormLabel>
300+
<FormDescription>
301+
Import components and groups.
302+
</FormDescription>
303+
</div>
304+
<FormControl>
305+
<Switch
306+
checked={field.value}
307+
onCheckedChange={field.onChange}
308+
/>
309+
</FormControl>
310+
</FormItem>
311+
)}
312+
/>
313+
<FormField
314+
control={form.control}
315+
name="includeSubscribers"
316+
render={({ field }) => (
317+
<FormItem className="flex flex-row items-center justify-between">
318+
<div className="space-y-0.5">
319+
<FormLabel>Subscribers</FormLabel>
320+
<FormDescription>
321+
Import email subscribers.
322+
</FormDescription>
323+
</div>
324+
<FormControl>
325+
<Switch
326+
checked={field.value}
327+
onCheckedChange={field.onChange}
328+
/>
329+
</FormControl>
330+
</FormItem>
331+
)}
332+
/>
333+
</FormCardContent>
334+
</>
335+
) : null}
336+
<FormCardFooter>
337+
<Button
338+
type="submit"
339+
disabled={
340+
!previewMutation.data ||
341+
isPending ||
342+
previewMutation.data.errors.length > 0
343+
}
344+
>
345+
{isPending ? "Importing..." : "Import"}
346+
</Button>
347+
</FormCardFooter>
348+
</FormCard>
349+
</form>
350+
</Form>
351+
);
352+
}

0 commit comments

Comments
 (0)