Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitf295f78

feat(platform): wire platform-status route, add cross-product admin widget + onboarding cross-sell

feat(platform): wire platform-status route, add cross-product admin widget + onboarding cross-sell

- src/app.tsx: register /api/platform-status route.
- src/routes/admin.tsx: new /admin/platform page rendering a card per
  sibling (Crontech / Gluecron / GateTest) with up-down indicator,
  latency, version, commit, last-checked; plus a new button on the
  admin landing page.
- src/routes/onboarding.tsx: gentle "Pairs well with" cross-sell card
  on /getting-started (outbound links only, no shared auth).
- src/lib/platform-siblings.ts: server-side fetch helper (3s timeout,
  30s cache, graceful unreachable). Env-configurable URLs.
- src/__tests__/platform-siblings.test.ts: smoke tests for lib + route.
Dictation App committed on April 20, 2026Parent: ccf9eef
3 files changed+2950f295f785b5e64e3bd78c1871c478023a8899fe16
3 changed files+295−0
Addedsrc/__tests__/platform-siblings.test.ts+139−0View fileUnifiedSplit
1/**
2 * Cross-product platform-status aggregator + admin widget smoke tests.
3 *
4 * The real siblings (crontech.ai, gluecron.com, gatetest.io) aren't
5 * reachable in test, so we stub `globalThis.fetch` to exercise the three
6 * branches: healthy JSON, non-2xx, and outright failure. Each branch must
7 * resolve — a sibling being down never crashes the widget.
8 */
9
10import { describe, it, expect, afterEach, beforeEach } from "bun:test";
11import app from "../app";
12import {
13 getSiblingStatuses,
14 siblingUrls,
15 __resetSiblingCache,
16} from "../lib/platform-siblings";
17
18const originalFetch = globalThis.fetch;
19
20beforeEach(() => {
21 __resetSiblingCache();
22});
23
24afterEach(() => {
25 globalThis.fetch = originalFetch;
26 __resetSiblingCache();
27 delete process.env.CRONTECH_STATUS_URL;
28 delete process.env.GLUECRON_STATUS_URL;
29 delete process.env.GATETEST_STATUS_URL;
30});
31
32describe("platform-siblings — siblingUrls", () => {
33 it("returns defaults pointing at production hosts", () => {
34 const urls = siblingUrls();
35 expect(urls.crontech).toBe("https://crontech.ai/api/platform-status");
36 expect(urls.gluecron).toBe("https://gluecron.com/api/platform-status");
37 expect(urls.gatetest).toBe("https://gatetest.io/api/platform-status");
38 });
39
40 it("honours env var overrides", () => {
41 process.env.CRONTECH_STATUS_URL = "https://example.com/ct";
42 process.env.GLUECRON_STATUS_URL = "https://example.com/gl";
43 process.env.GATETEST_STATUS_URL = "https://example.com/gt";
44 const urls = siblingUrls();
45 expect(urls.crontech).toBe("https://example.com/ct");
46 expect(urls.gluecron).toBe("https://example.com/gl");
47 expect(urls.gatetest).toBe("https://example.com/gt");
48 });
49});
50
51describe("platform-siblings — getSiblingStatuses", () => {
52 it("reports healthy when sibling returns healthy JSON", async () => {
53 globalThis.fetch = (async () =>
54 new Response(
55 JSON.stringify({
56 product: "x",
57 version: "1.2.3",
58 commit: "abcdef1234567",
59 healthy: true,
60 timestamp: "2026-04-20T00:00:00Z",
61 }),
62 { status: 200, headers: { "content-type": "application/json" } }
63 )) as unknown as typeof fetch;
64
65 const rows = await getSiblingStatuses({ force: true });
66 expect(rows).toHaveLength(3);
67 for (const r of rows) {
68 expect(r.reachable).toBe(true);
69 expect(r.healthy).toBe(true);
70 expect(r.version).toBe("1.2.3");
71 expect(r.commit).toBe("abcdef1234567");
72 expect(r.error).toBeNull();
73 expect(typeof r.latencyMs).toBe("number");
74 }
75 });
76
77 it("reports degraded (reachable but unhealthy) on non-2xx", async () => {
78 globalThis.fetch = (async () =>
79 new Response("nope", { status: 503 })) as unknown as typeof fetch;
80
81 const rows = await getSiblingStatuses({ force: true });
82 for (const r of rows) {
83 expect(r.reachable).toBe(true);
84 expect(r.healthy).toBe(false);
85 expect(r.error).toBe("HTTP 503");
86 }
87 });
88
89 it("reports unreachable on fetch error, never throws", async () => {
90 globalThis.fetch = (async () => {
91 throw new Error("ECONNREFUSED");
92 }) as unknown as typeof fetch;
93
94 const rows = await getSiblingStatuses({ force: true });
95 expect(rows).toHaveLength(3);
96 for (const r of rows) {
97 expect(r.reachable).toBe(false);
98 expect(r.healthy).toBe(false);
99 expect(r.error).toBeTruthy();
100 }
101 });
102
103 it("serves a cached result on the second call", async () => {
104 let calls = 0;
105 globalThis.fetch = (async () => {
106 calls++;
107 return new Response(
108 JSON.stringify({ healthy: true, version: "v", commit: "c" }),
109 { status: 200 }
110 );
111 }) as unknown as typeof fetch;
112
113 await getSiblingStatuses({ force: true });
114 expect(calls).toBe(3);
115 await getSiblingStatuses();
116 expect(calls).toBe(3);
117 });
118});
119
120describe("platform-status endpoint", () => {
121 it("GET /api/platform-status returns our own health JSON", async () => {
122 const res = await app.request("/api/platform-status");
123 expect(res.status).toBe(200);
124 const body = (await res.json()) as any;
125 expect(body.product).toBe("gluecron");
126 expect(body.healthy).toBe(true);
127 expect(typeof body.timestamp).toBe("string");
128 expect(body.siblings).toBeDefined();
129 expect(res.headers.get("access-control-allow-origin")).toBe("*");
130 });
131});
132
133describe("admin platform widget — auth gate", () => {
134 it("GET /admin/platform without auth → 302 /login", async () => {
135 const res = await app.request("/admin/platform");
136 expect(res.status).toBe(302);
137 expect(res.headers.get("location") || "").toContain("/login");
138 });
139});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
2424import contributorRoutes from "./routes/contributors";
2525import healthRoutes from "./routes/health-probe";
2626import healthDashboardRoutes from "./routes/health";
27import { platformStatus } from "./routes/platform-status";
2728import insightRoutes from "./routes/insights";
2829import dashboardRoutes from "./routes/dashboard";
2930import legalRoutes from "./routes/legal";
204205// Health liveness + metrics endpoints
205206app.route("/", healthRoutes);
206207
208// Cross-product platform status (public, CORS-open — see docs/PLATFORM_STATUS.md)
209app.route("/api/platform-status", platformStatus);
210
207211// Health dashboard (per-repo health page)
208212app.route("/", healthDashboardRoutes);
209213
Addedsrc/lib/platform-siblings.ts+152−0View fileUnifiedSplit
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
17const 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
23export type SiblingId = keyof typeof DEFAULTS;
24
25export 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
39const DISPLAY_NAMES: Record<SiblingId, string> = {
40 crontech: "Crontech",
41 gluecron: "Gluecron",
42 gatetest: "GateTest",
43};
44
45const TIMEOUT_MS = 3000;
46const CACHE_TTL_MS = 30_000;
47
48interface CacheEntry {
49 value: SiblingStatus[];
50 expiresAt: number;
51}
52let cache: CacheEntry | null = null;
53
54export 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
62async 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 */
135export 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. */
150export function __resetSiblingCache(): void {
151 cache = null;
152}
0153