@checkstack/status-page-frontend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/package.json +32 -0
- package/src/index.tsx +46 -0
- package/src/pages/PublicStatusPage.tsx +109 -0
- package/src/pages/StatusPageBuilderPage.tsx +809 -0
- package/src/pages/StatusPagesListPage.tsx +249 -0
- package/src/renderers.tsx +481 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
AlertOctagon,
|
|
6
|
+
Wrench,
|
|
7
|
+
HelpCircle,
|
|
8
|
+
CalendarCheck,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import { MarkdownBlock } from "@checkstack/ui";
|
|
11
|
+
import {
|
|
12
|
+
BUILTIN_WIDGET_IDS,
|
|
13
|
+
BannerDtoSchema,
|
|
14
|
+
SystemHealthDtoSchema,
|
|
15
|
+
GroupStatusDtoSchema,
|
|
16
|
+
UptimeDtoSchema,
|
|
17
|
+
IncidentsDtoSchema,
|
|
18
|
+
MaintenanceDtoSchema,
|
|
19
|
+
TextDtoSchema,
|
|
20
|
+
HeadingDtoSchema,
|
|
21
|
+
LinksDtoSchema,
|
|
22
|
+
ImageDtoSchema,
|
|
23
|
+
type PublicStatus,
|
|
24
|
+
type ResolvedBlock,
|
|
25
|
+
} from "@checkstack/status-page-common";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* PURE public widget renderers. The single security rule for this layer: a
|
|
29
|
+
* renderer receives the resolver's already-allow-listed `data` as props and has
|
|
30
|
+
* NO data-fetching ability (no RPC client, no fetch). Both the admin preview and
|
|
31
|
+
* the public page render through this registry.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface StatusMeta {
|
|
35
|
+
label: string;
|
|
36
|
+
/** Solid color for dots / uptime bars. */
|
|
37
|
+
solid: string;
|
|
38
|
+
/** Soft pill background + text. */
|
|
39
|
+
soft: string;
|
|
40
|
+
/** Hero banner background + border. */
|
|
41
|
+
hero: string;
|
|
42
|
+
Icon: React.ComponentType<{ className?: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STATUS: Record<PublicStatus, StatusMeta> = {
|
|
46
|
+
operational: {
|
|
47
|
+
label: "Operational",
|
|
48
|
+
solid: "bg-success",
|
|
49
|
+
soft: "bg-success/10 text-success",
|
|
50
|
+
hero: "bg-success/10 text-success ring-success/20",
|
|
51
|
+
Icon: CheckCircle2,
|
|
52
|
+
},
|
|
53
|
+
degraded: {
|
|
54
|
+
label: "Degraded",
|
|
55
|
+
solid: "bg-warning",
|
|
56
|
+
soft: "bg-warning/10 text-warning",
|
|
57
|
+
hero: "bg-warning/10 text-warning ring-warning/20",
|
|
58
|
+
Icon: AlertTriangle,
|
|
59
|
+
},
|
|
60
|
+
partial_outage: {
|
|
61
|
+
label: "Partial outage",
|
|
62
|
+
solid: "bg-warning",
|
|
63
|
+
soft: "bg-warning/10 text-warning",
|
|
64
|
+
hero: "bg-warning/10 text-warning ring-warning/20",
|
|
65
|
+
Icon: AlertTriangle,
|
|
66
|
+
},
|
|
67
|
+
major_outage: {
|
|
68
|
+
label: "Major outage",
|
|
69
|
+
solid: "bg-destructive",
|
|
70
|
+
soft: "bg-destructive/10 text-destructive",
|
|
71
|
+
hero: "bg-destructive/10 text-destructive ring-destructive/20",
|
|
72
|
+
Icon: AlertOctagon,
|
|
73
|
+
},
|
|
74
|
+
maintenance: {
|
|
75
|
+
label: "Maintenance",
|
|
76
|
+
solid: "bg-info",
|
|
77
|
+
soft: "bg-info/10 text-info",
|
|
78
|
+
hero: "bg-info/10 text-info ring-info/20",
|
|
79
|
+
Icon: Wrench,
|
|
80
|
+
},
|
|
81
|
+
unknown: {
|
|
82
|
+
label: "Unknown",
|
|
83
|
+
solid: "bg-muted-foreground/40",
|
|
84
|
+
soft: "bg-muted text-muted-foreground",
|
|
85
|
+
hero: "bg-muted text-muted-foreground ring-border",
|
|
86
|
+
Icon: HelpCircle,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const StatusPill: React.FC<{ status: PublicStatus }> = ({ status }) => {
|
|
91
|
+
const meta = STATUS[status];
|
|
92
|
+
return (
|
|
93
|
+
<span
|
|
94
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${meta.soft}`}
|
|
95
|
+
>
|
|
96
|
+
<span className={`size-1.5 rounded-full ${meta.solid}`} />
|
|
97
|
+
{meta.label}
|
|
98
|
+
</span>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type RendererProps = { data: unknown; label?: string };
|
|
103
|
+
|
|
104
|
+
/** A titled card section — the building block for most widgets. */
|
|
105
|
+
const Section: React.FC<{
|
|
106
|
+
label?: string;
|
|
107
|
+
action?: React.ReactNode;
|
|
108
|
+
children: React.ReactNode;
|
|
109
|
+
}> = ({ label, action, children }) => (
|
|
110
|
+
<section className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
|
111
|
+
{(label || action) && (
|
|
112
|
+
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3">
|
|
113
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
114
|
+
{label}
|
|
115
|
+
</h3>
|
|
116
|
+
{action}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
<div className="p-5">{children}</div>
|
|
120
|
+
</section>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/** The hero status banner — the most prominent block on the page. */
|
|
124
|
+
const BannerRenderer: React.FC<RendererProps> = ({ data }) => {
|
|
125
|
+
const parsed = BannerDtoSchema.safeParse(data);
|
|
126
|
+
if (!parsed.success) return null;
|
|
127
|
+
const meta = STATUS[parsed.data.status];
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className={`flex items-center gap-4 rounded-2xl px-6 py-5 ring-1 ${meta.hero}`}
|
|
131
|
+
>
|
|
132
|
+
<span
|
|
133
|
+
className={`flex size-11 shrink-0 items-center justify-center rounded-full ${meta.soft}`}
|
|
134
|
+
>
|
|
135
|
+
<meta.Icon className="size-6" />
|
|
136
|
+
</span>
|
|
137
|
+
<span className="text-lg font-semibold sm:text-xl">
|
|
138
|
+
{parsed.data.title}
|
|
139
|
+
</span>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const StatusRow: React.FC<{
|
|
145
|
+
label: string;
|
|
146
|
+
status: PublicStatus;
|
|
147
|
+
uptimePct?: number;
|
|
148
|
+
}> = ({ label, status, uptimePct }) => (
|
|
149
|
+
<div className="flex items-center justify-between gap-3 py-2.5">
|
|
150
|
+
<span className="min-w-0 truncate text-sm font-medium text-foreground">
|
|
151
|
+
{label}
|
|
152
|
+
</span>
|
|
153
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
154
|
+
{uptimePct !== undefined && (
|
|
155
|
+
<span className="text-xs tabular-nums text-muted-foreground">
|
|
156
|
+
{uptimePct.toFixed(2)}%
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
<StatusPill status={status} />
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const SystemHealthRenderer: React.FC<RendererProps> = ({ data, label }) => {
|
|
165
|
+
const parsed = SystemHealthDtoSchema.safeParse(data);
|
|
166
|
+
if (!parsed.success) return null;
|
|
167
|
+
return (
|
|
168
|
+
<Section label={label}>
|
|
169
|
+
<div className="divide-y divide-border">
|
|
170
|
+
{parsed.data.systems.map((s, i) => (
|
|
171
|
+
<StatusRow key={i} label={s.label} status={s.status} uptimePct={s.uptimePct} />
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
</Section>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const GroupStatusRenderer: React.FC<RendererProps> = ({ data, label }) => {
|
|
179
|
+
const parsed = GroupStatusDtoSchema.safeParse(data);
|
|
180
|
+
if (!parsed.success) return null;
|
|
181
|
+
return (
|
|
182
|
+
<Section
|
|
183
|
+
label={label ?? parsed.data.label}
|
|
184
|
+
action={<StatusPill status={parsed.data.status} />}
|
|
185
|
+
>
|
|
186
|
+
<div className="divide-y divide-border">
|
|
187
|
+
{parsed.data.systems.map((s, i) => (
|
|
188
|
+
<StatusRow key={i} label={s.label} status={s.status} />
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</Section>
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const shortDate = (iso: string) =>
|
|
196
|
+
new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
197
|
+
|
|
198
|
+
const UptimeRenderer: React.FC<RendererProps> = ({ data, label }) => {
|
|
199
|
+
const parsed = UptimeDtoSchema.safeParse(data);
|
|
200
|
+
if (!parsed.success) return null;
|
|
201
|
+
const { bars } = parsed.data;
|
|
202
|
+
// No buckets => no run history in the window. Show "no data" rather than a
|
|
203
|
+
// misleading "0.00%" (a healthy system with no history is not 0% uptime).
|
|
204
|
+
const hasData = bars.length > 0;
|
|
205
|
+
return (
|
|
206
|
+
<Section
|
|
207
|
+
label={label ?? parsed.data.label}
|
|
208
|
+
action={
|
|
209
|
+
hasData ? (
|
|
210
|
+
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
211
|
+
{parsed.data.uptimePct.toFixed(2)}% uptime
|
|
212
|
+
</span>
|
|
213
|
+
) : undefined
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{hasData ? (
|
|
217
|
+
<>
|
|
218
|
+
<div className="flex h-9 items-stretch gap-[3px]">
|
|
219
|
+
{bars.map((bar, i) => (
|
|
220
|
+
<div
|
|
221
|
+
key={i}
|
|
222
|
+
role="img"
|
|
223
|
+
aria-label={`${shortDate(bar.date)}: ${bar.uptimePct.toFixed(1)}% uptime`}
|
|
224
|
+
title={`${shortDate(bar.date)}: ${bar.uptimePct.toFixed(1)}%`}
|
|
225
|
+
className={`flex-1 rounded-[2px] transition-opacity hover:opacity-70 ${STATUS[bar.status].solid}`}
|
|
226
|
+
/>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
<div className="mt-1.5 flex justify-between text-[11px] text-muted-foreground">
|
|
230
|
+
<span>{shortDate(bars[0].date)}</span>
|
|
231
|
+
<span>{shortDate(bars.at(-1)?.date ?? bars[0].date)}</span>
|
|
232
|
+
</div>
|
|
233
|
+
</>
|
|
234
|
+
) : (
|
|
235
|
+
<p className="text-sm text-muted-foreground">
|
|
236
|
+
No uptime data for this period yet.
|
|
237
|
+
</p>
|
|
238
|
+
)}
|
|
239
|
+
</Section>
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const formatAt = (iso: string) =>
|
|
244
|
+
new Date(iso).toLocaleString(undefined, {
|
|
245
|
+
dateStyle: "medium",
|
|
246
|
+
timeStyle: "short",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const SEVERITY_CLASS: Record<string, string> = {
|
|
250
|
+
critical: "bg-destructive/10 text-destructive",
|
|
251
|
+
major: "bg-warning/10 text-warning",
|
|
252
|
+
minor: "bg-muted text-muted-foreground",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
type Update = { message: string; statusChange?: string; at: string };
|
|
256
|
+
|
|
257
|
+
const UpdatesTimeline: React.FC<{ updates: Update[] }> = ({ updates }) => {
|
|
258
|
+
if (updates.length === 0) return null;
|
|
259
|
+
return (
|
|
260
|
+
<ol className="mt-3 space-y-3 border-l border-border pl-4">
|
|
261
|
+
{updates.map((u, i) => (
|
|
262
|
+
<li key={i} className="relative">
|
|
263
|
+
<span className="absolute -left-[21px] top-1 size-2 rounded-full bg-border" />
|
|
264
|
+
{u.statusChange && (
|
|
265
|
+
<span className="mb-0.5 inline-block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
266
|
+
{u.statusChange}
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
269
|
+
<p className="text-sm text-foreground">{u.message}</p>
|
|
270
|
+
<p className="text-[11px] tabular-nums text-muted-foreground">
|
|
271
|
+
{formatAt(u.at)}
|
|
272
|
+
</p>
|
|
273
|
+
</li>
|
|
274
|
+
))}
|
|
275
|
+
</ol>
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Section divider label for the "recently resolved / past" subsection. */
|
|
280
|
+
const PastHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
281
|
+
<p className="border-t border-border pt-4 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
282
|
+
{children}
|
|
283
|
+
</p>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
/** A compact past (resolved/completed) row: check + title + when. */
|
|
287
|
+
const PastRow: React.FC<{ title: string; at?: string }> = ({ title, at }) => (
|
|
288
|
+
<div className="flex items-center justify-between gap-2 py-1.5 text-sm">
|
|
289
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
290
|
+
<CheckCircle2 className="size-4 shrink-0 text-success" />
|
|
291
|
+
<span className="truncate text-muted-foreground">{title}</span>
|
|
292
|
+
</span>
|
|
293
|
+
{at && (
|
|
294
|
+
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
|
295
|
+
{shortDate(at)}
|
|
296
|
+
</span>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const IncidentsRenderer: React.FC<RendererProps> = ({ data, label }) => {
|
|
302
|
+
const parsed = IncidentsDtoSchema.safeParse(data);
|
|
303
|
+
if (!parsed.success) return null;
|
|
304
|
+
const active = parsed.data.incidents.filter((i) => i.status !== "resolved");
|
|
305
|
+
const past = parsed.data.incidents.filter((i) => i.status === "resolved");
|
|
306
|
+
return (
|
|
307
|
+
<Section label={label ?? "Incidents"}>
|
|
308
|
+
<div className="space-y-5">
|
|
309
|
+
{active.length === 0 ? (
|
|
310
|
+
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
311
|
+
<CheckCircle2 className="size-4 text-success" />
|
|
312
|
+
No active incidents.
|
|
313
|
+
</p>
|
|
314
|
+
) : (
|
|
315
|
+
active.map((inc) => (
|
|
316
|
+
<article key={inc.id}>
|
|
317
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
318
|
+
<h4 className="font-semibold">{inc.title}</h4>
|
|
319
|
+
<span
|
|
320
|
+
className={`rounded-full px-2 py-0.5 text-[11px] font-medium capitalize ${SEVERITY_CLASS[inc.severity] ?? "bg-muted text-muted-foreground"}`}
|
|
321
|
+
>
|
|
322
|
+
{inc.severity}
|
|
323
|
+
</span>
|
|
324
|
+
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium capitalize text-muted-foreground">
|
|
325
|
+
{inc.status}
|
|
326
|
+
</span>
|
|
327
|
+
</div>
|
|
328
|
+
{inc.systems.length > 0 && (
|
|
329
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
330
|
+
Affected: {inc.systems.join(", ")}
|
|
331
|
+
</p>
|
|
332
|
+
)}
|
|
333
|
+
<UpdatesTimeline updates={inc.updates} />
|
|
334
|
+
</article>
|
|
335
|
+
))
|
|
336
|
+
)}
|
|
337
|
+
{past.length > 0 && (
|
|
338
|
+
<div className="space-y-1">
|
|
339
|
+
<PastHeader>Recently resolved</PastHeader>
|
|
340
|
+
{past.map((inc) => (
|
|
341
|
+
<PastRow key={inc.id} title={inc.title} at={inc.resolvedAt} />
|
|
342
|
+
))}
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
</Section>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const MaintenanceRenderer: React.FC<RendererProps> = ({ data, label }) => {
|
|
351
|
+
const parsed = MaintenanceDtoSchema.safeParse(data);
|
|
352
|
+
if (!parsed.success) return null;
|
|
353
|
+
const active = parsed.data.maintenances.filter((m) => m.status !== "completed");
|
|
354
|
+
const past = parsed.data.maintenances.filter((m) => m.status === "completed");
|
|
355
|
+
return (
|
|
356
|
+
<Section label={label ?? "Scheduled maintenance"}>
|
|
357
|
+
<div className="space-y-4">
|
|
358
|
+
{active.length === 0 ? (
|
|
359
|
+
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
360
|
+
<CalendarCheck className="size-4 text-muted-foreground" />
|
|
361
|
+
No scheduled maintenance.
|
|
362
|
+
</p>
|
|
363
|
+
) : (
|
|
364
|
+
active.map((m) => (
|
|
365
|
+
<article key={m.id} className="rounded-lg bg-info/5 p-3">
|
|
366
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
367
|
+
<Wrench className="size-4 text-info" />
|
|
368
|
+
<h4 className="font-semibold">{m.title}</h4>
|
|
369
|
+
<span className="rounded-full bg-info/10 px-2 py-0.5 text-[11px] font-medium capitalize text-info">
|
|
370
|
+
{m.status.replace("_", " ")}
|
|
371
|
+
</span>
|
|
372
|
+
</div>
|
|
373
|
+
<p className="mt-1.5 text-xs tabular-nums text-muted-foreground">
|
|
374
|
+
{formatAt(m.startAt)} – {formatAt(m.endAt)}
|
|
375
|
+
</p>
|
|
376
|
+
{m.systems.length > 0 && (
|
|
377
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
378
|
+
Affecting: {m.systems.join(", ")}
|
|
379
|
+
</p>
|
|
380
|
+
)}
|
|
381
|
+
<UpdatesTimeline updates={m.updates} />
|
|
382
|
+
</article>
|
|
383
|
+
))
|
|
384
|
+
)}
|
|
385
|
+
{past.length > 0 && (
|
|
386
|
+
<div className="space-y-1">
|
|
387
|
+
<PastHeader>Past maintenance</PastHeader>
|
|
388
|
+
{past.map((m) => (
|
|
389
|
+
<PastRow key={m.id} title={m.title} at={m.endAt} />
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</Section>
|
|
395
|
+
);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const TextRenderer: React.FC<RendererProps> = ({ data }) => {
|
|
399
|
+
const parsed = TextDtoSchema.safeParse(data);
|
|
400
|
+
if (!parsed.success || !parsed.data.markdown.trim()) return null;
|
|
401
|
+
return (
|
|
402
|
+
<div className="text-sm leading-relaxed text-muted-foreground">
|
|
403
|
+
<MarkdownBlock>{parsed.data.markdown}</MarkdownBlock>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const HeadingRenderer: React.FC<RendererProps> = ({ data }) => {
|
|
409
|
+
const parsed = HeadingDtoSchema.safeParse(data);
|
|
410
|
+
if (!parsed.success || !parsed.data.text) return null;
|
|
411
|
+
const size =
|
|
412
|
+
parsed.data.level === 1
|
|
413
|
+
? "text-2xl"
|
|
414
|
+
: parsed.data.level === 2
|
|
415
|
+
? "text-xl"
|
|
416
|
+
: "text-lg";
|
|
417
|
+
return (
|
|
418
|
+
<h2 className={`pt-2 font-semibold tracking-tight ${size}`}>
|
|
419
|
+
{parsed.data.text}
|
|
420
|
+
</h2>
|
|
421
|
+
);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const LinksRenderer: React.FC<RendererProps> = ({ data }) => {
|
|
425
|
+
const parsed = LinksDtoSchema.safeParse(data);
|
|
426
|
+
if (!parsed.success || parsed.data.links.length === 0) return null;
|
|
427
|
+
return (
|
|
428
|
+
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
|
429
|
+
{parsed.data.links.map((l, i) => (
|
|
430
|
+
<a
|
|
431
|
+
key={i}
|
|
432
|
+
href={l.url}
|
|
433
|
+
target="_blank"
|
|
434
|
+
rel="noopener noreferrer"
|
|
435
|
+
className="text-sm font-medium text-primary hover:underline"
|
|
436
|
+
>
|
|
437
|
+
{l.label}
|
|
438
|
+
</a>
|
|
439
|
+
))}
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const ImageRenderer: React.FC<RendererProps> = ({ data }) => {
|
|
445
|
+
const parsed = ImageDtoSchema.safeParse(data);
|
|
446
|
+
if (!parsed.success) return null;
|
|
447
|
+
return (
|
|
448
|
+
<img
|
|
449
|
+
src={parsed.data.url}
|
|
450
|
+
alt={parsed.data.alt ?? ""}
|
|
451
|
+
style={parsed.data.maxHeight ? { maxHeight: parsed.data.maxHeight } : undefined}
|
|
452
|
+
className="object-contain"
|
|
453
|
+
/>
|
|
454
|
+
);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const DividerRenderer: React.FC<RendererProps> = () => (
|
|
458
|
+
<hr className="border-border" />
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
/** Map widget type id -> pure renderer. Third-party renderers register here. */
|
|
462
|
+
export const WIDGET_RENDERERS: Record<string, React.FC<RendererProps>> = {
|
|
463
|
+
[BUILTIN_WIDGET_IDS.banner]: BannerRenderer,
|
|
464
|
+
[BUILTIN_WIDGET_IDS.systemHealth]: SystemHealthRenderer,
|
|
465
|
+
[BUILTIN_WIDGET_IDS.groupStatus]: GroupStatusRenderer,
|
|
466
|
+
[BUILTIN_WIDGET_IDS.uptime]: UptimeRenderer,
|
|
467
|
+
[BUILTIN_WIDGET_IDS.incidents]: IncidentsRenderer,
|
|
468
|
+
[BUILTIN_WIDGET_IDS.maintenance]: MaintenanceRenderer,
|
|
469
|
+
[BUILTIN_WIDGET_IDS.text]: TextRenderer,
|
|
470
|
+
[BUILTIN_WIDGET_IDS.heading]: HeadingRenderer,
|
|
471
|
+
[BUILTIN_WIDGET_IDS.links]: LinksRenderer,
|
|
472
|
+
[BUILTIN_WIDGET_IDS.image]: ImageRenderer,
|
|
473
|
+
[BUILTIN_WIDGET_IDS.divider]: DividerRenderer,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/** Render one resolved block via the registry (skips unknown / null data). */
|
|
477
|
+
export const BlockRenderer: React.FC<{ block: ResolvedBlock }> = ({ block }) => {
|
|
478
|
+
const Renderer = WIDGET_RENDERERS[block.type];
|
|
479
|
+
if (!Renderer || block.data === null || block.data === undefined) return null;
|
|
480
|
+
return <Renderer data={block.data} label={block.label} />;
|
|
481
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@checkstack/tsconfig/frontend.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"src"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../auth-frontend"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../catalog-common"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "../common"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../frontend-api"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "../status-page-common"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "../ui"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|