Openstatus www.openstatus.dev

chore: status page configuration settings (#1582)

authored by

Maximilian Kaske and committed by
GitHub
024ac9f2 54f831b5

+549 -420
+1 -1
apps/dashboard/src/components/common/note.tsx
··· 3 3 import { type VariantProps, cva } from "class-variance-authority"; 4 4 5 5 const noteVariants = cva( 6 - "flex items-center gap-2 rounded-md border px-3 py-2 [&>svg]:size-4 [&>svg]:text-current [&>svg]:shrink-0", 6 + "flex items-center gap-2 rounded-xl border px-3 py-2 [&>svg]:size-4 [&>svg]:text-current [&>svg]:shrink-0", 7 7 { 8 8 variants: { 9 9 variant: {
+3 -4
apps/dashboard/src/components/forms/form-alert-dialog.tsx
··· 61 61 <AlertDialogTrigger asChild> 62 62 {children ?? ( 63 63 <Button variant="destructive" size="sm"> 64 - Revoke 64 + Delete 65 65 </Button> 66 66 )} 67 67 </AlertDialogTrigger> 68 68 <AlertDialogContent> 69 69 <AlertDialogHeader> 70 70 <AlertDialogTitle> 71 - Are you sure about revoking `{title}`? 71 + Are you sure about delete `{title}`? 72 72 </AlertDialogTitle> 73 73 <AlertDialogDescription> 74 - This action cannot be undone. This will permanently revoke the API 75 - key. 74 + This action cannot be undone. This will permanently delete the item. 76 75 </AlertDialogDescription> 77 76 </AlertDialogHeader> 78 77 <form id="form-alert-dialog" className="space-y-0.5">
+3 -1
apps/dashboard/src/components/forms/monitor/form-danger-zone.tsx
··· 11 11 12 12 export function FormDangerZone({ 13 13 onSubmit, 14 + title, 14 15 }: { 15 16 onSubmit: () => Promise<void>; 17 + title: string; 16 18 }) { 17 19 return ( 18 20 <FormCard variant="destructive"> ··· 22 24 </FormCardHeader> 23 25 <FormCardFooter variant="destructive" className="justify-end"> 24 26 <FormAlertDialog 25 - title="OpenStatus API" 27 + title={title} 26 28 confirmationValue="delete monitor" 27 29 submitAction={onSubmit} 28 30 />
+1
apps/dashboard/src/components/forms/monitor/update.tsx
··· 265 265 }} 266 266 /> 267 267 <FormDangerZone 268 + title={monitor.name} 268 269 onSubmit={async () => { 269 270 await deleteMonitorMutation.mutateAsync({ id: Number.parseInt(id) }); 270 271 }}
+112 -6
apps/dashboard/src/components/forms/status-page/form-appearance.tsx
··· 1 1 import { useTransition } from "react"; 2 2 import { z } from "zod"; 3 3 4 + import { Link } from "@/components/common/link"; 4 5 import { 5 6 FormCard, 6 7 FormCardContent, ··· 12 13 } from "@/components/forms/form-card"; 13 14 import { Button } from "@/components/ui/button"; 14 15 import { 16 + Command, 17 + CommandEmpty, 18 + CommandGroup, 19 + CommandInput, 20 + CommandItem, 21 + CommandList, 22 + } from "@/components/ui/command"; 23 + import { 15 24 Form, 16 25 FormControl, 26 + FormDescription, 17 27 FormField, 18 28 FormItem, 19 29 FormLabel, 20 30 FormMessage, 21 31 } from "@/components/ui/form"; 32 + import { 33 + Popover, 34 + PopoverContent, 35 + PopoverTrigger, 36 + } from "@/components/ui/popover"; 22 37 import { 23 38 Select, 24 39 SelectContent, ··· 26 41 SelectTrigger, 27 42 SelectValue, 28 43 } from "@/components/ui/select"; 44 + import { cn } from "@/lib/utils"; 29 45 import { zodResolver } from "@hookform/resolvers/zod"; 46 + import { THEME_KEYS } from "@openstatus/theme-store"; 47 + import { THEMES } from "@openstatus/theme-store"; 48 + import type { ThemeKey } from "@openstatus/theme-store"; 30 49 import { isTRPCClientError } from "@trpc/client"; 31 - import { Laptop, Moon, Sun } from "lucide-react"; 50 + import { ArrowUpRight, Laptop, Moon, Sun } from "lucide-react"; 51 + import { Check, ChevronsUpDown } from "lucide-react"; 32 52 import { useForm } from "react-hook-form"; 33 53 import { toast } from "sonner"; 34 54 35 55 const schema = z.object({ 36 56 forceTheme: z.enum(["light", "dark", "system"]), 57 + configuration: z.object({ 58 + theme: z.string(), 59 + }), 37 60 }); 38 61 39 62 type FormValues = z.infer<typeof schema>; ··· 92 115 name="forceTheme" 93 116 render={({ field }) => ( 94 117 <FormItem> 95 - <FormLabel className="sr-only">Theme</FormLabel> 118 + <FormLabel>Mode</FormLabel> 96 119 <Select 97 120 onValueChange={field.onChange} 98 121 defaultValue={field.value} ··· 124 147 </SelectContent> 125 148 </Select> 126 149 <FormMessage /> 150 + <FormDescription> 151 + Override the user&apos;s preference. 152 + </FormDescription> 153 + </FormItem> 154 + )} 155 + /> 156 + <FormField 157 + control={form.control} 158 + name="configuration.theme" 159 + render={({ field }) => ( 160 + <FormItem> 161 + <FormLabel>Style</FormLabel> 162 + <Popover> 163 + <PopoverTrigger asChild> 164 + <FormControl> 165 + <Button 166 + id="community-theme" 167 + variant="outline" 168 + role="combobox" 169 + className={cn( 170 + "w-full justify-between", 171 + !field.value && "text-muted-foreground", 172 + )} 173 + > 174 + <span className="truncate"> 175 + {THEMES[field.value as ThemeKey]?.name || 176 + "Select a theme"} 177 + </span> 178 + <ChevronsUpDown className="opacity-50" /> 179 + </Button> 180 + </FormControl> 181 + </PopoverTrigger> 182 + <PopoverContent className="p-0" align="start"> 183 + <Command> 184 + <CommandInput 185 + placeholder="Search themes..." 186 + className="h-9" 187 + /> 188 + <CommandList> 189 + <CommandEmpty>No themes found.</CommandEmpty> 190 + <CommandGroup> 191 + {THEME_KEYS.map((theme) => { 192 + const { name, author } = THEMES[theme]; 193 + return ( 194 + <CommandItem 195 + value={theme} 196 + key={theme} 197 + keywords={[theme, name, author.name]} 198 + onSelect={(v) => field.onChange(v)} 199 + > 200 + <span className="truncate">{name}</span> 201 + <span className="truncate font-commit-mono text-muted-foreground text-xs"> 202 + by {author.name} 203 + </span> 204 + <Check 205 + className={cn( 206 + "ml-auto", 207 + theme === field.value 208 + ? "opacity-100" 209 + : "opacity-0", 210 + )} 211 + /> 212 + </CommandItem> 213 + ); 214 + })} 215 + </CommandGroup> 216 + </CommandList> 217 + </Command> 218 + </PopoverContent> 219 + </Popover> 220 + <FormMessage /> 221 + <FormDescription>Choose a theme to apply.</FormDescription> 127 222 </FormItem> 128 223 )} 129 224 /> 130 225 </FormCardContent> 131 226 <FormCardFooter> 132 227 <FormCardFooterInfo> 133 - Your user will still be able to change the theme via the theme 228 + Your user will still be able to change the mode via the theme 134 229 toggle. 135 230 </FormCardFooterInfo> 136 - <Button type="submit" disabled={isPending}> 137 - {isPending ? "Submitting..." : "Submit"} 138 - </Button> 231 + <div className="flex items-center gap-2"> 232 + <Button type="button" variant="ghost" asChild> 233 + <Link 234 + href="https://themes.openstatus.dev" 235 + rel="noreferrer" 236 + target="_blank" 237 + > 238 + View Theme Explorer <ArrowUpRight className="h-4 w-4" /> 239 + </Link> 240 + </Button> 241 + <Button type="submit" disabled={isPending}> 242 + {isPending ? "Submitting..." : "Submit"} 243 + </Button> 244 + </div> 139 245 </FormCardFooter> 140 246 </FormCard> 141 247 </form>
+154 -345
apps/dashboard/src/components/forms/status-page/form-configuration.tsx
··· 15 15 } from "@/components/forms/form-card"; 16 16 import { Button } from "@/components/ui/button"; 17 17 import { 18 - Command, 19 - CommandEmpty, 20 - CommandGroup, 21 - CommandInput, 22 - CommandItem, 23 - CommandList, 24 - } from "@/components/ui/command"; 25 - import { 26 18 Dialog, 27 19 DialogClose, 28 20 DialogContent, ··· 34 26 import { 35 27 Form, 36 28 FormControl, 37 - FormDescription, 38 29 FormField, 39 30 FormItem, 40 31 FormLabel, 41 32 FormMessage, 42 33 } from "@/components/ui/form"; 43 - import { Input } from "@/components/ui/input"; 44 - import { 45 - Popover, 46 - PopoverContent, 47 - PopoverTrigger, 48 - } from "@/components/ui/popover"; 49 34 import { 50 35 Select, 51 36 SelectContent, ··· 53 38 SelectTrigger, 54 39 SelectValue, 55 40 } from "@/components/ui/select"; 56 - import { Switch } from "@/components/ui/switch"; 57 - import { cn } from "@/lib/utils"; 58 41 import { zodResolver } from "@hookform/resolvers/zod"; 59 - import { THEMES, THEME_KEYS, type ThemeKey } from "@openstatus/theme-store"; 42 + import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 60 43 import { isTRPCClientError } from "@trpc/client"; 61 - import { ArrowUpRight, Check, ChevronsUpDown, Info } from "lucide-react"; 44 + import { ArrowUpRight } from "lucide-react"; 62 45 import { parseAsStringLiteral, useQueryStates } from "nuqs"; 63 - import { useForm } from "react-hook-form"; 46 + import { type UseFormReturn, useForm } from "react-hook-form"; 64 47 import { toast } from "sonner"; 65 48 66 49 const schema = z.object({ 67 - new: z.boolean(), 68 50 configuration: z.record( 69 51 z.string(), 70 52 z.string().or(z.boolean().nullish()).optional(), 71 53 ), 72 - homepageUrl: z.string().optional(), 73 - contactUrl: z.string().optional(), 74 54 }); 75 55 76 56 const configurationSchema = z ··· 107 87 const form = useForm<FormValues>({ 108 88 resolver: zodResolver(schema), 109 89 defaultValues: defaultValues ?? { 110 - new: false, 111 90 configuration: {}, 112 - homepageUrl: "", 113 - contactUrl: "", 114 91 }, 115 92 }); 116 - const watchNew = form.watch("new"); 117 93 const watchConfigurationType = form.watch("configuration.type") as 118 94 | "manual" 119 95 | "absolute"; ··· 165 141 <form id="redesign" onSubmit={form.handleSubmit(submitAction)}> 166 142 <FormCard variant="info"> 167 143 <FormCardHeader> 168 - <FormCardTitle>Status Page Redesign (beta)</FormCardTitle> 144 + <FormCardTitle>Tracker Configuration</FormCardTitle> 169 145 <FormCardDescription> 170 - Use the latest version of the status page and customize it. 146 + Configure which data should be shown in the monitor tracker. 171 147 </FormCardDescription> 172 148 </FormCardHeader> 173 - <FormCardContent className="grid gap-4 md:grid-cols-3"> 149 + <FormCardSeparator /> 150 + <FormCardContent className="grid gap-4 sm:grid-cols-3"> 151 + <FormField 152 + control={form.control} 153 + name="configuration.type" 154 + render={({ field }) => ( 155 + <FormItem> 156 + <FormLabel>Bar Type</FormLabel> 157 + <Select 158 + onValueChange={field.onChange} 159 + defaultValue={String(field.value) ?? "absolute"} 160 + > 161 + <FormControl> 162 + <SelectTrigger className="w-full capitalize"> 163 + <SelectValue placeholder="Select a type" /> 164 + </SelectTrigger> 165 + </FormControl> 166 + <SelectContent> 167 + {["absolute", "manual"].map((type) => ( 168 + <SelectItem 169 + key={type} 170 + value={type} 171 + className="capitalize" 172 + > 173 + {type} 174 + </SelectItem> 175 + ))} 176 + </SelectContent> 177 + </Select> 178 + <FormMessage /> 179 + </FormItem> 180 + )} 181 + /> 174 182 <FormField 175 183 control={form.control} 176 - name="new" 184 + name="configuration.value" 177 185 render={({ field }) => ( 178 - <FormItem className="flex flex-row items-center justify-between"> 179 - <div className="space-y-0.5"> 180 - <FormLabel>Enable New Version</FormLabel> 181 - <FormDescription> 182 - More controls, better UI. 183 - </FormDescription> 184 - </div> 185 - <FormControl> 186 - <Switch 187 - checked={field.value} 188 - onCheckedChange={field.onChange} 189 - /> 190 - </FormControl> 186 + <FormItem> 187 + <FormLabel>Card Value</FormLabel> 188 + <Select 189 + onValueChange={field.onChange} 190 + defaultValue={String(field.value) ?? "duration"} 191 + disabled={watchConfigurationType === "manual"} 192 + > 193 + <FormControl> 194 + <SelectTrigger className="w-full capitalize"> 195 + <SelectValue placeholder="Select a type" /> 196 + </SelectTrigger> 197 + </FormControl> 198 + <SelectContent> 199 + {["duration", "requests"].map((type) => ( 200 + <SelectItem 201 + key={type} 202 + value={type} 203 + className="capitalize" 204 + > 205 + {type} 206 + </SelectItem> 207 + ))} 208 + </SelectContent> 209 + </Select> 210 + <FormMessage /> 191 211 </FormItem> 192 212 )} 193 213 /> 194 - <div className="flex items-center md:col-span-2 md:justify-end"> 195 - <Button size="sm" asChild> 196 - <Link 197 - href={configLink} 198 - rel="noreferrer" 199 - target="_blank" 200 - className="inline-flex items-center gap-1" 201 - > 202 - View and configure status page{" "} 203 - <ArrowUpRight className="h-4 w-4" /> 204 - </Link> 205 - </Button> 206 - </div> 214 + <FormField 215 + control={form.control} 216 + name="configuration.uptime" 217 + render={({ field }) => ( 218 + <FormItem> 219 + <FormLabel>Show Uptime</FormLabel> 220 + <Select 221 + onValueChange={field.onChange} 222 + defaultValue={String(field.value) ?? "true"} 223 + > 224 + <FormControl> 225 + <SelectTrigger className="w-full capitalize"> 226 + <SelectValue placeholder="Select a type" /> 227 + </SelectTrigger> 228 + </FormControl> 229 + <SelectContent> 230 + {["true", "false"].map((type) => ( 231 + <SelectItem 232 + key={type} 233 + value={type} 234 + className="capitalize" 235 + > 236 + {type} 237 + </SelectItem> 238 + ))} 239 + </SelectContent> 240 + </Select> 241 + <FormMessage /> 242 + </FormItem> 243 + )} 244 + /> 245 + <Note className="col-span-full"> 246 + <ul className="list-inside list-disc"> 247 + <li> 248 + <span className="font-medium"> 249 + Bar Type{" "} 250 + <span className="font-semibold"> 251 + {watchConfigurationType} 252 + </span> 253 + </span> 254 + : {message.type[watchConfigurationType]} 255 + </li> 256 + <li> 257 + <span className="font-medium"> 258 + Card Value{" "} 259 + <span className="font-semibold"> 260 + {watchConfigurationValue} 261 + </span> 262 + </span> 263 + :{" "} 264 + {message.value[watchConfigurationValue] ?? 265 + message.value.default} 266 + </li> 267 + <li> 268 + <span className="font-medium"> 269 + Show Uptime{" "} 270 + <span className="font-semibold"> 271 + {watchConfigurationUptime} 272 + </span> 273 + </span> 274 + : {message.uptime[watchConfigurationUptime]} 275 + </li> 276 + </ul> 277 + </Note> 207 278 </FormCardContent> 208 - {watchNew && ( 209 - <> 210 - <FormCardSeparator /> 211 - <FormCardContent className="grid gap-4 sm:grid-cols-3"> 212 - <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> 213 - <FormCardTitle>Tracker Configuration</FormCardTitle> 214 - <FormCardDescription> 215 - Configure which data should be shown in the monitor 216 - tracker. 217 - </FormCardDescription> 218 - </FormCardHeader> 219 - <FormField 220 - control={form.control} 221 - name="configuration.type" 222 - render={({ field }) => ( 223 - <FormItem> 224 - <FormLabel>Bar Type</FormLabel> 225 - <Select 226 - onValueChange={field.onChange} 227 - defaultValue={String(field.value) ?? "absolute"} 228 - > 229 - <FormControl> 230 - <SelectTrigger className="w-full capitalize"> 231 - <SelectValue placeholder="Select a type" /> 232 - </SelectTrigger> 233 - </FormControl> 234 - <SelectContent> 235 - {["absolute", "manual"].map((type) => ( 236 - <SelectItem 237 - key={type} 238 - value={type} 239 - className="capitalize" 240 - > 241 - {type} 242 - </SelectItem> 243 - ))} 244 - </SelectContent> 245 - </Select> 246 - <FormMessage /> 247 - </FormItem> 248 - )} 249 - /> 250 - <FormField 251 - control={form.control} 252 - name="configuration.value" 253 - render={({ field }) => ( 254 - <FormItem> 255 - <FormLabel>Card Value</FormLabel> 256 - <Select 257 - onValueChange={field.onChange} 258 - defaultValue={String(field.value) ?? "duration"} 259 - disabled={watchConfigurationType === "manual"} 260 - > 261 - <FormControl> 262 - <SelectTrigger className="w-full capitalize"> 263 - <SelectValue placeholder="Select a type" /> 264 - </SelectTrigger> 265 - </FormControl> 266 - <SelectContent> 267 - {["duration", "requests"].map((type) => ( 268 - <SelectItem 269 - key={type} 270 - value={type} 271 - className="capitalize" 272 - > 273 - {type} 274 - </SelectItem> 275 - ))} 276 - </SelectContent> 277 - </Select> 278 - <FormMessage /> 279 - </FormItem> 280 - )} 281 - /> 282 - <FormField 283 - control={form.control} 284 - name="configuration.uptime" 285 - render={({ field }) => ( 286 - <FormItem> 287 - <FormLabel>Show Uptime</FormLabel> 288 - <Select 289 - onValueChange={field.onChange} 290 - defaultValue={String(field.value) ?? "true"} 291 - > 292 - <FormControl> 293 - <SelectTrigger className="w-full capitalize"> 294 - <SelectValue placeholder="Select a type" /> 295 - </SelectTrigger> 296 - </FormControl> 297 - <SelectContent> 298 - {["true", "false"].map((type) => ( 299 - <SelectItem 300 - key={type} 301 - value={type} 302 - className="capitalize" 303 - > 304 - {type} 305 - </SelectItem> 306 - ))} 307 - </SelectContent> 308 - </Select> 309 - <FormMessage /> 310 - </FormItem> 311 - )} 312 - /> 313 - <Note className="col-span-full"> 314 - <ul className="list-inside list-disc"> 315 - <li> 316 - <span className="font-medium"> 317 - Bar Type{" "} 318 - <span className="font-semibold"> 319 - {watchConfigurationType} 320 - </span> 321 - </span> 322 - : {message.type[watchConfigurationType]} 323 - </li> 324 - <li> 325 - <span className="font-medium"> 326 - Card Value{" "} 327 - <span className="font-semibold"> 328 - {watchConfigurationValue} 329 - </span> 330 - </span> 331 - :{" "} 332 - {message.value[watchConfigurationValue] ?? 333 - message.value.default} 334 - </li> 335 - <li> 336 - <span className="font-medium"> 337 - Show Uptime{" "} 338 - <span className="font-semibold"> 339 - {watchConfigurationUptime} 340 - </span> 341 - </span> 342 - : {message.uptime[watchConfigurationUptime]} 343 - </li> 344 - </ul> 345 - </Note> 346 - </FormCardContent> 347 - <FormCardSeparator /> 348 - <FormCardContent className="grid gap-4 sm:grid-cols-3"> 349 - <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> 350 - <FormCardTitle>Theme Explorer</FormCardTitle> 351 - <FormCardDescription> 352 - Configure the theme for the status page - or contribute 353 - your own. Learn more about it at{" "} 354 - <Link 355 - href="https://themes.openstatus.dev" 356 - rel="noreferrer" 357 - target="_blank" 358 - > 359 - themes.openstatus.dev 360 - </Link> 361 - . 362 - </FormCardDescription> 363 - </FormCardHeader> 364 - <FormField 365 - control={form.control} 366 - name="configuration.theme" 367 - render={({ field }) => ( 368 - <FormItem> 369 - <FormLabel>Theme</FormLabel> 370 - <Popover> 371 - <PopoverTrigger asChild> 372 - <FormControl> 373 - <Button 374 - id="community-theme" 375 - variant="outline" 376 - role="combobox" 377 - className={cn( 378 - "w-full justify-between", 379 - !field.value && "text-muted-foreground", 380 - )} 381 - > 382 - <span className="truncate"> 383 - {THEMES[field.value as ThemeKey]?.name || 384 - "Select a theme"} 385 - </span> 386 - <ChevronsUpDown className="opacity-50" /> 387 - </Button> 388 - </FormControl> 389 - </PopoverTrigger> 390 - <PopoverContent className="p-0" align="start"> 391 - <Command> 392 - <CommandInput 393 - placeholder="Search themes..." 394 - className="h-9" 395 - /> 396 - <CommandList> 397 - <CommandEmpty>No themes found.</CommandEmpty> 398 - <CommandGroup> 399 - {THEME_KEYS.map((theme) => { 400 - const { name, author } = THEMES[theme]; 401 - return ( 402 - <CommandItem 403 - value={theme} 404 - key={theme} 405 - keywords={[theme, name, author.name]} 406 - onSelect={(v) => field.onChange(v)} 407 - > 408 - <span className="truncate">{name}</span> 409 - <span className="truncate font-commit-mono text-muted-foreground text-xs"> 410 - by {author.name} 411 - </span> 412 - <Check 413 - className={cn( 414 - "ml-auto", 415 - theme === field.value 416 - ? "opacity-100" 417 - : "opacity-0", 418 - )} 419 - /> 420 - </CommandItem> 421 - ); 422 - })} 423 - </CommandGroup> 424 - </CommandList> 425 - </Command> 426 - </PopoverContent> 427 - </Popover> 428 - </FormItem> 429 - )} 430 - /> 431 - </FormCardContent> 432 - <FormCardSeparator /> 433 - <FormCardContent className="grid gap-4"> 434 - <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> 435 - <FormCardTitle>Links</FormCardTitle> 436 - <FormCardDescription> 437 - Configure the links for the status page. 438 - </FormCardDescription> 439 - </FormCardHeader> 440 - <FormField 441 - control={form.control} 442 - name="homepageUrl" 443 - render={({ field }) => ( 444 - <FormItem> 445 - <FormLabel>Homepage URL</FormLabel> 446 - <FormControl> 447 - <Input placeholder="https://acme.com" {...field} /> 448 - </FormControl> 449 - <FormMessage /> 450 - <FormDescription> 451 - What URL should the logo link to? Leave empty to hide. 452 - </FormDescription> 453 - </FormItem> 454 - )} 455 - /> 456 - <FormField 457 - control={form.control} 458 - name="contactUrl" 459 - render={({ field }) => ( 460 - <FormItem> 461 - <FormLabel>Contact URL</FormLabel> 462 - <FormControl> 463 - <Input 464 - placeholder="https://acme.com/contact" 465 - {...field} 466 - /> 467 - </FormControl> 468 - <FormMessage /> 469 - <FormDescription> 470 - Enter the URL for your contact page. Or start with{" "} 471 - <code className="rounded-md bg-muted px-1 py-0.5"> 472 - mailto: 473 - </code>{" "} 474 - to open the email client. Leave empty to hide. 475 - </FormDescription> 476 - </FormItem> 477 - )} 478 - /> 479 - </FormCardContent> 480 - </> 481 - )} 482 279 <FormCardFooter variant="info"> 483 280 <FormCardFooterInfo> 484 281 Learn more about{" "} ··· 487 284 rel="noreferrer" 488 285 target="_blank" 489 286 > 490 - Status Page Redesign (beta) 287 + Configuration 491 288 </Link> 492 289 . 493 290 </FormCardFooterInfo> 494 - <Button type="submit" disabled={isPending}> 495 - {isPending ? "Submitting..." : "Submit"} 496 - </Button> 291 + <div className="flex items-center gap-2"> 292 + <Button type="button" variant="ghost" asChild> 293 + <Link 294 + href={configLink} 295 + rel="noreferrer" 296 + target="_blank" 297 + className="inline-flex items-center gap-1" 298 + > 299 + View and configure status page{" "} 300 + <ArrowUpRight className="h-4 w-4" /> 301 + </Link> 302 + </Button> 303 + <Button type="submit" disabled={isPending}> 304 + {isPending ? "Submitting..." : "Submit"} 305 + </Button> 306 + </div> 497 307 </FormCardFooter> 498 308 </FormCard> 499 309 </form> 500 310 </Form> 501 311 <FormConfigurationDialog 502 312 defaultValues={defaultValues} 313 + form={form} 503 314 onSubmit={async (e) => { 504 315 await onSubmit(e); 505 316 // NOTE: make sure to sync the form with the new values ··· 545 356 }: { 546 357 defaultValues?: FormValues; 547 358 onSubmit: (values: FormValues) => Promise<void>; 359 + form: UseFormReturn<FormValues>; 548 360 }) { 549 361 const [open, setOpen] = useState(false); 550 362 const [isPending, startTransition] = useTransition(); ··· 578 390 }, 579 391 }); 580 392 await promise; 581 - setSearchParams({ 393 + await setSearchParams({ 582 394 type: null, 583 395 value: null, 584 396 uptime: null, ··· 587 399 setOpen(false); 588 400 } catch (error) { 589 401 console.error(error); 402 + } finally { 403 + if (typeof window !== "undefined") { 404 + window.location.reload(); 405 + } 590 406 } 591 407 }); 592 408 } ··· 595 411 <Dialog open={open} onOpenChange={setOpen}> 596 412 <DialogContent> 597 413 <DialogHeader> 598 - <DialogTitle>Status Page Redesign (beta)</DialogTitle> 414 + <DialogTitle>Status Page Configuration</DialogTitle> 599 415 <DialogDescription> 600 416 Do you want to update the status page based on the configured 601 - settings? 417 + settings? You can always change the settings later. 602 418 </DialogDescription> 603 419 </DialogHeader> 604 420 <div className="flex flex-col gap-2"> 605 421 <pre className="rounded-md border bg-muted/50 px-3 py-2 font-commit-mono text-sm"> 606 422 {JSON.stringify({ type, value, uptime, theme }, null, 2)} 607 423 </pre> 608 - {!defaultValues || !defaultValues.new ? ( 609 - <Note color="info" className="text-sm"> 610 - <Info /> 611 - <p>You will activate the new version of the status page.</p> 612 - </Note> 613 - ) : null} 614 424 </div> 615 425 <DialogFooter> 616 426 <DialogClose asChild> ··· 621 431 onClick={() => 622 432 submitAction({ 623 433 ...defaultValues, 624 - new: true, 625 434 configuration: { 626 435 type: type ?? undefined, 627 436 value: value ?? undefined,
+3 -1
apps/dashboard/src/components/forms/status-page/form-danger-zone.tsx
··· 11 11 12 12 export function FormDangerZone({ 13 13 onSubmit, 14 + title, 14 15 }: { 15 16 onSubmit: () => Promise<void>; 17 + title: string; 16 18 }) { 17 19 return ( 18 20 <FormCard variant="destructive"> ··· 22 24 </FormCardHeader> 23 25 <FormCardFooter variant="destructive" className="justify-end"> 24 26 <FormAlertDialog 25 - title="OpenStatus API" 27 + title={title} 26 28 confirmationValue="delete status page" 27 29 submitAction={onSubmit} 28 30 />
+144
apps/dashboard/src/components/forms/status-page/form-links.tsx
··· 1 + import { useTransition } from "react"; 2 + import { z } from "zod"; 3 + 4 + import { Link } from "@/components/common/link"; 5 + import { 6 + FormCard, 7 + FormCardContent, 8 + FormCardDescription, 9 + FormCardFooter, 10 + FormCardFooterInfo, 11 + FormCardHeader, 12 + FormCardTitle, 13 + } from "@/components/forms/form-card"; 14 + import { Button } from "@/components/ui/button"; 15 + import { 16 + Form, 17 + FormControl, 18 + FormDescription, 19 + FormField, 20 + FormItem, 21 + FormLabel, 22 + FormMessage, 23 + } from "@/components/ui/form"; 24 + import { Input } from "@/components/ui/input"; 25 + import { zodResolver } from "@hookform/resolvers/zod"; 26 + import { isTRPCClientError } from "@trpc/client"; 27 + import { useForm } from "react-hook-form"; 28 + import { toast } from "sonner"; 29 + 30 + const schema = z.object({ 31 + homepageUrl: z.string().optional(), 32 + contactUrl: z.string().optional(), 33 + }); 34 + 35 + type FormValues = z.infer<typeof schema>; 36 + 37 + export function FormLinks({ 38 + defaultValues, 39 + onSubmit, 40 + }: { 41 + defaultValues?: FormValues; 42 + onSubmit: (values: FormValues) => Promise<void>; 43 + }) { 44 + const [isPending, startTransition] = useTransition(); 45 + const form = useForm<FormValues>({ 46 + resolver: zodResolver(schema), 47 + defaultValues: defaultValues ?? { 48 + homepageUrl: "", 49 + contactUrl: "", 50 + }, 51 + }); 52 + 53 + function submitAction(values: FormValues) { 54 + if (isPending) return; 55 + 56 + startTransition(async () => { 57 + try { 58 + const promise = onSubmit(values); 59 + toast.promise(promise, { 60 + loading: "Saving...", 61 + success: "Saved", 62 + error: (error) => { 63 + if (isTRPCClientError(error)) { 64 + return error.message; 65 + } 66 + return "Failed to save"; 67 + }, 68 + }); 69 + await promise; 70 + } catch (error) { 71 + console.error(error); 72 + } 73 + }); 74 + } 75 + 76 + return ( 77 + <Form {...form}> 78 + <form onSubmit={form.handleSubmit(submitAction)}> 79 + <FormCard> 80 + <FormCardHeader> 81 + <FormCardTitle>Links</FormCardTitle> 82 + <FormCardDescription> 83 + Configure the links for the status page. 84 + </FormCardDescription> 85 + </FormCardHeader> 86 + <FormCardContent className="grid gap-4 sm:grid-cols-3"> 87 + <FormField 88 + control={form.control} 89 + name="homepageUrl" 90 + render={({ field }) => ( 91 + <FormItem className="sm:col-span-full"> 92 + <FormLabel>Homepage URL</FormLabel> 93 + <FormControl> 94 + <Input placeholder="https://acme.com" {...field} /> 95 + </FormControl> 96 + <FormMessage /> 97 + <FormDescription> 98 + What URL should the logo link to? Leave empty to hide. 99 + </FormDescription> 100 + </FormItem> 101 + )} 102 + /> 103 + <FormField 104 + control={form.control} 105 + name="contactUrl" 106 + render={({ field }) => ( 107 + <FormItem className="sm:col-span-full"> 108 + <FormLabel>Contact URL</FormLabel> 109 + <FormControl> 110 + <Input placeholder="https://acme.com/contact" {...field} /> 111 + </FormControl> 112 + <FormMessage /> 113 + <FormDescription> 114 + Enter the URL for your contact page. Or start with{" "} 115 + <code className="rounded-md bg-muted px-1 py-0.5"> 116 + mailto: 117 + </code>{" "} 118 + to open the email client. Leave empty to hide. 119 + </FormDescription> 120 + </FormItem> 121 + )} 122 + /> 123 + </FormCardContent> 124 + <FormCardFooter> 125 + <FormCardFooterInfo> 126 + Learn more about{" "} 127 + <Link 128 + href="https://docs.openstatus.dev/tutorial/how-to-configure-status-page/#3-links" 129 + rel="noreferrer" 130 + target="_blank" 131 + > 132 + links 133 + </Link> 134 + . 135 + </FormCardFooterInfo> 136 + <Button type="submit" disabled={isPending}> 137 + {isPending ? "Submitting..." : "Submit"} 138 + </Button> 139 + </FormCardFooter> 140 + </FormCard> 141 + </form> 142 + </Form> 143 + ); 144 + }
+43 -19
apps/dashboard/src/components/forms/status-page/update.tsx
··· 10 10 import { FormCustomDomain } from "./form-custom-domain"; 11 11 import { FormDangerZone } from "./form-danger-zone"; 12 12 import { FormGeneral } from "./form-general"; 13 + import { FormLinks } from "./form-links"; 13 14 import { FormMonitors } from "./form-monitors"; 14 15 import { FormPasswordProtection } from "./form-password-protection"; 15 16 ··· 81 82 }), 82 83 ); 83 84 85 + const updateLinksMutation = useMutation( 86 + trpc.page.updateLinks.mutationOptions({ 87 + onSuccess: () => refetch(), 88 + }), 89 + ); 90 + 84 91 if (!statusPage || !monitors || !workspace) return null; 85 92 86 93 const configLink = `https://${ ··· 92 99 <Note color="info"> 93 100 <Info /> 94 101 <p className="text-sm"> 95 - We've released a new version of the status page.{" "} 96 - <Link href="#redesign">Go to the section</Link> below to enable it. 102 + We've enabled the new version of the status page. Read more about the{" "} 103 + <Link 104 + href="https://docs.openstatus.dev/tutorial/how-to-configure-status-page/" 105 + rel="noreferrer" 106 + target="_blank" 107 + > 108 + configuration 109 + </Link> 110 + . 97 111 </p> 98 112 </Note> 99 113 <FormGeneral ··· 159 173 await updateCustomDomainMutation.mutateAsync({ 160 174 id: Number.parseInt(id), 161 175 customDomain: values.domain, 176 + }); 177 + }} 178 + /> 179 + <FormLinks 180 + defaultValues={{ 181 + homepageUrl: statusPage.homepageUrl ?? "", 182 + contactUrl: statusPage.contactUrl ?? "", 183 + }} 184 + onSubmit={async (values) => { 185 + await updateLinksMutation.mutateAsync({ 186 + id: Number.parseInt(id), 187 + homepageUrl: values.homepageUrl ?? undefined, 188 + contactUrl: values.contactUrl ?? undefined, 162 189 }); 163 190 }} 164 191 /> 165 192 <FormAppearance 166 193 defaultValues={{ 167 194 forceTheme: statusPage.forceTheme ?? "system", 195 + configuration: { 196 + theme: statusPage.configuration?.theme ?? "default", 197 + }, 168 198 }} 169 199 onSubmit={async (values) => { 170 200 await updatePageAppearanceMutation.mutateAsync({ 171 201 id: Number.parseInt(id), 172 202 forceTheme: values.forceTheme, 203 + configuration: values.configuration, 173 204 }); 174 205 }} 175 206 /> 176 207 <FormConfiguration 177 208 defaultValues={{ 178 - new: !statusPage.legacyPage, 179 209 configuration: statusPage.configuration ?? {}, 180 - homepageUrl: statusPage.homepageUrl ?? "", 181 - contactUrl: statusPage.contactUrl ?? "", 182 210 }} 183 211 onSubmit={async (values) => { 184 212 await updatePageConfigurationMutation.mutateAsync({ 185 213 id: Number.parseInt(id), 186 - configuration: values.new 187 - ? { 188 - uptime: 189 - typeof values.configuration.uptime === "boolean" 190 - ? values.configuration.uptime 191 - : values.configuration.uptime === "true", 192 - value: values.configuration.value ?? "duration", 193 - type: values.configuration.type ?? "absolute", 194 - theme: values.configuration.theme ?? "default", 195 - } 196 - : undefined, 197 - legacyPage: !values.new, 198 - homepageUrl: values.homepageUrl ?? undefined, 199 - contactUrl: values.contactUrl ?? undefined, 214 + configuration: { 215 + uptime: 216 + typeof values.configuration.uptime === "boolean" 217 + ? values.configuration.uptime 218 + : values.configuration.uptime === "true", 219 + value: values.configuration.value ?? "duration", 220 + type: values.configuration.type ?? "absolute", 221 + theme: values.configuration.theme ?? undefined, 222 + }, 200 223 }); 201 224 }} 202 225 configLink={configLink} ··· 216 239 }} 217 240 /> 218 241 <FormDangerZone 242 + title={statusPage.title} 219 243 onSubmit={async () => { 220 244 await deleteStatusPageMutation.mutateAsync({ 221 245 id: Number.parseInt(id),
+3 -3
apps/dashboard/src/data/maintenances.client.ts
··· 1 - import { Pencil, Trash2 } from "lucide-react"; 1 + import { Cog, Trash2 } from "lucide-react"; 2 2 3 3 export const actions = [ 4 4 { 5 5 id: "edit", 6 - label: "Edit", 7 - icon: Pencil, 6 + label: "Settings", 7 + icon: Cog, 8 8 variant: "default" as const, 9 9 }, 10 10 {
+3 -3
apps/dashboard/src/data/monitors.client.ts
··· 1 1 import { 2 + Cog, 2 3 Copy, 3 4 CopyPlus, 4 5 Globe, 5 6 Network, 6 - Pencil, 7 7 Server, 8 8 Trash2, 9 9 } from "lucide-react"; ··· 29 29 export const actions = [ 30 30 { 31 31 id: "edit", 32 - label: "Edit", 33 - icon: Pencil, 32 + label: "Settings", 33 + icon: Cog, 34 34 variant: "default" as const, 35 35 }, 36 36 {
+3 -3
apps/dashboard/src/data/notifications.client.ts
··· 18 18 import { sendTest as sendTestWebhook } from "@openstatus/notification-webhook"; 19 19 import { 20 20 BellIcon, 21 + Cog, 21 22 Mail, 22 23 MessageCircle, 23 - Pencil, 24 24 Trash2, 25 25 Webhook, 26 26 } from "lucide-react"; ··· 28 28 export const actions = [ 29 29 { 30 30 id: "edit", 31 - label: "Edit", 32 - icon: Pencil, 31 + label: "Settings", 32 + icon: Cog, 33 33 variant: "default" as const, 34 34 }, 35 35 {
+3 -3
apps/dashboard/src/data/status-pages.client.ts
··· 1 - import { Copy, Pencil, Trash2 } from "lucide-react"; 1 + import { Cog, Copy, Trash2 } from "lucide-react"; 2 2 3 3 export const actions = [ 4 4 { 5 5 id: "edit", 6 - label: "Edit", 7 - icon: Pencil, 6 + label: "Settings", 7 + icon: Cog, 8 8 variant: "default" as const, 9 9 }, 10 10 {
+3 -3
apps/dashboard/src/data/status-report-updates.client.ts
··· 1 1 import type { StatusReportStatus } from "@openstatus/db/src/schema"; 2 - import { Pencil, Trash2 } from "lucide-react"; 2 + import { Cog, Trash2 } from "lucide-react"; 3 3 4 4 export const actions = [ 5 5 { 6 6 id: "edit", 7 - label: "Edit", 8 - icon: Pencil, 7 + label: "Settings", 8 + icon: Cog, 9 9 variant: "default" as const, 10 10 }, 11 11 {
+3 -3
apps/dashboard/src/data/status-reports.client.ts
··· 1 - import { Eye, Pencil, Plus, Trash2 } from "lucide-react"; 1 + import { Cog, Eye, Plus, Trash2 } from "lucide-react"; 2 2 3 3 export const actions = [ 4 4 { 5 5 id: "edit", 6 - label: "Edit", 7 - icon: Pencil, 6 + label: "Settings", 7 + icon: Cog, 8 8 variant: "default" as const, 9 9 }, 10 10 {
+1 -5
apps/docs/src/content/docs/tutorial/how-to-configure-status-page.mdx
··· 16 16 17 17 We are releasing a new version of our status pages. 18 18 19 - <Aside>Newly created pages will automatically run on the new version. For old pages, this is an **opt-in toggle**. We will keep the current status page until next year to allow you migrate or provide any feedback.</Aside> 20 - 21 - You can always have a look how your status page will look like via `https://[slug].stpg.dev`. 22 - 23 - Example: [https://themes.openstatus.dev/status](https://themes.openstatus.dev/status) 19 + Explore the themes: [https://themes.openstatus.dev](https://themes.openstatus.dev) 24 20 25 21 ## Get started 26 22
+66 -20
packages/api/src/router/page.ts
··· 776 776 z.object({ 777 777 id: z.number(), 778 778 forceTheme: z.enum(["light", "dark", "system"]), 779 + configuration: z.object({ 780 + theme: z.string(), 781 + }), 779 782 }), 780 783 ) 781 784 .mutation(async (opts) => { ··· 784 787 eq(page.id, opts.input.id), 785 788 ]; 786 789 790 + const _page = await opts.ctx.db.query.page.findFirst({ 791 + where: and(...whereConditions), 792 + }); 793 + 794 + if (!_page) { 795 + throw new TRPCError({ 796 + code: "NOT_FOUND", 797 + message: "Page not found", 798 + }); 799 + } 800 + 801 + const currentConfiguration = 802 + (typeof _page.configuration === "object" && 803 + _page.configuration !== null && 804 + _page.configuration) || 805 + {}; 806 + const updatedConfiguration = { 807 + ...currentConfiguration, 808 + theme: opts.input.configuration.theme, 809 + }; 810 + 787 811 await opts.ctx.db 788 812 .update(page) 789 - .set({ forceTheme: opts.input.forceTheme, updatedAt: new Date() }) 813 + .set({ 814 + forceTheme: opts.input.forceTheme, 815 + configuration: updatedConfiguration, 816 + updatedAt: new Date(), 817 + }) 818 + .where(and(...whereConditions)) 819 + .run(); 820 + }), 821 + 822 + updateLinks: protectedProcedure 823 + .meta({ track: Events.UpdatePage }) 824 + .input( 825 + z.object({ 826 + id: z.number(), 827 + homepageUrl: z.string().nullish(), 828 + contactUrl: z.string().nullish(), 829 + }), 830 + ) 831 + .mutation(async (opts) => { 832 + const whereConditions: SQL[] = [ 833 + eq(page.workspaceId, opts.ctx.workspace.id), 834 + eq(page.id, opts.input.id), 835 + ]; 836 + 837 + await opts.ctx.db 838 + .update(page) 839 + .set({ 840 + homepageUrl: opts.input.homepageUrl, 841 + contactUrl: opts.input.contactUrl, 842 + updatedAt: new Date(), 843 + }) 790 844 .where(and(...whereConditions)) 791 845 .run(); 792 846 }), ··· 799 853 configuration: z 800 854 .record(z.string(), z.string().or(z.boolean()).optional()) 801 855 .nullish(), 802 - legacyPage: z.boolean(), 803 - homepageUrl: z.string().nullish(), 804 - contactUrl: z.string().nullish(), 805 856 }), 806 857 ) 807 858 .mutation(async (opts) => { ··· 821 872 }); 822 873 } 823 874 875 + const currentConfiguration = 876 + (typeof _page.configuration === "object" && 877 + _page.configuration !== null && 878 + _page.configuration) || 879 + {}; 880 + const updatedConfiguration = { 881 + ...currentConfiguration, 882 + ...opts.input.configuration, 883 + }; 884 + 824 885 await opts.ctx.db 825 886 .update(page) 826 887 .set({ 827 - configuration: opts.input.configuration, 828 - legacyPage: opts.input.legacyPage, 829 - homepageUrl: opts.input.homepageUrl, 830 - contactUrl: opts.input.contactUrl, 888 + configuration: updatedConfiguration, 831 889 updatedAt: new Date(), 832 890 }) 833 891 .where(and(...whereConditions)) 834 892 .run(); 835 - 836 - if (opts.input.legacyPage) { 837 - await redis.del(`page:${_page.slug}`); 838 - if (_page.customDomain) { 839 - await redis.del(`page:${_page.customDomain}`); 840 - } 841 - } else { 842 - await redis.set(`page:${_page.slug}`, 1); 843 - if (_page.customDomain) { 844 - await redis.set(`page:${_page.customDomain}`, 1); 845 - } 846 - } 847 893 }), 848 894 849 895 updateMonitors: protectedProcedure