CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
platform-siblings.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f295f78 | 1 | /** |
| 2 | * Cross-product health aggregator — fetches the `platform-status` endpoint | |
| 3 | * of each sibling product (Crontech, Gluecron, GateTest) so the admin | |
| 4 | * dashboard can render a single cross-product status panel. | |
| 5 | * | |
| 6 | * Contract (defined in docs/PLATFORM_STATUS.md): | |
| 7 | * | |
| 8 | * GET <base>/api/platform-status → { | |
| 9 | * product, version, commit, healthy, timestamp, siblings | |
| 10 | * } | |
| 11 | * | |
| 12 | * The endpoint is public + CORS-open + non-sensitive so no auth is required. | |
| 13 | * We fetch server-side anyway to centralise timeouts + caching and avoid | |
| 14 | * mixed-content or CORS surprises from admin browsers. | |
| 15 | */ | |
| 16 | ||
| 17 | const DEFAULTS = { | |
| 18 | crontech: "https://crontech.ai/api/platform-status", | |
| 19 | gluecron: "https://gluecron.com/api/platform-status", | |
| 20 | gatetest: "https://gatetest.io/api/platform-status", | |
| 21 | } as const; | |
| 22 | ||
| 23 | export type SiblingId = keyof typeof DEFAULTS; | |
| 24 | ||
| 25 | export interface SiblingStatus { | |
| 26 | id: SiblingId; | |
| 27 | name: string; | |
| 28 | url: string; | |
| 29 | reachable: boolean; | |
| 30 | healthy: boolean; | |
| 31 | latencyMs: number | null; | |
| 32 | version: string | null; | |
| 33 | commit: string | null; | |
| 34 | timestamp: string | null; | |
| 35 | checkedAt: string; | |
| 36 | error: string | null; | |
| 37 | } | |
| 38 | ||
| 39 | const DISPLAY_NAMES: Record<SiblingId, string> = { | |
| 40 | crontech: "Crontech", | |
| 41 | gluecron: "Gluecron", | |
| 42 | gatetest: "GateTest", | |
| 43 | }; | |
| 44 | ||
| 45 | const TIMEOUT_MS = 3000; | |
| 46 | const CACHE_TTL_MS = 30_000; | |
| 47 | ||
| 48 | interface CacheEntry { | |
| 49 | value: SiblingStatus[]; | |
| 50 | expiresAt: number; | |
| 51 | } | |
| 52 | let cache: CacheEntry | null = null; | |
| 53 | ||
| 54 | export function siblingUrls(): Record<SiblingId, string> { | |
| 55 | return { | |
| 56 | crontech: process.env.CRONTECH_STATUS_URL || DEFAULTS.crontech, | |
| 57 | gluecron: process.env.GLUECRON_STATUS_URL || DEFAULTS.gluecron, | |
| 58 | gatetest: process.env.GATETEST_STATUS_URL || DEFAULTS.gatetest, | |
| 59 | }; | |
| 60 | } | |
| 61 | ||
| 62 | async function fetchOne(id: SiblingId, url: string): Promise<SiblingStatus> { | |
| 63 | const checkedAt = new Date().toISOString(); | |
| 64 | const started = Date.now(); | |
| 65 | try { | |
| 66 | const res = await fetch(url, { | |
| 67 | method: "GET", | |
| 68 | headers: { accept: "application/json" }, | |
| 69 | signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 70 | }); | |
| 71 | const latencyMs = Date.now() - started; | |
| 72 | if (!res.ok) { | |
| 73 | return { | |
| 74 | id, | |
| 75 | name: DISPLAY_NAMES[id], | |
| 76 | url, | |
| 77 | reachable: true, | |
| 78 | healthy: false, | |
| 79 | latencyMs, | |
| 80 | version: null, | |
| 81 | commit: null, | |
| 82 | timestamp: null, | |
| 83 | checkedAt, | |
| 84 | error: `HTTP ${res.status}`, | |
| 85 | }; | |
| 86 | } | |
| 87 | const body = (await res.json()) as Partial<{ | |
| 88 | healthy: boolean; | |
| 89 | version: string; | |
| 90 | commit: string; | |
| 91 | timestamp: string; | |
| 92 | }>; | |
| 93 | return { | |
| 94 | id, | |
| 95 | name: DISPLAY_NAMES[id], | |
| 96 | url, | |
| 97 | reachable: true, | |
| 98 | healthy: body.healthy === true, | |
| 99 | latencyMs, | |
| 100 | version: typeof body.version === "string" ? body.version : null, | |
| 101 | commit: typeof body.commit === "string" ? body.commit : null, | |
| 102 | timestamp: typeof body.timestamp === "string" ? body.timestamp : null, | |
| 103 | checkedAt, | |
| 104 | error: null, | |
| 105 | }; | |
| 106 | } catch (err) { | |
| 107 | const latencyMs = Date.now() - started; | |
| 108 | const message = | |
| 109 | err instanceof Error | |
| 110 | ? err.name === "TimeoutError" || err.name === "AbortError" | |
| 111 | ? "timeout" | |
| 112 | : err.message | |
| 113 | : "unreachable"; | |
| 114 | return { | |
| 115 | id, | |
| 116 | name: DISPLAY_NAMES[id], | |
| 117 | url, | |
| 118 | reachable: false, | |
| 119 | healthy: false, | |
| 120 | latencyMs: latencyMs >= TIMEOUT_MS ? TIMEOUT_MS : latencyMs, | |
| 121 | version: null, | |
| 122 | commit: null, | |
| 123 | timestamp: null, | |
| 124 | checkedAt, | |
| 125 | error: message, | |
| 126 | }; | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Returns the health of all three sibling products. Cached for 30s so | |
| 132 | * admin page reloads don't hammer neighbours. Always resolves — a failed | |
| 133 | * fetch is reported as `{ reachable: false, healthy: false }`. | |
| 134 | */ | |
| 135 | export async function getSiblingStatuses(options?: { | |
| 136 | force?: boolean; | |
| 137 | }): Promise<SiblingStatus[]> { | |
| 138 | const now = Date.now(); | |
| 139 | if (!options?.force && cache && cache.expiresAt > now) { | |
| 140 | return cache.value; | |
| 141 | } | |
| 142 | const urls = siblingUrls(); | |
| 143 | const ids = Object.keys(urls) as SiblingId[]; | |
| 144 | const value = await Promise.all(ids.map((id) => fetchOne(id, urls[id]))); | |
| 145 | cache = { value, expiresAt: now + CACHE_TTL_MS }; | |
| 146 | return value; | |
| 147 | } | |
| 148 | ||
| 149 | /** Test-only — resets the in-memory cache. */ | |
| 150 | export function __resetSiblingCache(): void { | |
| 151 | cache = null; | |
| 152 | } |