Openstatus
www.openstatus.dev
1"use client";
2import { useFieldArray } from "react-hook-form";
3import type { UseFormReturn } from "react-hook-form";
4
5import {
6 numberCompareDictionary,
7 stringCompareDictionary,
8} from "@openstatus/assertions";
9import type { InsertMonitor } from "@openstatus/db/src/schema";
10import {
11 Button,
12 FormControl,
13 FormDescription,
14 FormField,
15 FormItem,
16 FormLabel,
17 Input,
18 Select,
19 SelectContent,
20 SelectItem,
21 SelectTrigger,
22 SelectValue,
23} from "@openstatus/ui";
24
25import { EmptyState } from "@/components/dashboard/empty-state";
26import { Icons } from "@/components/icons";
27import { SectionHeader } from "../shared/section-header";
28
29// IMPROVEMENT: use FormFields incl. error message (fixes the Select component)
30
31export const setEmptyOrStr = (v: unknown) => {
32 if (typeof v === "string" && v.trim() === "") return undefined;
33 return v;
34};
35
36interface Props {
37 form: UseFormReturn<InsertMonitor>;
38}
39
40// REMINDER: once we have different types of assertions based on different jobTypes
41// we shoulds start creating a mapping function with allowed assertions for each jobType
42
43export function SectionAssertions({ form }: Props) {
44 const statusAssertions = useFieldArray({
45 control: form.control,
46 name: "statusAssertions",
47 });
48 const headerAssertions = useFieldArray({
49 control: form.control,
50 name: "headerAssertions",
51 });
52 const textBodyAssertions = useFieldArray({
53 control: form.control,
54 name: "textBodyAssertions",
55 });
56
57 return (
58 <div className="grid w-full gap-4">
59 <SectionHeader
60 title="Timing Setting"
61 description={
62 <>
63 Add specific time limits to your requests to receive notifications
64 if an endpoint takes longer than expected.
65 </>
66 }
67 />
68 <div className="grid w-full gap-4 sm:grid-cols-6">
69 <FormField
70 control={form.control}
71 name="degradedAfter"
72 render={({ field }) => (
73 <FormItem className="col-span-6 sm:col-span-3">
74 <FormLabel>
75 Degraded <span className="font-normal">(in ms.)</span>
76 </FormLabel>
77 <FormControl>
78 <Input
79 type="number"
80 min={0}
81 max={60000}
82 placeholder="30000"
83 {...form.register(field.name, {
84 setValueAs: (v) => (v === "" ? null : v),
85 })}
86 />
87 </FormControl>
88 <FormDescription>
89 Time after which the endpoint is considered degraded.
90 </FormDescription>
91 </FormItem>
92 )}
93 />
94 <FormField
95 control={form.control}
96 name="timeout"
97 render={({ field }) => (
98 <FormItem className="col-span-6 sm:col-span-3">
99 <FormLabel>
100 Timeout <span className="font-normal">(in ms.)</span>
101 </FormLabel>
102 <FormControl>
103 <Input
104 type="number"
105 placeholder="45000"
106 min={0}
107 max={60000}
108 {...field}
109 />
110 </FormControl>
111 <FormDescription>
112 Max. time allowed for request to complete.
113 </FormDescription>
114
115 {/* <FormMessage /> */}
116 </FormItem>
117 )}
118 />
119 </div>
120 <SectionHeader
121 title="Assertions"
122 description={
123 <>
124 Validate the response to ensure your service is working as expected.
125 <br />
126 <span className="underline decoration-border underline-offset-4">
127 By default, we check for a{" "}
128 <span className="font-medium text-foreground">
129 <code>2xx</code> status code
130 </span>
131 </span>
132 .
133 </>
134 }
135 />
136 {form.getValues("jobType") === "http" ? (
137 <div className="flex flex-col gap-4">
138 {statusAssertions.fields.map((f, i) => (
139 <div key={f.id} className="grid grid-cols-12 items-center gap-4">
140 <p className="col-span-2 text-muted-foreground text-sm">
141 Status Code
142 </p>
143 <div className="col-span-3" />
144 <FormField
145 control={form.control}
146 name={`statusAssertions.${i}.compare`}
147 render={({ field }) => (
148 <FormItem className="col-span-3 w-full">
149 <Select
150 onValueChange={field.onChange}
151 defaultValue={field.value}
152 >
153 <FormControl>
154 <SelectTrigger>
155 <SelectValue defaultValue="eq" placeholder="Equal" />
156 </SelectTrigger>
157 </FormControl>
158 <SelectContent>
159 {Object.entries(numberCompareDictionary).map(
160 ([key, value]) => (
161 <SelectItem key={key} value={key}>
162 {value}
163 </SelectItem>
164 ),
165 )}
166 </SelectContent>
167 </Select>
168 </FormItem>
169 )}
170 />
171 <Input
172 {...form.register(`statusAssertions.${i}.target`, {
173 required: true,
174 valueAsNumber: true,
175 validate: (value) =>
176 value <= 599 || "Value must be 599 or lower",
177 })}
178 type="number"
179 placeholder="200"
180 className="col-span-3"
181 />
182 <div className="col-span-1">
183 <Button
184 size="icon"
185 onClick={() => statusAssertions.remove(i)}
186 variant="ghost"
187 type="button"
188 >
189 <Icons.trash className="h-4 w-4" />
190 </Button>
191 </div>
192 </div>
193 ))}
194 {headerAssertions.fields.map((f, i) => (
195 <div key={f.id} className="grid grid-cols-12 items-center gap-4">
196 <p className="col-span-2 text-muted-foreground text-sm">
197 Response Header
198 </p>
199 <Input
200 {...form.register(`headerAssertions.${i}.key`, {
201 required: true,
202 setValueAs: setEmptyOrStr,
203 })}
204 className="col-span-3"
205 placeholder="X-Header"
206 />
207 <FormField
208 control={form.control}
209 name={`headerAssertions.${i}.compare`}
210 render={({ field }) => (
211 <FormItem className="col-span-3 w-full">
212 <Select
213 onValueChange={field.onChange}
214 defaultValue={field.value}
215 >
216 <FormControl>
217 <SelectTrigger>
218 <SelectValue defaultValue="eq" placeholder="Equal" />
219 </SelectTrigger>
220 </FormControl>
221 <SelectContent>
222 {Object.entries(stringCompareDictionary).map(
223 ([key, value]) => (
224 <SelectItem key={key} value={key}>
225 {value}
226 </SelectItem>
227 ),
228 )}
229 </SelectContent>
230 </Select>
231 </FormItem>
232 )}
233 />
234 <Input
235 {...form.register(`headerAssertions.${i}.target`)}
236 className="col-span-3"
237 placeholder="x-value"
238 />
239 <div className="col-span-1">
240 <Button
241 size="icon"
242 onClick={() => headerAssertions.remove(i)}
243 variant="ghost"
244 >
245 <Icons.trash className="h-4 w-4" />
246 </Button>
247 </div>
248 </div>
249 ))}
250 {textBodyAssertions.fields.map((f, i) => (
251 <div key={f.id} className="grid grid-cols-12 items-center gap-4">
252 <p className="col-span-2 text-muted-foreground text-sm">Body</p>
253 <div className="col-span-3" />
254 <FormField
255 control={form.control}
256 name={`textBodyAssertions.${i}.compare`}
257 render={({ field }) => (
258 <FormItem className="col-span-3 w-full">
259 <Select
260 onValueChange={field.onChange}
261 defaultValue={field.value}
262 >
263 <FormControl>
264 <SelectTrigger>
265 <SelectValue defaultValue="eq" placeholder="Equal" />
266 </SelectTrigger>
267 </FormControl>
268 <SelectContent>
269 {Object.entries(stringCompareDictionary).map(
270 ([key, value]) => (
271 <SelectItem key={key} value={key}>
272 {value}
273 </SelectItem>
274 ),
275 )}
276 </SelectContent>
277 </Select>
278 </FormItem>
279 )}
280 />
281 <Input
282 {...form.register(`textBodyAssertions.${i}.target`, {
283 required: true,
284 })}
285 placeholder="<html>...</html>"
286 className="col-span-3"
287 />
288 <div className="col-span-1">
289 <Button
290 size="icon"
291 onClick={() => textBodyAssertions.remove(i)}
292 variant="ghost"
293 type="button"
294 >
295 <Icons.trash className="h-4 w-4" />
296 </Button>
297 </div>
298 </div>
299 ))}
300 <div className="flex flex-wrap gap-4">
301 <Button
302 variant="outline"
303 type="button"
304 onClick={() =>
305 statusAssertions.append({
306 version: "v1",
307 type: "status",
308 compare: "eq",
309 target: 200,
310 })
311 }
312 >
313 Add Status Code Assertion
314 </Button>
315 <Button
316 variant="outline"
317 type="button"
318 onClick={() =>
319 headerAssertions.append({
320 version: "v1",
321 type: "header",
322 key: "Content-Type",
323 compare: "eq",
324 target: "application/json",
325 })
326 }
327 >
328 Add Header Assertion
329 </Button>
330
331 <Button
332 variant="outline"
333 type="button"
334 onClick={() =>
335 textBodyAssertions.append({
336 version: "v1",
337 type: "textBody",
338 compare: "eq",
339 target: "",
340 })
341 }
342 >
343 Add String Body Assertion
344 </Button>
345 </div>
346 </div>
347 ) : (
348 <EmptyState
349 icon="alert-triangle"
350 title="No Assertions"
351 description="Assertions are only available for HTTP monitors."
352 />
353 )}
354 </div>
355 );
356}