SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
11.2 KB · 261 lines typescript
Raw Blame History
1import "server-only";2import { getDb, providerHealth, fetchRequests, statusIncidents, sql, gte, and, ne, or, isNull, isNotNull, desc } from "@fetcha/db";3import { internalApi } from "@/lib/api";45export type ComponentState = "operational" | "degraded" | "outage" | "not-launched" | "unknown";67export type DayPoint = { day: string; state: ComponentState | "no-data"; successRate: number | null; total: number };89export type StatusComponent = {10  key: string;11  name: string;12  description: string;13  state: ComponentState;14  detail?: string;15  history: DayPoint[]; // 30 entries, oldest first16};1718export type Incident = {19  id: string;20  component: string;21  title: string;22  body: string | null;23  severity: string;24  startedAt: Date;25  resolvedAt: Date | null;26};2728export type StatusSnapshot = {29  generatedAt: Date;30  overall: ComponentState;31  components: StatusComponent[];32  successRate24h: number | null;33  requests24h: number;34  openIncidents: Incident[];35  resolvedIncidents: Incident[];36  dbReachable: boolean;37};3839const EXCLUDED_ERROR_CODES = ["INVALID_REQUEST", "URL_NOT_ALLOWED", "USAGE_LIMIT_REACHED"] as const;40const HISTORY_DAYS = 30;4142const COMPONENT_DEFS: Array<{ key: string; name: string; description: string }> = [43  { key: "api", name: "API", description: "POST /v1/fetch, sessions, usage and authentication endpoints." },44  { key: "dashboard", name: "Dashboard", description: "Web console, playground and request logs." },45  { key: "proxy-network", name: "Proxy Network", description: "Aggregate upstream capacity across all network classes." },46  { key: "residential-network", name: "Residential Network", description: "Residential exit nodes with geo targeting." },47  { key: "browser-network", name: "Browser Network", description: "Managed headless browser execution." },48  { key: "authentication", name: "Authentication", description: "Sign-in, sign-up, email verification and API key validation." },49  { key: "billing", name: "Billing", description: "Checkout, invoices and self-serve plan changes." },50];5152function utcDayKey(d: Date): string {53  return d.toISOString().slice(0, 10);54}5556function healthToState(rows: Array<{ status: string }>): ComponentState {57  if (rows.some((r) => r.status === "healthy")) return "operational";58  if (rows.some((r) => r.status === "degraded")) return "degraded";59  return "outage";60}6162function rateToState(rate: number | null, total: number): DayPoint["state"] {63  if (rate === null || total === 0) return "no-data";64  if (rate >= 0.99) return "operational";65  if (rate >= 0.95) return "degraded";66  return "outage";67}6869function worst(states: ComponentState[]): ComponentState {70  const rank: Record<ComponentState, number> = { outage: 4, degraded: 3, unknown: 2, operational: 1, "not-launched": 0 };71  return states.reduce<ComponentState>((acc, s) => (rank[s] > rank[acc] ? s : acc), "operational");72}7374function emptyHistory(state: DayPoint["state"]): DayPoint[] {75  const out: DayPoint[] = [];76  const today = new Date();77  for (let i = HISTORY_DAYS - 1; i >= 0; i--) {78    const d = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - i));79    out.push({ day: utcDayKey(d), state, successRate: null, total: 0 });80  }81  return out;82}8384/** Marks days overlapped by incidents for a given component as degraded/outage; everything else stays as provided. */85function overlayIncidents(history: DayPoint[], incidents: Incident[], componentKey: string, componentName: string): DayPoint[] {86  const mine = incidents.filter((i) => {87    const c = i.component.toLowerCase();88    return c === componentKey || c === componentName.toLowerCase() || c === componentKey.replace(/-/g, "_");89  });90  if (mine.length === 0) return history;91  return history.map((p) => {92    const dayStart = new Date(`${p.day}T00:00:00.000Z`).getTime();93    const dayEnd = dayStart + 86_400_000;94    const hit = mine.filter((i) => i.startedAt.getTime() < dayEnd && (i.resolvedAt ? i.resolvedAt.getTime() : Date.now()) >= dayStart);95    if (hit.length === 0) return p;96    const major = hit.some((i) => ["major", "critical"].includes(i.severity.toLowerCase()));97    return { ...p, state: major ? "outage" : "degraded" };98  });99}100101export async function getStatusSnapshot(): Promise<StatusSnapshot> {102  const now = new Date();103  const since24h = new Date(now.getTime() - 24 * 3600 * 1000);104  const since30d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (HISTORY_DAYS - 1)));105106  let dbReachable = true;107  let healthRows: Array<{ network: string; status: string }> = [];108  let successRate24h: number | null = null;109  let requests24h = 0;110  let daily: Map<string, { total: number; ok: number }> = new Map();111  let openIncidents: Incident[] = [];112  let resolvedIncidents: Incident[] = [];113114  // --- Provider health (never expose provider names; we only read network + status).115  try {116    const db = getDb();117    healthRows = await db118      .select({ network: providerHealth.network, status: providerHealth.status })119      .from(providerHealth)120      .where(gte(providerHealth.checkedAt, since24h))121      .orderBy(desc(providerHealth.checkedAt))122      .limit(500);123  } catch {124    dbReachable = false;125  }126127  const excludeErrors = or(isNull(fetchRequests.errorCode), sql`${fetchRequests.errorCode} not in (${sql.join(EXCLUDED_ERROR_CODES.map((c) => sql`${c}`), sql`, `)})`);128129  // --- 24 h success rate.130  if (dbReachable) {131    try {132      const db = getDb();133      const [row] = await db134        .select({135          total: sql<number>`count(*)::int`,136          ok: sql<number>`count(*) filter (where ${fetchRequests.status} = 'success')::int`,137        })138        .from(fetchRequests)139        .where(and(gte(fetchRequests.createdAt, since24h), ne(fetchRequests.status, "pending"), excludeErrors));140      const total = Number(row?.total ?? 0);141      const ok = Number(row?.ok ?? 0);142      requests24h = total;143      successRate24h = total > 0 ? ok / total : null;144    } catch {145      dbReachable = false;146    }147  }148149  // --- 30 day daily buckets.150  if (dbReachable) {151    try {152      const db = getDb();153      const rows = await db154        .select({155          day: sql<string | Date>`date_trunc('day', ${fetchRequests.createdAt} at time zone 'UTC')`.as("day"),156          total: sql<number>`count(*)::int`,157          ok: sql<number>`count(*) filter (where ${fetchRequests.status} = 'success')::int`,158        })159        .from(fetchRequests)160        .where(and(gte(fetchRequests.createdAt, since30d), ne(fetchRequests.status, "pending"), excludeErrors))161        .groupBy(sql`1`)162        .orderBy(sql`1`);163      daily = new Map(164        rows.map((r) => {165          const d = r.day instanceof Date ? r.day : new Date(r.day);166          return [utcDayKey(d), { total: Number(r.total), ok: Number(r.ok) }];167        }),168      );169    } catch {170      dbReachable = false;171    }172  }173174  // --- Incidents.175  if (dbReachable) {176    try {177      const db = getDb();178      const toIncident = (r: typeof statusIncidents.$inferSelect): Incident => ({179        id: r.id,180        component: r.component,181        title: r.title,182        body: r.body,183        severity: r.severity,184        startedAt: new Date(r.startedAt),185        resolvedAt: r.resolvedAt ? new Date(r.resolvedAt) : null,186      });187      const open = await db.select().from(statusIncidents).where(isNull(statusIncidents.resolvedAt)).orderBy(desc(statusIncidents.startedAt)).limit(20);188      const resolved = await db.select().from(statusIncidents).where(isNotNull(statusIncidents.resolvedAt)).orderBy(desc(statusIncidents.startedAt)).limit(10);189      openIncidents = open.map(toIncident);190      resolvedIncidents = resolved.map(toIncident);191    } catch {192      // incidents are optional; keep whatever we have193    }194  }195196  // --- API readiness via the internal API.197  let apiState: ComponentState = "operational";198  let apiDetail: string | undefined;199  try {200    const ready = await internalApi.ready();201    const s = String(ready.status ?? "").toLowerCase();202    apiState = s === "ok" || s === "ready" || s === "healthy" ? "operational" : "degraded";203    if (apiState === "degraded") apiDetail = "Readiness check reports a degraded dependency.";204  } catch {205    apiState = "degraded";206    apiDetail = "Readiness check unreachable from the status page.";207  }208209  // --- Build the 30-day history from daily fetch success rates.210  const trafficHistory: DayPoint[] = emptyHistory("no-data").map((p) => {211    const b = daily.get(p.day);212    if (!b || b.total === 0) return p;213    const rate = b.ok / b.total;214    return { day: p.day, total: b.total, successRate: rate, state: rateToState(rate, b.total) };215  });216  const allIncidents = [...openIncidents, ...resolvedIncidents];217218  const residentialRows = healthRows.filter((r) => r.network === "residential");219  const residentialState: ComponentState = dbReachable ? healthToState(residentialRows) : "unknown";220  const proxyState: ComponentState = dbReachable ? healthToState(healthRows) : "unknown";221  const dashboardState: ComponentState = dbReachable ? "operational" : "degraded";222  const authState: ComponentState = dbReachable ? "operational" : "degraded";223224  const rate24Detail = successRate24h === null ? "No requests in the last 24 hours." : `${(successRate24h * 100).toFixed(2)}% success over ${requests24h.toLocaleString("en-US")} requests (24 h).`;225226  const components: StatusComponent[] = COMPONENT_DEFS.map((def) => {227    switch (def.key) {228      case "api":229        return { ...def, state: apiState, detail: apiDetail ?? rate24Detail, history: overlayIncidents(trafficHistory, allIncidents, def.key, def.name) };230      case "dashboard":231        return { ...def, state: dashboardState, detail: dbReachable ? undefined : "Database unreachable while rendering this page.", history: overlayIncidents(emptyHistory("operational"), allIncidents, def.key, def.name) };232      case "proxy-network":233        return {234          ...def,235          state: proxyState,236          detail: !dbReachable ? "Health data unavailable." : healthRows.length === 0 ? "No health checks recorded in the last 24 hours." : undefined,237          history: overlayIncidents(trafficHistory, allIncidents, def.key, def.name),238        };239      case "residential-network":240        return {241          ...def,242          state: residentialState,243          detail: !dbReachable ? "Health data unavailable." : residentialRows.length === 0 ? "No residential health checks recorded in the last 24 hours." : rate24Detail,244          history: overlayIncidents(trafficHistory, allIncidents, def.key, def.name),245        };246      case "browser-network":247        return { ...def, state: "not-launched", detail: "Browser execution is not yet available (BROWSER_UNAVAILABLE).", history: emptyHistory("no-data") };248      case "authentication":249        return { ...def, state: authState, detail: dbReachable ? undefined : "Database unreachable while rendering this page.", history: overlayIncidents(emptyHistory("operational"), allIncidents, def.key, def.name) };250      case "billing":251        return { ...def, state: "not-launched", detail: "Checkout is not live. Upgrades by email to sales@fetcha.co.", history: emptyHistory("no-data") };252      default:253        return { ...def, state: "unknown", history: emptyHistory("no-data") };254    }255  });256257  const overall = worst(components.map((c) => c.state).filter((s) => s !== "not-launched"));258259  return { generatedAt: now, overall, components, successRate24h, requests24h, openIncidents, resolvedIncidents, dbReachable };260}261