g1t/apps/sudo/app/routes/maintenance-new.tsx

108 lines5,272 bytesCodeBlame
1import { redirect } from "react-router";
2
3import type { Route } from "./+types/maintenance-new";
4import { BackLink } from "~/components/incidents";
5import { Button, Field, Input, Notice, PageHeader, Section, Textarea } from "~/components/ui";
6import { text } from "~/lib/forms";
7import { localValue, utc } from "~/lib/incidents";
8import { statusAdmin } from "~/lib/services.server";
9import { settle } from "~/lib/settle";
10import { requireStaff } from "~/lib/staff";
11
12export const meta: Route.MetaFunction = () => [{ title: "Schedule maintenance · sudo" }, { name: "robots", content: "noindex, nofollow" }];
13
14export async function loader({ context }: Route.LoaderArgs) {
15 requireStaff(context);
16 const components = await settle(statusAdmin.components());
17 // A sensible default: tomorrow, 02:00 to 03:00 UTC.
18 const now = new Date();
19 const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 2));
20 return {
21 components: components.ok ? components.value : [],
22 error: components.ok ? null : components.error,
23 start: localValue(start),
24 end: localValue(new Date(start.getTime() + 3600_000)),
25 };
26}
27
28type ActionData = { error: string; values: Record<string, string>; chosen: string[] };
29
30export async function action({ request, context }: Route.ActionArgs) {
31 const staff = requireStaff(context);
32 const form = await request.formData();
33 const values = Object.fromEntries(["title", "message", "starts_at", "ends_at"].map((k) => [k, text(form, k)]));
34 const chosen = form.getAll("components").map(String);
35 const result = await settle(
36 statusAdmin.scheduleMaintenance({
37 title: text(form, "title"),
38 message: String(form.get("message") ?? ""),
39 components: chosen,
40 starts_at: utc(text(form, "starts_at")) ?? "",
41 ends_at: utc(text(form, "ends_at")) ?? "",
42 notify: form.get("notify") === "on",
43 by: staff.email,
44 }),
45 );
46 if (!result.ok) return { error: result.error, values, chosen } satisfies ActionData;
47 if (!result.value.ok) return { error: result.value.error.message, values, chosen } satisfies ActionData;
48 return redirect(`/incidents/maintenance/${result.value.value.id}?done=scheduled`);
49}
50
51export default function ScheduleMaintenance({ loaderData, actionData }: Route.ComponentProps) {
52 const { components, error, start, end } = loaderData;
53 const failed = actionData as ActionData | undefined;
54 const v = failed?.values ?? {};
55 return (
56 <main className="mx-auto max-w-3xl px-4 py-8 sm:py-10">
57 <BackLink to="/incidents?tab=maintenance">Incidents</BackLink>
58 <div className="mt-3">
59 <PageHeader
60 title="Schedule maintenance"
61 description="Shown on status.g1t.sh as upcoming maintenance at once; during the window its parts show “Under maintenance” and their checks do not count against uptime. It completes on its own when the window ends."
62 />
63 </div>
64 <div className="mt-5 space-y-3">
65 {error && <Notice tone="warn">The status worker did not answer: {error}</Notice>}
66 {failed && <Notice tone="error">{failed.error}</Notice>}
67 </div>
68 <form method="post" className="mt-6 space-y-6">
69 <Section title="What and when">
70 <div className="grid gap-4 sm:grid-cols-2">
71 <Field label="Title" className="sm:col-span-2">
72 <Input name="title" required maxLength={120} autoFocus defaultValue={v.title} placeholder="Database upgrade" />
73 </Field>
74 <Field label="Starts (UTC)">
75 <Input type="datetime-local" name="starts_at" required defaultValue={v.starts_at || start} />
76 </Field>
77 <Field label="Ends (UTC)" hint="Up to 72 hours after it starts.">
78 <Input type="datetime-local" name="ends_at" required defaultValue={v.ends_at || end} />
79 </Field>
80 </div>
81 </Section>
82 <Section title="Parts affected">
83 <div className="flex flex-wrap gap-2">
84 {components.map((c) => (
85 <label
86 key={c.key}
87 className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-2.5 py-1.5 text-sm has-checked:border-merged/60 has-checked:bg-merged/10"
88 >
89 <input type="checkbox" name="components" value={c.key} defaultChecked={failed?.chosen.includes(c.key)} className="accent-[#b6a8ff]" />
90 {c.name}
91 </label>
92 ))}
93 </div>
94 </Section>
95 <Section title="Message" description="What will happen and what people will notice. Shown publicly.">
96 <Textarea name="message" rows={4} required maxLength={4000} aria-label="Message" defaultValue={v.message} placeholder="We are upgrading the database behind git. Pushes may pause for up to five minutes; clones and the website keep working." />
97 <label className="mt-3 flex items-center gap-2 text-sm">
98 <input type="checkbox" name="notify" defaultChecked className="accent-[#b6a8ff]" />
99 Email subscribers now, when it starts, and when it ends
100 </label>
101 </Section>
102 <Button type="submit" variant="lavender">
103 Schedule
104 </Button>
105 </form>
106 </main>
107 );
108}