Openstatus www.openstatus.dev

chore: theme-store package (#1392)

* chore: theme-store package

* fix: tsc

* chore: status-page-configuration

* chore: note

* chore: note

* refactor: configuration token

authored by

Maximilian Kaske and committed by
GitHub
80dcdcf2 b2321194

+639 -362
+1
apps/dashboard/package.json
··· 25 25 "@openstatus/analytics": "workspace:*", 26 26 "@openstatus/api": "workspace:*", 27 27 "@openstatus/assertions": "workspace:*", 28 + "@openstatus/theme-store": "workspace:*", 28 29 "@openstatus/db": "workspace:*", 29 30 "@openstatus/emails": "workspace:*", 30 31 "@openstatus/error": "workspace:*",
+466 -228
apps/dashboard/src/components/forms/status-page/form-configuration.tsx
··· 1 - import { useEffect, useTransition } from "react"; 1 + import { useEffect, useState, useTransition } from "react"; 2 2 import { z } from "zod"; 3 3 4 4 import { Link } from "@/components/common/link"; ··· 14 14 FormCardTitle, 15 15 } from "@/components/forms/form-card"; 16 16 import { Button } from "@/components/ui/button"; 17 + import { 18 + Dialog, 19 + DialogClose, 20 + DialogContent, 21 + DialogDescription, 22 + DialogFooter, 23 + DialogHeader, 24 + DialogTitle, 25 + } from "@/components/ui/dialog"; 17 26 import { 18 27 Form, 19 28 FormControl, ··· 33 42 } from "@/components/ui/select"; 34 43 import { Switch } from "@/components/ui/switch"; 35 44 import { zodResolver } from "@hookform/resolvers/zod"; 45 + import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 36 46 import { isTRPCClientError } from "@trpc/client"; 47 + import { ArrowUpRight, Globe, Info } from "lucide-react"; 48 + import { parseAsStringLiteral, useQueryStates } from "nuqs"; 37 49 import { useForm } from "react-hook-form"; 38 50 import { toast } from "sonner"; 39 51 ··· 44 56 contactUrl: z.string().optional(), 45 57 }); 46 58 59 + const configurationSchema = z 60 + .object({ 61 + type: z.enum(["manual", "absolute"]), 62 + value: z.enum(["duration", "requests"]).nullish(), 63 + uptime: z.boolean().or(z.literal("true").or(z.literal("false"))), 64 + theme: z.enum(THEME_KEYS as [string, ...string[]]), 65 + }) 66 + .refine( 67 + (data) => { 68 + // If type is "manual", value must be null or undefined 69 + if (data.type === "manual") { 70 + return data.value === null || data.value === undefined; 71 + } 72 + return true; 73 + }, 74 + { 75 + message: "Value must be null when type is manual", 76 + path: ["value"], 77 + }, 78 + ); 79 + 47 80 type FormValues = z.infer<typeof schema>; 48 81 49 82 export function FormConfiguration({ 50 83 defaultValues, 51 84 onSubmit, 85 + configLink, 52 86 }: { 53 87 defaultValues?: FormValues; 54 88 onSubmit: (values: FormValues) => Promise<void>; 89 + configLink: string; 55 90 }) { 56 91 const [isPending, startTransition] = useTransition(); 57 92 const form = useForm<FormValues>({ ··· 76 111 77 112 useEffect(() => { 78 113 if (watchConfigurationType === "manual") { 79 - // TODO: this is not working 80 114 form.setValue("configuration.value", undefined); 81 115 } else { 82 116 form.setValue("configuration.value", "duration"); ··· 111 145 } 112 146 113 147 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"} 148 + <> 149 + <Form {...form}> 150 + <form id="redesign" onSubmit={form.handleSubmit(submitAction)}> 151 + <FormCard> 152 + <FormCardHeader> 153 + <FormCardTitle>Status Page Redesign (beta)</FormCardTitle> 154 + <FormCardDescription> 155 + Use the latest version of the status page and customize it. 156 + </FormCardDescription> 157 + </FormCardHeader> 158 + <FormCardContent className="grid gap-4 sm:grid-cols-2"> 159 + <FormField 160 + control={form.control} 161 + name="new" 162 + render={({ field }) => ( 163 + <FormItem className="flex flex-row items-center justify-between"> 164 + <div className="space-y-0.5"> 165 + <FormLabel>Enable New Version</FormLabel> 166 + <FormDescription> 167 + More controls, better UI. 168 + </FormDescription> 169 + </div> 170 + <FormControl> 171 + <Switch 172 + checked={field.value} 173 + onCheckedChange={field.onChange} 174 + /> 175 + </FormControl> 176 + </FormItem> 177 + )} 178 + /> 179 + <div className="flex items-center sm:justify-end"> 180 + <Button variant="secondary" size="sm" asChild> 181 + <Link 182 + href={configLink} 183 + rel="noreferrer" 184 + target="_blank" 185 + className="inline-flex items-center gap-1" 186 + > 187 + View and configure status page{" "} 188 + <ArrowUpRight className="h-4 w-4" /> 189 + </Link> 190 + </Button> 191 + </div> 192 + <Note color="info" className="col-span-full"> 193 + <Globe /> 194 + <p className="text-sm"> 195 + With that version, we provide a new shorter domain{" "} 196 + <code className="font-commit-mono"> 197 + https://[slug].stpg.dev 198 + </code> 199 + . Once globally enabled, it will act as redirector for the old 200 + domain{" "} 201 + <code className="font-commit-mono"> 202 + https://[slug].openstatus.dev 203 + </code> 204 + . 205 + </p> 206 + </Note> 207 + </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" 159 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> 160 370 <FormControl> 161 - <SelectTrigger className="w-full capitalize"> 162 - <SelectValue placeholder="Select a type" /> 163 - </SelectTrigger> 371 + <Select 372 + onValueChange={field.onChange} 373 + defaultValue={String(field.value) ?? "default"} 374 + > 375 + <FormControl> 376 + <SelectTrigger className="w-full capitalize"> 377 + <SelectValue placeholder="Select a theme" /> 378 + </SelectTrigger> 379 + </FormControl> 380 + <SelectContent> 381 + {Object.values(THEMES).map((theme) => ( 382 + <SelectItem 383 + key={theme.id} 384 + value={theme.id} 385 + className="capitalize" 386 + > 387 + {theme.name} 388 + </SelectItem> 389 + ))} 390 + </SelectContent> 391 + </Select> 164 392 </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 - > 393 + </FormItem> 394 + )} 395 + /> 396 + </FormCardContent> 397 + <FormCardSeparator /> 398 + <FormCardContent className="grid gap-4"> 399 + <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> 400 + <FormCardTitle>Links</FormCardTitle> 401 + <FormCardDescription> 402 + Configure the links for the status page. 403 + </FormCardDescription> 404 + </FormCardHeader> 405 + <FormField 406 + control={form.control} 407 + name="homepageUrl" 408 + render={({ field }) => ( 409 + <FormItem> 410 + <FormLabel>Homepage URL</FormLabel> 192 411 <FormControl> 193 - <SelectTrigger className="w-full capitalize"> 194 - <SelectValue placeholder="Select a type" /> 195 - </SelectTrigger> 412 + <Input placeholder="https://acme.com" {...field} /> 196 413 </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 - > 414 + <FormMessage /> 415 + <FormDescription> 416 + What URL should the logo link to? Leave empty to hide. 417 + </FormDescription> 418 + </FormItem> 419 + )} 420 + /> 421 + <FormField 422 + control={form.control} 423 + name="contactUrl" 424 + render={({ field }) => ( 425 + <FormItem> 426 + <FormLabel>Contact URL</FormLabel> 223 427 <FormControl> 224 - <SelectTrigger className="w-full capitalize"> 225 - <SelectValue placeholder="Select a type" /> 226 - </SelectTrigger> 428 + <Input 429 + placeholder="https://acme.com/contact" 430 + {...field} 431 + /> 227 432 </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/tutorial/how-to-configure-status-page" 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> 433 + <FormMessage /> 434 + <FormDescription> 435 + Enter the URL for your contact page. Or start with{" "} 436 + <code className="rounded-md bg-muted px-1 py-0.5"> 437 + mailto: 438 + </code>{" "} 439 + to open the email client. Leave empty to hide. 440 + </FormDescription> 441 + </FormItem> 442 + )} 443 + /> 444 + </FormCardContent> 445 + </> 446 + )} 447 + <FormCardFooter> 448 + <FormCardFooterInfo> 449 + Learn more about{" "} 450 + <Link 451 + href="https://docs.openstatus.dev/tutorial/how-to-configure-status-page" 452 + rel="noreferrer" 453 + target="_blank" 454 + > 455 + Status Page Redesign (beta) 456 + </Link> 457 + . 458 + </FormCardFooterInfo> 459 + <Button type="submit" disabled={isPending}> 460 + {isPending ? "Submitting..." : "Submit"} 461 + </Button> 462 + </FormCardFooter> 463 + </FormCard> 464 + </form> 465 + </Form> 466 + <FormConfigurationDialog 467 + defaultValues={defaultValues} 468 + onSubmit={async (e) => { 469 + await onSubmit(e); 470 + // NOTE: make sure to sync the form with the new values 471 + form.reset(e); 472 + }} 473 + /> 474 + </> 347 475 ); 348 476 } 349 477 ··· 366 494 false: "shares only the current status.", 367 495 }, 368 496 } as const; 497 + 498 + // ?type=manual&value=manual&uptime=true&theme=default 499 + 500 + const searchParams = { 501 + type: parseAsStringLiteral(["manual", "absolute"]), 502 + value: parseAsStringLiteral(["duration", "requests"]), 503 + uptime: parseAsStringLiteral(["true", "false"]), 504 + theme: parseAsStringLiteral(Object.keys(THEMES)), 505 + }; 506 + 507 + function FormConfigurationDialog({ 508 + defaultValues, 509 + onSubmit, 510 + }: { 511 + defaultValues?: FormValues; 512 + onSubmit: (values: FormValues) => Promise<void>; 513 + }) { 514 + const [open, setOpen] = useState(false); 515 + const [isPending, startTransition] = useTransition(); 516 + const [{ type, value, uptime, theme }, setSearchParams] = 517 + useQueryStates(searchParams); 518 + 519 + useEffect(() => { 520 + if (type) setOpen(true); 521 + }, [type]); 522 + 523 + function submitAction(values: FormValues) { 524 + if (isPending) return; 525 + 526 + const data = configurationSchema.safeParse(values.configuration); 527 + if (!data.success) { 528 + toast.error(data.error.message); 529 + return; 530 + } 531 + 532 + startTransition(async () => { 533 + try { 534 + const promise = onSubmit(values); 535 + toast.promise(promise, { 536 + loading: "Saving...", 537 + success: "Saved", 538 + error: (error) => { 539 + if (isTRPCClientError(error)) { 540 + return error.message; 541 + } 542 + return "Failed to save"; 543 + }, 544 + }); 545 + await promise; 546 + setSearchParams({ 547 + type: null, 548 + value: null, 549 + uptime: null, 550 + theme: null, 551 + }); 552 + setOpen(false); 553 + } catch (error) { 554 + console.error(error); 555 + } 556 + }); 557 + } 558 + 559 + return ( 560 + <Dialog open={open} onOpenChange={setOpen}> 561 + <DialogContent> 562 + <DialogHeader> 563 + <DialogTitle>Status Page Redesign (beta)</DialogTitle> 564 + <DialogDescription> 565 + Do you want to update the status page based on the configured 566 + settings? 567 + </DialogDescription> 568 + </DialogHeader> 569 + <div className="flex flex-col gap-2"> 570 + <pre className="rounded-md border bg-muted/50 px-3 py-2 font-commit-mono text-sm"> 571 + {JSON.stringify({ type, value, uptime, theme }, null, 2)} 572 + </pre> 573 + {!defaultValues || !defaultValues.new ? ( 574 + <Note color="info" className="text-sm"> 575 + <Info /> 576 + <p>You will activate the new version of the status page.</p> 577 + </Note> 578 + ) : null} 579 + </div> 580 + <DialogFooter> 581 + <DialogClose asChild> 582 + <Button variant="outline">Cancel</Button> 583 + </DialogClose> 584 + <Button 585 + type="button" 586 + onClick={() => 587 + submitAction({ 588 + ...defaultValues, 589 + new: true, 590 + configuration: { 591 + type: type ?? undefined, 592 + value: value ?? undefined, 593 + uptime: uptime ?? undefined, 594 + theme: theme ?? undefined, 595 + }, 596 + }) 597 + } 598 + disabled={isPending} 599 + > 600 + {isPending ? "Saving..." : "Save"} 601 + </Button> 602 + </DialogFooter> 603 + </DialogContent> 604 + </Dialog> 605 + ); 606 + }
+6 -44
apps/dashboard/src/components/forms/status-page/form-monitors.tsx
··· 36 36 import { PopoverContent } from "@/components/ui/popover"; 37 37 import { Popover, PopoverTrigger } from "@/components/ui/popover"; 38 38 import { 39 - Select, 40 - SelectContent, 41 - SelectItem, 42 - SelectTrigger, 43 - SelectValue, 44 - } from "@/components/ui/select"; 45 - import { 46 39 Sortable, 47 40 SortableContent, 48 41 SortableItem, ··· 63 56 id: number; 64 57 name: string; 65 58 url: string; 59 + active: boolean | null; 66 60 }; 67 - 68 - const DISABLED_TYPES = ["none"]; 69 61 70 62 const schema = z.object({ 71 63 monitors: z.array( 72 64 z.object({ 73 65 id: z.number(), 74 66 order: z.number(), 75 - type: z.enum(["all", "hide", "none"]), 67 + active: z.boolean().nullable(), 76 68 }), 77 69 ), 78 70 }); ··· 130 122 newMonitors.map((m, index) => ({ 131 123 id: m.id, 132 124 order: index, 133 - type: "none" as const, 125 + active: m.active, 134 126 })), 135 127 ); 136 128 }, ··· 239 231 { 240 232 id: monitor.id, 241 233 order: watchMonitors.length, 242 - type: "none", 234 + active: monitor.active, 243 235 }, 244 236 ]); 245 237 } ··· 309 301 ); 310 302 } 311 303 312 - const types = { 313 - all: { 314 - label: "Show all uptime", 315 - }, 316 - hide: { 317 - label: "Hide values", 318 - }, 319 - none: { 320 - label: "Only status reports", 321 - }, 322 - }; 323 - 324 304 interface MonitorRowProps 325 305 extends Omit<React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { 326 306 monitor: Monitor; ··· 344 324 <div className="self-center truncate text-muted-foreground text-sm"> 345 325 {monitor.url} 346 326 </div> 347 - <div> 348 - <Select> 349 - <SelectTrigger className="h-7 w-full shadow-none" disabled> 350 - <SelectValue placeholder="Select type (coming soon)" /> 351 - </SelectTrigger> 352 - <SelectContent> 353 - {Object.entries(types).map(([key, value]) => ( 354 - <SelectItem 355 - key={key} 356 - value={key} 357 - disabled={DISABLED_TYPES.includes(key)} 358 - > 359 - {value.label}{" "} 360 - {DISABLED_TYPES.includes(key) && ( 361 - <span className="text-foreground text-xs">(Upgrade)</span> 362 - )} 363 - </SelectItem> 364 - ))} 365 - </SelectContent> 366 - </Select> 327 + <div className="truncate text-muted-foreground text-sm"> 328 + {monitor.active ? "Active" : "Inactive"} 367 329 </div> 368 330 </div> 369 331 </SortableItem>
+43 -30
apps/dashboard/src/components/forms/status-page/update.tsx
··· 1 + import { Link } from "@/components/common/link"; 2 + import { Note } from "@/components/common/note"; 1 3 import { FormCardGroup } from "@/components/forms/form-card"; 2 4 import { useTRPC } from "@/lib/trpc/client"; 3 5 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 6 + import { Info } from "lucide-react"; 4 7 import { useParams, useRouter } from "next/navigation"; 5 8 import { FormAppearance } from "./form-appearance"; 6 9 import { FormConfiguration } from "./form-configuration"; ··· 80 83 81 84 if (!statusPage || !monitors || !workspace) return null; 82 85 86 + const configLink = `https://${ 87 + statusPage.slug 88 + }.stpg.dev?configuration-token=${statusPage.createdAt?.getTime().toString()}`; 89 + 83 90 return ( 84 91 <FormCardGroup> 92 + <Note color="info"> 93 + <Info /> 94 + <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. 97 + </p> 98 + </Note> 85 99 <FormGeneral 86 100 defaultValues={{ 87 101 title: statusPage.title, ··· 105 119 monitors: statusPage.monitors.map((monitor) => ({ 106 120 id: monitor.id, 107 121 order: monitor.order, 108 - // type: monitor.type, 109 - type: "none" as const, 122 + active: monitor.active ?? null, 110 123 })), 111 124 }} 112 125 onSubmit={async (values) => { ··· 139 152 }); 140 153 }} 141 154 /> 142 - {/* TODO: feature flagged - remove once we have the new version in production */} 143 - {process.env.NEXT_PUBLIC_STATUS_PAGE_V2 === "true" || 144 - process.env.NODE_ENV === "development" ? ( 145 - <FormConfiguration 146 - defaultValues={{ 147 - new: !statusPage.legacyPage, 148 - configuration: statusPage.configuration ?? {}, 149 - homepageUrl: statusPage.homepageUrl ?? "", 150 - contactUrl: statusPage.contactUrl ?? "", 151 - }} 152 - onSubmit={async (values) => { 153 - await updatePageConfigurationMutation.mutateAsync({ 154 - id: Number.parseInt(id), 155 - configuration: values.new 156 - ? { 157 - // NOTE: convert to boolean 158 - uptime: values.configuration.uptime === "true", 159 - value: values.configuration.value ?? "duration", 160 - type: values.configuration.type ?? "absolute", 161 - } 162 - : undefined, 163 - legacyPage: !values.new, 164 - homepageUrl: values.homepageUrl ?? undefined, 165 - contactUrl: values.contactUrl ?? undefined, 166 - }); 167 - }} 168 - /> 169 - ) : null} 155 + <FormConfiguration 156 + defaultValues={{ 157 + new: !statusPage.legacyPage, 158 + configuration: statusPage.configuration ?? {}, 159 + homepageUrl: statusPage.homepageUrl ?? "", 160 + contactUrl: statusPage.contactUrl ?? "", 161 + }} 162 + onSubmit={async (values) => { 163 + await updatePageConfigurationMutation.mutateAsync({ 164 + id: Number.parseInt(id), 165 + configuration: values.new 166 + ? { 167 + uptime: 168 + typeof values.configuration.uptime === "boolean" 169 + ? values.configuration.uptime 170 + : values.configuration.uptime === "true", 171 + value: values.configuration.value ?? "duration", 172 + type: values.configuration.type ?? "absolute", 173 + theme: values.configuration.theme ?? "default", 174 + } 175 + : undefined, 176 + legacyPage: !values.new, 177 + homepageUrl: values.homepageUrl ?? undefined, 178 + contactUrl: values.contactUrl ?? undefined, 179 + }); 180 + }} 181 + configLink={configLink} 182 + /> 170 183 <FormPasswordProtection 171 184 locked={workspace.limits["password-protection"] === false} 172 185 defaultValues={{
+1
apps/status-page/package.json
··· 25 25 "@openstatus/analytics": "workspace:*", 26 26 "@openstatus/api": "workspace:*", 27 27 "@openstatus/assertions": "workspace:*", 28 + "@openstatus/theme-store": "workspace:*", 28 29 "@openstatus/db": "workspace:*", 29 30 "@openstatus/emails": "workspace:*", 30 31 "@openstatus/error": "workspace:*",
+5 -5
apps/status-page/src/app/(public)/page.tsx
··· 19 19 import { StatusMonitor } from "@/components/status-page/status-monitor"; 20 20 import { Button } from "@/components/ui/button"; 21 21 import { monitors } from "@/data/monitors"; 22 - import { THEMES, THEME_KEYS } from "@/lib/community-themes"; 23 22 import { useTRPC } from "@/lib/trpc/client"; 24 23 import { cn } from "@/lib/utils"; 24 + import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 25 25 import { useQuery } from "@tanstack/react-query"; 26 26 27 27 export default function Page() { ··· 72 72 <div className="prose dark:prose-invert prose-sm max-w-none"> 73 73 <p> 74 74 You can contribute your own theme by creating a new file in the{" "} 75 - <code>src/lib/community-themes</code> directory. You&apos;ll only 76 - need to override css variables. Make sure your object is satisfiying 77 - the <code>Theme</code> interface. 75 + <code>@openstatus.theme-store</code> package. You&apos;ll only need 76 + to override css variables. Make sure your object is satisfiying the{" "} 77 + <code>Theme</code> interface. 78 78 </p> 79 79 <p> 80 80 Go to the{" "} 81 - <Link href="https://github.com/openstatusHQ/openstatus/tree/main/apps/status-page/src/lib/community-themes"> 81 + <Link href="https://github.com/openstatusHQ/openstatus/tree/main/packages/theme-store"> 82 82 GitHub directory 83 83 </Link>{" "} 84 84 to see the existing themes and create a new one by forking and
+6 -1
apps/status-page/src/app/(status-page)/[domain]/layout.tsx
··· 48 48 defaultCommunityTheme={validation.data?.theme} 49 49 > 50 50 {children} 51 - <FloatingButton /> 51 + <FloatingButton 52 + pageId={page?.id} 53 + // NOTE: token to avoid showing the floating button to random users 54 + // timestamp is our token - it is hard to guess 55 + token={page?.createdAt?.getTime().toString()} 56 + /> 52 57 <FloatingTheme /> 53 58 <Toaster 54 59 toastOptions={{
+74 -51
apps/status-page/src/components/status-page/floating-button.tsx
··· 16 16 SelectValue, 17 17 } from "@/components/ui/select"; 18 18 import { Separator } from "@/components/ui/separator"; 19 - import { THEMES, THEME_KEYS } from "@/lib/community-themes"; 20 19 import { cn } from "@/lib/utils"; 20 + import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 21 21 import { Settings } from "lucide-react"; 22 22 import { useTheme } from "next-themes"; 23 + import { parseAsString, useQueryState } from "nuqs"; 23 24 import type React from "react"; 24 25 import { createContext, useContext, useEffect, useState } from "react"; 25 26 27 + export const IS_DEV = process.env.NODE_ENV === "development"; 28 + 26 29 export const VARIANT = ["success", "degraded", "error", "info"] as const; 27 30 export type VariantType = (typeof VARIANT)[number]; 28 31 29 - export const CARD_TYPE = [ 30 - "duration", 31 - "requests", 32 - "dominant", 33 - "manual", 34 - ] as const; 32 + export const CARD_TYPE = ["duration", "requests", "manual"] as const; 35 33 export type CardType = (typeof CARD_TYPE)[number]; 36 34 37 - export const BAR_TYPE = ["absolute", "dominant", "manual"] as const; 35 + export const BAR_TYPE = ["absolute", "manual"] as const; 38 36 export type BarType = (typeof BAR_TYPE)[number]; 39 37 40 38 export const COMMUNITY_THEME = THEME_KEYS; ··· 149 147 ); 150 148 } 151 149 152 - export function FloatingButton({ className }: { className?: string }) { 150 + export function FloatingButton({ 151 + className, 152 + pageId, 153 + token, 154 + }: { 155 + className?: string; 156 + pageId?: number; 157 + token?: string; 158 + }) { 153 159 const { 154 160 cardType, 155 161 setCardType, ··· 163 169 setRadius, 164 170 } = useStatusPage(); 165 171 const [display, setDisplay] = useState(false); 172 + const [configToken, setConfigToken] = useQueryState( 173 + "configuration-token", 174 + parseAsString, 175 + ); 166 176 177 + // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> 167 178 useEffect(() => { 168 179 const enabled = 169 - sessionStorage.getItem("status-page-configuration") === "true"; 180 + localStorage.getItem("configuration-token") === token || 181 + configToken === token; 170 182 const host = window.location.host; 171 183 if ( 172 184 (host.includes("localhost") || ··· 176 188 enabled 177 189 ) { 178 190 setDisplay(true); 179 - } else if (process.env.NODE_ENV === "development") { 191 + localStorage.setItem("configuration-token", token); 192 + } else if (IS_DEV) { 180 193 setDisplay(true); 181 194 } 182 - }, []); 195 + 196 + if (configToken) setConfigToken(null); 197 + }, [open, token]); 183 198 184 199 if (!display) return null; 185 200 ··· 189 204 <PopoverTrigger asChild> 190 205 <Button 191 206 size="icon" 192 - variant="outline" 193 - className="size-12 rounded-full dark:bg-background" 207 + variant="secondary" 208 + className="size-12 rounded-full border" 194 209 > 195 210 <Settings className="size-5" /> 196 211 <span className="sr-only">Open status page settings</span> ··· 205 220 </p> 206 221 </div> 207 222 <div className="grid grid-cols-2 gap-4"> 208 - <div className="space-y-2"> 209 - <Label htmlFor="show-uptime">Show Uptime</Label> 210 - <Select 211 - value={showUptime ? "true" : "false"} 212 - onValueChange={(v) => setShowUptime(v === "true")} 213 - > 214 - <SelectTrigger id="show-uptime" className="w-full capitalize"> 215 - <SelectValue /> 216 - </SelectTrigger> 217 - <SelectContent> 218 - {["true", "false"].map((v) => ( 219 - <SelectItem key={v} value={v} className="capitalize"> 220 - {v} 221 - </SelectItem> 222 - ))} 223 - </SelectContent> 224 - </Select> 225 - </div> 226 223 <div className="space-y-2"> 227 224 <Label htmlFor="bar-type">Bar Type</Label> 228 225 <Select ··· 273 270 </Select> 274 271 </div> 275 272 <div className="space-y-2"> 276 - <Label htmlFor="theme">Theme</Label> 277 - <ThemeToggle id="theme" className="w-full" /> 278 - </div> 279 - <div className="space-y-2"> 280 - <Label htmlFor="community-theme">Community Theme</Label> 273 + <Label htmlFor="show-uptime">Show Uptime</Label> 281 274 <Select 282 - value={communityTheme} 283 - onValueChange={(v) => setCommunityTheme(v as CommunityTheme)} 275 + value={showUptime ? "true" : "false"} 276 + onValueChange={(v) => setShowUptime(v === "true")} 284 277 > 285 - <SelectTrigger 286 - id="community-theme" 287 - className="w-full capitalize" 288 - > 278 + <SelectTrigger id="show-uptime" className="w-full capitalize"> 289 279 <SelectValue /> 290 280 </SelectTrigger> 291 281 <SelectContent> 292 - {COMMUNITY_THEME.map((v) => ( 282 + {["true", "false"].map((v) => ( 293 283 <SelectItem key={v} value={v} className="capitalize"> 294 284 {v} 295 285 </SelectItem> ··· 297 287 </SelectContent> 298 288 </Select> 299 289 </div> 290 + {IS_DEV ? ( 291 + <div className="space-y-2"> 292 + <Label htmlFor="radius">Radius</Label> 293 + <Select 294 + value={radius} 295 + onValueChange={(v) => setRadius(v as Radius)} 296 + > 297 + <SelectTrigger id="radius" className="w-full capitalize"> 298 + <SelectValue /> 299 + </SelectTrigger> 300 + <SelectContent> 301 + {RADIUS.map((v) => ( 302 + <SelectItem key={v} value={v} className="capitalize"> 303 + {v} 304 + </SelectItem> 305 + ))} 306 + </SelectContent> 307 + </Select> 308 + </div> 309 + ) : null} 310 + {IS_DEV ? ( 311 + <div className="space-y-2"> 312 + <Label htmlFor="theme">Theme</Label> 313 + <ThemeToggle id="theme" className="w-full" /> 314 + </div> 315 + ) : null} 300 316 <div className="space-y-2"> 301 - <Label htmlFor="radius">Radius</Label> 317 + <Label htmlFor="community-theme">Community Theme</Label> 302 318 <Select 303 - value={radius} 304 - onValueChange={(v) => setRadius(v as Radius)} 319 + value={communityTheme} 320 + onValueChange={(v) => setCommunityTheme(v as CommunityTheme)} 305 321 > 306 - <SelectTrigger id="radius" className="w-full capitalize"> 322 + <SelectTrigger 323 + id="community-theme" 324 + className="w-full capitalize" 325 + > 307 326 <SelectValue /> 308 327 </SelectTrigger> 309 328 <SelectContent> 310 - {RADIUS.map((v) => ( 329 + {COMMUNITY_THEME.map((v) => ( 311 330 <SelectItem key={v} value={v} className="capitalize"> 312 331 {v} 313 332 </SelectItem> ··· 321 340 <div className="p-4"> 322 341 <Button className="w-full" size="sm" asChild> 323 342 <a 324 - href="https://github.com/openstatusHQ/openstatus-template" 343 + href={ 344 + pageId 345 + ? `https://app.openstatus.dev/status-pages/${pageId}/edit?type=${barType}&value=${cardType}&uptime=${showUptime}&theme=${communityTheme}` 346 + : "https://app.openstatus.dev/status-pages" 347 + } 325 348 target="_blank" 326 349 rel="noreferrer" 327 350 > 328 - GitHub Repo 351 + Dashboard 329 352 </a> 330 353 </Button> 331 354 </div>
+1 -1
apps/status-page/src/components/status-page/floating-theme.tsx
··· 15 15 SelectTrigger, 16 16 SelectValue, 17 17 } from "@/components/ui/select"; 18 - import { THEMES, THEME_KEYS } from "@/lib/community-themes"; 19 18 import { cn } from "@/lib/utils"; 19 + import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 20 20 import { Palette } from "lucide-react"; 21 21 import { useEffect } from "react"; 22 22 import { useState } from "react";
apps/status-page/src/lib/community-themes/README.md packages/theme-store/README.md
apps/status-page/src/lib/community-themes/github-contrast.ts packages/theme-store/src/github-contrast.ts
apps/status-page/src/lib/community-themes/index.ts packages/theme-store/src/index.ts
apps/status-page/src/lib/community-themes/supabase.ts packages/theme-store/src/supabase.ts
apps/status-page/src/lib/community-themes/types.ts packages/theme-store/src/types.ts
+1 -1
packages/api/src/router/statusPage.ts
··· 79 79 80 80 const monitors = _page.monitorsToPages 81 81 // NOTE: we cannot nested `where` in drizzle to filter active monitors 82 - .filter((m) => m.monitor.active && !m.monitor.deletedAt) 82 + .filter((m) => !m.monitor.deletedAt) 83 83 .map((m) => { 84 84 const events = getEvents({ 85 85 maintenances: _page.maintenances,
+1 -1
packages/db/src/schema/shared.ts
··· 129 129 }) 130 130 .omit({ 131 131 // workspaceId: true, 132 - id: true, 132 + // id: true, 133 133 password: true, 134 134 }); 135 135
+15
packages/theme-store/package.json
··· 1 + { 2 + "name": "@openstatus/theme-store", 3 + "version": "1.0.0", 4 + "description": "", 5 + "main": "src/index.ts", 6 + "scripts": {}, 7 + "dependencies": {}, 8 + "devDependencies": { 9 + "@openstatus/tsconfig": "workspace:*", 10 + "typescript": "5.7.2" 11 + }, 12 + "keywords": [], 13 + "author": "", 14 + "license": "ISC" 15 + }
+4
packages/theme-store/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/base.json", 3 + "include": ["src", "*.ts"] 4 + }
+15
pnpm-lock.yaml
··· 113 113 '@openstatus/react': 114 114 specifier: workspace:* 115 115 version: link:../../packages/react 116 + '@openstatus/theme-store': 117 + specifier: workspace:* 118 + version: link:../../packages/theme-store 116 119 '@openstatus/tinybird': 117 120 specifier: workspace:* 118 121 version: link:../../packages/tinybird ··· 573 576 '@openstatus/react': 574 577 specifier: workspace:* 575 578 version: link:../../packages/react 579 + '@openstatus/theme-store': 580 + specifier: workspace:* 581 + version: link:../../packages/theme-store 576 582 '@openstatus/tinybird': 577 583 specifier: workspace:* 578 584 version: link:../../packages/tinybird ··· 1615 1621 tsup: 1616 1622 specifier: 7.2.0 1617 1623 version: 7.2.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2))(typescript@5.7.2) 1624 + typescript: 1625 + specifier: 5.7.2 1626 + version: 5.7.2 1627 + 1628 + packages/theme-store: 1629 + devDependencies: 1630 + '@openstatus/tsconfig': 1631 + specifier: workspace:* 1632 + version: link:../tsconfig 1618 1633 typescript: 1619 1634 specifier: 5.7.2 1620 1635 version: 5.7.2