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

164 lines7,367 bytesCodeBlame
1import { ExternalLink } from "lucide-react";
2import { redirect } from "react-router";
3
4import type { MaintenanceChange } from "@g1t/contracts";
5
6import type { Route } from "./+types/maintenance";
7import { BackLink, MaintenanceBadge } from "~/components/incidents";
8import { Button, EmptyState, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui";
9import { text } from "~/lib/forms";
10import { duration, secondsBetween } from "~/lib/incidents";
11import { statusAdmin } from "~/lib/services.server";
12import { settle } from "~/lib/settle";
13import { requireStaff } from "~/lib/staff";
14
15export const meta: Route.MetaFunction = ({ loaderData }) => [
16 { title: `${loaderData?.maintenance?.title ?? "Maintenance"} · sudo` },
17 { name: "robots", content: "noindex, nofollow" },
18];
19
20const 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
28export 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
41export 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
53export 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}