1import { A, useParams, useSearchParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import Eye from "lucide-solid/icons/eye";
4import EyeOff from "lucide-solid/icons/eye-off";
5import HardDrive from "lucide-solid/icons/hard-drive";
6import Package from "lucide-solid/icons/package";
7import RotateCcw from "lucide-solid/icons/rotate-ccw";
8import Search from "lucide-solid/icons/search";
9import X from "lucide-solid/icons/x";
10import {
11 createEffect, createMemo, createResource, createSignal, createUniqueId, For, type JSX, Match, onCleanup, Show, Switch,
12} from "solid-js";
13import { Portal } from "solid-js/web";
14import {
15 type Action, type LogLine, type Series, type ServiceDetail, type TraceSummary, unacknowledgedRestart,
16} from "../types/model.ts";
17import { logField, needles, parseSearch, traceField, type TraceSort } from "../types/search.ts";
18import { api, queries, query, reason } from "../api.ts";
19import { Ago } from "../components/Ago.tsx";
20import { AppIcon } from "../components/AppIcon.tsx";
21import { Copy } from "../components/Copy.tsx";
22import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
23import { ListPage } from "../components/ListPage.tsx";
24import { LogView } from "../components/LogView.tsx";
25import { OpenApp } from "../components/OpenApp.tsx";
26import { lastGood, Loaded } from "../components/Loaded.tsx";
27import { RangePicker } from "../components/Metric.tsx";
28import { TimeChart } from "../components/TimeChart.tsx";
29import { type Sort, SortHeader } from "../components/SortHeader.tsx";
30import { Status, StatusLabel } from "../components/Status.tsx";
31import { Strip } from "../components/Strip.tsx";
32import { TabBar } from "../components/TabBar.tsx";
33import { toast } from "../components/Toast.tsx";
34import { ago, bytes, clock, cores, datetime, duration, plural } from "../format.ts";
35import { stream } from "../live.ts";
36import { Definition } from "./Service.definition.tsx";
37import { Flame, ms } from "./Service.flame.tsx";
38import "./Service.css";
39
40const TABS = [["", "logs"], ["traces", "traces"], ["metrics", "metrics"], ["secrets", "secrets"], ["definition", "definition"]] as const;
41const LEVELS = [[undefined, "all"], ["warn", "warn"], ["error", "error"]] as const;
42
43/** URL state of the logs and traces tabs; `at` opens the logs at a moment instead of following. */
44type Query = { q?: string; level?: "warn" | "error"; at?: string; from?: string; to?: string; errors?: "1"; sort?: TraceSort; trace?: string };
45
46/** Each service's latest unfiltered lines, so returning to a service draws its logs at once. */
47const recent = new Map<string, LogLine[]>();
48
49/** Newest at the bottom; follows new lines while scrolled to the end, and loads older ones near the top. */
50function Logs(props: { service: ServiceDetail; tools: HTMLElement }) {
51 const [params, setParams] = useSearchParams<Query>();
52 const at = () => (params.at ? Number(params.at) : null);
53 const bounded = () => at() !== null || !!params.to;
54 const unfiltered = () => !params.q && !params.level && !params.from && !params.to && at() === null;
55 const dateValue = (value?: string) => {
56 if (!value) return "";
57 const date = new Date(Number(value) * 1000);
58 return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
59 };
60 const [older, setOlder] = createSignal<LogLine[]>([]);
61 const [newer, setNewer] = createSignal<LogLine[]>([]);
62 const [exhausted, setExhausted] = createSignal(false);
63 const [loadingOlder, setLoadingOlder] = createSignal(false);
64 const [follow, setFollow] = createSignal(true);
65 const [unseen, setUnseen] = createSignal(0);
66 /** Why the last poll failed, until one succeeds. */
67 const [stalled, setStalled] = createSignal<string>();
68 const fetchLines = (limit: number, extra: { before?: string; after?: string }) =>
69 parseResponse(api.services[":id"].logs.$get({
70 param: { id: props.service.id },
71 query: { q: params.q ?? "", ...(params.level ? { level: params.level } : {}), limit: String(limit),
72 ...(params.from ? { after: params.from } : {}), ...(params.to ? { before: params.to } : {}), ...extra },
73 })).then((lines) => lines.reverse());
74 const [first, { refetch }] = createResource<LogLine[], string>(
75 // A string key, since a fresh array from an unrelated rerun would count as a change and refetch.
76 () => JSON.stringify([props.service.id, params.q, params.level, params.at, params.from, params.to]),
77 (_, { refetching }) => {
78 setOlder([]);
79 setNewer([]);
80 setExhausted(false);
81 setFollow(!bounded());
82 setUnseen(0);
83 setStalled();
84 const cached = !refetching && unfiltered() && recent.get(props.service.id);
85 if (cached) {
86 queueMicrotask(() => poll());
87 return cached;
88 }
89 return fetchLines(1000, at() === null ? {} : { before: String(at()! + 30) });
90 },
91 );
92 const loaded = lastGood(first);
93 const all = createMemo(() => [...older(), ...(loaded() ?? []), ...newer()]);
94 createEffect(() => first.state === "ready" && unfiltered() && recent.set(props.service.id, all().slice(-1000)));
95
96 const loadOlder = async () => {
97 const oldest = all()[0];
98 if (!oldest || loadingOlder() || exhausted()) return;
99 if (params.from && oldest.t <= Number(params.from)) return setExhausted(true);
100 setLoadingOlder(true);
101 try {
102 const lines = await fetchLines(5000, { before: String(oldest.t) });
103 if (!lines.length) setExhausted(true);
104 setOlder([...lines, ...older()]);
105 } catch (failure) {
106 toast(`Couldn't load older lines. ${reason(failure)}`);
107 } finally {
108 setLoadingOlder(false);
109 }
110 };
111
112 const poll = async () => {
113 const last = all().at(-1);
114 if (bounded() || !last || first.loading) return;
115 let lines: LogLine[];
116 try {
117 lines = await fetchLines(1000, { after: String(last.t) });
118 setStalled();
119 } catch (failure) {
120 setStalled(reason(failure));
121 return;
122 }
123 // A search changed while this was under way, so the lines belong to the old one.
124 if (!lines.length || first.loading || last !== all().at(-1)) return;
125 setNewer([...newer(), ...lines]);
126 if (!follow()) setUnseen(unseen() + lines.length);
127 };
128 const timer = setInterval(poll, 2000);
129 onCleanup(() => clearInterval(timer));
130 const marked = createMemo(() => (at() === null ? undefined : all().find((line) => line.t > at()!)));
131 const hints = () => [
132 ...props.service.containers.filter((container) => !container.hook && props.service.containers.length > 1).slice(0, 1)
133 .map((container) => [`container:${container.name}`, "one container's lines"] as const),
134 ["stream:stderr", "error output only"],
135 ["-health", "hide lines with a word"],
136 ['"connection refused"', "an exact phrase"],
137 ] as const;
138
139 return (
140 <div class="logs-tab fill">
141 <Portal mount={props.tools} ref={(el) => (el.style.display = "contents")}>
142 <Filters placeholder="search logs…" hints={hints()}>
143 <TabBar label="Level">
144 <For each={LEVELS}>
145 {([level, label]) => (
146 <button aria-pressed={params.level === level} onClick={() => setParams({ level }, { replace: true })}
147 data-tip={level === "warn" ? "warnings and errors" : undefined}>
148 {label}
149 </button>
150 )}
151 </For>
152 </TabBar>
153 <label>from <input type="datetime-local" value={dateValue(params.from)}
154 onChange={(event) => setParams({ from: event.currentTarget.value ? String(Date.parse(event.currentTarget.value) / 1000) : undefined }, { replace: true })} /></label>
155 <label>to <input type="datetime-local" value={dateValue(params.to)}
156 onChange={(event) => setParams({ to: event.currentTarget.value ? String(Date.parse(event.currentTarget.value) / 1000) : undefined }, { replace: true })} /></label>
157 <Show when={props.service.logs}>{(link) => <OpenApp app={link().app} href={link().url}>{link().app.name}</OpenApp>}</Show>
158 <Liveness every="lines every 2 s" paused={bounded() ? "Showing the selected time range" : undefined}
159 failure={stalled() ?? (first.state === "errored" ? reason(first.error) : undefined)} />
160 </Filters>
161 </Portal>
162 <Loaded data={first} what="logs" retry={refetch} skeleton={
163 <div class="log-view">
164 <div class="logs">
165 <For each={Array(14)}>
166 {(_, i) => (
167 <div class="log"><span class="skeleton" /><span class="skeleton" style={{ width: `${40 + ((i() * 37) % 55)}%` }} /></div>
168 )}
169 </For>
170 </div>
171 </div>
172 }>
173 {() => (
174 <Show when={all().length} fallback={
175 <div class="empty">{params.q || params.level ? "No lines match." : "No recent logs."}</div>
176 }>
177 <LogView lines={all()} marks={needles(parseSearch(params.q ?? "", logField))} follow={follow()} unseen={unseen()}
178 onStart={loadOlder}
179 onContainer={(name) => setParams({ q: [params.q, `container:${name}`].filter(Boolean).join(" ") }, { replace: true })}
180 onFollow={(end) => {
181 setFollow(end && at() === null);
182 if (end) setUnseen(0);
183 }}
184 onLatest={() => (at() === null ? setFollow(true) : setParams({ at: undefined }))}
185 before={(line, index) => (
186 <>
187 <Show when={index() === 0}>
188 <div class="log-edge">
189 <Show when={!exhausted()} fallback="No older lines">
190 <button class="button small" disabled={loadingOlder()} aria-busy={loadingOlder()} onClick={loadOlder}>
191 load older
192 </button>
193 </Show>
194 </div>
195 </Show>
196 <Show when={line === marked()}><div class="log-edge at">{clock(at()!)}</div></Show>
197 </>
198 )} />
199 </Show>
200 )}
201 </Loaded>
202 </div>
203 );
204}
205
206const traceList = query("traces", ({ id, ...query }: { id: string; q: string; errors?: "1"; sort?: TraceSort; limit: string }) =>
207 parseResponse(api.services[":id"].traces.$get({ param: { id }, query })));
208const oneTrace = query("trace", (id: string) => parseResponse(api.traces[":id"].$get({ param: { id } })));
209const PAGE = 50;
210
211function AppMetrics(props: { service: ServiceDetail; range: number }) {
212 const [name, setName] = createSignal(props.service.applicationMetrics[0]!);
213 const [data, { refetch }] = createResource(
214 () => [props.service.id, name(), props.range] as const,
215 ([id, metric, range]) => parseResponse(api.services[":id"].metrics[":name"].$get({
216 param: { id, name: metric }, query: { range: String(range) },
217 })),
218 );
219 const timer = setInterval(refetch, 30_000);
220 onCleanup(() => clearInterval(timer));
221 return (
222 <div class="card metric">
223 <div class="card-title">
224 application metrics
225 <span class="spacer" />
226 <label>Metric <select value={name()} onChange={(event) => setName(event.currentTarget.value)}>
227 <For each={props.service.applicationMetrics}>{(metric) => <option value={metric}>{metric}</option>}</For>
228 </select></label>
229 </div>
230 <Loaded data={data} what="application metrics" retry={refetch} skeleton={<div class="skeleton" style={{ height: "196px" }} />}>
231 {(series) => <Show when={series().length} fallback={<div class="empty">No samples in this range.</div>}>
232 <TimeChart series={series()} format={(value) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} />
233 </Show>}
234 </Loaded>
235 </div>
236 );
237}
238
239function Traces(props: { service: ServiceDetail; tools: HTMLElement }) {
240 const [params, setParams] = useSearchParams<Query>();
241 const [limit, setLimit] = createSignal(PAGE);
242 const [traces, { refetch }] = traceList.use(() =>
243 ({ id: props.service.id, q: params.q ?? "", errors: params.errors, sort: params.sort, limit: String(limit()) }));
244 const timer = setInterval(refetch, 10_000);
245 onCleanup(() => clearInterval(timer));
246 const shown = lastGood(traces);
247 /** Rows keep an unchanged trace's summary, so a refresh doesn't rebuild them and take focus or scroll with it. */
248 const rows = createMemo<TraceSummary[]>((previous) => {
249 const known = new Map(previous.map((summary) => [summary.id, summary]));
250 return (shown() ?? []).map((summary) => {
251 const old = known.get(summary.id);
252 return old && old.spans === summary.spans && old.error === summary.error ? old : summary;
253 });
254 }, []);
255 const failing = createMemo<string | undefined>((previous) =>
256 traces.state === "errored" ? reason(traces.error) : traces.state === "ready" ? undefined : previous);
257 // Pinned once, so newer traces arriving above it don't swap the one being read; never from a search's old answer.
258 createEffect(() => {
259 const first = rows()[0];
260 if (!params.trace && first && traces.state === "ready") setParams({ trace: first.id }, { replace: true });
261 });
262 const [trace, { refetch: refetchTrace }] = oneTrace.use(() => params.trace ?? false);
263 const slowest = () => Math.max(...rows().map((summary) => summary.root.duration));
264 const sort = (): Sort<"when" | "duration"> => ({
265 key: params.sort === "slowest" || params.sort === "fastest" ? "duration" : "when",
266 desc: params.sort !== "oldest" && params.sort !== "fastest",
267 });
268 // A column's first click shows its most telling end: the newest, or the slowest.
269 const onSort = ({ key, desc }: Sort<"when" | "duration">) => setParams({
270 sort: key === "when" ? (desc || key !== sort().key ? undefined : "oldest") : desc || key !== sort().key ? "slowest" : "fastest",
271 trace: undefined,
272 }, { replace: true });
273 /** Up and down move the selection, keeping focus on the chosen row. */
274 const step = (event: KeyboardEvent) => {
275 const offset = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[event.key];
276 const next = offset && rows()[rows().findIndex((summary) => summary.id === params.trace) + offset];
277 if (!next) return;
278 event.preventDefault();
279 setParams({ trace: next.id }, { replace: true });
280 (event.currentTarget as HTMLElement).querySelector<HTMLButtonElement>(`[data-trace="${next.id}"]`)?.focus();
281 };
282 const hints = [
283 ["status:5xx", "by status code"],
284 ["path:/api", "paths that start with /api"],
285 ["duration:>1s", "slower than a second"],
286 ["method:post", "by method"],
287 ["-path:/health", "hide matching requests"],
288 ] as const;
289 return (
290 <>
291 <Portal mount={props.tools} ref={(el) => (el.style.display = "contents")}>
292 <Filters placeholder="search traces…" hints={hints}>
293 <TabBar label="Show">
294 <For each={[undefined, "1"] as const}>
295 {(errors) => (
296 <button aria-pressed={params.errors === errors} onClick={() => setParams({ errors, trace: undefined }, { replace: true })}>
297 {errors ? "errors" : "all"}
298 </button>
299 )}
300 </For>
301 </TabBar>
302 <Liveness every="traces every 10 s" failure={failing()} />
303 </Filters>
304 </Portal>
305 <Loaded data={traces} what="traces" retry={refetch} skeleton={
306 <div class="fill traces">
307 <For each={Array(6)}>
308 {(_, i) => <div class="skeleton row-skeleton" style={{ width: `${35 + ((i() * 37) % 45)}%` }} />}
309 </For>
310 </div>
311 }>
312 {() => (
313 <Show when={rows().length} fallback={
314 <Show when={!params.q && !params.errors} fallback={<div class="empty">No traces match in the last week.</div>}>
315 <div class="empty traces-empty">
316 <p><strong>No traces from {props.service.name} yet.</strong></p>
317 </div>
318 </Show>
319 }>
320 <div class="fill traces">
321 <div class="trace-list">
322 <table class="data">
323 <thead>
324 <tr>
325 <SortHeader column="when" label="when" sort={sort()} onSort={onSort} class="when" />
326 <th>request</th>
327 <th class="code">status</th>
328 <th class="num spans">spans</th>
329 <SortHeader column="duration" label="duration" sort={sort()} onSort={onSort} num />
330 </tr>
331 </thead>
332 <tbody onKeyDown={step}>
333 <For each={rows()}>
334 {(summary) => {
335 const root = summary.root;
336 const selected = () => params.trace === summary.id;
337 const status = root.attributes["http.response.status_code"];
338 return (
339 <tr ref={(row) => createEffect(() => selected() && row.scrollIntoView({ block: "nearest" }))}
340 classList={{ selected: selected() }} onClick={() => setParams({ trace: summary.id }, { replace: true })}>
341 <td class="when"><Ago t={root.start} /></td>
342 <td class="request">
343 <button data-trace={summary.id} aria-current={selected() || undefined}>
344 {root.attributes["http.request.method"] ?? ""} {root.attributes["url.path"] ?? root.name}
345 </button>
346 </td>
347 <td class="code" classList={{ client: String(status).startsWith("4") }}>
348 <span>
349 <Show when={summary.error}>{(error) => <Status health="down" label={error()} />}</Show>
350 {status}
351 </span>
352 </td>
353 <td class="num spans" data-tip={summary.services.length > 1 ? summary.services.join(", ") : undefined}>
354 {summary.spans}
355 </td>
356 <td class="num">
357 <div class="duration">
358 <span class="track">
359 <span class="bar" style={{ width: `${(root.duration / slowest()) * 100}%` }} />
360 </span>
361 {ms(root.duration)}
362 </div>
363 </td>
364 </tr>
365 );
366 }}
367 </For>
368 </tbody>
369 </table>
370 <Show when={rows().length === limit()}>
371 <div class="log-edge">
372 <button class="button small" disabled={traces.loading} aria-busy={traces.loading} onClick={() => setLimit(limit() + PAGE)}>
373 load more
374 </button>
375 </div>
376 </Show>
377 </div>
378 <Loaded data={trace} what="this trace" retry={refetchTrace} skeleton={<div class="skeleton flame-skeleton" />}>
379 {(loaded) => <Flame trace={loaded()} needles={needles(parseSearch(params.q ?? "", traceField))} job={props.service.id} />}
380 </Loaded>
381 </div>
382 </Show>
383 )}
384 </Loaded>
385 </>
386 );
387}
388
389/** Whether a tab still takes in new data; `paused` and `failure` say why it doesn't. */
390function Liveness(props: { every: string; paused?: string; failure?: string }) {
391 const health = () => (props.failure ? "degraded" : props.paused ? "stopped" : "healthy");
392 return (
393 <span class="liveness" classList={{ live: health() === "healthy" }} role="status" tabIndex={0}
394 data-tip={props.failure ?? props.paused ?? `Checks for new ${props.every}`}>
395 <StatusLabel health={health()}>{props.failure ? "reconnecting" : props.paused ? "paused" : "live"}</StatusLabel>
396 </span>
397 );
398}
399
400function Secrets(props: { service: ServiceDetail; onChange: () => unknown }) {
401 const [shown, setShown] = createSignal<Record<string, string>>({});
402 const [revealing, setRevealing] = createSignal<string>();
403 const param = (name: string) => ({ param: { id: props.service.id, name } });
404 const hide = (name: string) => setShown(({ [name]: _, ...rest }) => rest);
405 const reveal = async (name: string) => {
406 setRevealing(name);
407 try {
408 const { value } = await parseResponse(api.services[":id"].secrets[":name"].$get(param(name)));
409 setShown({ ...shown(), [name]: value });
410 } catch (failure) {
411 toast(`Couldn't reveal ${name}. ${reason(failure)}`);
412 } finally {
413 setRevealing();
414 }
415 };
416 const rotate = (name: string) => showConfirmDialog({
417 title: `Rotate ${name}?`,
418 description: `${props.service.name} gets a new random ${name} and restarts to use it. `
419 + "Anything still using the old value stops working until you update it.",
420 confirmLabel: "rotate",
421 destructive: true,
422 onConfirm: async () => {
423 await parseResponse(api.services[":id"].secrets[":name"].rotate.$post(param(name)));
424 hide(name);
425 await props.onChange();
426 },
427 });
428 const set = (name: string) => showTextDialog({
429 title: `Set ${name}`,
430 description: `${props.service.name} restarts to use the new value.`,
431 label: "New value",
432 confirmLabel: "set",
433 onConfirm: async (value) => {
434 await parseResponse(api.services[":id"].secrets[":name"].$put({ ...param(name), json: { value } }));
435 hide(name);
436 await props.onChange();
437 },
438 });
439 return (
440 <Show when={props.service.secrets} fallback={<div class="empty">Secrets aren't wired up to the dashboard yet.</div>}>
441 {(secrets) => (
442 <Show when={secrets().length} fallback={
443 <div class="empty">
444 <p><strong>{props.service.name} has no secrets.</strong></p>
445 <p>Secrets its service file declares appear here.</p>
446 </div>
447 }>
448 <table class="data secrets">
449 <thead><tr><th>secret</th><th>value</th><th /></tr></thead>
450 <tbody>
451 <For each={secrets()}>
452 {(secret) => (
453 <tr>
454 <td class="mono name">{secret.name}</td>
455 <td class="mono">
456 <Show when={shown()[secret.name]} fallback={
457 <button class="button small" disabled={revealing() === secret.name} aria-busy={revealing() === secret.name}
458 aria-label={`Reveal ${secret.name}`} onClick={() => reveal(secret.name)}>
459 <Eye size={14} />reveal
460 </button>
461 }>
462 {(value) => (
463 <span class="inline">
464 <Copy value={value()} />
465 <button class="button small icon-only" aria-label={`Hide ${secret.name}`} data-tip="hide"
466 onClick={() => hide(secret.name)}>
467 <EyeOff size={14} />
468 </button>
469 </span>
470 )}
471 </Show>
472 </td>
473 <td class="actions">
474 <div>
475 <Show when={secret.generated} fallback={<button onClick={() => set(secret.name)}>set</button>}>
476 <button onClick={() => rotate(secret.name)}>rotate</button>
477 </Show>
478 </div>
479 </td>
480 </tr>
481 )}
482 </For>
483 </tbody>
484 </table>
485 </Show>
486 )}
487 </Show>
488 );
489}
490
491/**
492 * The search box and filters that share the tab row with the tabs, kept in the URL. Focusing the empty box lists
493 * `hints`, examples of the search syntax that fill the box when picked.
494 */
495function Filters(props: { placeholder: string; hints: readonly (readonly [string, string])[]; children: JSX.Element }) {
496 const [params, setParams] = useSearchParams<Query>();
497 const [open, setOpen] = createSignal(false);
498 const hintsId = createUniqueId();
499 let input!: HTMLInputElement;
500 let debounce: ReturnType<typeof setTimeout> | undefined;
501 const search = (value: string) => {
502 clearTimeout(debounce);
503 setParams({ q: value || undefined, trace: undefined }, { replace: true });
504 };
505 const key = (event: KeyboardEvent) => {
506 if (event.key !== "/" || (event.target as Element).closest("input, textarea, [contenteditable]")) return;
507 event.preventDefault();
508 input.focus();
509 };
510 document.addEventListener("keydown", key);
511 onCleanup(() => {
512 document.removeEventListener("keydown", key);
513 clearTimeout(debounce);
514 });
515 return (
516 <>
517 <label class="search-box">
518 <Search size={14} />
519 <input ref={input} type="search" placeholder={props.placeholder} aria-label={props.placeholder.slice(0, -1)}
520 aria-describedby={hintsId} value={params.q ?? ""}
521 onFocus={(event) => setOpen(!event.currentTarget.value)}
522 onBlur={() => setOpen(false)}
523 onInput={(event) => {
524 clearTimeout(debounce);
525 const value = event.currentTarget.value;
526 setOpen(!value);
527 debounce = setTimeout(() => search(value), 200);
528 }}
529 onKeyDown={(event) => {
530 if (event.key !== "Escape") return;
531 if (!event.currentTarget.value) return setOpen(false);
532 event.currentTarget.value = "";
533 search("");
534 }} />
535 <kbd aria-hidden="true">/</kbd>
536 <div id={hintsId} class="search-hints" hidden={!open()}>
537 <For each={props.hints}>
538 {([example, meaning]) => (
539 <button type="button" tabIndex={-1} onMouseDown={(event) => event.preventDefault()}
540 onClick={() => {
541 input.value = example;
542 setOpen(false);
543 search(example);
544 }}>
545 {example}<span>{meaning}</span>
546 </button>
547 )}
548 </For>
549 </div>
550 </label>
551 {props.children}
552 </>
553 );
554}
555
556/** Remounts per service, so switching shows the new one's cached state or a skeleton, never the last one's. */
557export function Service() {
558 const params = useParams<{ id: string }>();
559 return <Show when={params.id} keyed><ServicePage /></Show>;
560}
561
562function ServicePage() {
563 const params = useParams<{ id: string; tab?: string }>();
564 const [service, { refetch }] = queries.service.use(() => params.id);
565 const timer = setInterval(refetch, 10_000);
566 onCleanup(() => clearInterval(timer));
567 // After an action the refetched detail is fresher than the stream until the stream's next tick.
568 const [since, setSince] = createSignal(0);
569 stream.start();
570 const refresh = async () => {
571 setSince(Date.now() / 1000);
572 await refetch();
573 };
574 const act = async (action: Action) => {
575 await parseResponse(api.services[":id"][":action"].$post({ param: { id: params.id, action } }));
576 await refresh();
577 };
578
579 const [range, setRange] = createSignal(3600);
580
581 return (
582 <Loaded data={service} what="this service" retry={refetch} skeleton={
583 <div class="page">
584 <div class="page-head">
585 <span class="skeleton" style={{ width: "32px", height: "32px" }} />
586 <span class="skeleton" style={{ width: "140px", height: "24px" }} />
587 <For each={[92, 96, 110]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>
588 </div>
589 <div class="skeleton" style={{ height: "112px" }} />
590 </div>
591 }>
592 {(data) => {
593 const tick = () => {
594 const latest = stream.latest();
595 return latest && latest.t > since() ? latest.services[data().id] : undefined;
596 };
597 const health = () => tick()?.health ?? data().health;
598 /** Nomad leaves usage unmeasured when the task driver reports none; the tip adds the reservation and the charted peak. */
599 const reading = (now: number | null, reserved: string, format: (value: number) => string) => (latest: Series[]) => {
600 const values = latest[0]?.v ?? [];
601 return {
602 value: now === null ? "–" : format(now),
603 tip: reserved + (values.length ? `, ${duration(range())} peak ${format(Math.max(...values.map((v) => v ?? 0)))}` : ""),
604 stale: !stream.live(),
605 };
606 };
607 const failing = () =>
608 (health() === "degraded" || health() === "down" ? data().checks.find((check) => !check.passing) : undefined);
609 const unacknowledged = () => unacknowledgedRestart(data());
610 const restarts = () => data().containers.reduce((sum, container) => sum + container.restarts, 0);
611 const main = () => data().containers.filter((container) => !container.hook);
612 const started = () => (health() === "healthy" ? main()[0]?.startedAt : undefined);
613 /** One pill per image the main tasks run, however many tasks share it. */
614 const images = () => [...new Set(main().flatMap((container) => container.image ?? []))].map((image) => ({
615 image, tasks: main().filter((container) => container.image === image).map((container) => container.name),
616 }));
617 const [clearing, setClearing] = createSignal(false);
618 const acknowledge = () => {
619 setClearing(true);
620 parseResponse(api.services[":id"].restarts.acknowledge.$post({ param: { id: params.id } }))
621 .then(refresh, (failure) => toast(`Couldn't clear the restart. ${reason(failure)}`))
622 .finally(() => setClearing(false));
623 };
624 const also = (verb: string) => data().dependents?.length
625 ? `, ${verb} ${new Intl.ListFormat("en").format(data().dependents!.map((link) => link.name))}` : "";
626 const restart = () => showConfirmDialog({
627 title: `Restart ${data().name}?`,
628 description: `${data().name} goes offline for a moment. Anyone using it is interrupted${also("as are")}.`,
629 confirmLabel: "restart",
630 onConfirm: () => act("restart"),
631 });
632 const stop = () => showConfirmDialog({
633 title: `Stop ${data().name}?`,
634 description: `${data().name} stays offline until you start it again. Anyone using it loses access${also("as do")}.`,
635 confirmLabel: "stop",
636 destructive: true,
637 onConfirm: () => act("stop"),
638 });
639 const start = () => showConfirmDialog({
640 title: `Start ${data().name}?`,
641 description: `${data().name} comes back online${data().release ? ` on release ${data().release!.slice(0, 8)}` : ""}.`,
642 confirmLabel: "start",
643 onConfirm: () => act("start"),
644 });
645 const tabHref = (tab: string) => `/services/${data().id}${tab ? "/" + tab : ""}`;
646 let tools!: HTMLDivElement;
647 return (
648 <ListPage id="service" class="service-page" flush head={
649 <>
650 <div class="page-head">
651 <span class="service-icon"><AppIcon id={data().id} name={data().name} icon={data().icon} /></span>
652 <h1>{data().name}</h1>
653 <Show when={data().url}>{(url) => <OpenApp app={data()} href={url()} icon={false} />}</Show>
654 <div class="stats">
655 <Show when={started()} fallback={<span class="stat" role="status"><StatusLabel health={health()} /></span>}>
656 {(t) => (
657 <span class="stat" role="status" tabIndex={0}
658 data-tip={[`since ${datetime(t())}`, ...data().checks.map((check) => `${check.name}: ${check.output}`)].join("; ")}>
659 <StatusLabel health="healthy">up {duration(Date.now() / 1000 - t())}</StatusLabel>
660 </span>
661 )}
662 </Show>
663 <For each={images()}>
664 {({ image, tasks }) => {
665 const [ref, digest] = image.split("@") as [string, string?];
666 const [name, tag] = ref.slice(ref.lastIndexOf("/") + 1).split(":") as [string, string?];
667 return (
668 <span class="stat image">
669 <Copy value={image} label={images().length > 1 ? `${tasks.join(" and ")} image` : "image"}>
670 <Package size={13} aria-hidden="true" /><b>{name}</b>
671 <span class="version">{digest ? digest.slice(digest.indexOf(":") + 1, digest.indexOf(":") + 9) : tag ?? "latest"}</span>
672 </Copy>
673 </span>
674 );
675 }}
676 </For>
677 <For each={data().datasets}>
678 {(dataset) => (
679 <A class="stat" href={`/storage?${new URLSearchParams({ dataset: dataset.name })}`}
680 data-tip={`${dataset.mountpoint}, ${bytes(dataset.snapshots)} more in snapshots`}>
681 <HardDrive size={13} aria-label="data" /><b>{bytes(dataset.used)}</b>
682 </A>
683 )}
684 </For>
685 <Show when={unacknowledged()}>
686 {(last) => (
687 <span class="stat restart">
688 <A href={`${tabHref("")}?at=${Math.floor(last().t)}`}
689 data-tip={`${last().reason}, ${plural(restarts(), "restart")} since deploy`}>
690 <RotateCcw size={13} aria-hidden="true" />restarted {ago(last().t)}
691 </A>
692 <button class="button icon-only" aria-label="Clear restart" data-tip="clear" disabled={clearing()}
693 aria-busy={clearing()} onClick={acknowledge}>
694 <Show when={!clearing()}><X size={12} /></Show>
695 </button>
696 </span>
697 )}
698 </Show>
699 </div>
700 <span class="spacer" />
701 <button class="button" onClick={restart}>restart</button>
702 <Show when={health() === "stopped"} fallback={<button class="button danger" onClick={stop}>stop</button>}>
703 <button class="button" onClick={start}>start</button>
704 </Show>
705 </div>
706 <Show when={failing()}>
707 {(check) => (
708 <div class="problem" role="status">
709 <StatusLabel health={health()}>{check().name}</StatusLabel>
710 <span class="output">{check().output}</span>
711 <A href={`${tabHref("")}?level=error`}>error logs</A>
712 </div>
713 )}
714 </Show>
715 </>
716 } summary={
717 <div class="service-strips">
718 <Strip title="cpu" metric="service.cpu" service={data().id} range={range()} format={cores}
719 height={64} tabs={<RangePicker value={range()} onChange={setRange} />}
720 now={reading(tick()?.cpu ?? data().cpu, `${cores(data().cpuLimit)} cores reserved`, cores)} />
721 <Strip title="memory" metric="service.memory" service={data().id} range={range()} format={bytes}
722 max={data().memoryLimit} height={64 + 24} last
723 now={reading(tick()?.memory ?? data().memory, `${bytes(data().memoryLimit)} limit`, bytes)} />
724 </div>
725 }>
726 <div ref={tools} class="tab-row">
727 <TabBar label="View">
728 <For each={TABS.filter(([tab]) => tab !== "traces" || data().applicationTraces).filter(([tab]) => tab !== "metrics" || data().applicationMetrics.length > 0)}>
729 {([tab, label]) => <A href={tabHref(tab)} end>{label}</A>}
730 </For>
731 </TabBar>
732 </div>
733 <Switch>
734 <Match when={!params.tab}><Logs service={data()} tools={tools} /></Match>
735 <Match when={params.tab === "traces" && data().applicationTraces}><Traces service={data()} tools={tools} /></Match>
736 <Match when={params.tab === "metrics" && data().applicationMetrics.length > 0}><AppMetrics service={data()} range={range()} /></Match>
737 <Match when={params.tab === "secrets"}><Secrets service={data()} onChange={refresh} /></Match>
738 <Match when={params.tab === "definition"}><Definition service={data()} /></Match>
739 </Switch>
740 </ListPage>
741 );
742 }}
743 </Loaded>
744 );
745}