1import { A } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import LockKeyhole from "lucide-solid/icons/lock-keyhole";
4import { For, Show } from "solid-js";
5import type { Container, ServiceDetail, ServiceLink, TaskDefinition } from "../types/model.ts";
6import { api, queries, query } from "../api.ts";
7import { Ago } from "../components/Ago.tsx";
8import { Copy } from "../components/Copy.tsx";
9import { Loaded } from "../components/Loaded.tsx";
10import { StatusLabel } from "../components/Status.tsx";
11import { ago, bytes, cores, duration, plural } from "../format.ts";
12import { stream } from "../live.ts";
13
14/** Null links come from a source the host doesn't expose yet. */
15function 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. */
40function 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
63function 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
83function 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
173const 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. */
176export 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}