Skip to content

Commit 259931c

Browse files
committed
feat: integrate components from external services
ci: apply automated fixes
1 parent 55305b0 commit 259931c

34 files changed

Lines changed: 6063 additions & 22 deletions

apps/web/src/app/(landing)/status/[id]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export default async function Page(args: { params: Promise<RouteParams> }) {
8282
days: HISTORY_DAYS,
8383
}),
8484
api.externalService.incidents.prefetch({ slug: service.slug }),
85+
api.externalService.components.prefetch({
86+
slug: service.slug,
87+
days: HISTORY_DAYS,
88+
}),
8589
]);
8690

8791
return (
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use client";
2+
3+
import { ExternalServiceComponents } from "@openstatus/ui/components/blocks/external-service-components";
4+
5+
import { api } from "@/trpc/rq-client";
6+
7+
export function ServiceComponents({
8+
slug,
9+
serviceName,
10+
days,
11+
}: {
12+
slug: string;
13+
serviceName: string;
14+
days: number;
15+
}) {
16+
const [data] = api.externalService.components.useSuspenseQuery({
17+
slug,
18+
days,
19+
});
20+
21+
if (!data.supported || data.components.length === 0) return null;
22+
23+
return (
24+
<>
25+
<h2>{serviceName} components</h2>
26+
<div className="not-prose">
27+
<ExternalServiceComponents components={data.components} days={days} />
28+
</div>
29+
</>
30+
);
31+
}

apps/web/src/app/(landing)/status/[id]/service-detail.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"use client";
22

3+
import { ExternalServicePill } from "@openstatus/ui/components/blocks/external-service-pill";
34
import { Suspense } from "react";
45

56
import { BASE_URL } from "@/lib/metadata/shared-metadata";
67
import { api } from "@/trpc/rq-client";
78

8-
import { ExternalServicePill } from "../external-service-pill";
99
import { formatRelative, getStatusAnswer, isStale } from "../utils";
1010
import { HistoryBars } from "./history-bars";
1111
import { Incidents } from "./incidents";
12+
import { ServiceComponents } from "./service-components";
1213

1314
function jsonLd(args: {
1415
serviceName: string;
@@ -139,6 +140,14 @@ export function ServiceDetail({ slug, days }: { slug: string; days: number }) {
139140
<HistoryBars daily={history} days={days} />
140141
</div>
141142

143+
<Suspense fallback={null}>
144+
<ServiceComponents
145+
slug={service.slug}
146+
serviceName={service.name}
147+
days={days}
148+
/>
149+
</Suspense>
150+
142151
<h2>{service.name} recent incidents</h2>
143152
<Suspense
144153
fallback={

apps/web/src/app/(landing)/status/external-status-grid.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import { useQueryState } from "nuqs";
44
import { useMemo } from "react";
55

6+
import { ExternalServicePill } from "@openstatus/ui/components/blocks/external-service-pill";
7+
68
import { Grid } from "@/content/mdx-components/grid";
79
import { api } from "@/trpc/rq-client";
810

911
import { ContentBoxLink } from "../content-box";
10-
import { ExternalServicePill } from "./external-service-pill";
1112
import { filterServices } from "./filter-services";
1213
import { qParser } from "./search-params";
1314

apps/web/src/app/api/og/external-service/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ const INDEX_TITLE = "External Status";
1919
const INDEX_DESCRIPTION =
2020
"Check if your external providers are working properly";
2121

22-
// Mirrors getPillStyle in (landing)/status/external-service-pill.tsx so the OG
23-
// label matches the on-page pill.
22+
// Mirrors getPillStyle in @openstatus/ui external-service-pill so the OG label
23+
// matches the on-page pill.
2424
function getStatus(args: { indicator: string; status: string }): {
2525
label: string;
2626
bg: string;

apps/workflows/src/cron/external-status.ts

Lines changed: 155 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { getLogger } from "@logtape/logtape";
22
import { listExternalServices } from "@openstatus/services/external-service";
33
import type { ExternalServiceRow } from "@openstatus/services/external-service";
4+
import {
5+
type UpsertExternalComponentInput,
6+
upsertExternalComponentsForService,
7+
} from "@openstatus/services/external-service-component";
48
import {
59
type UpsertExternalIncidentInput,
610
upsertExternalIncidentsForService,
711
} from "@openstatus/services/external-service-incident";
812
import { FetchError, fetchers } from "@openstatus/status-fetcher";
913
import type {
14+
NormalizedComponent,
1015
NormalizedIncident,
1116
StatusFetcher,
1217
StatusPageEntry,
@@ -24,7 +29,7 @@ const logger = getLogger(["workflow", "external-status"]);
2429

2530
const tb = new OSTinybird(env().TINY_BIRD_API_KEY);
2631

27-
// 10 per phase × 2 phases = peak 20 concurrent HTTP requests upstream; keeps
32+
// 10 per phase × 3 phases = peak 30 concurrent HTTP requests upstream; keeps
2833
// Atlassian/Incident.io CDNs comfortable while still parallelising heavily.
2934
const PHASE_CONCURRENCY = 10;
3035

@@ -84,6 +89,28 @@ function toUpsertInput(
8489
};
8590
}
8691

92+
type ComponentSnapshot = {
93+
component_id: string;
94+
external_service_id: number;
95+
indicator: string;
96+
status: string;
97+
fetched_at: number;
98+
};
99+
100+
function toComponentUpsertInput(
101+
component: NormalizedComponent,
102+
): UpsertExternalComponentInput {
103+
return {
104+
upstreamComponentId: component.upstreamComponentId,
105+
name: component.name,
106+
description: component.description,
107+
groupName: component.groupName,
108+
position: component.position,
109+
indicator: component.severity,
110+
status: component.status,
111+
};
112+
}
113+
87114
type PhaseCounts = {
88115
successCount: number;
89116
failureCount: number;
@@ -101,6 +128,11 @@ type IncidentPhaseOutcome =
101128
| { kind: "skip"; slug: string }
102129
| { kind: "fail"; slug: string; reason: string };
103130

131+
type ComponentPhaseOutcome =
132+
| { kind: "ok"; slug: string; snapshots: ComponentSnapshot[] }
133+
| { kind: "skip"; slug: string }
134+
| { kind: "fail"; slug: string; reason: string };
135+
104136
type Triplet = {
105137
row: ExternalServiceRow;
106138
entry: StatusPageEntry;
@@ -193,6 +225,73 @@ function runIncidentPhase(
193225
);
194226
}
195227

228+
function runComponentPhase(
229+
triplets: Triplet[],
230+
tickStartedAt: Date,
231+
fetchedAt: number,
232+
): Effect.Effect<ComponentPhaseOutcome[]> {
233+
return Effect.forEach(
234+
triplets,
235+
({ row, entry, fetcher }) => {
236+
if (!fetcher || !fetcher.fetchComponents) {
237+
return Effect.succeed<ComponentPhaseOutcome>({
238+
kind: "skip",
239+
slug: entry.id,
240+
});
241+
}
242+
return fetcher.fetchComponents(entry).pipe(
243+
Effect.flatMap((components) =>
244+
Effect.tryPromise({
245+
try: () =>
246+
upsertExternalComponentsForService({
247+
ctx: { db },
248+
externalServiceId: row.id,
249+
components: components.map(toComponentUpsertInput),
250+
now: tickStartedAt,
251+
}),
252+
catch: (e) =>
253+
new FetchError({
254+
url: entry.status_page_url,
255+
fetcherName: fetcher.name,
256+
entryId: entry.id,
257+
cause: e instanceof Error ? e : new Error(String(e)),
258+
}),
259+
}).pipe(
260+
Effect.map((result): ComponentPhaseOutcome => {
261+
// History rows key on our PK, so the upstream→PK map from the
262+
// upsert turns each normalized component into a snapshot.
263+
const byUpstream = new Map(
264+
components.map((c) => [c.upstreamComponentId, c]),
265+
);
266+
const snapshots: ComponentSnapshot[] = [];
267+
for (const upserted of result.upserted) {
268+
const c = byUpstream.get(upserted.upstreamComponentId);
269+
if (!c) continue;
270+
snapshots.push({
271+
component_id: String(upserted.id),
272+
external_service_id: row.id,
273+
indicator: c.severity,
274+
status: c.status,
275+
fetched_at: fetchedAt,
276+
});
277+
}
278+
return { kind: "ok", slug: entry.id, snapshots };
279+
}),
280+
),
281+
),
282+
Effect.catchAll((err: FetchError) =>
283+
Effect.succeed<ComponentPhaseOutcome>({
284+
kind: "fail",
285+
slug: entry.id,
286+
reason: err.message,
287+
}),
288+
),
289+
);
290+
},
291+
{ concurrency: PHASE_CONCURRENCY },
292+
);
293+
}
294+
196295
function summarizeStatus(outcomes: StatusPhaseOutcome[]): {
197296
counts: PhaseCounts;
198297
snapshots: Snapshot[];
@@ -254,6 +353,39 @@ function summarizeIncidents(outcomes: IncidentPhaseOutcome[]): PhaseCounts {
254353
};
255354
}
256355

356+
function summarizeComponents(outcomes: ComponentPhaseOutcome[]): {
357+
counts: PhaseCounts;
358+
snapshots: ComponentSnapshot[];
359+
} {
360+
const snapshots: ComponentSnapshot[] = [];
361+
let successCount = 0;
362+
let failureCount = 0;
363+
let skippedCount = 0;
364+
for (const o of outcomes) {
365+
if (o.kind === "ok") {
366+
successCount++;
367+
snapshots.push(...o.snapshots);
368+
} else if (o.kind === "skip") {
369+
skippedCount++;
370+
} else {
371+
failureCount++;
372+
logger.warn(
373+
"external-status components: failed for slug={slug}: {reason}",
374+
{ slug: o.slug, reason: o.reason },
375+
);
376+
}
377+
}
378+
return {
379+
counts: {
380+
successCount,
381+
failureCount,
382+
skippedCount,
383+
total: outcomes.length,
384+
},
385+
snapshots,
386+
};
387+
}
388+
257389
function buildTriplets(services: ExternalServiceRow[]): Triplet[] {
258390
return services.map((row) => {
259391
const entry = toStatusPageEntry(row);
@@ -265,30 +397,37 @@ function buildTriplets(services: ExternalServiceRow[]): Triplet[] {
265397
export async function runExternalStatusTick(): Promise<{
266398
status: PhaseCounts;
267399
incidents: PhaseCounts;
400+
components: PhaseCounts;
268401
}> {
269402
const services = await listExternalServices({ ctx: { db } });
270403

271404
const triplets = buildTriplets(services);
272405
const tickStartedAt = new Date();
273406

274-
const [statusOutcomes, incidentOutcomes] = await Effect.runPromise(
275-
Effect.all(
276-
[
277-
runStatusPhase(triplets, tickStartedAt.getTime()),
278-
runIncidentPhase(triplets, tickStartedAt),
279-
],
280-
{ concurrency: "unbounded" },
281-
),
282-
);
407+
const [statusOutcomes, incidentOutcomes, componentOutcomes] =
408+
await Effect.runPromise(
409+
Effect.all(
410+
[
411+
runStatusPhase(triplets, tickStartedAt.getTime()),
412+
runIncidentPhase(triplets, tickStartedAt),
413+
runComponentPhase(triplets, tickStartedAt, tickStartedAt.getTime()),
414+
],
415+
{ concurrency: "unbounded" },
416+
),
417+
);
283418

284419
const status = summarizeStatus(statusOutcomes);
285420
const incidents = summarizeIncidents(incidentOutcomes);
421+
const components = summarizeComponents(componentOutcomes);
286422

287423
if (status.snapshots.length > 0) {
288424
await tb.publishExternalStatus(status.snapshots);
289425
}
426+
if (components.snapshots.length > 0) {
427+
await tb.publishExternalStatusComponent(components.snapshots);
428+
}
290429

291-
return { status: status.counts, incidents };
430+
return { status: status.counts, incidents, components: components.counts };
292431
}
293432

294433
export async function handleExternalStatusCron(c: Context) {
@@ -309,7 +448,7 @@ export async function handleExternalStatusCron(c: Context) {
309448
Effect.tap((res) =>
310449
Effect.sync(() => {
311450
logger.info(
312-
"external-status tick complete: status={statusOk}/{statusTotal} ({statusFail} failures, {statusSkip} skipped), incidents={incOk}/{incTotal} ({incFail} failures, {incSkip} skipped)",
451+
"external-status tick complete: status={statusOk}/{statusTotal} ({statusFail} failures, {statusSkip} skipped), incidents={incOk}/{incTotal} ({incFail} failures, {incSkip} skipped), components={compOk}/{compTotal} ({compFail} failures, {compSkip} skipped)",
313452
{
314453
statusOk: res.status.successCount,
315454
statusTotal: res.status.total,
@@ -319,6 +458,10 @@ export async function handleExternalStatusCron(c: Context) {
319458
incTotal: res.incidents.total,
320459
incFail: res.incidents.failureCount,
321460
incSkip: res.incidents.skippedCount,
461+
compOk: res.components.successCount,
462+
compTotal: res.components.total,
463+
compFail: res.components.failureCount,
464+
compSkip: res.components.skippedCount,
322465
},
323466
);
324467
void cronCompleted();

0 commit comments

Comments
 (0)