1import { A } from "@solidjs/router";
2import { type InferResponseType, parseResponse } from "hono/client";
3import { createMemo, createResource, For, type JSX, onCleanup, Show } from "solid-js";
4import { api, queries } from "../api.ts";
5import { Ago } from "../components/Ago.tsx";
6import { showConfirmDialog } from "../components/Dialog.tsx";
7import { ListPage } from "../components/ListPage.tsx";
8import { lastGood, Loaded } from "../components/Loaded.tsx";
9import { Status } from "../components/Status.tsx";
10import "./Deploys.css";
11
12type Overview = InferResponseType<typeof api.deploys.$get>;
13export type StageInfo = Overview["stages"][number];
14export type Deployed = Overview["history"][number];
15
16export function followRun(id: string, events: {
17 open?: () => void;
18 line?: (text: string) => void;
19 exit: (code: number) => void;
20 lost: () => void;
21}) {
22 const source = new EventSource(`/api/deploys/runs/${id}`);
23 source.onopen = () => events.open?.();
24 source.onmessage = (event) => events.line?.(event.data);
25 source.onerror = () => source.readyState === EventSource.CLOSED && events.lost();
26 source.addEventListener("exit", (event) => {
27 source.close();
28 events.exit(Number(event.data));
29 });
30 return source;
31}
32
33export function confirmDestroyStage(stage: StageInfo,
34 started: (run: InferResponseType<typeof api.deploys.stages[":id"]["destroy"]["$post"]>) => unknown,
35 returnFocus?: HTMLElement,
36) {
37 const { id, hostname, clone, mount } = stage;
38 return showConfirmDialog({
39 title: `Destroy ${id}?`,
40 description: () => <>
41 The preview{hostname ? <> at <span class="mono">{hostname}</span></> : ""} goes offline
42 {clone ? <> and <span class="mono">{mount}</span> is deleted with everything in it</> : ""}.
43 Production isn't touched.
44 </>,
45 confirmLabel: "destroy",
46 destructive: true,
47 returnFocus,
48 onConfirm: async () => started(await parseResponse(api.deploys.stages[":id"].destroy.$post({ param: { id } }))),
49 });
50}
51
52export const EVENT: Record<string, string> = { main: "main", bootstrap: "first release", rollback: "rolled back", previous: "adopted" };
53
54/** Deploy state, polled, and service names for the ids in it; both pages read the same shape. */
55export function useDeploys() {
56 const [state, { refetch }] = queries.deploys.use();
57 // Only for names, icons and links; ids stand in when it fails.
58 const services = lastGood(queries.services.use()[0]);
59 const timer = setInterval(refetch, 5000);
60 onCleanup(() => clearInterval(timer));
61 const service = (id: string) => services()?.find((item) => item.id === id);
62 return { state, refetch, service, name: (id: string) => service(id)?.name ?? id };
63}
64
65export function Changed(props: { ids: string[] | null; name: (id: string) => string; warn?: string[] }) {
66 return (
67 <Show when={props.ids} fallback={<span class="muted" data-tip="not known for this release">–</span>}>
68 {(ids) => (
69 <div class="chips">
70 <For each={ids().toSorted((a, b) => props.name(a).localeCompare(props.name(b)))} fallback={<span class="muted">nothing</span>}>
71 {(id) => (
72 <A class="chip" classList={{ warn: props.warn?.includes(id) }} href={`/services/${id}`}
73 data-tip={props.warn?.includes(id) ? "changed in production after staging" : undefined}>
74 {props.name(id)}
75 </A>
76 )}
77 </For>
78 </div>
79 )}
80 </Show>
81 );
82}
83
84/** What a lane draws in one row: a node that starts it, a line through, or the curve into production. */
85type Mark = "start" | "pass" | "merge";
86
87interface Row {
88 key: string;
89 href?: string;
90 /** Production's line through this row. */
91 trunk?: "start" | "pass" | "end" | "only";
92 node: JSX.Element;
93 marks: [lane: number, mark: Mark][];
94 title: JSX.Element;
95 flags?: JSX.Element;
96 changes?: JSX.Element;
97 time?: number;
98 class?: string;
99}
100
101export function Deploys() {
102 const { state, refetch, name } = useDeploys();
103 return (
104 <ListPage id="deploys" class="deploys" flush head={<div class="page-head"><h1>deploys</h1><span class="spacer" /><A class="button" href="/deploys/main">deploy main</A></div>}>
105 <Loaded data={state} what="deploys" retry={refetch} skeleton={
106 <div class="graph edge" style={{ "--lanes": 1 }}>
107 <For each={Array(8)}>{() => <div class="row"><span /><span class="skeleton" /><span class="skeleton" /></div>}</For>
108 </div>
109 }>
110 {(data) => {
111 // Polls mostly return what's already shown; rebuilding rows then would drop hover and focus.
112 const same = createMemo(data, undefined, { equals: (a, b) => JSON.stringify(a) === JSON.stringify(b) });
113 const rows = createMemo(() => {
114 const { stages, history, run, current, recorded } = same();
115 const stageRows = [...stages].sort((a, b) => b.created - a.created);
116 // Branches that rejoin production higher up sit nearer its line, so no curve crosses another branch.
117 const lanes = new Map([...stages].sort((a, b) => (b.base ?? 0) - (a.base ?? 0)).map((stage, i) => [stage.id, i + 1]));
118 const top: Row[] = [];
119 if (run?.code === null) {
120 top.push({
121 key: run.id, href: `/deploys/${run.title === "deploy main" ? "main" : run.target}`, node: <Status health="deploying" label="running" />, marks: [],
122 title: <b>{run.title}…</b>, class: "pending",
123 });
124 } else if (current && !recorded) {
125 const staged = stages.find((stage) => stage.release === current);
126 top.push({
127 key: current, href: staged ? `/deploys/${staged.id}` : run?.code ? `/deploys/${run.target}` : undefined, marks: [],
128 node: <Status health="degraded" label="unfinished" />,
129 title: <><b>{staged ? name(staged.service) : "deploy"}</b><span class="mono muted">{current.slice(0, 8)}</span></>,
130 flags: <span class="chip failed">didn't finish</span>,
131 });
132 }
133 const trunk: Row[] = [...top, ...history.map((entry, i): Row => ({
134 key: String(entry.n),
135 href: `/deploys/${entry.n}`,
136 node: <span class="dot" classList={{ head: i === 0 && recorded, rollback: entry.source === "rollback" }} />,
137 marks: [],
138 title: (
139 <>
140 <b>{EVENT[entry.source] ?? name(entry.source.replace(/-preview-[0-9a-f]+$/, ""))}</b>
141 <span class="mono muted">{entry.release.slice(0, 8)}</span>
142 </>
143 ),
144 flags: <>
145 <Show when={i === 0 && recorded}><span class="chip production">production</span></Show>
146 <Show when={entry.legacy}><span class="chip" data-tip="built with the old release format">legacy</span></Show>
147 </>,
148 changes: <Changed ids={entry.changed} name={name} />,
149 time: entry.time,
150 }))];
151 trunk.forEach((row, i) => (row.trunk = trunk.length === 1 ? "only" : i === 0 ? "start" : i === trunk.length - 1 ? "end" : "pass"));
152 const all: Row[] = [...stageRows.map((stage): Row => ({
153 key: stage.id,
154 href: `/deploys/${stage.id}`,
155 node: stage.health
156 ? <Status health={stage.health} label={stage.health === "healthy" ? "running" : stage.health} />
157 : <span class="dot" data-tip="health unknown without Nomad" />,
158 marks: [],
159 title: <><b>{name(stage.service)}</b><span class="mono muted">{stage.id.slice(stage.service.length + 1)}</span></>,
160 flags: <>
161 <Show when={!stage.release} fallback={<Show when={!stage.ready}><span class="chip warn">not ready</span></Show>}>
162 <span class="chip failed">staging failed</span>
163 </Show>
164 <Show when={stage.overrides.length}>
165 <span class="chip warn" tabindex="0" data-tip={`staged with --env ${stage.overrides.join(", ")}`}>unsaved settings</span>
166 </Show>
167 <Show when={stage.release === current && recorded}><span class="chip production">in production</span></Show>
168 </>,
169 changes: <Changed ids={stage.changed} name={name} warn={stage.behind} />,
170 time: stage.created,
171 })), ...trunk];
172 for (const stage of stages) {
173 const lane = lanes.get(stage.id)!;
174 const from = all.findIndex((row) => row.key === stage.id);
175 const to = stage.base ? all.findIndex((row) => row.key === String(stage.base)) : all.length;
176 all[from]!.marks.push([lane, "start"]);
177 for (let i = from + 1; i < Math.min(to, all.length); i++) all[i]!.marks.push([lane, "pass"]);
178 if (to < all.length) all[to]!.marks.push([lane, "merge"]);
179 }
180 return { all, lanes: stages.length, empty: !stages.length };
181 });
182 return (
183 <div class="graph edge" role="list" style={{ "--lanes": rows().lanes }}>
184 <Show when={rows().empty}>
185 <p class="hint">No stages. Run <span class="mono">deploy.py stage &lt;service&gt;</span> to preview a change here.</p>
186 </Show>
187 <For each={rows().all}>
188 {(row) => (
189 <div class={`row ${row.class ?? ""}`} classList={{ link: !!row.href }} role="listitem">
190 <div class="rail" aria-hidden="true">
191 <Show when={row.trunk && row.trunk !== "only"}><i class={`trunk ${row.trunk}`} /></Show>
192 <For each={row.marks}>
193 {([lane, mark]) => (
194 <>
195 <i class={`lane ${mark}`} style={{ "--x": lane }} />
196 <Show when={mark === "merge"}><i class="curve" style={{ "--x": lane }} /></Show>
197 </>
198 )}
199 </For>
200 <span class="node" style={{ "--x": row.trunk ? 0 : row.marks.find(([, mark]) => mark === "start")?.[0] }}>
201 {row.node}
202 </span>
203 </div>
204 <div class="what">
205 <Show when={row.href} fallback={<span class="title">{row.title}</span>}>
206 {(href) => <A class="title" href={href()}>{row.title}</A>}
207 </Show>
208 {row.flags}
209 </div>
210 <div class="changes">{row.changes}</div>
211 <div class="when"><Show when={row.time}>{(t) => <Ago t={t()} />}</Show></div>
212 </div>
213 )}
214 </For>
215 </div>
216 );
217 }}
218 </Loaded>
219 </ListPage>
220 );
221}