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

112 lines5,523 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, zoneContext } from "~/lib/staff";
11import { zoneAbbr } from "~/lib/time";
12
13export const meta: Route.MetaFunction = () => [{ title: "Schedule maintenance · sudo" }, { name: "robots", content: "noindex, nofollow" }];
14
15export async function loader({ context }: Route.LoaderArgs) {
16 requireStaff(context);
17 const { zone } = context.get(zoneContext);
18 const components = await settle(statusAdmin.components());
19 // A sensible default: tomorrow, 02:00 to 03:00 UTC, shown in the staff member's zone.
20 const now = new Date();
21 const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 2));
22 return {
23 components: components.ok ? components.value : [],
24 error: components.ok ? null : components.error,
25 start: localValue(start, zone),
26 end: localValue(new Date(start.getTime() + 3600_000), zone),
27 zone: zoneAbbr(start, zone),
28 };
29}
30
31type ActionData = { error: string; values: Record<string, string>; chosen: string[] };
32
33export async function action({ request, context }: Route.ActionArgs) {
34 const staff = requireStaff(context);
35 const { zone } = context.get(zoneContext);
36 const form = await request.formData();
37 const values = Object.fromEntries(["title", "message", "starts_at", "ends_at"].map((k) => [k, text(form, k)]));
38 const chosen = form.getAll("components").map(String);
39 const result = await settle(
40 statusAdmin.scheduleMaintenance({
41 title: text(form, "title"),
42 message: String(form.get("message") ?? ""),
43 components: chosen,
44 starts_at: utc(text(form, "starts_at"), zone) ?? "",
45 ends_at: utc(text(form, "ends_at"), zone) ?? "",
46 notify: form.get("notify") === "on",
47 by: staff.email,
48 }),
49 );
50 if (!result.ok) return { error: result.error, values, chosen } satisfies ActionData;
51 if (!result.value.ok) return { error: result.value.error.message, values, chosen } satisfies ActionData;
52 return redirect(`/incidents/maintenance/${result.value.value.id}?done=scheduled`);
53}
54
55export default function ScheduleMaintenance({ loaderData, actionData }: Route.ComponentProps) {
56 const { components, error, start, end, zone } = loaderData;
57 const failed = actionData as ActionData | undefined;
58 const v = failed?.values ?? {};
59 return (
60 <main className="mx-auto max-w-3xl px-4 py-8 sm:py-10">
61 <BackLink to="/incidents?tab=maintenance">Incidents</BackLink>
62 <div className="mt-3">
63 <PageHeader
64 title="Schedule maintenance"
65 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."
66 />
67 </div>
68 <div className="mt-5 space-y-3">
69 {error && <Notice tone="warn">The status worker did not answer: {error}</Notice>}
70 {failed && <Notice tone="error">{failed.error}</Notice>}
71 </div>
72 <form method="post" className="mt-6 space-y-6">
73 <Section title="What and when">
74 <div className="grid gap-4 sm:grid-cols-2">
75 <Field label="Title" className="sm:col-span-2">
76 <Input name="title" required maxLength={120} autoFocus defaultValue={v.title} placeholder="Database upgrade" />
77 </Field>
78 <Field label={`Starts (${zone})`}>
79 <Input type="datetime-local" name="starts_at" required defaultValue={v.starts_at || start} />
80 </Field>
81 <Field label={`Ends (${zone})`} hint="Up to 72 hours after it starts.">
82 <Input type="datetime-local" name="ends_at" required defaultValue={v.ends_at || end} />
83 </Field>
84 </div>
85 </Section>
86 <Section title="Parts affected">
87 <div className="flex flex-wrap gap-2">
88 {components.map((c) => (
89 <label
90 key={c.key}
91 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"
92 >
93 <input type="checkbox" name="components" value={c.key} defaultChecked={failed?.chosen.includes(c.key)} className="accent-[#b6a8ff]" />
94 {c.name}
95 </label>
96 ))}
97 </div>
98 </Section>
99 <Section title="Message" description="What will happen and what people will notice. Shown publicly.">
100 <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." />
101 <label className="mt-3 flex items-center gap-2 text-sm">
102 <input type="checkbox" name="notify" defaultChecked className="accent-[#b6a8ff]" />
103 Email subscribers now, when it starts, and when it ends
104 </label>
105 </Section>
106 <Button type="submit" variant="lavender">
107 Schedule
108 </Button>
109 </form>
110 </main>
111 );
112}