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

108 lines5,272 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1import { 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}