| 69 | } |
| 70 | |
| 71 | export class ServerGraphStore implements GraphStore { |
| 72 | private readonly baseUrl: string; |
| 73 | private _hasData = false; |
| 74 | |
| 75 | // Visualization caps threaded into /api/graph as ?maxNodes=&maxEdges=. |
| 76 | // Defaults match LadybugStore and SettingsDrawer's DEFAULT_MAX_*. The |
| 77 | // agent caps the response server-side (see serve.py:fetch_graph). |
| 78 | private maxVisNodes = 20000; |
| 79 | private maxVisEdges = 20000; |
| 80 | |
| 81 | constructor(baseUrl: string) { |
| 82 | // Strip trailing slash for consistent URL building |
| 83 | this.baseUrl = baseUrl.replace(/\/+$/, ''); |
| 84 | |
| 85 | // Apply persisted visualization limits so the first fetch uses them |
| 86 | // without waiting for the user to open Settings and click Update. |
| 87 | // Mirrors LadybugStore's behavior in ensureReady(). |
| 88 | try { |
| 89 | const savedNodes = localStorage.getItem('ot:maxVisNodes'); |
| 90 | const savedEdges = localStorage.getItem('ot:maxVisEdges'); |
| 91 | const n = savedNodes != null ? Number(savedNodes) : NaN; |
| 92 | const e = savedEdges != null ? Number(savedEdges) : NaN; |
| 93 | // Integers only — the /api/graph contract rejects non-integer caps, |
| 94 | // so a persisted decimal would make every fetch 400. |
| 95 | if (Number.isInteger(n) && n > 0) this.maxVisNodes = n; |
| 96 | if (Number.isInteger(e) && e > 0) this.maxVisEdges = e; |
| 97 | } catch { |
| 98 | /* localStorage unavailable (SSR, restricted browser) — keep defaults */ |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // ---- Helpers -------------------------------------------------------- |
| 103 | |
| 104 | private async get<T>( |
| 105 | path: string, |
| 106 | params?: Record<string, string>, |
| 107 | ): Promise<T> { |
| 108 | const base = this.baseUrl.endsWith('/') ? this.baseUrl : this.baseUrl + '/'; |
| 109 | const url = new URL(path.startsWith('/') ? path.slice(1) : path, base); |
| 110 | if (params) { |
| 111 | for (const [k, v] of Object.entries(params)) { |
| 112 | if (v !== undefined && v !== '') url.searchParams.set(k, v); |
| 113 | } |
| 114 | } |
| 115 | const res = await fetch(url.toString()); |
| 116 | if (!res.ok) { |
| 117 | const body = await res.text().catch(() => ''); |
| 118 | throw new Error(`Server error ${res.status}: ${body}`); |
| 119 | } |
| 120 | return res.json() as Promise<T>; |
| 121 | } |
| 122 | |
| 123 | private async post<T>(path: string, body: unknown): Promise<T> { |
| 124 | const base = this.baseUrl.endsWith('/') ? this.baseUrl : this.baseUrl + '/'; |
| 125 | const url = new URL(path.startsWith('/') ? path.slice(1) : path, base); |
| 126 | const res = await fetch(url.toString(), { |
| 127 | method: 'POST', |
| 128 | headers: { 'Content-Type': 'application/json' }, |
nothing calls this directly
no outgoing calls
no test coverage detected