| 1 | import { ExternalLink } from "lucide-react"; |
| 2 | import { redirect } from "react-router"; |
| 3 | |
| 4 | import type { MaintenanceChange } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/maintenance"; |
| 7 | import { BackLink, MaintenanceBadge } from "~/components/incidents"; |
| 8 | import { Button, EmptyState, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui"; |
| 9 | import { text } from "~/lib/forms"; |
| 10 | import { duration, secondsBetween } from "~/lib/incidents"; |
| 11 | import { statusAdmin } from "~/lib/services.server"; |
| 12 | import { settle } from "~/lib/settle"; |
| 13 | import { requireStaff } from "~/lib/staff"; |
| 14 | |
| 15 | export const meta: Route.MetaFunction = ({ loaderData }) => [ |
| 16 | { title: `${loaderData?.maintenance?.title ?? "Maintenance"} · sudo` }, |
| 17 | { name: "robots", content: "noindex, nofollow" }, |
| 18 | ]; |
| 19 | |
| 20 | const DONE: Record<string, string> = { |
| 21 | scheduled: "Scheduled. status.g1t.sh lists it as upcoming within 30 seconds.", |
| 22 | update: "Update posted.", |
| 23 | start: "Started early.", |
| 24 | complete: "Marked complete.", |
| 25 | cancel: "Cancelled.", |
| 26 | }; |
| 27 | |
| 28 | export async function loader({ params, request, context }: Route.LoaderArgs) { |
| 29 | requireStaff(context); |
| 30 | const [board, components] = await Promise.all([settle(statusAdmin.board()), settle(statusAdmin.components())]); |
| 31 | const maintenance = board.ok ? (board.value.maintenance.find((m) => m.id === params.id) ?? null) : null; |
| 32 | if (board.ok && !maintenance) throw new Response("No such maintenance, or it ended over 90 days ago.", { status: 404 }); |
| 33 | return { |
| 34 | maintenance, |
| 35 | names: components.ok ? components.value : [], |
| 36 | error: board.ok ? null : board.error, |
| 37 | done: DONE[new URL(request.url).searchParams.get("done") ?? ""] ?? null, |
| 38 | }; |
| 39 | } |
| 40 | |
| 41 | export async function action({ params, request, context }: Route.ActionArgs) { |
| 42 | const staff = requireStaff(context); |
| 43 | const form = await request.formData(); |
| 44 | const act = text(form, "intent") as MaintenanceChange["action"]; |
| 45 | const result = await settle( |
| 46 | statusAdmin.changeMaintenance(params.id, { action: act, message: String(form.get("message") ?? ""), notify: form.get("notify") === "on", by: staff.email }), |
| 47 | ); |
| 48 | if (!result.ok) return { error: result.error }; |
| 49 | if (!result.value.ok) return { error: result.value.error.message }; |
| 50 | return redirect(`/incidents/maintenance/${params.id}?done=${act}`); |
| 51 | } |
| 52 | |
| 53 | export default function Maintenance({ loaderData, actionData }: Route.ComponentProps) { |
| 54 | const { maintenance: m, names, error, done } = loaderData; |
| 55 | const failed = actionData as { error: string } | undefined; |
| 56 | if (!m) { |
| 57 | return ( |
| 58 | <main className="mx-auto max-w-4xl px-4 py-8"> |
| 59 | <Notice tone="warn">The status worker did not answer: {error}</Notice> |
| 60 | </main> |
| 61 | ); |
| 62 | } |
| 63 | const name = new Map(names.map((c) => [c.key, c.name])); |
| 64 | const live = m.state === "scheduled" || m.state === "in_progress"; |
| 65 | return ( |
| 66 | <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10"> |
| 67 | <BackLink to="/incidents?tab=maintenance">Incidents</BackLink> |
| 68 | <div className="mt-3"> |
| 69 | <PageHeader |
| 70 | title={m.title} |
| 71 | description={ |
| 72 | <> |
| 73 | <MaintenanceBadge state={m.state} /> <When at={m.starts_at} time /> to <When at={m.ends_at} time /> · {duration(secondsBetween(m.starts_at, m.ends_at))} ·{" "} |
| 74 | {m.components.map((k) => name.get(k) ?? k).join(", ")} |
| 75 | </> |
| 76 | } |
| 77 | actions={ |
| 78 | <a href={m.url} className="inline-flex items-center gap-1.5 text-sm text-merged hover:underline"> |
| 79 | On the status page <ExternalLink size={13} aria-hidden="true" /> |
| 80 | </a> |
| 81 | } |
| 82 | /> |
| 83 | </div> |
| 84 | <div className="mt-4 space-y-3"> |
| 85 | {done && <Notice tone="ok">{done}</Notice>} |
| 86 | {failed && <Notice tone="error">{failed.error}</Notice>} |
| 87 | </div> |
| 88 | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]"> |
| 89 | <div className="min-w-0 space-y-6"> |
| 90 | {live && ( |
| 91 | <Section title="Post an update" description="Shown on its page and the status page."> |
| 92 | <form method="post" className="space-y-3"> |
| 93 | <Textarea name="message" rows={3} maxLength={4000} aria-label="Update" placeholder="Running a little long: about 20 more minutes." /> |
| 94 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 95 | <label className="flex items-center gap-2 text-sm"> |
| 96 | <input type="checkbox" name="notify" className="accent-[#b6a8ff]" /> |
| 97 | Email subscribers |
| 98 | </label> |
| 99 | <Button type="submit" name="intent" value="update" variant="lavender"> |
| 100 | Post update |
| 101 | </Button> |
| 102 | </div> |
| 103 | </form> |
| 104 | </Section> |
| 105 | )} |
| 106 | <Section title="Updates" description="Newest first, as the status page shows them."> |
| 107 | {m.updates.length === 0 ? ( |
| 108 | <EmptyState title="No updates" /> |
| 109 | ) : ( |
| 110 | <ol className="space-y-3"> |
| 111 | {m.updates.map((u) => ( |
| 112 | <li key={u.id} className="rounded-md border border-line bg-raised/30 px-3.5 py-3"> |
| 113 | <p className="text-xs text-faint"> |
| 114 | <When at={u.at} time /> |
| 115 | </p> |
| 116 | <p className="mt-1 text-sm whitespace-pre-line text-fg-soft">{u.text}</p> |
| 117 | </li> |
| 118 | ))} |
| 119 | </ol> |
| 120 | )} |
| 121 | </Section> |
| 122 | </div> |
| 123 | <aside className="min-w-0 space-y-6"> |
| 124 | {live ? ( |
| 125 | <Section title="Move it along" description="It starts and completes on its own with its window; do it by hand when the work runs early or late."> |
| 126 | <form method="post" className="space-y-3"> |
| 127 | <Field label="Message (optional)"> |
| 128 | <Textarea name="message" rows={2} maxLength={4000} placeholder="Said for you when empty." /> |
| 129 | </Field> |
| 130 | <label className="flex items-center gap-2 text-sm"> |
| 131 | <input type="checkbox" name="notify" defaultChecked className="accent-[#b6a8ff]" /> |
| 132 | Email subscribers |
| 133 | </label> |
| 134 | <div className="flex flex-wrap gap-2"> |
| 135 | {m.state === "scheduled" && ( |
| 136 | <Button type="submit" name="intent" value="start" variant="quiet"> |
| 137 | Start now |
| 138 | </Button> |
| 139 | )} |
| 140 | <Button type="submit" name="intent" value="complete" variant="quiet"> |
| 141 | Complete |
| 142 | </Button> |
| 143 | <Button type="submit" name="intent" value="cancel" variant="danger"> |
| 144 | Cancel |
| 145 | </Button> |
| 146 | </div> |
| 147 | </form> |
| 148 | </Section> |
| 149 | ) : ( |
| 150 | <Section title="Finished"> |
| 151 | <p className="text-sm text-muted">This maintenance is {m.state === "completed" ? "complete" : "cancelled"}. It stays in the status page's history.</p> |
| 152 | </Section> |
| 153 | )} |
| 154 | <Section title="Scheduled by"> |
| 155 | <p className="font-mono text-xs text-fg-soft">{m.created_by}</p> |
| 156 | <p className="mt-1 text-xs text-faint"> |
| 157 | <When at={m.created_at} time /> |
| 158 | </p> |
| 159 | </Section> |
| 160 | </aside> |
| 161 | </div> |
| 162 | </main> |
| 163 | ); |
| 164 | } |