| 1 | import { DetailedError, hc, parseResponse } from "hono/client"; |
| 2 | import { createResource, untrack } from "solid-js"; |
| 3 | import type { Api } from "./api.contract.ts"; |
| 4 | import type { Metric } from "./types/model.ts"; |
| 5 | |
| 6 | export const api = hc<Api>("/api", { |
| 7 | fetch: (input: RequestInfo | URL, init?: RequestInit) => { |
| 8 | if (init?.method !== "GET") return fetch(input, init); |
| 9 | const timeout = AbortSignal.timeout(15_000); |
| 10 | return fetch(input, { ...init, signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout }); |
| 11 | }, |
| 12 | }); |
| 13 | |
| 14 | /** |
| 15 | * The server has no source for this yet (HTTP 501); its message names what's missing. It stays missing until the |
| 16 | * server restarts, so polling it is pointless. |
| 17 | */ |
| 18 | export const unconnected = (error: unknown) => error instanceof DetailedError && error.statusCode === 501; |
| 19 | |
| 20 | /** A sentence for a failed request: the server's own text or JSON message when it sent one. */ |
| 21 | export function reason(error: unknown): string { |
| 22 | if (!(error instanceof DetailedError)) return "The dashboard didn't answer. Check your connection, then retry."; |
| 23 | const data = error.detail?.data; |
| 24 | const text = typeof data === "string" ? data.trim() : data?.message; |
| 25 | if (typeof text === "string" && text && !text.startsWith("<")) return text; |
| 26 | return error.statusCode >= 502 |
| 27 | ? "The dashboard server isn't answering. It may be restarting, so retry in a moment." |
| 28 | : `The dashboard answered ${error.message}. Check its logs, then retry.`; |
| 29 | } |
| 30 | |
| 31 | /** The last answer to each query, so a page opened again renders at once while it refetches. */ |
| 32 | const answers = new Map<string, unknown>(); |
| 33 | /** The one request under way per query, which every load joins, including a page opening after its link's hover. */ |
| 34 | const loading = new Map<string, Promise<unknown>>(); |
| 35 | |
| 36 | /** |
| 37 | * A GET that every view showing it shares. `use` is `createResource` that renders the last answer at once and refetches |
| 38 | * it, and whose loads join one already under way; `preload` starts a first load early, for a route's link hover. |
| 39 | */ |
| 40 | export function query<T, A = void>(name: string, fetch: (arg: A) => Promise<T>) { |
| 41 | const keyOf = (arg: A) => JSON.stringify([name, arg ?? null]); |
| 42 | function request(key: string, arg: A) { |
| 43 | const answer = fetch(arg); |
| 44 | loading.set(key, answer); |
| 45 | answer.then((value) => answers.set(key, value), () => {}).finally(() => loading.delete(key)); |
| 46 | return answer; |
| 47 | } |
| 48 | return { |
| 49 | preload(arg: A) { |
| 50 | const key = keyOf(arg); |
| 51 | if (!answers.has(key) && !loading.has(key)) request(key, arg).catch(() => {}); |
| 52 | }, |
| 53 | /** `arg` is reactive; while it's false nothing loads. */ |
| 54 | use(arg: () => A | false = () => undefined as A) { |
| 55 | const key = () => { |
| 56 | const value = arg(); |
| 57 | return value !== false && keyOf(value); |
| 58 | }; |
| 59 | const [resource, actions] = createResource<T, string>(key, (key, { value, refetching }) => { |
| 60 | if (!refetching && answers.has(key)) { |
| 61 | queueMicrotask(() => actions.refetch()); |
| 62 | return answers.get(key) as T; |
| 63 | } |
| 64 | // A new key with no answer yet shows its skeleton, since Solid keeps the previous key's value as `latest`. |
| 65 | if (!refetching && value !== undefined) actions.mutate(undefined); |
| 66 | // Solid drops an answer once a newer load starts, so a poll that restarted a slow request would never land. |
| 67 | return (loading.get(key) as Promise<T> | undefined) ?? request(key, untrack(arg) as A); |
| 68 | }); |
| 69 | return [resource, actions] as const; |
| 70 | }, |
| 71 | }; |
| 72 | } |
| 73 | |
| 74 | /** Queries that several pages share or a route preloads. */ |
| 75 | export const queries = { |
| 76 | services: query("services", () => parseResponse(api.services.$get())), |
| 77 | service: query("service", (id: string) => parseResponse(api.services[":id"].$get({ param: { id } }))), |
| 78 | launcher: query("launcher", () => parseResponse(api.launcher.$get())), |
| 79 | host: query("host", () => parseResponse(api.host.$get())), |
| 80 | storage: query("storage", () => parseResponse(api.storage.$get())), |
| 81 | deploys: query("deploys", () => parseResponse(api.deploys.$get())), |
| 82 | vms: query("vms", () => parseResponse(api.vms.$get())), |
| 83 | metric: query("metric", ({ metric, range, service }: { metric: Metric; range: number; service?: string }) => |
| 84 | parseResponse(api.metrics[":metric"].$get({ param: { metric }, query: { range: String(range), ...(service ? { service } : {}) } }))), |
| 85 | seedbox: query("seedbox", () => parseResponse(api.seedbox.$get())), |
| 86 | seedboxHistory: query("seedbox history", () => parseResponse(api.seedbox.history.$get())), |
| 87 | paperClover: query("paper clover", () => parseResponse(api["paper-clover"].$get())), |
| 88 | paperCloverActivity: query("paper clover activity", () => parseResponse(api["paper-clover"].activity.$get())), |
| 89 | }; |