| 1 | import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router"; |
| 2 | import { type InferResponseType, parseResponse } from "hono/client"; |
| 3 | import ExternalLink from "lucide-solid/icons/external-link"; |
| 4 | import { |
| 5 | createEffect, createMemo, createResource, createSignal, For, type JSX, Match, on, onCleanup, type Resource, Show, Switch, |
| 6 | untrack, |
| 7 | } from "solid-js"; |
| 8 | import type { Health, LogLine } from "../types/model.ts"; |
| 9 | import { api, query } from "../api.ts"; |
| 10 | import { Ago } from "../components/Ago.tsx"; |
| 11 | import { AppIcon } from "../components/AppIcon.tsx"; |
| 12 | import { Copy } from "../components/Copy.tsx"; |
| 13 | import { showConfirmDialog } from "../components/Dialog.tsx"; |
| 14 | import { ListPage } from "../components/ListPage.tsx"; |
| 15 | import { LogView } from "../components/LogView.tsx"; |
| 16 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; |
| 17 | import { OpenApp } from "../components/OpenApp.tsx"; |
| 18 | import { StatusLabel } from "../components/Status.tsx"; |
| 19 | import { TabBar } from "../components/TabBar.tsx"; |
| 20 | import { TimeChart } from "../components/TimeChart.tsx"; |
| 21 | import { toast } from "../components/Toast.tsx"; |
| 22 | import { ago, bytes, clock, cores, count, datetime, duration, plural } from "../format.ts"; |
| 23 | import { confirmDestroyStage, type Deployed, EVENT, followRun, type StageInfo, useDeploys } from "./Deploys.tsx"; |
| 24 | import "./Deploy.css"; |
| 25 | |
| 26 | interface 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. */ |
| 37 | const [dismissed, setDismissed] = createSignal<string[]>([]); |
| 38 | |
| 39 | function 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 | |
| 64 | type Line = [op: " " | "-" | "+", text: string]; |
| 65 | |
| 66 | /** One service file's diff, with long unchanged runs folded behind a button. */ |
| 67 | function 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 | |
| 130 | function 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. */ |
| 154 | function 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 | |
| 171 | type Runtime = InferResponseType<(typeof api.deploys.stages)[":id"]["runtime"]["$get"], 200>; |
| 172 | type Allocation = Runtime["jobs"][number]["allocations"][number]; |
| 173 | type LogQuery = { after?: string; before?: string; limit?: string }; |
| 174 | |
| 175 | /** Allocations and usage of a stage or deploy, polled while its jobs still run. */ |
| 176 | function 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. */ |
| 185 | const 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 | |
| 190 | function 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. */ |
| 203 | function 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 | |
| 242 | const 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. */ |
| 248 | function 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 | */ |
| 306 | function 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 | |
| 362 | export 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 | } |