Openstatus www.openstatus.dev

feat: dashboard status-page v2 configuration (#1360)

* chore: status page form configuration

* wip:

* wip: link configuration

* fix: legacy web

* refactor: configuration name

* wip:

* fix: form types

* chore: rename

* chore: feature flag

authored by

Maximilian Kaske and committed by
GitHub
0387ae05 9136d873

+452 -18
+2 -11
apps/dashboard/src/components/forms/monitor/form-visibility.tsx
··· 94 94 <div className="space-y-0.5"> 95 95 <FormLabel>Allow public access</FormLabel> 96 96 <FormDescription> 97 - Change monitor visibility. When checked, the monitor stats 98 - from the overview page will be public. You will be able to 99 - share it via a connected status page or{" "} 100 - <Link 101 - href={`https://openstatus.dev/public/monitors/${id}`} 102 - target="_blank" 103 - rel="noopener noreferrer" 104 - > 105 - https://openstatus.dev/public/monitors/{id} 106 - </Link> 107 - . 97 + Change monitor visibility. The monitor stats will be 98 + attached to the status page the monitor is connected to. 108 99 </FormDescription> 109 100 </div> 110 101 <FormControl>
+2 -2
apps/dashboard/src/components/forms/status-page/form-appearance.tsx
··· 86 86 Forced theme will override the user&apos;s preference. 87 87 </FormCardDescription> 88 88 </FormCardHeader> 89 - <FormCardContent> 89 + <FormCardContent className="grid gap-4 sm:grid-cols-3"> 90 90 <FormField 91 91 control={form.control} 92 92 name="forceTheme" ··· 98 98 defaultValue={field.value} 99 99 > 100 100 <FormControl> 101 - <SelectTrigger> 101 + <SelectTrigger className="w-full"> 102 102 <SelectValue placeholder="Select a theme" /> 103 103 </SelectTrigger> 104 104 </FormControl>
+368
apps/dashboard/src/components/forms/status-page/form-configuration.tsx
··· 1 + import { useEffect, useTransition } from "react"; 2 + import { z } from "zod"; 3 + 4 + import { Link } from "@/components/common/link"; 5 + import { Note } from "@/components/common/note"; 6 + import { 7 + FormCard, 8 + FormCardContent, 9 + FormCardDescription, 10 + FormCardFooter, 11 + FormCardFooterInfo, 12 + FormCardHeader, 13 + FormCardSeparator, 14 + FormCardTitle, 15 + } from "@/components/forms/form-card"; 16 + import { Button } from "@/components/ui/button"; 17 + import { 18 + Form, 19 + FormControl, 20 + FormDescription, 21 + FormField, 22 + FormItem, 23 + FormLabel, 24 + FormMessage, 25 + } from "@/components/ui/form"; 26 + import { Input } from "@/components/ui/input"; 27 + import { 28 + Select, 29 + SelectContent, 30 + SelectItem, 31 + SelectTrigger, 32 + SelectValue, 33 + } from "@/components/ui/select"; 34 + import { Switch } from "@/components/ui/switch"; 35 + import { zodResolver } from "@hookform/resolvers/zod"; 36 + import { isTRPCClientError } from "@trpc/client"; 37 + import { useForm } from "react-hook-form"; 38 + import { toast } from "sonner"; 39 + 40 + const schema = z.object({ 41 + new: z.boolean(), 42 + configuration: z.record(z.string(), z.string().or(z.boolean()).optional()), 43 + homepageUrl: z.string().optional(), 44 + contactUrl: z.string().optional(), 45 + }); 46 + 47 + type FormValues = z.infer<typeof schema>; 48 + 49 + export function FormConfiguration({ 50 + defaultValues, 51 + onSubmit, 52 + }: { 53 + defaultValues?: FormValues; 54 + onSubmit: (values: FormValues) => Promise<void>; 55 + }) { 56 + const [isPending, startTransition] = useTransition(); 57 + const form = useForm<FormValues>({ 58 + resolver: zodResolver(schema), 59 + defaultValues: defaultValues ?? { 60 + new: false, 61 + configuration: {}, 62 + homepageUrl: "", 63 + contactUrl: "", 64 + }, 65 + }); 66 + const watchNew = form.watch("new"); 67 + const watchConfigurationType = form.watch("configuration.type") as 68 + | "manual" 69 + | "absolute"; 70 + const watchConfigurationValue = form.watch("configuration.value") as 71 + | "duration" 72 + | "requests"; 73 + const watchConfigurationUptime = form.watch("configuration.uptime") as 74 + | "true" 75 + | "false"; 76 + 77 + useEffect(() => { 78 + if (watchConfigurationType === "manual") { 79 + // TODO: this is not working 80 + form.setValue("configuration.value", undefined); 81 + } else { 82 + form.setValue("configuration.value", "duration"); 83 + form.setValue("configuration.type", "absolute"); 84 + if (!watchConfigurationUptime) { 85 + form.setValue("configuration.uptime", "true"); 86 + } 87 + } 88 + }, [watchConfigurationType, watchConfigurationUptime, form]); 89 + 90 + function submitAction(values: FormValues) { 91 + if (isPending) return; 92 + 93 + startTransition(async () => { 94 + try { 95 + const promise = onSubmit(values); 96 + toast.promise(promise, { 97 + loading: "Saving...", 98 + success: "Saved", 99 + error: (error) => { 100 + if (isTRPCClientError(error)) { 101 + return error.message; 102 + } 103 + return "Failed to save"; 104 + }, 105 + }); 106 + await promise; 107 + } catch (error) { 108 + console.error(error); 109 + } 110 + }); 111 + } 112 + 113 + return ( 114 + <Form {...form}> 115 + <form onSubmit={form.handleSubmit(submitAction)}> 116 + <FormCard> 117 + <FormCardHeader> 118 + <FormCardTitle>Status Page Redesign (beta)</FormCardTitle> 119 + <FormCardDescription> 120 + Use the latest version of the status page and customize it. 121 + </FormCardDescription> 122 + </FormCardHeader> 123 + <FormCardContent> 124 + <FormField 125 + control={form.control} 126 + name="new" 127 + render={({ field }) => ( 128 + <FormItem className="flex flex-row items-center"> 129 + <FormLabel>Enable New Version</FormLabel> 130 + <FormControl> 131 + <Switch 132 + checked={field.value} 133 + onCheckedChange={field.onChange} 134 + /> 135 + </FormControl> 136 + </FormItem> 137 + )} 138 + /> 139 + </FormCardContent> 140 + {watchNew && ( 141 + <> 142 + <FormCardSeparator /> 143 + <FormCardContent className="grid gap-4 sm:grid-cols-3"> 144 + <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> 145 + <FormCardTitle>Tracker Configuration</FormCardTitle> 146 + <FormCardDescription> 147 + Configure which data should be shown in the monitor tracker. 148 + </FormCardDescription> 149 + </FormCardHeader> 150 + <FormField 151 + control={form.control} 152 + name="configuration.type" 153 + render={({ field }) => ( 154 + <FormItem> 155 + <FormLabel>Bar Type</FormLabel> 156 + <Select 157 + onValueChange={field.onChange} 158 + defaultValue={String(field.value) ?? "absolute"} 159 + > 160 + <FormControl> 161 + <SelectTrigger className="w-full capitalize"> 162 + <SelectValue placeholder="Select a type" /> 163 + </SelectTrigger> 164 + </FormControl> 165 + <SelectContent> 166 + {["absolute", "manual"].map((type) => ( 167 + <SelectItem 168 + key={type} 169 + value={type} 170 + className="capitalize" 171 + > 172 + {type} 173 + </SelectItem> 174 + ))} 175 + </SelectContent> 176 + </Select> 177 + <FormMessage /> 178 + </FormItem> 179 + )} 180 + /> 181 + <FormField 182 + control={form.control} 183 + name="configuration.value" 184 + render={({ field }) => ( 185 + <FormItem> 186 + <FormLabel>Card Value</FormLabel> 187 + <Select 188 + onValueChange={field.onChange} 189 + defaultValue={String(field.value) ?? "duration"} 190 + disabled={watchConfigurationType === "manual"} 191 + > 192 + <FormControl> 193 + <SelectTrigger className="w-full capitalize"> 194 + <SelectValue placeholder="Select a type" /> 195 + </SelectTrigger> 196 + </FormControl> 197 + <SelectContent> 198 + {["duration", "requests"].map((type) => ( 199 + <SelectItem 200 + key={type} 201 + value={type} 202 + className="capitalize" 203 + > 204 + {type} 205 + </SelectItem> 206 + ))} 207 + </SelectContent> 208 + </Select> 209 + <FormMessage /> 210 + </FormItem> 211 + )} 212 + /> 213 + <FormField 214 + control={form.control} 215 + name="configuration.uptime" 216 + render={({ field }) => ( 217 + <FormItem> 218 + <FormLabel>Show Uptime</FormLabel> 219 + <Select 220 + onValueChange={field.onChange} 221 + defaultValue={String(field.value) ?? "true"} 222 + > 223 + <FormControl> 224 + <SelectTrigger className="w-full capitalize"> 225 + <SelectValue placeholder="Select a type" /> 226 + </SelectTrigger> 227 + </FormControl> 228 + <SelectContent> 229 + {["true", "false"].map((type) => ( 230 + <SelectItem 231 + key={type} 232 + value={type} 233 + className="capitalize" 234 + > 235 + {type} 236 + </SelectItem> 237 + ))} 238 + </SelectContent> 239 + </Select> 240 + <FormMessage /> 241 + </FormItem> 242 + )} 243 + /> 244 + <Note color="info" className="col-span-full"> 245 + <ul className="list-inside list-disc"> 246 + <li> 247 + <span className="font-medium"> 248 + Bar Type{" "} 249 + <span className="font-semibold"> 250 + {watchConfigurationType} 251 + </span> 252 + </span> 253 + : {message.type[watchConfigurationType]} 254 + </li> 255 + <li> 256 + <span className="font-medium"> 257 + Card Value{" "} 258 + <span className="font-semibold"> 259 + {watchConfigurationValue} 260 + </span> 261 + </span> 262 + :{" "} 263 + {message.value[watchConfigurationValue] ?? 264 + message.value.default} 265 + </li> 266 + <li> 267 + <span className="font-medium"> 268 + Show Uptime{" "} 269 + <span className="font-semibold"> 270 + {watchConfigurationUptime} 271 + </span> 272 + </span> 273 + : {message.uptime[watchConfigurationUptime]} 274 + </li> 275 + </ul> 276 + </Note> 277 + </FormCardContent> 278 + <FormCardSeparator /> 279 + <FormCardContent className="grid gap-4"> 280 + <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> 281 + <FormCardTitle>Links</FormCardTitle> 282 + <FormCardDescription> 283 + Configure the links for the status page. 284 + </FormCardDescription> 285 + </FormCardHeader> 286 + <FormField 287 + control={form.control} 288 + name="homepageUrl" 289 + render={({ field }) => ( 290 + <FormItem> 291 + <FormLabel>Homepage URL</FormLabel> 292 + <FormControl> 293 + <Input placeholder="https://acme.com" {...field} /> 294 + </FormControl> 295 + <FormMessage /> 296 + <FormDescription> 297 + What URL should the logo link to? Leave empty to hide. 298 + </FormDescription> 299 + </FormItem> 300 + )} 301 + /> 302 + <FormField 303 + control={form.control} 304 + name="contactUrl" 305 + render={({ field }) => ( 306 + <FormItem> 307 + <FormLabel>Contact URL</FormLabel> 308 + <FormControl> 309 + <Input 310 + placeholder="https://acme.com/contact" 311 + {...field} 312 + /> 313 + </FormControl> 314 + <FormMessage /> 315 + <FormDescription> 316 + Enter the URL for your contact page. Or start with{" "} 317 + <code className="rounded-md bg-muted px-1 py-0.5"> 318 + mailto: 319 + </code>{" "} 320 + to open the email client. Leave empty to hide. 321 + </FormDescription> 322 + </FormItem> 323 + )} 324 + /> 325 + </FormCardContent> 326 + </> 327 + )} 328 + <FormCardFooter> 329 + <FormCardFooterInfo> 330 + Learn more about{" "} 331 + <Link 332 + href="https://docs.openstatus.dev/" 333 + rel="noreferrer" 334 + target="_blank" 335 + > 336 + Status Page Redesign (beta) 337 + </Link> 338 + . 339 + </FormCardFooterInfo> 340 + <Button type="submit" disabled={isPending}> 341 + {isPending ? "Submitting..." : "Submit"} 342 + </Button> 343 + </FormCardFooter> 344 + </FormCard> 345 + </form> 346 + </Form> 347 + ); 348 + } 349 + 350 + // TODO: 351 + const message = { 352 + type: { 353 + manual: 354 + "only shares the duration of reports and maintenaces you are setting up - nothing else.", 355 + absolute: 356 + "shares the status of your endpoint for the duration of the different statuses.", 357 + }, 358 + value: { 359 + duration: "shares the duration of the different statuses.", 360 + requests: 361 + "shares the number of requests received (success, degraded, error).", 362 + default: "shares only the worse status of the day", 363 + }, 364 + uptime: { 365 + true: "shares the uptime percentage and current status of your endpoint.", 366 + false: "shares only the current status.", 367 + }, 368 + } as const;
+1 -1
apps/dashboard/src/components/forms/status-page/form-general.tsx
··· 250 250 <FormMessage /> 251 251 <FormDescription> 252 252 Select an icon for your status page. Ideally sized 253 - 512x512px. 253 + 512x512px. Will be used as favicon. 254 254 </FormDescription> 255 255 </FormItem> 256 256 )}
+34
apps/dashboard/src/components/forms/status-page/update.tsx
··· 3 3 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 4 4 import { useParams, useRouter } from "next/navigation"; 5 5 import { FormAppearance } from "./form-appearance"; 6 + import { FormConfiguration } from "./form-configuration"; 6 7 import { FormCustomDomain } from "./form-custom-domain"; 7 8 import { FormDangerZone } from "./form-danger-zone"; 8 9 import { FormGeneral } from "./form-general"; ··· 71 72 }), 72 73 ); 73 74 75 + const updatePageConfigurationMutation = useMutation( 76 + trpc.page.updatePageConfiguration.mutationOptions({ 77 + onSuccess: () => refetch(), 78 + }), 79 + ); 80 + 74 81 if (!statusPage || !monitors || !workspace) return null; 75 82 76 83 return ( ··· 132 139 }); 133 140 }} 134 141 /> 142 + {/* TODO: feature flagged - remove once we have the new version in production */} 143 + {process.env.NEXT_PUBLIC_STATUS_PAGE_V2 === "true" ? ( 144 + <FormConfiguration 145 + defaultValues={{ 146 + new: !statusPage.legacyPage, 147 + configuration: statusPage.configuration ?? {}, 148 + homepageUrl: statusPage.homepageUrl ?? "", 149 + contactUrl: statusPage.contactUrl ?? "", 150 + }} 151 + onSubmit={async (values) => { 152 + await updatePageConfigurationMutation.mutateAsync({ 153 + id: Number.parseInt(id), 154 + configuration: values.new 155 + ? { 156 + // NOTE: convert to boolean 157 + uptime: values.configuration.uptime === "true", 158 + value: values.configuration.value ?? "duration", 159 + type: values.configuration.type ?? "absolute", 160 + } 161 + : undefined, 162 + legacyPage: !values.new, 163 + homepageUrl: values.homepageUrl ?? undefined, 164 + contactUrl: values.contactUrl ?? undefined, 165 + }); 166 + }} 167 + /> 168 + ) : null} 135 169 <FormPasswordProtection 136 170 locked={workspace.limits["password-protection"] === false} 137 171 defaultValues={{
+2 -2
apps/web/src/components/forms/status-page/form.tsx
··· 32 32 33 33 interface Props { 34 34 defaultSection?: string; 35 - defaultValues?: InsertPage; 35 + defaultValues?: Omit<InsertPage, "configuration">; 36 36 allMonitors?: Monitor[]; 37 37 /** 38 38 * gives the possibility to check all the monitors ··· 55 55 plan, 56 56 workspaceSlug, 57 57 }: Props) { 58 - const form = useForm<InsertPage>({ 58 + const form = useForm<Omit<InsertPage, "configuration">>({ 59 59 resolver: zodResolver(insertPageSchema), 60 60 defaultValues: { 61 61 title: defaultValues?.title || "", // FIXME: you can save a page without title, causing unexpected slug behavior
+39 -2
packages/api/src/router/page.ts
··· 67 67 .meta({ track: Events.CreatePage, trackProps: ["slug"] }) 68 68 .input(insertPageSchema) 69 69 .mutation(async (opts) => { 70 - const { monitors, workspaceId, id, ...pageProps } = opts.input; 70 + const { monitors, workspaceId, id, configuration, ...pageProps } = 71 + opts.input; 71 72 72 73 const monitorIds = monitors?.map((item) => item.monitorId) || []; 73 74 ··· 101 102 102 103 const newPage = await opts.ctx.db 103 104 .insert(page) 104 - .values({ workspaceId: opts.ctx.workspace.id, ...pageProps }) 105 + .values({ 106 + workspaceId: opts.ctx.workspace.id, 107 + configuration: JSON.stringify(configuration), 108 + ...pageProps, 109 + }) 105 110 .returning() 106 111 .get(); 107 112 ··· 747 752 await opts.ctx.db 748 753 .update(page) 749 754 .set({ forceTheme: opts.input.forceTheme, updatedAt: new Date() }) 755 + .where(and(...whereConditions)) 756 + .run(); 757 + }), 758 + 759 + updatePageConfiguration: protectedProcedure 760 + .meta({ track: Events.UpdatePage }) 761 + .input( 762 + z.object({ 763 + id: z.number(), 764 + configuration: z 765 + .record(z.string(), z.string().or(z.boolean()).optional()) 766 + .nullish(), 767 + legacyPage: z.boolean(), 768 + homepageUrl: z.string().nullish(), 769 + contactUrl: z.string().nullish(), 770 + }), 771 + ) 772 + .mutation(async (opts) => { 773 + const whereConditions: SQL[] = [ 774 + eq(page.workspaceId, opts.ctx.workspace.id), 775 + eq(page.id, opts.input.id), 776 + ]; 777 + 778 + await opts.ctx.db 779 + .update(page) 780 + .set({ 781 + configuration: opts.input.configuration, 782 + legacyPage: opts.input.legacyPage, 783 + homepageUrl: opts.input.homepageUrl, 784 + contactUrl: opts.input.contactUrl, 785 + updatedAt: new Date(), 786 + }) 750 787 .where(and(...whereConditions)) 751 788 .run(); 752 789 }),
+4
packages/db/src/schema/pages/validation.ts
··· 40 40 41 41 export const selectPageSchema = createSelectSchema(page).extend({ 42 42 password: z.string().optional().nullable().default(""), 43 + configuration: z 44 + .record(z.string(), z.string().or(z.boolean()).optional()) 45 + .nullish() 46 + .default({}), 43 47 }); 44 48 45 49 export type InsertPage = z.infer<typeof insertPageSchema>;