| 1 | import { A } from "@solidjs/router"; |
| 2 | import { parseResponse } from "hono/client"; |
| 3 | import LockKeyhole from "lucide-solid/icons/lock-keyhole"; |
| 4 | import { For, Show } from "solid-js"; |
| 5 | import type { Container, ServiceDetail, ServiceLink, TaskDefinition } from "../types/model.ts"; |
| 6 | import { api, queries, query } from "../api.ts"; |
| 7 | import { Ago } from "../components/Ago.tsx"; |
| 8 | import { Copy } from "../components/Copy.tsx"; |
| 9 | import { Loaded } from "../components/Loaded.tsx"; |
| 10 | import { StatusLabel } from "../components/Status.tsx"; |
| 11 | import { ago, bytes, cores, duration, plural } from "../format.ts"; |
| 12 | import { stream } from "../live.ts"; |
| 13 | |
| 14 | /** Null links come from a source the host doesn't expose yet. */ |
| 15 | function Links(props: { label: string; links: ServiceLink[] | null }) { |
| 16 | return ( |
| 17 | <> |
| 18 | <dt>{props.label}</dt> |
| 19 | <Show when={props.links} fallback={<dd class="muted" data-tip="not connected yet">–</dd>}> |
| 20 | {(links) => ( |
| 21 | <dd class="chips"> |
| 22 | <For each={links()} fallback={<span class="muted">nothing</span>}> |
| 23 | {(link) => { |
| 24 | const health = () => stream.latest()?.services[link.id]?.health ?? link.health; |
| 25 | return ( |
| 26 | <A class="chip" href={`/services/${link.id}`} data-tip={link.kind}> |
| 27 | <StatusLabel health={health()}>{link.name}</StatusLabel> |
| 28 | </A> |
| 29 | ); |
| 30 | }} |
| 31 | </For> |
| 32 | </dd> |
| 33 | )} |
| 34 | </Show> |
| 35 | </> |
| 36 | ); |
| 37 | } |
| 38 | |
| 39 | /** When a task runs, as a chip label and a sentence. */ |
| 40 | function phase(task: TaskDefinition, service: ServiceDetail) { |
| 41 | if (!task.hook) return { label: "main", about: null }; |
| 42 | if (task.sidecar) { |
| 43 | return { |
| 44 | label: "sidecar", |
| 45 | about: `Starts ${task.hook === "prestart" ? "before" : "after"} the main tasks and keeps running beside them.`, |
| 46 | }; |
| 47 | } |
| 48 | if (task.hook === "prestart") { |
| 49 | // A wait task is named for what it waits on: wait-<service>-<its task>. |
| 50 | const waited = service.requirements?.find((link) => task.name.startsWith(`wait-${link.id}-`)); |
| 51 | return { |
| 52 | label: "runs first", |
| 53 | about: waited |
| 54 | ? `Waits for ${waited.name} to answer, then exits. The main tasks start once it has.` |
| 55 | : "Runs once and exits before the main tasks start.", |
| 56 | }; |
| 57 | } |
| 58 | return task.hook === "poststart" |
| 59 | ? { label: "after start", about: "Runs once after the main tasks start." } |
| 60 | : { label: "on stop", about: "Runs once after the main tasks stop." }; |
| 61 | } |
| 62 | |
| 63 | function State(props: { container: Container | undefined; hook: boolean }) { |
| 64 | return ( |
| 65 | <Show when={props.container} fallback={<span class="muted">not in the current allocation</span>}> |
| 66 | {(container) => ( |
| 67 | <span class="state"> |
| 68 | {container().state === "running" |
| 69 | ? <StatusLabel health="healthy">running</StatusLabel> |
| 70 | : container().state === "pending" |
| 71 | ? <StatusLabel health="starting">starting</StatusLabel> |
| 72 | : <StatusLabel health="stopped">{props.hook ? "finished" : "stopped"}</StatusLabel>} |
| 73 | <Show when={container().startedAt}>{(t) => <span class="muted">started <Ago t={t()} /></span>}</Show> |
| 74 | <Show when={container().restarts}> |
| 75 | {(n) => <span class="muted" data-tip={container().lastRestart?.reason}>{plural(n(), "restart")}</span>} |
| 76 | </Show> |
| 77 | </span> |
| 78 | )} |
| 79 | </Show> |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | function Task(props: { task: TaskDefinition; service: ServiceDetail }) { |
| 84 | const task = () => props.task; |
| 85 | const about = () => phase(task(), props.service); |
| 86 | const container = () => props.service.containers.find((item) => item.name === task().name); |
| 87 | const list = (label: string, items: string[]) => ( |
| 88 | <Show when={items.length}> |
| 89 | <dt>{label}</dt> |
| 90 | <dd class="mono">{items.join(", ")}</dd> |
| 91 | </Show> |
| 92 | ); |
| 93 | return ( |
| 94 | <section class="task card" aria-label={task().name}> |
| 95 | <div class="task-head"> |
| 96 | <h3 class="mono">{task().name}</h3> |
| 97 | <span class="chip" classList={{ main: !task().hook }}>{about().label}</span> |
| 98 | <span class="spacer" /> |
| 99 | <State container={container()} hook={!!task().hook} /> |
| 100 | </div> |
| 101 | <Show when={about().about}>{(text) => <p class="about">{text()}</p>}</Show> |
| 102 | <dl class="kv"> |
| 103 | <dt>image</dt> |
| 104 | <dd class="mono"> |
| 105 | <Show when={task().image} fallback={<span class="muted">–</span>}>{(image) => <Copy value={image()} />}</Show> |
| 106 | </dd> |
| 107 | <dt>runs as</dt> |
| 108 | <dd class="mono">{task().user ?? <span class="muted">the image's user</span>}</dd> |
| 109 | <dt>reserves</dt> |
| 110 | <dd> |
| 111 | {plural(Number(cores(task().cpu)), "core")}, {bytes(task().memory)} |
| 112 | <Show when={task().memoryMax}>{(max) => <span class="muted"> (up to {bytes(max())})</span>}</Show> |
| 113 | </dd> |
| 114 | <Show when={task().hostNetwork || task().ports.length}> |
| 115 | <dt>ports</dt> |
| 116 | <dd> |
| 117 | <Show when={!task().hostNetwork} fallback="shares the host's network"> |
| 118 | <ul class="plain"> |
| 119 | <For each={task().ports}> |
| 120 | {(port) => ( |
| 121 | <li> |
| 122 | <span class="mono">{port.container ?? "–"}</span> <span class="muted">as</span> {port.label} |
| 123 | <span class="muted"> |
| 124 | , host port {port.host ?? "picked at start"}{port.network === "loopback" ? ", this machine only" : ""} |
| 125 | </span> |
| 126 | </li> |
| 127 | )} |
| 128 | </For> |
| 129 | </ul> |
| 130 | </Show> |
| 131 | </dd> |
| 132 | </Show> |
| 133 | <Show when={task().mounts.length}> |
| 134 | <dt>mounts</dt> |
| 135 | <dd> |
| 136 | <table class="mounts"> |
| 137 | <tbody> |
| 138 | <For each={task().mounts}> |
| 139 | {(mount) => ( |
| 140 | <tr> |
| 141 | <td class="mono">{mount.target}</td> |
| 142 | <td class="mono muted">{mount.source}</td> |
| 143 | <td class="muted">{mount.readOnly ? "read-only" : ""}</td> |
| 144 | </tr> |
| 145 | )} |
| 146 | </For> |
| 147 | </tbody> |
| 148 | </table> |
| 149 | </dd> |
| 150 | </Show> |
| 151 | {list("tmpfs", task().tmpfs)} |
| 152 | {list("devices", task().devices)} |
| 153 | {list("capabilities", task().capabilities)} |
| 154 | {list("extra hosts", task().extraHosts)} |
| 155 | <Show when={task().env.length}> |
| 156 | <dt>env</dt> |
| 157 | <dd class="chips"> |
| 158 | <For each={task().env}> |
| 159 | {(env) => ( |
| 160 | <span class="chip mono" data-tip={env.from === "secret" ? "from a secret" : env.from === "template" ? "filled in at start" : undefined}> |
| 161 | <Show when={env.from === "secret"}><LockKeyhole size={11} aria-label="secret" /></Show> |
| 162 | {env.name} |
| 163 | </span> |
| 164 | )} |
| 165 | </For> |
| 166 | </dd> |
| 167 | </Show> |
| 168 | </dl> |
| 169 | </section> |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | const definitions = query("definition", (id: string) => parseResponse(api.services[":id"].definition.$get({ param: { id } }))); |
| 174 | |
| 175 | /** What the service runs right now, read from its Nomad job and the release's service file. */ |
| 176 | export function Definition(props: { service: ServiceDetail }) { |
| 177 | const [definition, { refetch }] = definitions.use(() => props.service.id); |
| 178 | const [deploys] = queries.deploys.use(); |
| 179 | /** The newest deploy that changed this service, else the first, which deployed everything. */ |
| 180 | const deploy = () => { |
| 181 | const history = deploys()?.history; |
| 182 | return history?.find((entry) => entry.changed?.includes(props.service.id)) ?? history?.at(-1); |
| 183 | }; |
| 184 | const release = () => (props.service.release ?? deploy()?.release)?.slice(0, 8); |
| 185 | return ( |
| 186 | <Loaded data={definition} what="the definition" retry={refetch} skeleton={ |
| 187 | <div class="definition"> |
| 188 | <div class="skeleton" style={{ height: "150px", "max-width": "560px" }} /> |
| 189 | <div class="skeleton" style={{ height: "220px", "margin-top": "24px" }} /> |
| 190 | </div> |
| 191 | }> |
| 192 | {(loaded) => { |
| 193 | const groups = () => loaded().groups; |
| 194 | const services = () => groups().flatMap((group) => group.services); |
| 195 | const routed = () => services().filter((service) => service.hostnames.length); |
| 196 | const checked = () => services().flatMap((service) => service.check ?? []); |
| 197 | return ( |
| 198 | <div class="definition"> |
| 199 | <dl class="kv"> |
| 200 | <dt>address</dt> |
| 201 | <dd> |
| 202 | <For each={routed()} fallback={<span class="muted">none, only other services reach it</span>}> |
| 203 | {(service) => ( |
| 204 | <div> |
| 205 | <For each={service.hostnames}> |
| 206 | {(host, i) => <>{i() ? ", " : ""}<a href={`https://${host}`} target="_blank" rel="noreferrer">{host}</a></>} |
| 207 | </For> |
| 208 | <span class="muted"> |
| 209 | {" "}{service.authRole ? `sign-in required, ${service.authRole} group` : "no sign-in gate"} |
| 210 | </span> |
| 211 | </div> |
| 212 | )} |
| 213 | </For> |
| 214 | </dd> |
| 215 | <dt>release</dt> |
| 216 | <dd> |
| 217 | <Show when={release()} fallback={<span class="muted">–</span>}> |
| 218 | <A class="mono" href={deploy() ? `/deploys/${deploy()!.n}` : "/deploys"}>{release()}</A> |
| 219 | </Show> |
| 220 | <span class="muted"> |
| 221 | {" "}deployed {ago(props.service.deployedAt)},{" "} |
| 222 | {props.service.rollout === "simple" ? "updates with a brief outage" : "updates without downtime"} |
| 223 | </span> |
| 224 | </dd> |
| 225 | <Links label="needs" links={props.service.requirements} /> |
| 226 | <Links label="needed by" links={props.service.dependents} /> |
| 227 | <Show when={props.service.datasets.length}> |
| 228 | <dt>data</dt> |
| 229 | <dd> |
| 230 | <For each={props.service.datasets}> |
| 231 | {(dataset) => ( |
| 232 | <div> |
| 233 | <A class="mono" href={`/storage?${new URLSearchParams({ dataset: dataset.name })}`}>{dataset.mountpoint}</A> |
| 234 | <span class="muted"> {bytes(dataset.used)}, {bytes(dataset.snapshots)} in snapshots</span> |
| 235 | </div> |
| 236 | )} |
| 237 | </For> |
| 238 | </dd> |
| 239 | </Show> |
| 240 | <Show when={checked().length}> |
| 241 | <dt>health check</dt> |
| 242 | <dd> |
| 243 | <For each={checked()}> |
| 244 | {(check) => ( |
| 245 | <div> |
| 246 | {check.type === "http" ? <>GET <span class="mono">{check.path}</span></> : check.type.toUpperCase()} |
| 247 | <span class="muted"> |
| 248 | {check.task ? ` on ${check.task}` : ""} every {duration(check.interval)}, gives up after {duration(check.timeout)} |
| 249 | </span> |
| 250 | <Show when={check.restartAfter}> |
| 251 | {(after) => ( |
| 252 | <div class="muted"> |
| 253 | {plural(after().failures, "failure")} in a row restart it, once it's been up {duration(after().grace)} |
| 254 | </div> |
| 255 | )} |
| 256 | </Show> |
| 257 | </div> |
| 258 | )} |
| 259 | </For> |
| 260 | </dd> |
| 261 | </Show> |
| 262 | <For each={groups()}> |
| 263 | {(group) => ( |
| 264 | <> |
| 265 | <dt>{groups().length > 1 ? `${group.name} restarts` : "restarts"}</dt> |
| 266 | <dd> |
| 267 | up to {group.restart.attempts} every {duration(group.restart.interval)}, {duration(group.restart.delay)} apart |
| 268 | <span class="muted">, then {group.restart.fail ? "gives up" : "waits and tries again"}</span> |
| 269 | </dd> |
| 270 | </> |
| 271 | )} |
| 272 | </For> |
| 273 | </dl> |
| 274 | |
| 275 | <For each={groups()}> |
| 276 | {(group) => ( |
| 277 | <> |
| 278 | <h2> |
| 279 | tasks |
| 280 | <Show when={groups().length > 1 || group.count > 1}> |
| 281 | <span class="muted"> {group.name}, {plural(group.count, "copy", "copies")}</span> |
| 282 | </Show> |
| 283 | </h2> |
| 284 | <div class="tasks"> |
| 285 | <For each={[...group.tasks].sort((a, b) => Number(!!a.hook) - Number(!!b.hook))}> |
| 286 | {(task) => <Task task={task} service={props.service} />} |
| 287 | </For> |
| 288 | </div> |
| 289 | </> |
| 290 | )} |
| 291 | </For> |
| 292 | |
| 293 | <h2> |
| 294 | service file |
| 295 | <span class="muted"> service/{props.service.id}/service.pkl{release() ? ` in release ${release()}` : ""}</span> |
| 296 | </h2> |
| 297 | <Show when={loaded().source} fallback={<p class="muted">This release has no service file for {props.service.name}.</p>}> |
| 298 | {(source) => <pre class="source">{source()}</pre>} |
| 299 | </Show> |
| 300 | </div> |
| 301 | ); |
| 302 | }} |
| 303 | </Loaded> |
| 304 | ); |
| 305 | } |