| 1 | import { A, useParams, useSearchParams } from "@solidjs/router"; |
| 2 | import { parseResponse } from "hono/client"; |
| 3 | import Eye from "lucide-solid/icons/eye"; |
| 4 | import EyeOff from "lucide-solid/icons/eye-off"; |
| 5 | import HardDrive from "lucide-solid/icons/hard-drive"; |
| 6 | import Package from "lucide-solid/icons/package"; |
| 7 | import RotateCcw from "lucide-solid/icons/rotate-ccw"; |
| 8 | import Search from "lucide-solid/icons/search"; |
| 9 | import X from "lucide-solid/icons/x"; |
| 10 | import { |
| 11 | createEffect, createMemo, createResource, createSignal, createUniqueId, For, type JSX, Match, onCleanup, Show, Switch, |
| 12 | } from "solid-js"; |
| 13 | import { Portal } from "solid-js/web"; |
| 14 | import { |
| 15 | type Action, type LogLine, type Series, type ServiceDetail, type TraceSummary, unacknowledgedRestart, |
| 16 | } from "../types/model.ts"; |
| 17 | import { logField, needles, parseSearch, traceField, type TraceSort } from "../types/search.ts"; |
| 18 | import { api, queries, query, reason } from "../api.ts"; |
| 19 | import { Ago } from "../components/Ago.tsx"; |
| 20 | import { AppIcon } from "../components/AppIcon.tsx"; |
| 21 | import { Copy } from "../components/Copy.tsx"; |
| 22 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; |
| 23 | import { ListPage } from "../components/ListPage.tsx"; |
| 24 | import { LogView } from "../components/LogView.tsx"; |
| 25 | import { OpenApp } from "../components/OpenApp.tsx"; |
| 26 | import { lastGood, Loaded } from "../components/Loaded.tsx"; |
| 27 | import { RangePicker } from "../components/Metric.tsx"; |
| 28 | import { TimeChart } from "../components/TimeChart.tsx"; |
| 29 | import { type Sort, SortHeader } from "../components/SortHeader.tsx"; |
| 30 | import { Status, StatusLabel } from "../components/Status.tsx"; |
| 31 | import { Strip } from "../components/Strip.tsx"; |
| 32 | import { TabBar } from "../components/TabBar.tsx"; |
| 33 | import { toast } from "../components/Toast.tsx"; |
| 34 | import { ago, bytes, clock, cores, datetime, duration, plural } from "../format.ts"; |
| 35 | import { stream } from "../live.ts"; |
| 36 | import { Definition } from "./Service.definition.tsx"; |
| 37 | import { Flame, ms } from "./Service.flame.tsx"; |
| 38 | import "./Service.css"; |
| 39 | |
| 40 | const TABS = [["", "logs"], ["traces", "traces"], ["metrics", "metrics"], ["secrets", "secrets"], ["definition", "definition"]] as const; |
| 41 | const 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. */ |
| 44 | type 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. */ |
| 47 | const 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. */ |
| 50 | function 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 | |
| 206 | const 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 }))); |
| 208 | const oneTrace = query("trace", (id: string) => parseResponse(api.traces[":id"].$get({ param: { id } }))); |
| 209 | const PAGE = 50; |
| 210 | |
| 211 | function 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 | |
| 239 | function 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. */ |
| 390 | function 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 | |
| 400 | function 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 | */ |
| 495 | function 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. */ |
| 557 | export function Service() { |
| 558 | const params = useParams<{ id: string }>(); |
| 559 | return <Show when={params.id} keyed><ServicePage /></Show>; |
| 560 | } |
| 561 | |
| 562 | function 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 | } |