@fix-portal/ci-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/dist/index.js ADDED
@@ -0,0 +1,889 @@
1
+ // src/CiAdminContext.tsx
2
+ import { createContext, use } from "react";
3
+ var CiAdminContext = createContext(false);
4
+ CiAdminContext.displayName = "CiAdminContext";
5
+ var CiAdminProvider = CiAdminContext.Provider;
6
+ function useCiAdmin() {
7
+ return use(CiAdminContext);
8
+ }
9
+
10
+ // src/CiConfigContext.tsx
11
+ import { createContext as createContext2, use as use2 } from "react";
12
+ var DEFAULT_CI_API_BASE = "https://ci.fixportal.org";
13
+ var CiConfigContext = createContext2({ apiBase: DEFAULT_CI_API_BASE });
14
+ CiConfigContext.displayName = "CiConfigContext";
15
+ var CiConfigProvider = CiConfigContext.Provider;
16
+ function useCiConfig() {
17
+ return use2(CiConfigContext);
18
+ }
19
+
20
+ // src/pages/CiBoardContent.tsx
21
+ import { useState as useState5 } from "react";
22
+
23
+ // src/hooks/useDashboardSnapshot.ts
24
+ import { useQuery } from "@tanstack/react-query";
25
+
26
+ // src/api/getDashboardSnapshot.ts
27
+ async function getDashboardSnapshot(apiBase) {
28
+ const base = apiBase.replace(/\/$/, "");
29
+ const response = await fetch(`${base}/api/dashboard/snapshot`);
30
+ if (response.status === 204) return null;
31
+ if (!response.ok) throw new Error(`Dashboard snapshot failed: ${response.status}`);
32
+ return response.json();
33
+ }
34
+
35
+ // src/hooks/useDashboardSnapshot.ts
36
+ function useDashboardSnapshot() {
37
+ const { apiBase } = useCiConfig();
38
+ return useQuery({
39
+ queryKey: ["dashboard-snapshot", apiBase],
40
+ queryFn: () => getDashboardSnapshot(apiBase),
41
+ refetchInterval: 6e4,
42
+ // The 60s poll already drives freshness; without these, an incidental tab
43
+ // focus refetches and re-renders the whole board between ticks. Set per-query
44
+ // (not on the shared app QueryClient) so the host app is unaffected;
45
+ // structural sharing then lets the memoised boards skip a no-change tick.
46
+ staleTime: 3e4,
47
+ refetchOnWindowFocus: false
48
+ });
49
+ }
50
+
51
+ // src/hooks/useCollapseState.ts
52
+ import { useCallback, useState } from "react";
53
+ var KEY = "ci-dashboard:collapsed";
54
+ function load() {
55
+ try {
56
+ const raw = localStorage.getItem(KEY);
57
+ return new Set(raw ? JSON.parse(raw) : []);
58
+ } catch {
59
+ return /* @__PURE__ */ new Set();
60
+ }
61
+ }
62
+ function save(set) {
63
+ try {
64
+ localStorage.setItem(KEY, JSON.stringify([...set]));
65
+ } catch {
66
+ }
67
+ }
68
+ function useCollapseState() {
69
+ const [collapsed, setCollapsed] = useState(load);
70
+ const mutate = useCallback((fn) => {
71
+ setCollapsed((prev) => {
72
+ const next = new Set(prev);
73
+ fn(next);
74
+ save(next);
75
+ return next;
76
+ });
77
+ }, []);
78
+ return {
79
+ isCollapsed: useCallback((name) => collapsed.has(name), [collapsed]),
80
+ allCollapsed: useCallback(
81
+ (names) => names.length > 0 && names.every((n) => collapsed.has(n)),
82
+ [collapsed]
83
+ ),
84
+ toggle: useCallback((name) => mutate((s) => s.has(name) ? s.delete(name) : s.add(name)), [mutate]),
85
+ collapseAll: useCallback((names) => mutate((s) => names.forEach((n) => s.add(n))), [mutate]),
86
+ expandAll: useCallback(() => mutate((s) => s.clear()), [mutate])
87
+ };
88
+ }
89
+
90
+ // src/hooks/useHideNoCi.ts
91
+ import { useCallback as useCallback2, useState as useState2 } from "react";
92
+ var KEY2 = "ci-dashboard:hide-no-ci";
93
+ function load2() {
94
+ try {
95
+ return localStorage.getItem(KEY2) === "true";
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+ function useHideNoCi() {
101
+ const [hidden, setHidden] = useState2(load2);
102
+ const toggle = useCallback2(() => {
103
+ setHidden((prev) => {
104
+ const next = !prev;
105
+ try {
106
+ localStorage.setItem(KEY2, String(next));
107
+ } catch {
108
+ }
109
+ return next;
110
+ });
111
+ }, []);
112
+ return { hidden, toggle };
113
+ }
114
+
115
+ // src/lib/isNoCi.ts
116
+ function isNoCi(repository) {
117
+ return (repository.workflows?.length ?? 0) === 0;
118
+ }
119
+
120
+ // src/lib/computeSummary.ts
121
+ var ALWAYS_VISIBLE_KEYS = /* @__PURE__ */ new Set([
122
+ "repos",
123
+ "workflows",
124
+ "nloc",
125
+ // inventory
126
+ "open-prs",
127
+ // Review panel (carries next-in-queue / last-merged)
128
+ "running",
129
+ "failing",
130
+ "no-ci",
131
+ // core CI status
132
+ "deploys-running",
133
+ "deploys-failing",
134
+ // deploy lane (zero = nothing deploying / all clean)
135
+ "packages-failing"
136
+ // package lane
137
+ ]);
138
+ function computeSummary(repos) {
139
+ const workflows = repos.flatMap((r) => r.workflows);
140
+ const deploys = repos.flatMap((r) => r.deploys ?? []);
141
+ const packages = repos.flatMap((r) => r.packages ?? []);
142
+ const openPrs = repos.flatMap((r) => r.pullRequests ?? []);
143
+ const nloc = repos.reduce((acc, r) => acc + (r.metrics?.nloc ?? 0), 0);
144
+ const all = [
145
+ { key: "repos", count: repos.length },
146
+ { key: "workflows", count: workflows.length },
147
+ { key: "failing", count: workflows.filter((w) => w.state === "failure").length },
148
+ { key: "running", count: workflows.filter((w) => w.state === "running").length },
149
+ { key: "no-ci", count: repos.filter(isNoCi).length },
150
+ { key: "open-prs", count: openPrs.length },
151
+ { key: "nloc", count: nloc },
152
+ { key: "deploys-failing", count: deploys.filter((d) => d.state === "failure").length },
153
+ { key: "deploys-running", count: deploys.filter((d) => d.state === "running").length },
154
+ { key: "packages-failing", count: packages.filter((p) => p.state === "failure").length }
155
+ ];
156
+ return all.filter((c) => ALWAYS_VISIBLE_KEYS.has(c.key) || c.count > 0);
157
+ }
158
+
159
+ // src/components/SummaryStrip.tsx
160
+ import { useEffect, useRef, useState as useState3 } from "react";
161
+
162
+ // src/lib/formatCompactNumber.ts
163
+ function formatCompactNumber(value) {
164
+ if (value < 1e3) return String(value);
165
+ if (value < 1e6) return `${(value / 1e3).toFixed(1)}k`;
166
+ return `${(value / 1e6).toFixed(1)}M`;
167
+ }
168
+
169
+ // src/lib/relativeTime.ts
170
+ function formatRelativeTime(iso) {
171
+ const then = new Date(iso).getTime();
172
+ if (Number.isNaN(then)) return "";
173
+ const minutes = Math.round((Date.now() - then) / 6e4);
174
+ if (minutes < 1) return "just now";
175
+ if (minutes < 60) return `${minutes}m ago`;
176
+ const hours = Math.round(minutes / 60);
177
+ if (hours < 24) return `${hours}h ago`;
178
+ const days = Math.round(hours / 24);
179
+ return `${days}d ago`;
180
+ }
181
+
182
+ // src/components/CiWeatherBar.tsx
183
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
184
+ var BLOCK_WORD = {
185
+ passing: "healthy",
186
+ failing: "failing",
187
+ noData: "no data"
188
+ };
189
+ function CiWeatherBar({ trend }) {
190
+ if (trend.length === 0) return null;
191
+ const failing = trend.filter((b) => b.state === "failing").length;
192
+ const healthy = trend.filter((b) => b.state === "passing").length;
193
+ const label = `CI health, last 24h: ${failing} failing, ${healthy} healthy`;
194
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
195
+ /* @__PURE__ */ jsx("div", { className: "ci-weather", role: "img", "aria-label": label, children: trend.map((b, i) => (
196
+ // Per-block hover reveals which hour a block is and its state — the
197
+ // data was previously exposed only to screen readers via aria-label.
198
+ /* @__PURE__ */ jsx(
199
+ "span",
200
+ {
201
+ className: "ci-weather__block",
202
+ "data-state": b.state,
203
+ title: `${formatRelativeTime(b.bucketStart)} \xB7 ${BLOCK_WORD[b.state]}`
204
+ },
205
+ i
206
+ )
207
+ )) }),
208
+ /* @__PURE__ */ jsxs("span", { className: "ci-weather__readout", "aria-hidden": "true", children: [
209
+ failing,
210
+ " failing \xB7 ",
211
+ healthy,
212
+ " healthy"
213
+ ] })
214
+ ] });
215
+ }
216
+
217
+ // src/components/SummaryStrip.tsx
218
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
219
+ var SUMMARY_LABELS = {
220
+ repos: "Repositories",
221
+ workflows: "Workflows",
222
+ failing: "Failing",
223
+ running: "Running",
224
+ "no-ci": "No CI",
225
+ "open-prs": "Open PRs",
226
+ nloc: "Lines of code",
227
+ "deploys-failing": "Deploys failing",
228
+ "deploys-running": "Deploys running",
229
+ "packages-failing": "Packages failing"
230
+ };
231
+ var PANELS = [
232
+ { title: "Review", keys: ["open-prs"] },
233
+ { title: "CI status", keys: ["running", "failing", "packages-failing", "deploys-running", "deploys-failing", "no-ci"] },
234
+ { title: "Inventory", keys: ["repos", "workflows", "nloc"] }
235
+ ];
236
+ var NEUTRAL_KEYS = /* @__PURE__ */ new Set(["repos", "workflows", "nloc"]);
237
+ var EMPTY_TREND = [];
238
+ function labelFor(key, count) {
239
+ if (key === "open-prs") return count === 1 ? "Open PR" : "Open PRs";
240
+ return SUMMARY_LABELS[key] ?? key.replaceAll("-", " ");
241
+ }
242
+ function formatCount(key, count) {
243
+ return key === "nloc" ? formatCompactNumber(count) : count;
244
+ }
245
+ function toneFor(key, count) {
246
+ if (count === 0 || NEUTRAL_KEYS.has(key)) return "ok";
247
+ if (key === "open-prs") return "review";
248
+ if (key === "failing" || key === "deploys-failing" || key === "packages-failing") return "fail";
249
+ if (key === "running" || key === "deploys-running") return "run";
250
+ if (key === "no-ci") return "no-ci";
251
+ return "alert";
252
+ }
253
+ function SummaryStrip({ summary, onOpenPrs, lastMerged, nextPr = null, ciTrend = EMPTY_TREND }) {
254
+ const byKey = new Map(summary.map((s) => [s.key, s.count]));
255
+ const [trendInfoOpen, setTrendInfoOpen] = useState3(false);
256
+ const trendLabelRef = useRef(null);
257
+ useEffect(() => {
258
+ if (!trendInfoOpen) return;
259
+ function handleOutside(e) {
260
+ if (!trendLabelRef.current?.contains(e.target)) {
261
+ setTrendInfoOpen(false);
262
+ }
263
+ }
264
+ function handleEsc(e) {
265
+ if (e.key === "Escape") setTrendInfoOpen(false);
266
+ }
267
+ document.addEventListener("mousedown", handleOutside);
268
+ document.addEventListener("keydown", handleEsc);
269
+ return () => {
270
+ document.removeEventListener("mousedown", handleOutside);
271
+ document.removeEventListener("keydown", handleEsc);
272
+ };
273
+ }, [trendInfoOpen]);
274
+ return /* @__PURE__ */ jsx2("section", { className: "summary-panels", children: PANELS.map((panel) => {
275
+ const items = [];
276
+ for (const k of panel.keys) {
277
+ const count = byKey.get(k);
278
+ if (count !== void 0) items.push({ key: k, count });
279
+ }
280
+ if (items.length === 0) return null;
281
+ const isReview = panel.title === "Review";
282
+ const isCiStatus = panel.title === "CI status";
283
+ return /* @__PURE__ */ jsxs2("div", { className: `summary-panel${isReview ? " summary-panel--review" : ""}${isCiStatus ? " summary-panel--ci" : ""}`, children: [
284
+ /* @__PURE__ */ jsx2("span", { className: "summary-panel__title", children: panel.title }),
285
+ /* @__PURE__ */ jsx2("div", { className: "summary-panel__items", children: items.map((item) => {
286
+ const body = /* @__PURE__ */ jsxs2(Fragment2, { children: [
287
+ /* @__PURE__ */ jsx2("span", { className: "summary__count", children: formatCount(item.key, item.count) }),
288
+ /* @__PURE__ */ jsx2("span", { className: "summary__label", children: labelFor(item.key, item.count) })
289
+ ] });
290
+ if (item.key === "open-prs" && onOpenPrs) {
291
+ return /* @__PURE__ */ jsx2(
292
+ "button",
293
+ {
294
+ type: "button",
295
+ className: "summary__item summary__item--btn",
296
+ "data-key": item.key,
297
+ "data-tone": toneFor(item.key, item.count),
298
+ onClick: onOpenPrs,
299
+ disabled: item.count === 0,
300
+ children: body
301
+ },
302
+ item.key
303
+ );
304
+ }
305
+ return /* @__PURE__ */ jsx2("div", { className: "summary__item", "data-key": item.key, "data-tone": toneFor(item.key, item.count), children: body }, item.key);
306
+ }) }),
307
+ isReview && nextPr && /* @__PURE__ */ jsxs2("div", { className: "summary-panel__next", children: [
308
+ /* @__PURE__ */ jsx2("span", { className: "summary-panel__q-lab", children: "next in queue" }),
309
+ /* @__PURE__ */ jsxs2("span", { className: "summary-panel__q-body", children: [
310
+ nextPr.repo,
311
+ " #",
312
+ nextPr.number
313
+ ] }),
314
+ /* @__PURE__ */ jsx2("span", { className: "summary-panel__q-title", children: nextPr.title })
315
+ ] }),
316
+ isReview && lastMerged && /* @__PURE__ */ jsxs2("a", { className: "summary-panel__merged", href: lastMerged.htmlUrl, target: "_blank", rel: "noopener noreferrer", children: [
317
+ /* @__PURE__ */ jsxs2("span", { className: "summary-panel__q-lab", children: [
318
+ "last merged ",
319
+ /* @__PURE__ */ jsxs2("span", { className: "summary-panel__q-age", children: [
320
+ "(",
321
+ formatRelativeTime(lastMerged.mergedAt),
322
+ ")"
323
+ ] })
324
+ ] }),
325
+ /* @__PURE__ */ jsxs2("span", { className: "summary-panel__q-body", children: [
326
+ lastMerged.repo,
327
+ " #",
328
+ lastMerged.number
329
+ ] }),
330
+ /* @__PURE__ */ jsx2("span", { className: "summary-panel__q-title", children: lastMerged.title })
331
+ ] }),
332
+ panel.title === "CI status" && ciTrend.length > 0 && /* @__PURE__ */ jsxs2("div", { className: "summary-panel__trend", children: [
333
+ /* @__PURE__ */ jsx2(CiWeatherBar, { trend: ciTrend }),
334
+ /* @__PURE__ */ jsxs2("div", { ref: trendLabelRef, className: "summary-panel__trend-label-row", children: [
335
+ /* @__PURE__ */ jsx2("span", { className: "summary-panel__trend-lab", children: "CI health \xB7 24h" }),
336
+ /* @__PURE__ */ jsx2(
337
+ "button",
338
+ {
339
+ type: "button",
340
+ className: "ci-trend-info-btn",
341
+ "aria-label": "CI health information",
342
+ "aria-expanded": trendInfoOpen,
343
+ "aria-controls": "ci-trend-popover",
344
+ onClick: () => setTrendInfoOpen((o) => !o),
345
+ children: "i"
346
+ }
347
+ ),
348
+ trendInfoOpen && /* @__PURE__ */ jsxs2(
349
+ "section",
350
+ {
351
+ id: "ci-trend-popover",
352
+ "aria-label": "CI health explanation",
353
+ className: "ci-trend-popover",
354
+ children: [
355
+ /* @__PURE__ */ jsx2("div", { className: "ci-trend-popover__title", children: "CI health \xB7 24h" }),
356
+ /* @__PURE__ */ jsx2("p", { children: "Each bar is a 1-hour bucket of CI activity across the whole org." }),
357
+ /* @__PURE__ */ jsxs2("p", { children: [
358
+ /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", className: "ci-trend-popover__swatch ci-trend-popover__swatch--fail", children: "\u25A0" }),
359
+ " Red \u2014 any run failed that hour (on any branch)."
360
+ ] }),
361
+ /* @__PURE__ */ jsxs2("p", { children: [
362
+ /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", className: "ci-trend-popover__swatch ci-trend-popover__swatch--pass", children: "\u25A0" }),
363
+ " Green \u2014 runs present, none failed. Quiet hours inherit the previous state."
364
+ ] }),
365
+ /* @__PURE__ */ jsx2("p", { children: "Oldest bar on the left, newest on the right." }),
366
+ /* @__PURE__ */ jsx2("div", { className: "ci-trend-popover__caret", "aria-hidden": "true" })
367
+ ]
368
+ }
369
+ )
370
+ ] })
371
+ ] })
372
+ ] }, panel.title);
373
+ }) });
374
+ }
375
+
376
+ // src/components/RepoBoard.tsx
377
+ import { memo as memo3 } from "react";
378
+
379
+ // src/components/SignalChip.tsx
380
+ import { memo } from "react";
381
+
382
+ // src/lib/stateLabel.ts
383
+ var STATE_LABELS = {
384
+ success: "passing",
385
+ failure: "failing",
386
+ running: "running",
387
+ unknown: "status unknown"
388
+ };
389
+ function stateLabel(state) {
390
+ return STATE_LABELS[state];
391
+ }
392
+
393
+ // src/components/SignalChip.tsx
394
+ import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
395
+ function meta(wf) {
396
+ if (wf.state === "unknown") return wf.lastRun ? "no status" : "no runs";
397
+ return formatRelativeTime(wf.lastRun.updatedAt);
398
+ }
399
+ var SignalChip = memo(function SignalChip2({ workflow }) {
400
+ const url = workflow.lastRun?.htmlUrl;
401
+ const linkable = Boolean(url);
402
+ const className = `chip chip--${workflow.state}${linkable ? "" : " chip--static"}`;
403
+ const body = /* @__PURE__ */ jsxs3(Fragment3, { children: [
404
+ /* @__PURE__ */ jsx3("span", { className: "chip__dot", "aria-hidden": "true" }),
405
+ /* @__PURE__ */ jsx3("span", { className: "chip__label", children: workflow.name }),
406
+ /* @__PURE__ */ jsx3("span", { className: "sr-only", children: stateLabel(workflow.state) }),
407
+ /* @__PURE__ */ jsx3("span", { className: "chip__meta", children: meta(workflow) })
408
+ ] });
409
+ return linkable ? /* @__PURE__ */ jsx3("a", { className, href: url, title: stateLabel(workflow.state), target: "_blank", rel: "noopener noreferrer", children: body }) : /* @__PURE__ */ jsx3("span", { className, title: stateLabel(workflow.state), children: body });
410
+ });
411
+
412
+ // src/components/RepoMetricsLine.tsx
413
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
414
+ function RepoMetricsLine({ metrics }) {
415
+ if (!metrics || metrics.nloc === 0) return null;
416
+ return /* @__PURE__ */ jsxs4("dl", { className: "repo-metrics", "aria-label": "code metrics", children: [
417
+ /* @__PURE__ */ jsxs4("div", { title: "non-comment lines of code", children: [
418
+ /* @__PURE__ */ jsx4("dt", { children: "NLOC" }),
419
+ /* @__PURE__ */ jsx4("dd", { children: formatCompactNumber(metrics.nloc) })
420
+ ] }),
421
+ /* @__PURE__ */ jsxs4("div", { title: "average cyclomatic complexity (branch paths per function)", children: [
422
+ /* @__PURE__ */ jsx4("dt", { children: "avg CCN" }),
423
+ /* @__PURE__ */ jsx4("dd", { children: metrics.avgComplexity.toFixed(1) })
424
+ ] }),
425
+ /* @__PURE__ */ jsxs4("div", { title: "number of functions", children: [
426
+ /* @__PURE__ */ jsx4("dt", { children: "functions" }),
427
+ /* @__PURE__ */ jsx4("dd", { children: formatCompactNumber(metrics.functionCount) })
428
+ ] }),
429
+ metrics.highComplexityCount > 0 && /* @__PURE__ */ jsxs4("div", { className: "repo-metrics__complex", title: "functions over CCN 15 \u2014 refactor candidates", children: [
430
+ /* @__PURE__ */ jsx4("dt", { children: "complex" }),
431
+ /* @__PURE__ */ jsx4("dd", { children: metrics.highComplexityCount })
432
+ ] })
433
+ ] });
434
+ }
435
+
436
+ // src/components/PullRequestList.tsx
437
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
438
+ function PullRequestList({ pullRequests }) {
439
+ if (pullRequests.length === 0) return null;
440
+ return /* @__PURE__ */ jsxs5("div", { className: "repo-prs", children: [
441
+ /* @__PURE__ */ jsxs5("span", { className: "repo-prs__count", children: [
442
+ pullRequests.length,
443
+ " open PR",
444
+ pullRequests.length === 1 ? "" : "s"
445
+ ] }),
446
+ /* @__PURE__ */ jsx5("ul", { children: pullRequests.map((pr) => /* @__PURE__ */ jsxs5("li", { className: pr.isDraft ? "repo-prs__item repo-prs__item--draft" : "repo-prs__item", children: [
447
+ /* @__PURE__ */ jsxs5("a", { href: pr.htmlUrl, target: "_blank", rel: "noopener noreferrer", children: [
448
+ /* @__PURE__ */ jsxs5("span", { className: "repo-prs__num", children: [
449
+ "#",
450
+ pr.number
451
+ ] }),
452
+ /* @__PURE__ */ jsx5("span", { className: "repo-prs__title", children: pr.title })
453
+ ] }),
454
+ /* @__PURE__ */ jsxs5("span", { className: "repo-prs__meta", children: [
455
+ pr.author,
456
+ " \xB7 ",
457
+ formatRelativeTime(pr.createdAt),
458
+ pr.isDraft ? " \xB7 draft" : ""
459
+ ] })
460
+ ] }, pr.number)) })
461
+ ] });
462
+ }
463
+
464
+ // src/components/JobLaneRow.tsx
465
+ import { memo as memo2 } from "react";
466
+
467
+ // src/lib/dedupeJobLabel.ts
468
+ function dedupeJobLabel(name) {
469
+ const parts = name.split(" / ");
470
+ const deduped = parts.filter((part, i) => i === 0 || part !== parts[i - 1]);
471
+ return deduped.join(" / ");
472
+ }
473
+
474
+ // src/components/JobLaneRow.tsx
475
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
476
+ var JobLaneRow = memo2(function JobLaneRow2({
477
+ kind,
478
+ glyph,
479
+ label,
480
+ signals
481
+ }) {
482
+ if (signals.length === 0) return null;
483
+ return /* @__PURE__ */ jsxs6("div", { className: `repo-joblane repo-joblane--${kind}`, children: [
484
+ /* @__PURE__ */ jsxs6("span", { className: "repo-joblane__label", children: [
485
+ glyph,
486
+ " ",
487
+ label
488
+ ] }),
489
+ /* @__PURE__ */ jsx6("div", { className: "repo-joblane__chips", children: signals.map((s, i) => /* @__PURE__ */ jsxs6(
490
+ "a",
491
+ {
492
+ className: `chip chip--${s.state} chip--joblane`,
493
+ href: s.htmlUrl,
494
+ title: `${s.workflow} \xB7 ${s.state}`,
495
+ target: "_blank",
496
+ rel: "noopener noreferrer",
497
+ children: [
498
+ /* @__PURE__ */ jsx6("span", { className: "chip__dot", "aria-hidden": "true" }),
499
+ /* @__PURE__ */ jsx6("span", { className: "chip__label", children: dedupeJobLabel(s.name) }),
500
+ /* @__PURE__ */ jsx6("span", { className: "sr-only", children: stateLabel(s.state) }),
501
+ /* @__PURE__ */ jsx6("span", { className: "chip__meta", children: formatRelativeTime(s.updatedAt) })
502
+ ]
503
+ },
504
+ `${s.workflow}/${s.name}/${i}`
505
+ )) })
506
+ ] });
507
+ });
508
+
509
+ // src/lib/worstState.ts
510
+ function worstState(states) {
511
+ if (states.includes("failure")) return "failure";
512
+ if (states.includes("running")) return "running";
513
+ if (states.includes("success")) return "success";
514
+ return "none";
515
+ }
516
+
517
+ // src/components/RepoActivityIndicator.tsx
518
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
519
+ function RepoActivityIndicator({ repository }) {
520
+ const prCount = (repository.pullRequests ?? []).length;
521
+ const ci = worstState((repository.workflows ?? []).map((w) => w.state));
522
+ const cd = worstState([...repository.deploys ?? [], ...repository.packages ?? []].map((s) => s.state));
523
+ return /* @__PURE__ */ jsxs7("span", { className: "repo-activity", children: [
524
+ prCount > 0 && /* @__PURE__ */ jsxs7("span", { className: "repo-activity__pr", children: [
525
+ prCount,
526
+ " PR"
527
+ ] }),
528
+ /* @__PURE__ */ jsxs7("span", { className: "repo-activity__sig", children: [
529
+ "CI",
530
+ /* @__PURE__ */ jsx7("span", { className: "repo-activity__dot", "data-activity": ci, "aria-label": `CI ${ci}` })
531
+ ] }),
532
+ /* @__PURE__ */ jsxs7("span", { className: "repo-activity__sig", children: [
533
+ "CD",
534
+ /* @__PURE__ */ jsx7("span", { className: "repo-activity__dot", "data-activity": cd, "aria-label": `CD ${cd}` })
535
+ ] })
536
+ ] });
537
+ }
538
+
539
+ // src/components/RepoBoard.tsx
540
+ import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
541
+ var RepoBoard = memo3(function RepoBoard2({
542
+ repository,
543
+ collapsed,
544
+ onToggle
545
+ }) {
546
+ const pullRequests = repository.pullRequests ?? [];
547
+ const noCi = isNoCi(repository);
548
+ return /* @__PURE__ */ jsxs8("section", { className: `repo-board${collapsed ? " repo-board--collapsed" : ""}${noCi ? " repo-board--no-ci" : ""}`, children: [
549
+ /* @__PURE__ */ jsxs8("header", { children: [
550
+ /* @__PURE__ */ jsxs8("button", { type: "button", className: "repo-board__toggle", onClick: () => onToggle(repository.name), "aria-expanded": !collapsed, children: [
551
+ /* @__PURE__ */ jsx8("span", { className: "repo-board__chev", "aria-hidden": "true", children: "\u25B8" }),
552
+ repository.name
553
+ ] }),
554
+ noCi && /* @__PURE__ */ jsx8("span", { className: "repo-board__noci-tag", children: "No CI" }),
555
+ /* @__PURE__ */ jsx8(RepoMetricsLine, { metrics: repository.metrics }),
556
+ /* @__PURE__ */ jsx8(RepoActivityIndicator, { repository }),
557
+ /* @__PURE__ */ jsx8("a", { className: "repo-board__gh-link", href: repository.htmlUrl, target: "_blank", rel: "noopener noreferrer", "aria-label": `Open ${repository.name} on GitHub`, children: "GitHub \u2197" })
558
+ ] }),
559
+ !collapsed && /* @__PURE__ */ jsxs8(Fragment4, { children: [
560
+ repository.workflows.length === 0 ? /* @__PURE__ */ jsx8("div", { className: "repo-board__empty", children: "no workflows" }) : /* @__PURE__ */ jsxs8("div", { className: "repo-workflows", children: [
561
+ /* @__PURE__ */ jsxs8("span", { className: "repo-workflows__label", children: [
562
+ "Workflows \xB7 ",
563
+ repository.workflows.length
564
+ ] }),
565
+ /* @__PURE__ */ jsx8("div", { className: "repo-top-signals", children: repository.workflows.map((wf) => /* @__PURE__ */ jsx8(SignalChip, { workflow: wf }, wf.file)) })
566
+ ] }),
567
+ /* @__PURE__ */ jsx8(JobLaneRow, { kind: "deploys", glyph: "\u25B2", label: "Deploys", signals: repository.deploys ?? [] }),
568
+ /* @__PURE__ */ jsx8(JobLaneRow, { kind: "packages", glyph: "\u25A3", label: "Packages", signals: repository.packages ?? [] }),
569
+ /* @__PURE__ */ jsx8(PullRequestList, { pullRequests })
570
+ ] })
571
+ ] });
572
+ });
573
+
574
+ // src/components/RepoSection.tsx
575
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
576
+ function RepoSection({ label, count, collapsed, onToggle }) {
577
+ return /* @__PURE__ */ jsxs9(
578
+ "button",
579
+ {
580
+ type: "button",
581
+ className: "repo-section",
582
+ "aria-expanded": !collapsed,
583
+ onClick: onToggle,
584
+ children: [
585
+ /* @__PURE__ */ jsx9("span", { className: "repo-section__chevron", "aria-hidden": "true", children: collapsed ? "\u25B8" : "\u25BE" }),
586
+ /* @__PURE__ */ jsx9("span", { className: "repo-section__label", children: label }),
587
+ /* @__PURE__ */ jsxs9("span", { className: "repo-section__count", "aria-hidden": "true", children: [
588
+ "\xB7 ",
589
+ count
590
+ ] })
591
+ ]
592
+ }
593
+ );
594
+ }
595
+
596
+ // src/components/MetricsLegend.tsx
597
+ import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
598
+ var ITEMS = [
599
+ ["NLOC", "non-comment lines of code"],
600
+ ["avg CCN", "average cyclomatic complexity (branch paths per function)"],
601
+ ["functions", "number of functions"],
602
+ ["complex", "functions over CCN 15 \u2014 refactor candidates"]
603
+ ];
604
+ function MetricsLegend() {
605
+ return /* @__PURE__ */ jsxs10("footer", { className: "metrics-legend", "aria-label": "Lizard metrics legend", children: [
606
+ /* @__PURE__ */ jsx10("span", { className: "metrics-legend__title", children: "Lizard metrics" }),
607
+ ITEMS.map(([term, meaning]) => /* @__PURE__ */ jsxs10("span", { className: "metrics-legend__item", children: [
608
+ /* @__PURE__ */ jsx10("b", { children: term }),
609
+ " ",
610
+ meaning
611
+ ] }, term))
612
+ ] });
613
+ }
614
+
615
+ // src/components/StatusLegend.tsx
616
+ import { jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
617
+ var STATUS_ITEMS = [
618
+ ["success", "passing"],
619
+ ["failure", "failing"],
620
+ ["running", "running"],
621
+ ["unknown", "unknown / no runs"]
622
+ ];
623
+ function StatusLegend() {
624
+ return /* @__PURE__ */ jsxs11("footer", { className: "status-legend", "aria-label": "Status colour key", children: [
625
+ /* @__PURE__ */ jsx11("span", { className: "status-legend__title", children: "Status" }),
626
+ STATUS_ITEMS.map(([state, label]) => /* @__PURE__ */ jsxs11("span", { className: "status-legend__item", children: [
627
+ /* @__PURE__ */ jsx11("span", { className: "status-legend__dot", "data-state": state, "aria-hidden": "true" }),
628
+ label
629
+ ] }, state)),
630
+ /* @__PURE__ */ jsxs11("span", { className: "status-legend__item", children: [
631
+ /* @__PURE__ */ jsx11("span", { className: "status-legend__noci", "aria-hidden": "true" }),
632
+ "No-CI repo"
633
+ ] }),
634
+ /* @__PURE__ */ jsxs11("span", { className: "status-legend__gloss", children: [
635
+ /* @__PURE__ */ jsx11("b", { children: "CI" }),
636
+ " workflow runs \xB7 ",
637
+ /* @__PURE__ */ jsx11("b", { children: "CD" }),
638
+ " deploys & packages"
639
+ ] })
640
+ ] });
641
+ }
642
+
643
+ // src/components/PullRequestStepper.tsx
644
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState4 } from "react";
645
+
646
+ // src/lib/prAgeTone.ts
647
+ function prAgeTone(createdAtIso, now = Date.now()) {
648
+ const days = (now - new Date(createdAtIso).getTime()) / 864e5;
649
+ if (days > 14) return "red";
650
+ if (days > 7) return "amber";
651
+ return "quiet";
652
+ }
653
+
654
+ // src/components/PullRequestStepper.tsx
655
+ import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
656
+ function PullRequestStepper({ prs, onClose }) {
657
+ const [i, setI] = useState4(0);
658
+ const pr = prs[i];
659
+ const dialogRef = useRef2(null);
660
+ useEffect2(() => {
661
+ const previouslyFocused = document.activeElement;
662
+ const dlg = dialogRef.current;
663
+ dlg?.showModal();
664
+ dlg?.focus();
665
+ return () => previouslyFocused?.focus?.();
666
+ }, []);
667
+ if (!pr) return null;
668
+ return /* @__PURE__ */ jsxs12(
669
+ "dialog",
670
+ {
671
+ ref: dialogRef,
672
+ className: "pr-modal",
673
+ "aria-label": "Open pull requests",
674
+ tabIndex: -1,
675
+ onCancel: (e) => {
676
+ e.preventDefault();
677
+ onClose();
678
+ },
679
+ onClick: (e) => {
680
+ if (e.target === e.currentTarget) onClose();
681
+ },
682
+ onKeyDown: (e) => {
683
+ if (e.key === "ArrowRight") setI((p) => Math.min(p + 1, prs.length - 1));
684
+ if (e.key === "ArrowLeft") setI((p) => Math.max(p - 1, 0));
685
+ },
686
+ children: [
687
+ /* @__PURE__ */ jsxs12("div", { className: "pr-modal__top", children: [
688
+ /* @__PURE__ */ jsx12("span", { className: "pr-modal__title", children: "Open pull requests" }),
689
+ /* @__PURE__ */ jsxs12("span", { className: "pr-modal__counter", children: [
690
+ i + 1,
691
+ " / ",
692
+ prs.length
693
+ ] }),
694
+ /* @__PURE__ */ jsx12("button", { type: "button", className: "pr-modal__x", onClick: onClose, "aria-label": "Close", children: "\u2715" })
695
+ ] }),
696
+ /* @__PURE__ */ jsxs12("div", { className: `pr-card pr-card--${prAgeTone(pr.createdAt)}${pr.isDraft ? " pr-card--draft" : ""}`, children: [
697
+ /* @__PURE__ */ jsxs12("div", { className: "pr-card__head", children: [
698
+ /* @__PURE__ */ jsx12("span", { className: "pr-card__repo", children: pr.repo }),
699
+ /* @__PURE__ */ jsxs12("span", { className: "pr-card__num", children: [
700
+ "#",
701
+ pr.number
702
+ ] }),
703
+ /* @__PURE__ */ jsxs12("span", { className: "pr-card__meta", children: [
704
+ formatRelativeTime(pr.createdAt),
705
+ " \xB7 ",
706
+ pr.isDraft ? "draft" : "ready"
707
+ ] })
708
+ ] }),
709
+ /* @__PURE__ */ jsx12("div", { className: "pr-card__title", children: pr.title }),
710
+ /* @__PURE__ */ jsxs12("div", { className: "pr-card__foot", children: [
711
+ /* @__PURE__ */ jsxs12("span", { className: "pr-card__author", children: [
712
+ "@",
713
+ pr.author
714
+ ] }),
715
+ /* @__PURE__ */ jsx12("a", { className: "pr-card__gh", href: pr.htmlUrl, target: "_blank", rel: "noopener noreferrer", children: "Open on GitHub \u2197" })
716
+ ] })
717
+ ] }),
718
+ prs.length > 1 && /* @__PURE__ */ jsxs12("div", { className: "pr-modal__nav", children: [
719
+ /* @__PURE__ */ jsx12("button", { type: "button", onClick: () => setI((p) => Math.max(p - 1, 0)), disabled: i === 0, children: "\u2039 Prev" }),
720
+ /* @__PURE__ */ jsx12("button", { type: "button", onClick: () => setI((p) => Math.min(p + 1, prs.length - 1)), disabled: i === prs.length - 1, children: "Next \u203A" })
721
+ ] })
722
+ ]
723
+ }
724
+ );
725
+ }
726
+
727
+ // src/lib/flattenOpenPrs.ts
728
+ function flattenOpenPrs(repositories) {
729
+ return repositories.flatMap((r) => (r.pullRequests ?? []).map((pr) => ({ ...pr, repo: r.name }))).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
730
+ }
731
+
732
+ // src/pages/CiBoardContent.tsx
733
+ import { Fragment as Fragment5, jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
734
+ function CiBoardContent() {
735
+ const snapshot = useDashboardSnapshot();
736
+ const collapse = useCollapseState();
737
+ const hideNoCi = useHideNoCi();
738
+ const isAdmin = useCiAdmin();
739
+ const [stepperOpen, setStepperOpen] = useState5(false);
740
+ if (snapshot.isPending) {
741
+ return /* @__PURE__ */ jsx13("main", { className: "dashboard-page", children: /* @__PURE__ */ jsx13("div", { className: "state-msg", children: "Loading dashboard\u2026" }) });
742
+ }
743
+ if (snapshot.isError) {
744
+ return /* @__PURE__ */ jsx13("main", { className: "dashboard-page", children: /* @__PURE__ */ jsx13("div", { className: "state-msg state-msg--error", children: "Dashboard unavailable." }) });
745
+ }
746
+ if (!snapshot.data) {
747
+ return /* @__PURE__ */ jsx13("main", { className: "dashboard-page", children: /* @__PURE__ */ jsx13("div", { className: "state-msg", children: "Waiting for the first refresh\u2026" }) });
748
+ }
749
+ const { refreshedAt, repositories: allRepositories, lastMergedPr: rawLastMerged } = snapshot.data;
750
+ const repositories = isAdmin ? allRepositories : allRepositories.filter((r) => !r.private);
751
+ const summary = isAdmin ? snapshot.data.summary : computeSummary(repositories);
752
+ const lastMergedPr = rawLastMerged && repositories.some((r) => r.name === rawLastMerged.repo) ? rawLastMerged : null;
753
+ const repoNames = repositories.map((r) => r.name);
754
+ const noCiCount = repositories.filter(isNoCi).length;
755
+ const visibleRepos = hideNoCi.hidden ? repositories.filter((r) => !isNoCi(r)) : repositories;
756
+ const hiddenCount = repositories.length - visibleRepos.length;
757
+ const publicRepos = visibleRepos.filter((r) => !r.private);
758
+ const privateRepos = visibleRepos.filter((r) => r.private);
759
+ const showGroups = publicRepos.length > 0 && privateRepos.length > 0;
760
+ const KEY_PUBLIC = "section:public";
761
+ const KEY_PRIVATE = "section:private";
762
+ const sectionKeys = showGroups ? [KEY_PUBLIC, KEY_PRIVATE] : [];
763
+ const allCollapsed = collapse.allCollapsed([...repoNames, ...sectionKeys]);
764
+ const openPrs = flattenOpenPrs(repositories);
765
+ const nextPr = openPrs[0] ?? null;
766
+ let repoListContent;
767
+ if (visibleRepos.length === 0 && hideNoCi.hidden) {
768
+ repoListContent = /* @__PURE__ */ jsx13("div", { className: "state-msg", children: "All repositories are No-CI \u2014 hidden." });
769
+ } else if (showGroups) {
770
+ repoListContent = /* @__PURE__ */ jsxs13(Fragment5, { children: [
771
+ /* @__PURE__ */ jsx13(
772
+ RepoSection,
773
+ {
774
+ label: "Public",
775
+ count: publicRepos.length,
776
+ collapsed: collapse.isCollapsed(KEY_PUBLIC),
777
+ onToggle: () => collapse.toggle(KEY_PUBLIC)
778
+ }
779
+ ),
780
+ !collapse.isCollapsed(KEY_PUBLIC) && publicRepos.map((repository) => /* @__PURE__ */ jsx13(
781
+ RepoBoard,
782
+ {
783
+ repository,
784
+ collapsed: collapse.isCollapsed(repository.name),
785
+ onToggle: collapse.toggle
786
+ },
787
+ repository.name
788
+ )),
789
+ /* @__PURE__ */ jsx13(
790
+ RepoSection,
791
+ {
792
+ label: "Private",
793
+ count: privateRepos.length,
794
+ collapsed: collapse.isCollapsed(KEY_PRIVATE),
795
+ onToggle: () => collapse.toggle(KEY_PRIVATE)
796
+ }
797
+ ),
798
+ !collapse.isCollapsed(KEY_PRIVATE) && privateRepos.map((repository) => /* @__PURE__ */ jsx13(
799
+ RepoBoard,
800
+ {
801
+ repository,
802
+ collapsed: collapse.isCollapsed(repository.name),
803
+ onToggle: collapse.toggle
804
+ },
805
+ repository.name
806
+ ))
807
+ ] });
808
+ } else {
809
+ repoListContent = visibleRepos.map((repository) => /* @__PURE__ */ jsx13(
810
+ RepoBoard,
811
+ {
812
+ repository,
813
+ collapsed: collapse.isCollapsed(repository.name),
814
+ onToggle: collapse.toggle
815
+ },
816
+ repository.name
817
+ ));
818
+ }
819
+ return /* @__PURE__ */ jsxs13("main", { className: "dashboard-page", children: [
820
+ /* @__PURE__ */ jsxs13("div", { className: "dashboard__toolbar", children: [
821
+ /* @__PURE__ */ jsxs13("span", { className: "dashboard__scope", children: [
822
+ snapshot.data.org,
823
+ " \xB7 ",
824
+ isAdmin ? "all repositories" : "public repositories"
825
+ ] }),
826
+ /* @__PURE__ */ jsxs13("span", { className: "dashboard__toolbar-right", children: [
827
+ noCiCount > 0 && /* @__PURE__ */ jsx13(
828
+ "button",
829
+ {
830
+ type: "button",
831
+ className: `dashboard__hide-noci${hideNoCi.hidden ? " dashboard__hide-noci--on" : ""}`,
832
+ onClick: hideNoCi.toggle,
833
+ "aria-pressed": hideNoCi.hidden,
834
+ children: hideNoCi.hidden ? `Show No-CI \xB7 ${hiddenCount} hidden` : "Hide No-CI"
835
+ }
836
+ ),
837
+ /* @__PURE__ */ jsx13(
838
+ "button",
839
+ {
840
+ type: "button",
841
+ className: "dashboard__collapse-all",
842
+ onClick: () => allCollapsed ? collapse.expandAll() : collapse.collapseAll([...repoNames, ...sectionKeys]),
843
+ children: allCollapsed ? "\u229E Expand all" : "\u229F Collapse all"
844
+ }
845
+ ),
846
+ /* @__PURE__ */ jsxs13("span", { className: "dashboard__refreshed", children: [
847
+ /* @__PURE__ */ jsx13("span", { className: "live-dot", "aria-hidden": "true" }),
848
+ "updated ",
849
+ formatRelativeTime(refreshedAt)
850
+ ] })
851
+ ] })
852
+ ] }),
853
+ /* @__PURE__ */ jsx13(SummaryStrip, { summary, onOpenPrs: isAdmin ? () => setStepperOpen(true) : void 0, lastMerged: lastMergedPr, nextPr, ciTrend: snapshot.data.ciTrend ?? [] }),
854
+ /* @__PURE__ */ jsx13("div", { className: "repo-list", children: repoListContent }),
855
+ /* @__PURE__ */ jsx13(StatusLegend, {}),
856
+ /* @__PURE__ */ jsx13(MetricsLegend, {}),
857
+ stepperOpen && /* @__PURE__ */ jsx13(PullRequestStepper, { prs: openPrs, onClose: () => setStepperOpen(false) })
858
+ ] });
859
+ }
860
+
861
+ // src/DefaultFooter.tsx
862
+ import { jsx as jsx14 } from "react/jsx-runtime";
863
+ function DefaultFooter() {
864
+ return /* @__PURE__ */ jsx14("footer", { className: "site-footer", children: /* @__PURE__ */ jsx14("div", { className: "site-footer__band", "aria-hidden": "true", children: /* @__PURE__ */ jsx14("span", { className: "site-footer__tagline", children: "Continuous-integration overview" }) }) });
865
+ }
866
+
867
+ // src/CiBoard.tsx
868
+ import { jsx as jsx15, jsxs as jsxs14 } from "react/jsx-runtime";
869
+ function CiBoard({ adminSignal, apiBase = DEFAULT_CI_API_BASE, logo, footerSlot }) {
870
+ return /* @__PURE__ */ jsx15(CiConfigProvider, { value: { apiBase }, children: /* @__PURE__ */ jsxs14("div", { className: "ci-page", children: [
871
+ /* @__PURE__ */ jsxs14("div", { className: "ci-embed", children: [
872
+ /* @__PURE__ */ jsx15("header", { className: "ci-embed__header", children: /* @__PURE__ */ jsxs14("span", { className: "ci-embed__lockup", children: [
873
+ logo ?? /* @__PURE__ */ jsx15("span", { className: "ci-embed__wordmark-text", children: "CI Dashboard" }),
874
+ /* @__PURE__ */ jsxs14("span", { className: "ci-embed__descriptor", children: [
875
+ "CI Dashboard ",
876
+ adminSignal ? "[Admin]" : "[Guest]"
877
+ ] })
878
+ ] }) }),
879
+ /* @__PURE__ */ jsx15(CiAdminProvider, { value: adminSignal, children: /* @__PURE__ */ jsx15(CiBoardContent, {}) })
880
+ ] }),
881
+ footerSlot ?? /* @__PURE__ */ jsx15(DefaultFooter, {})
882
+ ] }) });
883
+ }
884
+ export {
885
+ CiBoard,
886
+ DEFAULT_CI_API_BASE,
887
+ DefaultFooter
888
+ };
889
+ //# sourceMappingURL=index.js.map