1import { DetailedError, hc, parseResponse } from "hono/client";
2import { createResource, untrack } from "solid-js";
3import type { Api } from "./api.contract.ts";
4import type { Metric } from "./types/model.ts";
5
6export 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 */
18export 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. */
21export 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. */
32const 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. */
34const 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 */
40export 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. */
75export 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};