1import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router";
2import { type InferResponseType, parseResponse } from "hono/client";
3import ExternalLink from "lucide-solid/icons/external-link";
4import {
5 createEffect, createMemo, createResource, createSignal, For, type JSX, Match, on, onCleanup, type Resource, Show, Switch,
6 untrack,
7} from "solid-js";
8import type { Health, LogLine } from "../types/model.ts";
9import { api, query } from "../api.ts";
10import { Ago } from "../components/Ago.tsx";
11import { AppIcon } from "../components/AppIcon.tsx";
12import { Copy } from "../components/Copy.tsx";
13import { showConfirmDialog } from "../components/Dialog.tsx";
14import { ListPage } from "../components/ListPage.tsx";
15import { LogView } from "../components/LogView.tsx";
16import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
17import { OpenApp } from "../components/OpenApp.tsx";
18import { StatusLabel } from "../components/Status.tsx";
19import { TabBar } from "../components/TabBar.tsx";
20import { TimeChart } from "../components/TimeChart.tsx";
21import { toast } from "../components/Toast.tsx";
22import { ago, bytes, clock, cores, count, datetime, duration, plural } from "../format.ts";
23import { confirmDestroyStage, type Deployed, EVENT, followRun, type StageInfo, useDeploys } from "./Deploys.tsx";
24import "./Deploy.css";
25
26interface Run {
27 id: string;
28 title: string;
29 lines: string[];
30 /** "lost" when the server forgot the run before it ended, e.g. after a restart. */
31 code: number | null | "lost";
32 /** What to do once it succeeds; unset for runs this page didn't start. */
33 done?: () => void;
34}
35
36/** Failed runs dismissed this session, so they stay gone across pages. */
37const [dismissed, setDismissed] = createSignal<string[]>([]);
38
39function RunCard(props: { run: Run }) {
40 let log!: HTMLDivElement;
41 createEffect(on(() => props.run.lines.length, () => (log.scrollTop = log.scrollHeight)));
42 const code = () => props.run.code;
43 return (
44 <div class="run">
45 <div class="run-head">
46 <span class="mono">{props.run.title}</span>
47 <span role="status">
48 <StatusLabel health={code() === null ? "deploying" : code() === "lost" ? "degraded" : "down"}>
49 {code() === null ? "running…" : code() === "lost" ? "output lost" : `failed with exit code ${code()}`}
50 </StatusLabel>
51 </span>
52 <span class="spacer" />
53 <Show when={code() !== null}>
54 <button class="button small" onClick={() => setDismissed([...dismissed(), props.run.id])}>dismiss</button>
55 </Show>
56 </div>
57 <div class="output" role="log" ref={log}>
58 <For each={props.run.lines}>{(line) => <div classList={{ command: line.startsWith("$ ") }}>{line}</div>}</For>
59 </div>
60 </div>
61 );
62}
63
64type Line = [op: " " | "-" | "+", text: string];
65
66/** One service file's diff, with long unchanged runs folded behind a button. */
67function FileDiff(props: { id: string; lines: Line[]; name: string; icon: JSX.Element; warn?: boolean }) {
68 const [open, setOpen] = createSignal<number[]>([]);
69 const CONTEXT = 3;
70 const blocks = createMemo(() => {
71 const out: { start: number; lines: [Line, number | null, number | null][]; fold: boolean }[] = [];
72 let a = 1;
73 let b = 1;
74 const numbered = props.lines.map((line): [Line, number | null, number | null] =>
75 line[0] === "+" ? [line, null, b++] : line[0] === "-" ? [line, a++, null] : [line, a++, b++]);
76 for (let i = 0; i < numbered.length;) {
77 let j = i;
78 while (j < numbered.length && numbered[j]![0][0] === " ") j++;
79 const head = i === 0 ? 0 : CONTEXT;
80 const tail = j === numbered.length ? 0 : CONTEXT;
81 if (j - i - head - tail >= 4) {
82 if (head) out.push({ start: i, lines: numbered.slice(i, i + head), fold: false });
83 out.push({ start: i + head, lines: numbered.slice(i + head, j - tail), fold: true });
84 if (tail) out.push({ start: j - tail, lines: numbered.slice(j - tail, j), fold: false });
85 } else if (j > i) out.push({ start: i, lines: numbered.slice(i, j), fold: false });
86 let k = j;
87 while (k < numbered.length && numbered[k]![0][0] !== " ") k++;
88 if (k > j) out.push({ start: j, lines: numbered.slice(j, k), fold: false });
89 i = k;
90 }
91 return out;
92 });
93 const added = () => props.lines.filter(([op]) => op === "+").length;
94 const removed = () => props.lines.filter(([op]) => op === "-").length;
95 return (
96 <section class="file edge">
97 <header>
98 <span class="icon">{props.icon}</span>
99 <A href={`/services/${props.id}`}>{props.name}</A>
100 <span class="mono muted">service/{props.id}/service.pkl</span>
101 <Show when={props.warn}>
102 <span class="chip warn" tabindex="0" data-tip="production changed this file after staging">may undo production</span>
103 </Show>
104 <span class="spacer" />
105 <span class="counts mono"><Show when={added()}><ins>+{added()}</ins></Show> <Show when={removed()}><del>−{removed()}</del></Show></span>
106 </header>
107 <div class="diff mono">
108 <For each={blocks()}>
109 {(block) => (
110 <Show when={block.fold && !open().includes(block.start)} fallback={
111 <For each={block.lines}>
112 {([[op, text], a, b]) => (
113 <div class={`line ${op === "+" ? "add" : op === "-" ? "del" : ""}`}>
114 <span class="n">{a}</span><span class="n">{b}</span><span class="op">{op}</span><span class="text">{text}</span>
115 </div>
116 )}
117 </For>
118 }>
119 <button class="fold" onClick={() => setOpen([...open(), block.start])}>
120 {count(block.lines.length)} unchanged lines
121 </button>
122 </Show>
123 )}
124 </For>
125 </div>
126 </section>
127 );
128}
129
130function Changes(props: { from: string | null; to: string | null; warn?: string[]; name: (id: string) => string;
131 icon: (id: string) => JSX.Element; empty: string; same: string; }) {
132 // A string key, since each poll hands over fresh objects that would otherwise count as a change and refetch.
133 const [changes, { refetch }] = createResource(() => props.to && `${props.from ?? ""}..${props.to}`, (key) => {
134 const [from, to] = key.split("..") as [string, string];
135 return parseResponse(api.deploys.changes.$get({ query: { to, ...(from ? { from } : {}) } }));
136 });
137 return (
138 <Show when={props.to} fallback={<p class="empty">{props.empty}</p>}>
139 <Loaded data={changes} what="changes" retry={refetch} skeleton={<div class="skeleton" style={{ height: "240px" }} />}>
140 {(files) => (
141 <For each={files()} fallback={<p class="empty">{props.same}</p>}>
142 {(file) => (
143 <FileDiff id={file.id} lines={file.lines} name={props.name(file.id)} icon={props.icon(file.id)}
144 warn={props.warn?.includes(file.id)} />
145 )}
146 </For>
147 )}
148 </Loaded>
149 </Show>
150 );
151}
152
153/** Recorded output of staging or of a deploy, kept by the host. */
154function Output(props: { fetch: () => Promise<{ lines: string[] | null }>; empty: string }) {
155 const [output, { refetch }] = createResource(props.fetch);
156 return (
157 <Loaded data={output} what="the output" retry={refetch} skeleton={<div class="fill"><SkeletonRows count={12} /></div>}>
158 {(result) => (
159 <Show when={result().lines} fallback={<p class="empty">{props.empty}</p>}>
160 {(lines) => (
161 <div class="output fill" role="log">
162 <For each={lines()}>{(line) => <div classList={{ command: line.startsWith("$ ") }}>{line}</div>}</For>
163 </div>
164 )}
165 </Show>
166 )}
167 </Loaded>
168 );
169}
170
171type Runtime = InferResponseType<(typeof api.deploys.stages)[":id"]["runtime"]["$get"], 200>;
172type Allocation = Runtime["jobs"][number]["allocations"][number];
173type LogQuery = { after?: string; before?: string; limit?: string };
174
175/** Allocations and usage of a stage or deploy, polled while its jobs still run. */
176function useRuntime(name: string, key: () => string, fetch: (key: string) => Promise<Runtime>) {
177 const [runtime, { refetch }] = query(name, fetch).use(key);
178 const latest = lastGood(runtime);
179 const timer = setInterval(() => latest()?.to === null && refetch(), 10000);
180 onCleanup(() => clearInterval(timer));
181 return { runtime, latest, refetch };
182}
183
184/** Every task restart across the jobs, newest first. */
185const restarts = (runtime: Runtime | undefined) => (runtime?.jobs ?? [])
186 .flatMap((job) => job.allocations.flatMap((allocation) => allocation.tasks))
187 .flatMap((task) => (task.lastRestart ? [{ ...task.lastRestart, count: task.restarts }] : []))
188 .sort((a, b) => b.t - a.t);
189
190function RestartStat(props: { runtime: Runtime | undefined; href: string }) {
191 return (
192 <Show when={restarts(props.runtime)[0]}>
193 {(last) => (
194 <A class="stat warn" href={props.href} data-tip={`${last().reason}, ${ago(last().t)}`}>
195 {plural(restarts(props.runtime).reduce((sum, restart) => sum + restart.count, 0), "restart")}
196 </A>
197 )}
198 </Show>
199 );
200}
201
202/** Cpu over memory on one time axis, stacked by job. */
203function Usage(props: { runtime: Runtime; name: (job: string) => string }) {
204 const STRIPS = [["cpu", cores], ["memory", bytes]] as const;
205 /** Usage is null until a metrics store records it. */
206 const jobs = () => props.runtime.jobs.flatMap(({ id, cpu, memory }) => (cpu && memory ? [{ id, cpu, memory }] : []));
207 const empty = () => jobs().length < props.runtime.jobs.length ? "No usage history on this host yet: it needs a metrics store."
208 : jobs().every((job) => job.memory.v.every((v) => v === null))
209 ? props.runtime.jobs.length ? "No usage. The job never started." : "No service changed here."
210 : null;
211 return (
212 <Show when={!empty()} fallback={<p class="empty usage-empty">{empty()}</p>}>
213 <div class="usage">
214 <For each={STRIPS}>
215 {([key, format], i) => {
216 const series = () => jobs().map((job) => ({ ...job[key], name: jobs().length === 1 ? key : props.name(job.id) }));
217 const totals = () => series()[0]!.t.map((_, index) =>
218 series().reduce<number | null>((sum, item) => (item.v[index] == null ? sum : (sum ?? 0) + item.v[index]!), null));
219 const peak = () => format(Math.max(...totals().map((v) => v ?? 0)));
220 return (
221 <section class="strip" aria-label={key}>
222 <div class="strip-head">
223 <h3>{key}</h3>
224 <span class="rule" />
225 <Show when={props.runtime.to === null} fallback={<span class="now peak">peak <b>{peak()}</b></span>}>
226 <span class="now" tabIndex={0} data-tip={`peak ${peak()}`}>
227 <b>{format(totals().findLast((v) => v !== null) ?? 0)}</b>
228 </span>
229 </Show>
230 </div>
231 <TimeChart series={series()} format={format} height={i() ? 80 : 56} inline sync="deploy" timeAxis={i() === 1}
232 stacked={series().length > 1} />
233 </section>
234 );
235 }}
236 </For>
237 </div>
238 </Show>
239 );
240}
241
242const ALLOCATION: Record<Allocation["state"], [Health, string]> = {
243 pending: ["starting", "pending"], running: ["healthy", "running"], complete: ["stopped", "replaced"],
244 failed: ["down", "failed"], lost: ["down", "lost"], unknown: ["degraded", "unknown"],
245};
246
247/** The jobs' allocations, newest first, with a service column when there's more than one job. */
248function Allocations(props: { runtime: Runtime; name: (job: string) => string }) {
249 const rows = () => props.runtime.jobs.flatMap((job) => job.allocations.map((allocation) => ({ job: job.id, ...allocation })))
250 .sort((a, b) => b.created - a.created);
251 const many = () => props.runtime.jobs.length > 1;
252 return (
253 <Show when={rows().length} fallback={
254 <p class="empty">No allocations. Nomad forgets old ones after a while, so older deploys may have none left.</p>
255 }>
256 <table class="data allocations">
257 <thead>
258 <tr>
259 <Show when={many()}><th>service</th></Show>
260 <th>allocation</th><th>state</th><th>checks</th><th class="num">restarts</th><th>started</th>
261 </tr>
262 </thead>
263 <tbody>
264 <For each={rows()}>
265 {(row) => {
266 const [health, label] = row.state === "running" && row.healthy !== true
267 ? row.healthy === null ? ["starting", "starting"] as const : ["degraded", "unhealthy"] as const
268 : row.state === "complete" && row.healthy === false ? ["degraded", "replaced"] as const
269 : ALLOCATION[row.state];
270 const last = row.tasks.map((task) => task.lastRestart).filter((restart) => restart !== null).sort((a, b) => b.t - a.t)[0];
271 return (
272 <tr>
273 <Show when={many()}><td><A href={`/services/${row.job}`}>{props.name(row.job)}</A></td></Show>
274 <td class="mono"><Copy value={row.id} label="allocation ID">{row.id.slice(0, 8)}</Copy></td>
275 <td tabIndex={row.ended ? 0 : undefined}
276 data-tip={row.ended ? `ran ${duration(row.ended - row.created)}, until ${datetime(row.ended)}` : undefined}>
277 <StatusLabel health={health}>{label}</StatusLabel>
278 </td>
279 <td class="chips">
280 <For each={row.checks} fallback={<span class="muted">–</span>}>
281 {(check) => (
282 <span class="chip" tabIndex={0} data-tip={check.output}>
283 <StatusLabel health={check.passing ? "healthy" : "degraded"}>{check.name}</StatusLabel>
284 </span>
285 )}
286 </For>
287 </td>
288 <td class="num" tabIndex={last ? 0 : undefined} data-tip={last && `${last.reason}, ${ago(last.t)}`}>
289 {count(row.tasks.reduce((sum, task) => sum + task.restarts, 0))}
290 </td>
291 <td class="nowrap"><Ago t={row.created} /></td>
292 </tr>
293 );
294 }}
295 </For>
296 </tbody>
297 </table>
298 </Show>
299 );
300}
301
302/**
303 * A job's lines in the page's window, newest at the bottom; older ones load near the top, and newer ones arrive
304 * while the job still runs. The last restart of each task is marked.
305 */
306function JobLogs(props: {
307 fetch: (query: LogQuery) => Promise<LogLine[]>;
308 runtime: Runtime | undefined;
309 empty: JSX.Element;
310}) {
311 const PAGE = 500;
312 const [lines, setLines] = createSignal<LogLine[]>([]);
313 const [start, setStart] = createSignal(false);
314 const [follow, setFollow] = createSignal(true);
315 const [first, { refetch }] = createResource(() => props.fetch({ limit: String(PAGE) }).then((got) => {
316 setLines(got.reverse());
317 setStart(got.length < PAGE);
318 setFollow(true);
319 return true;
320 }));
321 let loading = false;
322 const older = async () => {
323 const oldest = lines()[0];
324 if (loading || start() || !oldest) return;
325 loading = true;
326 const got = await props.fetch({ before: String(oldest.t), limit: String(PAGE) }).catch(() => null);
327 loading = false;
328 if (!got || oldest !== lines()[0]) return;
329 setStart(got.length < PAGE);
330 setLines([...got.reverse(), ...lines()]);
331 };
332 const timer = setInterval(async () => {
333 const last = lines().at(-1);
334 if (first.state !== "ready" || props.runtime?.to !== null) return;
335 const got = await props.fetch(last ? { after: String(last.t) } : {}).catch(() => []);
336 if (!got.length || last !== lines().at(-1)) return;
337 setLines([...lines(), ...got.reverse()].slice(-5000));
338 }, 3000);
339 onCleanup(() => clearInterval(timer));
340 const marked = createMemo(() => new Map(restarts(props.runtime).flatMap((restart) => {
341 const line = lines().find((item) => item.t >= restart.t);
342 return line ? [[line, restart] as const] : [];
343 })));
344 return (
345 <div class="job-logs fill">
346 <Loaded data={first} what="the logs" retry={refetch} skeleton={<SkeletonRows count={12} />}>
347 {() => (
348 <Show when={lines().length} fallback={<p class="logs empty">{props.empty}</p>}>
349 <LogView lines={lines()} follow={follow()} onFollow={setFollow} onStart={older}
350 before={(line) => (
351 <Show when={marked().get(line)}>
352 {(restart) => <div class="log-edge restart">restarted at {clock(restart().t)}: {restart().reason}</div>}
353 </Show>
354 )} />
355 </Show>
356 )}
357 </Loaded>
358 </div>
359 );
360}
361
362export function Deploy() {
363 const params = useParams<{ id: string; tab?: string }>();
364 const navigate = useNavigate();
365 const { state, refetch, service, name } = useDeploys();
366 const deploys = lastGood(state);
367 const app = (id: string) => service(id) ?? { id, name: id, icon: null };
368 const icon = (id: string) => <AppIcon {...app(id)} />;
369
370 const [run, setRun] = createSignal<Run | null>(null);
371 let events: EventSource | undefined;
372 onCleanup(() => events?.close());
373 const tail = (id: string, title: string, done?: () => void) => {
374 events?.close();
375 setRun({ id, title, lines: [], code: null, done });
376 events = followRun(id, {
377 open: () => setRun((r) => r && { ...r, lines: [] }),
378 line: (text) => setRun((r) => r && { ...r, lines: [...r.lines, text] }),
379 lost: () => setRun((r) => r && { ...r, code: "lost" }),
380 exit: async (code) => {
381 await refetch();
382 const finished = untrack(run);
383 if (code !== 0) return setRun((r) => r && { ...r, code });
384 setRun(null);
385 finished?.done?.();
386 },
387 });
388 };
389 // Picks up this page's run when it was started elsewhere or before the page opened, unless it succeeded or was dismissed.
390 createEffect(() => {
391 const active = deploys()?.run;
392 if (!active || (params.id === "main" ? active.title !== "deploy main" : active.target !== params.id) || active.code === 0 || dismissed().includes(active.id)) return;
393 if (untrack(run)?.id !== active.id) tail(active.id, active.title);
394 });
395 const shown = () => (run() && !dismissed().includes(run()!.id) ? run() : null);
396 const act = ({ id, title }: Pick<Run, "id" | "title">, done: () => void) => {
397 tail(id, title, done);
398 refetch();
399 };
400 const wait = () => (deploys()?.run?.code === null ? "Wait for the current run to finish" : "");
401
402 const redeploys = (ids: string[] | null, release: string) =>
403 !ids ? `Production switches to release ${release.slice(0, 8)}. Services that differ from it redeploy.`
404 : !ids.length ? `Production switches to release ${release.slice(0, 8)} without redeploying any service.`
405 : `${new Intl.ListFormat("en").format(ids.map(name))} ${ids.length === 1 ? "redeploys" : "redeploy"} `
406 + `on release ${release.slice(0, 8)}. Everything else keeps running.`;
407
408 /** The job's lines in the logs app, bounded to the window once its jobs stopped. */
409 const logsUrl = (job: string, runtime: Runtime | undefined) => {
410 const url = service("victoria")?.url;
411 const iso = (t: number) => new Date(t * 1000).toISOString();
412 const window = runtime?.to ? ` _time:[${iso(runtime.from)}, ${iso(runtime.to)}]` : "";
413 return url && `${url}/select/vmui/#/?query=${encodeURIComponent(`{job="${job}"}${window}`)}`;
414 };
415 const OpenLogs = (props: { job: string; runtime: Runtime | undefined }) => (
416 <Show when={logsUrl(props.job, props.runtime)}>
417 {(href) => <a class="button small" href={href()} target="_blank" rel="noreferrer">open in logs<ExternalLink size={12} /></a>}
418 </Show>
419 );
420 const usage = (runtime: Resource<Runtime>, retry: () => void) => (
421 <Loaded data={runtime} what="usage" retry={retry} skeleton={<div class="skeleton" style={{ height: "184px" }} />}>
422 {(loaded) => <Usage runtime={loaded()} name={name} />}
423 </Loaded>
424 );
425 const status = (runtime: Resource<Runtime>, retry: () => void) => (
426 <Loaded data={runtime} what="allocations" retry={retry} skeleton={<div class="skeleton" style={{ height: "64px" }} />}>
427 {(loaded) => <Allocations runtime={loaded()} name={name} />}
428 </Loaded>
429 );
430
431 const Tabs = (props: { tabs: [string, string][] }) => (
432 <TabBar label="View">
433 <For each={props.tabs}>{([tab, label]) => <A href={`/deploys/${params.id}${tab ? "/" + tab : ""}`} end>{label}</A>}</For>
434 </TabBar>
435 );
436
437 const MainPage = () => {
438 const candidate = () => deploys()?.main;
439 const blocked = () => !candidate() ? "Upload main with python3 tools/deploy.py publish"
440 : candidate()!.release === deploys()?.current && deploys()?.recorded ? "Already deployed"
441 : wait();
442 const deploy = () => {
443 const main = candidate()!;
444 return showConfirmDialog({
445 title: "Deploy main?",
446 description: `Commit ${main.commit.slice(0, 12)} deploys the full repository configuration to production. Preview data stays in staging.`,
447 confirmLabel: "deploy main",
448 onConfirm: async () => act(await parseResponse(api.deploys.main[":release"].deploy.$post({ param: { release: main.release } })), () => toast("Deployed main")),
449 });
450 };
451 return (
452 <ListPage id="deploy" class="deploy-page" flush head={<>
453 <div class="page-head">
454 <A class="crumb" href="/deploys">deploys</A><h1>main</h1>
455 <Show when={candidate()}>{(main) => <span class="id mono"><Copy value={main().commit} label="commit ID">{main().commit.slice(0, 12)}</Copy></span>}</Show>
456 <span class="spacer" />
457 <button class="button" disabled={!!blocked()} data-tip={blocked() || undefined} onClick={deploy}>deploy main</button>
458 </div>
459 <Show when={shown()}>{(r) => <RunCard run={r()} />}</Show>
460 </>}>
461 <p class="hint">Upload main: <Copy value="python3 tools/deploy.py publish" label="upload command"><span class="mono">python3 tools/deploy.py publish</span></Copy></p>
462 <Show when={candidate()} fallback={<p class="empty">No main commit uploaded yet.</p>}>
463 {(main) => <>
464 <pre class="commit-message">{main().description}</pre>
465 <Changes from={deploys()?.current ?? null} to={main().release} name={name} icon={icon}
466 empty="This release is no longer on the host. Upload main again." same="Same service files as production." />
467 </>}
468 </Show>
469 </ListPage>
470 );
471 };
472
473 const StagePage = (props: { stage: StageInfo }) => {
474 const stage = () => props.stage;
475 const data = () => deploys()!;
476 const hex = () => stage().id.slice(stage().service.length + 1);
477 /** The deploy this was staged on, while production has moved past it. */
478 const base = () => (stage().base === data().history[0]?.n ? undefined : data().history.find((entry) => entry.n === stage().base));
479 const behind = () => stage().behind.filter((id) => stage().changed?.includes(id));
480 const { runtime, latest, refetch: retry } = useRuntime("stage runtime", () => stage().id,
481 (id) => parseResponse(api.deploys.stages[":id"].runtime.$get({ param: { id } })));
482 const destroy = () => {
483 const { id } = stage();
484 return confirmDestroyStage(stage(), (run) => act(run, () => {
485 navigate("/deploys");
486 toast(`Destroyed ${id}`);
487 }));
488 };
489 return (
490 <ListPage id="deploy" class="deploy-page" flush head={
491 <>
492 <div class="page-head">
493 <A class="crumb" href="/deploys">deploys</A>
494 <h1>{name(stage().service)}</h1>
495 <span class="id mono"><Copy value={stage().id} label="stage ID">{hex()}</Copy></span>
496 <Show when={stage().hostname} fallback={<span class="muted" data-tip="the stage file doesn't record its hostname yet">preview –</span>}>
497 {(hostname) => <OpenApp app={app(stage().service)} href={`https://${hostname()}`}>preview</OpenApp>}
498 </Show>
499 <Show when={stage().files}>{(href) => <OpenApp app={app("copyparty")} href={href()}>data</OpenApp>}</Show>
500 <div class="stats">
501 <Show when={stage().health} fallback={<span class="stat muted" data-tip="Nomad isn't connected">health –</span>}>
502 {(health) => (
503 <span class="stat" role="status">
504 <StatusLabel health={health()}>{health() === "healthy" ? "running" : undefined}</StatusLabel>
505 </span>
506 )}
507 </Show>
508 <Show when={stage().release && !stage().ready}><span class="stat warn">not ready</span></Show>
509 <RestartStat runtime={latest()} href={`/deploys/${stage().id}/status`} />
510 <span class="stat">staged <b><Ago t={stage().created} /></b></span>
511 <Show when={base()}>
512 {(entry) => (
513 <A class="stat warn" href={`/deploys/${entry().n}`}
514 data-tip={`staged on ${entry().release.slice(0, 8)}, production is ${data().current?.slice(0, 8)}`}>
515 behind production
516 </A>
517 )}
518 </Show>
519 <Show when={stage().overrides.length}>
520 <span class="stat warn" tabindex="0" data-tip={`staged with --env ${stage().overrides.join(", ")}`}>unsaved settings</span>
521 </Show>
522 <Show when={stage().release === data().current}>
523 <Show when={data().recorded} fallback={<span class="stat failed">deploy didn't finish</span>}>
524 <span class="stat production">in production</span>
525 </Show>
526 </Show>
527 </div>
528 <span class="spacer" />
529 <button class="button danger" disabled={!!wait()} data-tip={wait() || undefined} onClick={destroy}>destroy</button>
530 </div>
531 <Show when={shown()}>{(r) => <RunCard run={r()} />}</Show>
532 </>
533 } summary={usage(runtime, retry)}>
534 <p class="hint">Update this preview: <Copy value={`python3 tools/deploy.py stage ${stage().service}`} label="update command"><span class="mono">python3 tools/deploy.py stage {stage().service}</span></Copy></p>
535 <div class="tab-row">
536 <Tabs tabs={[["", "changes"], ["status", "status"], ["logs", "logs"], ["output", "output"]]} />
537 <Show when={params.tab === "logs"}><OpenLogs job={stage().id} runtime={latest()} /></Show>
538 </div>
539 <Switch>
540 <Match when={!params.tab}>
541 <Changes from={data().current} to={stage().release} warn={behind()} name={name} icon={icon}
542 empty="Staging didn't finish, so there's nothing to compare. The output tab shows where it stopped."
543 same="Same service files as production." />
544 </Match>
545 <Match when={params.tab === "status"}>{status(runtime, retry)}</Match>
546 <Match when={params.tab === "logs"}>
547 <JobLogs runtime={latest()}
548 fetch={(query) => parseResponse(api.deploys.stages[":id"].logs.$get({ param: { id: stage().id }, query }))}
549 empty={
550 <Show when={stage().health === "down"} fallback="No lines from this stage's job yet. They appear once it starts.">
551 Nothing from this stage's job. <A href={`/deploys/${stage().id}/output`}>Its staging output</A> shows why it didn't start.
552 </Show>
553 } />
554 </Match>
555 <Match when={params.tab === "output"}>
556 <Output fetch={() => parseResponse(api.deploys.stages[":id"].output.$get({ param: { id: stage().id } }))}
557 empty="The host kept no output from staging this." />
558 </Match>
559 </Switch>
560 </ListPage>
561 );
562 };
563
564 const DeployPage = (props: { entry: Deployed }) => {
565 const entry = () => props.entry;
566 const data = () => deploys()!;
567 const previous = () => data().history.find((item) => item.n === entry().n - 1);
568 const next = () => data().history.find((item) => item.n === entry().n + 1);
569 const live = () => entry().n === data().history[0]?.n && data().recorded;
570 const stage = () => data().stages.find((item) => item.id === entry().source);
571 const { runtime, latest, refetch: retry } = useRuntime("deploy runtime", () => String(entry().n),
572 (n) => parseResponse(api.deploys.history[":n"].runtime.$get({ param: { n } })));
573 const [search, setSearch] = useSearchParams<{ job?: string }>();
574 const jobs = () => latest()?.jobs.map((job) => job.id) ?? [];
575 const job = () => (search.job && jobs().includes(search.job) ? search.job : jobs()[0]);
576 const rollback = () => {
577 const { n, release, redeploys: ids } = entry();
578 return showConfirmDialog({
579 title: `Roll back to ${release.slice(0, 8)}?`,
580 description: redeploys(ids, release),
581 confirmLabel: "roll back",
582 onConfirm: async () => act(await parseResponse(api.deploys.history[":n"].rollback.$post({ param: { n: String(n) } })),
583 () => toast(`Rolled back to ${release.slice(0, 8)}`)),
584 });
585 };
586 return (
587 <ListPage id="deploy" class="deploy-page" flush head={
588 <>
589 <div class="page-head">
590 <A class="crumb" href="/deploys">deploys</A>
591 <h1>{EVENT[entry().source] ?? name(entry().source.replace(/-preview-[0-9a-f]+$/, ""))}</h1>
592 <span class="id mono"><Copy value={entry().release} label="release ID">{entry().release.slice(0, 8)}</Copy></span>
593 <div class="stats">
594 <Show when={live()} fallback={
595 <Show when={next()}>
596 {(after) => (
597 <A class="stat" href={`/deploys/${after().n}`} data-tip={`replaced ${datetime(after().time)}`}>
598 live <b>{duration(after().time - entry().time)}</b>
599 </A>
600 )}
601 </Show>
602 }>
603 <span class="stat production">production</span>
604 </Show>
605 <span class="stat">deployed <b><Ago t={entry().time} /></b></span>
606 <Show when={!EVENT[entry().source]}>
607 <Show when={stage()} fallback={<span class="stat">from <b class="mono">{entry().source}</b></span>}>
608 <A class="stat" href={`/deploys/${entry().source}`}>from <b class="mono">{entry().source}</b></A>
609 </Show>
610 </Show>
611 <Show when={entry().legacy}><span class="stat" data-tip="built with the old release format">legacy</span></Show>
612 <RestartStat runtime={latest()} href={`/deploys/${entry().n}/status`} />
613 </div>
614 <span class="spacer" />
615 <Show when={entry().release !== data().current}>
616 <button class="button" disabled={!!wait()} data-tip={wait() || undefined} onClick={rollback}>roll back</button>
617 </Show>
618 </div>
619 <Show when={shown()}>{(r) => <RunCard run={r()} />}</Show>
620 </>
621 } summary={usage(runtime, retry)}>
622 <div class="tab-row">
623 <Tabs tabs={[["", "changes"], ["status", "status"], ["logs", "logs"], ["output", "output"]]} />
624 <Show when={params.tab === "logs" && job()}>
625 {(id) => (
626 <>
627 <Show when={jobs().length > 1}>
628 <select class="search" aria-label="Service" value={id()}
629 onChange={(event) => setSearch({ job: event.currentTarget.value }, { replace: true })}>
630 <For each={jobs()}>{(item) => <option value={item}>{name(item)}</option>}</For>
631 </select>
632 </Show>
633 <OpenLogs job={id()} runtime={latest()} />
634 </>
635 )}
636 </Show>
637 </div>
638 <Switch>
639 <Match when={!params.tab}>
640 <Changes from={previous()?.release ?? null} to={entry().release} name={name} icon={icon}
641 empty="This release is no longer on the host." same="Same service files as the deploy before it." />
642 </Match>
643 <Match when={params.tab === "status"}>{status(runtime, retry)}</Match>
644 <Match when={params.tab === "logs" && job()} keyed>
645 {(id) => (
646 <JobLogs runtime={latest()}
647 fetch={(query) => parseResponse(api.deploys.history[":n"].logs.$get({
648 param: { n: String(entry().n) }, query: { ...query, job: id },
649 }))}
650 empty={`No lines from ${name(id)} while this deploy was live.`} />
651 )}
652 </Match>
653 <Match when={params.tab === "output"}>
654 <Output fetch={() => parseResponse(api.deploys.history[":n"].output.$get({ param: { n: String(entry().n) } }))}
655 empty="The host kept no output for this deploy." />
656 </Match>
657 </Switch>
658 </ListPage>
659 );
660 };
661
662 // Remounts per stage or deploy, so switching never shows the last one's usage, logs or output.
663 return (
664 <Show when={params.id} keyed>
665 <Loaded data={state} what="deploys" retry={refetch} skeleton={
666 <div class="page">
667 <div class="page-head">
668 <span class="skeleton" style={{ width: "240px", height: "24px" }} />
669 <For each={[72, 110]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>
670 </div>
671 <div class="skeleton" style={{ height: "184px" }} />
672 </div>
673 }>
674 {(data) => (
675 <Switch fallback={
676 <div class="page">
677 <div class="page-head"><A class="crumb" href="/deploys">deploys</A><h1>{params.id}</h1></div>
678 <p class="empty">
679 {/^\d+$/.test(params.id) ? `No deploy number ${params.id} yet.` : `No stage named ${params.id}. It may have been destroyed.`}
680 </p>
681 </div>
682 }>
683 <Match when={params.id === "main"}><MainPage /></Match>
684 <Match when={data().stages.find((stage) => stage.id === params.id)}>
685 {(stage) => <StagePage stage={stage()} />}
686 </Match>
687 <Match when={data().history.find((entry) => String(entry.n) === params.id)}>
688 {(entry) => <DeployPage entry={entry()} />}
689 </Match>
690 </Switch>
691 )}
692 </Loaded>
693 </Show>
694 );
695}