@hot-updater/console 1.0.0-rc.2 → 1.0.0-rc.4

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.
Files changed (126) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/InsightsPageHeader-C-Xc2MB9.js +1 -0
  3. package/.output/public/assets/api-keys-C1sgC6ko.js +1 -0
  4. package/.output/public/assets/{api-keys-api-BHcODB7q.js → api-keys-api-LchP65vQ.js} +1 -1
  5. package/.output/public/assets/badge-BuMDU0Rf.js +1 -0
  6. package/.output/public/assets/card-BEvx_QKh.js +1 -0
  7. package/.output/public/assets/{chart-no-axes-combined-Dy4WJv2U.js → chart-no-axes-combined-C0G0fQ3J.js} +1 -1
  8. package/.output/public/assets/{composite-Bp98A0Ld.js → composite-CHTH1Xhh.js} +1 -1
  9. package/.output/public/assets/createServerFn-DHc2erub.js +3 -0
  10. package/.output/public/assets/dialog-dyZ1SphZ.js +1 -0
  11. package/.output/public/assets/{dist-C_NydL0e.js → dist-Bh1gaDfB.js} +1 -1
  12. package/.output/public/assets/field-79SVH26s.js +1 -0
  13. package/.output/public/assets/index-BNkxg4jz.js +10 -0
  14. package/.output/public/assets/input-group-BbHfaBzQ.js +1 -0
  15. package/.output/public/assets/insights-D_7uSKfc.js +1 -0
  16. package/.output/public/assets/installations-BzoHWuXw.js +1 -0
  17. package/.output/public/assets/{installations-CE33wgbY.js → installations-DvjbUqd1.js} +2 -2
  18. package/.output/public/assets/{lazyRouteComponent-Ccp3bwBY.js → lazyRouteComponent-BN0MS05o.js} +1 -1
  19. package/.output/public/assets/{link-CI8idAUi.js → link-BG06whwB.js} +1 -1
  20. package/.output/public/assets/{loader-circle-BoxLrsOu.js → loader-circle-Bbp8LGVC.js} +1 -1
  21. package/.output/public/assets/{refresh-cw-Bqc72UZG.js → refresh-cw-DZgiAOMk.js} +1 -1
  22. package/.output/public/assets/routes-BFE-oRIT.js +1 -0
  23. package/.output/public/assets/{routes-DGVXdzpw.js → routes-DaPMY31P.js} +2 -2
  24. package/.output/public/assets/{shield-check-uK3MHWJZ.js → shield-check-DoIiaaXU.js} +1 -1
  25. package/.output/public/assets/signing-BTRhaF5D.js +1 -0
  26. package/.output/public/assets/styles-C7U0oVke.css +2 -0
  27. package/.output/public/assets/table-BBEzJ7Xl.js +1 -0
  28. package/.output/public/assets/useCompositeListItem-BkPDaqyj.js +42 -0
  29. package/.output/server/{__23tanstack-start-server-fn-resolver-B7ioJhtE.mjs → __23tanstack-start-server-fn-resolver-Dr2ixDgQ.mjs} +40 -32
  30. package/.output/server/_libs/@base-ui/react+[...].mjs +1 -1
  31. package/.output/server/_libs/@reduxjs/toolkit+[...].mjs +1554 -0
  32. package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
  33. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +1 -1
  34. package/.output/server/_libs/d3-array.mjs +231 -0
  35. package/.output/server/_libs/d3-color.mjs +330 -0
  36. package/.output/server/_libs/d3-format.mjs +252 -0
  37. package/.output/server/_libs/d3-interpolate.mjs +207 -0
  38. package/.output/server/_libs/d3-path.mjs +77 -0
  39. package/.output/server/_libs/d3-scale+[...].mjs +1907 -0
  40. package/.output/server/_libs/d3-shape.mjs +818 -0
  41. package/.output/server/_libs/decimal.js-light.mjs +884 -0
  42. package/.output/server/_libs/es-toolkit.mjs +1159 -0
  43. package/.output/server/_libs/eventemitter3.mjs +236 -0
  44. package/.output/server/_libs/immer.mjs +532 -0
  45. package/.output/server/_libs/lucide-react.mjs +9 -2
  46. package/.output/server/_libs/next-themes.mjs +1 -1
  47. package/.output/server/_libs/react-redux+[...].mjs +205 -0
  48. package/.output/server/_libs/recharts+[...].mjs +15054 -0
  49. package/.output/server/_libs/sonner.mjs +1 -1
  50. package/.output/server/_libs/tanstack__react-query.mjs +1 -1
  51. package/.output/server/_runtime.mjs +10 -1
  52. package/.output/server/_ssr/InsightsPageHeader-C5Has8Ry.mjs +60 -0
  53. package/.output/server/_ssr/{api-keys-CZeTPQys.mjs → api-keys-DJml_XoK.mjs} +10 -8
  54. package/.output/server/_ssr/{api-keys-api-BUPQVSpk.mjs → api-keys-api-CrCZyvnl.mjs} +2 -2
  55. package/.output/server/_ssr/{api-keys-rpc-Cs7tS5_j.mjs → api-keys-rpc-CFaNf-q0.mjs} +3 -3
  56. package/.output/server/_ssr/{api-rpc-tAupfGQm.mjs → api-rpc-B61KL3Eo.mjs} +25 -25
  57. package/.output/server/_ssr/{auth-rpc-DUaeOWKK.mjs → auth-rpc-D_hv52-J.mjs} +4 -4
  58. package/.output/server/_ssr/{auth.server-DW71WzsD.mjs → auth.server-DF_shCeq.mjs} +1 -1
  59. package/.output/server/_ssr/badge-BgsCP9Dp.mjs +28 -0
  60. package/.output/server/_ssr/{bundle-signing-rpc-BhnBw0-d.mjs → bundle-signing-rpc-BBipZnFs.mjs} +2 -2
  61. package/.output/server/_ssr/{bundle-signing.server-C4NW5mWx.mjs → bundle-signing.server-Co_zHdJx.mjs} +1 -1
  62. package/.output/server/_ssr/bundleActivity-D3_fIda2.mjs +27 -0
  63. package/.output/server/_ssr/{card-BRiPmi0C.mjs → card-DrbxCPtG.mjs} +3 -25
  64. package/.output/server/_ssr/chart-qxa-_30a.mjs +162 -0
  65. package/.output/server/_ssr/{config.server-afDuAwaG.mjs → config.server-CBr6PDj5.mjs} +5 -5
  66. package/.output/server/_ssr/{createSsrRpc-DjWNJ0uc.mjs → createSsrRpc-NHF2PxuK.mjs} +4 -4
  67. package/.output/server/_ssr/{deleteBundle-BTGwBgzx.mjs → deleteBundle-BAq3JuGr.mjs} +1 -1
  68. package/.output/server/_ssr/{dialog-DS-nw-pA.mjs → dialog-DUaIgpgd.mjs} +1 -1
  69. package/.output/server/_ssr/{downloadBundle-CNnYxYO2.mjs → downloadBundle-B3ert-pY.mjs} +1 -1
  70. package/.output/server/_ssr/{table-CnZmon_Z.mjs → field-CqNUlqUq.mjs} +4 -50
  71. package/.output/server/_ssr/{getBundleChildren-C0vh4ZvE.mjs → getBundleChildren-COMt16m_.mjs} +1 -1
  72. package/.output/server/_ssr/{input-group-BpsnmvZw.mjs → input-group-Bb8ueylQ.mjs} +51 -2
  73. package/.output/server/_ssr/insights-B-DUbksX.mjs +502 -0
  74. package/.output/server/_ssr/insights-recovery-DSFM-YTL.mjs +21 -0
  75. package/.output/server/_ssr/insights-recovery-rpc-P71FH66n.mjs +37 -0
  76. package/.output/server/_ssr/{insights-rpc-38DujK0G.mjs → insights-rpc-DrIQCjpD.mjs} +2 -2
  77. package/.output/server/_ssr/insightsRecovery-CqcosG7B.mjs +149 -0
  78. package/.output/server/_ssr/{installations-CtA59MQG.mjs → installations-BQrppti7.mjs} +2 -2
  79. package/.output/server/_ssr/{installations-DiVKEF5T.mjs → installations-YumqpNpa.mjs} +426 -11
  80. package/.output/server/_ssr/{router-B9wG_9f8.mjs → router-iP95Vqi2.mjs} +14 -14
  81. package/.output/server/_ssr/{routes-CKrrS2q2.mjs → routes-BDabsmc7.mjs} +2 -2
  82. package/.output/server/_ssr/{routes-D7Yh_jfj.mjs → routes-ivsV2TDu.mjs} +224 -13
  83. package/.output/server/_ssr/{runtime.server-DacU1TBV.mjs → runtime.server-jmZOKIQM.mjs} +1 -1
  84. package/.output/server/_ssr/{server-D_C2rMzf.mjs → server-p7yrVBfD.mjs} +3 -3
  85. package/.output/server/_ssr/{signing-Dz1WkZOf.mjs → signing-CxCbnFCt.mjs} +5 -4
  86. package/.output/server/_ssr/ssr.mjs +1 -1
  87. package/.output/server/_ssr/table-D1mL8fJu.mjs +53 -0
  88. package/.output/server/_tanstack-start-manifest_v-Cwpu0C9Q.mjs +100 -0
  89. package/.output/server/index.mjs +154 -154
  90. package/package.json +3 -3
  91. package/src/components/features/bundles/BundleActivityChart.tsx +95 -0
  92. package/src/components/features/bundles/BundleInsightsSummary.tsx +121 -0
  93. package/src/components/features/insights/InsightsControls.tsx +1 -15
  94. package/src/components/features/insights/InsightsOverview.tsx +368 -179
  95. package/src/components/features/releases/ReleaseEditorSheet.tsx +10 -0
  96. package/src/components/ui/empty.tsx +101 -0
  97. package/src/lib/bundle-activity.ts +21 -0
  98. package/src/lib/insights-recovery-rpc.ts +38 -0
  99. package/src/lib/insights-recovery.ts +59 -0
  100. package/src/lib/server/bundleActivity.ts +37 -0
  101. package/src/lib/server/insightsHistory.ts +52 -0
  102. package/src/lib/server/insightsRecovery.ts +186 -0
  103. package/src/routes/index.tsx +33 -3
  104. package/src/routes/insights.tsx +1 -42
  105. package/.output/public/assets/HashValueDisplay-CB0Eskit.js +0 -1
  106. package/.output/public/assets/InsightsPageHeader-Car5LNiZ.js +0 -1
  107. package/.output/public/assets/api-keys-BzKocqRy.js +0 -1
  108. package/.output/public/assets/card-Dhr_eFYj.js +0 -1
  109. package/.output/public/assets/createServerFn-CA8fs7QD.js +0 -3
  110. package/.output/public/assets/dialog-CUTrcPlB.js +0 -1
  111. package/.output/public/assets/index-DdAz-CXv.js +0 -10
  112. package/.output/public/assets/input-group-cSOG4wh_.js +0 -1
  113. package/.output/public/assets/insights-C0PBSZ-n.js +0 -1
  114. package/.output/public/assets/installations-CStZja8x.js +0 -1
  115. package/.output/public/assets/routes-DTlwOr8I.js +0 -1
  116. package/.output/public/assets/signing-CMf4aqDO.js +0 -1
  117. package/.output/public/assets/styles-BCvbtKsm.css +0 -2
  118. package/.output/public/assets/table-2vcV89QB.js +0 -1
  119. package/.output/public/assets/useCompositeListItem-CHLITE_4.js +0 -1
  120. package/.output/public/assets/x-DCd0j8c6.js +0 -1
  121. package/.output/server/_ssr/HashValueDisplay-DwpGLfrl.mjs +0 -55
  122. package/.output/server/_ssr/InsightsPageHeader-Df4Brj8u.mjs +0 -484
  123. package/.output/server/_ssr/insights-BdS364Xu.mjs +0 -460
  124. package/.output/server/_tanstack-start-manifest_v-2MjTyxil.mjs +0 -97
  125. package/src/components/features/insights/InsightsBundleReports.tsx +0 -51
  126. /package/.output/public/assets/{preload-helper-CQ36nxok.js → preload-helper-uTix4PVD.js} +0 -0
@@ -1,203 +1,392 @@
1
+ import { useQuery } from "@tanstack/react-query";
1
2
  import { Link } from "@tanstack/react-router";
2
- import { ArrowUpRight } from "lucide-react";
3
+ import { ArrowUpRight, RotateCw, TriangleAlert } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
3
6
 
4
7
  import { Button, buttonVariants } from "@/components/ui/button";
5
8
  import {
6
9
  Card,
7
10
  CardContent,
8
- CardDescription,
9
11
  CardFooter,
10
12
  CardHeader,
11
13
  CardTitle,
12
14
  } from "@/components/ui/card";
15
+ import {
16
+ ChartContainer,
17
+ ChartTooltip,
18
+ ChartTooltipContent,
19
+ } from "@/components/ui/chart";
13
20
  import { Skeleton } from "@/components/ui/skeleton";
14
- import type { ReportingInstallations } from "@/lib/insights-api";
15
- import { outcomeLabels } from "@/lib/insights-view";
21
+ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
22
+ import type { RecoveryInput, RecoveryReport } from "@/lib/insights-recovery";
23
+ import { getRecoveryReportRpc } from "@/lib/insights-recovery-rpc";
16
24
 
17
- import { EventTimestamp, useInsightsTimeFormat } from "./EventDetails";
18
- import type { InsightsReportSelection } from "./InsightsBundleReports";
19
25
  import { InsightsErrorAlert } from "./InsightsErrorAlert";
20
26
 
21
- type InsightsOverviewProps =
22
- | { readonly status: "loading" }
23
- | { readonly status: "error"; readonly error: Error }
24
- | {
25
- readonly status: "success";
26
- readonly active: ReportingInstallations;
27
- readonly onOutcomeSelect: (selection: InsightsReportSelection) => void;
28
- };
29
-
30
- const windowCopy = {
31
- "24h": "the last 24 hours",
32
- "7d": "the last 7 days",
33
- "30d": "the last 30 days",
34
- } as const;
35
-
36
- export function InsightsOverview(props: InsightsOverviewProps) {
37
- const dateTimeFormat = useInsightsTimeFormat();
38
-
39
- if (props.status === "loading") {
40
- return (
41
- <Card aria-label="Loading reporting installations" className="shadow-sm">
42
- <CardHeader className="px-4 pt-4 pb-2 sm:px-6 sm:pt-6">
43
- <Skeleton className="h-4 w-40" />
44
- <Skeleton className="h-3 w-full max-w-md" />
45
- </CardHeader>
46
- <CardContent className="px-4 pb-6 sm:px-6">
47
- <Skeleton className="h-12 w-28" />
48
- </CardContent>
49
- <CardFooter className="border-t px-4 py-3 sm:px-6">
50
- <Skeleton className="h-8 w-full max-w-sm" />
51
- </CardFooter>
52
- </Card>
53
- );
54
- }
55
-
56
- if (props.status === "error") {
57
- return (
58
- <InsightsErrorAlert
59
- error={props.error}
60
- fallbackTitle="Reporting installations unavailable"
61
- />
62
- );
63
- }
64
-
65
- const { active } = props;
66
- const bundle = active.bundle;
27
+ const dates = new Intl.DateTimeFormat("en", {
28
+ month: "short",
29
+ day: "numeric",
30
+ timeZone: "UTC",
31
+ });
32
+ const times = new Intl.DateTimeFormat("en", {
33
+ month: "short",
34
+ day: "numeric",
35
+ hour: "numeric",
36
+ timeZone: "UTC",
37
+ });
38
+ const colors = [
39
+ "var(--chart-2)",
40
+ "var(--foreground)",
41
+ "var(--chart-1)",
42
+ "var(--muted-foreground)",
43
+ "var(--chart-4)",
44
+ ];
45
+ const dashes = [undefined, "6 3", "2 3"];
67
46
 
47
+ export function ActivityChart({ report }: { readonly report: RecoveryReport }) {
48
+ const [metric, setMetric] = useState<"active" | "rollback">("active");
49
+ const [selectedId, setSelectedId] = useState<string | null>(null);
50
+ const selected = report.series.find(
51
+ (series) => series.releaseId === selectedId,
52
+ );
53
+ const config = Object.fromEntries(
54
+ report.series.map((series, index) => [
55
+ `bundle${index}`,
56
+ {
57
+ label: series.releaseId,
58
+ color: colors[index % colors.length],
59
+ },
60
+ ]),
61
+ );
62
+ const data = (report.series[0]?.points ?? []).map((point, pointIndex) => ({
63
+ startMs: point.startMs,
64
+ ...Object.fromEntries(
65
+ report.series.map((series, index) => [
66
+ `bundle${index}`,
67
+ metric === "active"
68
+ ? series.points[pointIndex].active
69
+ : series.points[pointIndex].recoveredInstallations,
70
+ ]),
71
+ ),
72
+ }));
73
+ const formatTick = (ms: number) =>
74
+ (report.intervalMs < 86_400_000 ? times : dates).format(ms);
75
+ const lastSpike = selected
76
+ ? [...selected.points].reverse().find((point) => point.spike)
77
+ : undefined;
78
+ const total =
79
+ metric === "active"
80
+ ? report.series.reduce(
81
+ (sum, series) => sum + series.activeInstallations,
82
+ 0,
83
+ )
84
+ : report.series.filter((series) => series.recoveredInstallations > 0)
85
+ .length;
68
86
  return (
69
- <section
70
- aria-labelledby="reporting-installations-heading"
71
- className="flex flex-col gap-4"
72
- >
73
- <Card className="min-w-0 overflow-hidden shadow-sm">
74
- <CardHeader className="gap-2 px-4 pt-4 pb-2 sm:px-6 sm:pt-6">
75
- <CardTitle className="text-sm font-medium">
76
- <h2 id="reporting-installations-heading">
77
- Reporting installations
78
- </h2>
79
- </CardTitle>
80
- <CardDescription>
81
- {active.platform === "ios" ? "iOS" : "Android"} · {active.channel} ·{" "}
82
- {windowCopy[active.window]}. Installations are counted by their last
83
- report received.
84
- </CardDescription>
85
- </CardHeader>
86
- <CardContent className="px-4 pt-2 pb-5 sm:px-6 sm:pb-6">
87
- <p className="text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl">
88
- {active.reportingInstallations.count.toLocaleString()}
87
+ <>
88
+ <div className="flex flex-wrap items-start justify-between gap-4">
89
+ <div>
90
+ <p className="text-sm text-muted-foreground">
91
+ {metric === "active"
92
+ ? "Active installations"
93
+ : "Bundles with rollbacks"}
89
94
  </p>
90
- </CardContent>
91
- <CardFooter className="flex flex-wrap items-center justify-between gap-3 border-t bg-muted/15 px-4 py-3 sm:px-6">
92
- <dl className="min-w-0 text-xs">
93
- <dt className="text-muted-foreground">Measured at</dt>
94
- <dd className="font-medium tabular-nums">
95
- <EventTimestamp
96
- formatter={dateTimeFormat}
97
- touch
98
- value={active.reportingInstallations.measuredAtMs}
99
- />
100
- </dd>
101
- </dl>
102
- <Link
103
- className={buttonVariants({
104
- className: "h-11 px-3 lg:h-8",
105
- variant: "outline",
106
- })}
107
- to="/installations"
108
- >
109
- View events
110
- <ArrowUpRight aria-hidden="true" data-icon="inline-end" />
111
- </Link>
112
- </CardFooter>
113
- </Card>
114
- {bundle ? (
115
- <Card className="min-w-0 overflow-hidden shadow-sm">
116
- <CardHeader>
117
- <CardTitle>Selected bundle</CardTitle>
118
- <CardDescription className="break-all">
119
- {bundle.bundleId}
120
- </CardDescription>
121
- </CardHeader>
122
- <CardContent>
123
- <dl className="flex flex-col gap-5">
124
- <div className="flex flex-wrap items-center justify-between gap-3">
125
- <div>
126
- <dt className="text-sm">Selected bundle installations</dt>
127
- <dd className="mt-1 text-2xl font-semibold tabular-nums">
128
- {bundle.reportingInstallations.count.toLocaleString()}
129
- </dd>
130
- </div>
131
- <div className="text-xs text-muted-foreground">
132
- Measured at{" "}
133
- <EventTimestamp
134
- formatter={dateTimeFormat}
135
- touch
136
- value={bundle.reportingInstallations.measuredAtMs}
95
+ <p className="mt-1 text-4xl font-semibold tracking-tight tabular-nums">
96
+ {total.toLocaleString()}
97
+ </p>
98
+ </div>
99
+ <ToggleGroup
100
+ aria-label="Activity metric"
101
+ value={[metric]}
102
+ onValueChange={(values) => {
103
+ if (values[0] === "active" || values[0] === "rollback")
104
+ setMetric(values[0]);
105
+ }}
106
+ spacing={0}
107
+ variant="outline"
108
+ >
109
+ <ToggleGroupItem className="h-11 lg:h-8" value="active">
110
+ Active
111
+ </ToggleGroupItem>
112
+ <ToggleGroupItem className="h-11 lg:h-8" value="rollback">
113
+ Rollback
114
+ </ToggleGroupItem>
115
+ </ToggleGroup>
116
+ </div>
117
+ {report.series.length === 0 ? (
118
+ <div className="flex h-48 items-center justify-center text-center text-sm text-muted-foreground">
119
+ No bundle reports in this period. Try another channel or check again
120
+ after the app reports activity.
121
+ </div>
122
+ ) : (
123
+ <>
124
+ <div>
125
+ <p className="mb-2 text-right text-xs text-muted-foreground">
126
+ {metric === "active"
127
+ ? "Installations"
128
+ : "Recovered installations per interval"}{" "}
129
+ · UTC
130
+ </p>
131
+ <ChartContainer
132
+ aria-label={`${metric === "active" ? "Active" : "Rollback"} trend for all reported bundle IDs`}
133
+ className="h-48 w-full aspect-auto sm:h-56"
134
+ config={config}
135
+ >
136
+ <LineChart
137
+ accessibilityLayer
138
+ data={data}
139
+ margin={{ left: -12, right: 12, top: 8 }}
140
+ >
141
+ <CartesianGrid vertical={false} />
142
+ <XAxis
143
+ axisLine={false}
144
+ dataKey="startMs"
145
+ minTickGap={40}
146
+ tickFormatter={formatTick}
147
+ tickLine={false}
148
+ />
149
+ <YAxis
150
+ allowDecimals={false}
151
+ axisLine={false}
152
+ tickLine={false}
153
+ width={48}
154
+ />
155
+ <ChartTooltip
156
+ content={
157
+ <ChartTooltipContent
158
+ className="max-h-64 max-w-[calc(100vw-3rem)] overflow-auto"
159
+ labelFormatter={(_, payload) =>
160
+ `${times.format(payload[0]?.payload.startMs)} · UTC`
161
+ }
162
+ />
163
+ }
164
+ />
165
+ {report.series.map((series, index) => (
166
+ <Line
167
+ key={series.releaseId}
168
+ dataKey={`bundle${index}`}
169
+ type="linear"
170
+ stroke={`var(--color-bundle${index})`}
171
+ strokeWidth={selectedId === series.releaseId ? 3 : 2}
172
+ strokeOpacity={!selected || selected === series ? 1 : 0.4}
173
+ strokeDasharray={
174
+ dashes[Math.floor(index / colors.length) % dashes.length]
175
+ }
176
+ dot={{ r: 2 }}
177
+ activeDot={{ r: 4 }}
178
+ isAnimationActive={false}
137
179
  />
138
- </div>
139
- </div>
140
- {(
141
- [
142
- ["applied", bundle.appliedReports],
143
- ["recovered", bundle.recoveredReports],
144
- ["adopted", bundle.adoptedReports],
145
- ] as const
146
- ).map(([outcome, measurement]) => (
147
- <div
148
- className="flex flex-wrap items-center justify-between gap-3"
149
- key={outcome}
180
+ ))}
181
+ </LineChart>
182
+ </ChartContainer>
183
+ </div>
184
+ <div
185
+ aria-label="Reported bundles"
186
+ className="grid max-h-48 grid-cols-1 gap-1 overflow-y-auto sm:grid-cols-2"
187
+ >
188
+ {report.series.map((series, index) => (
189
+ <button
190
+ type="button"
191
+ key={series.releaseId}
192
+ aria-label={`Highlight bundle ${series.releaseId}`}
193
+ aria-pressed={selectedId === series.releaseId}
194
+ title={series.releaseId}
195
+ onClick={() =>
196
+ setSelectedId(
197
+ selectedId === series.releaseId ? null : series.releaseId,
198
+ )
199
+ }
200
+ className="flex min-w-0 items-center gap-2 rounded-md px-2 py-2 text-left text-xs hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted"
201
+ >
202
+ <svg
203
+ aria-hidden="true"
204
+ width="20"
205
+ height="8"
206
+ className="shrink-0"
150
207
  >
151
- <div>
152
- <dt className="text-sm">{outcomeLabels[outcome]}</dt>
153
- <dd className="mt-1 text-2xl font-semibold tabular-nums">
154
- {measurement.count.toLocaleString()}
155
- </dd>
156
- <div className="mt-1 text-xs text-muted-foreground">
157
- Measured at{" "}
158
- <EventTimestamp
159
- formatter={dateTimeFormat}
160
- touch
161
- value={measurement.measuredAtMs}
162
- />
163
- </div>
164
- </div>
165
- <Button
166
- className="h-11 lg:h-8"
167
- variant="outline"
168
- onClick={() =>
169
- props.onOutcomeSelect({
170
- bundle: {
171
- platform: active.platform,
172
- channel: active.channel,
173
- bundleId: bundle.bundleId,
174
- outcome,
175
- },
176
- sinceMs: active.sinceMs,
177
- beforeReceivedAtMs: active.beforeReceivedAtMs,
178
- })
208
+ <line
209
+ x1="0"
210
+ x2="20"
211
+ y1="4"
212
+ y2="4"
213
+ stroke={colors[index % colors.length]}
214
+ strokeWidth="2"
215
+ strokeDasharray={
216
+ dashes[Math.floor(index / colors.length) % dashes.length]
179
217
  }
180
- >
181
- View {outcome} reports
182
- <ArrowUpRight aria-hidden="true" data-icon="inline-end" />
183
- </Button>
184
- </div>
185
- ))}
186
- </dl>
187
- </CardContent>
188
- <CardFooter>
189
- <p className="text-xs text-muted-foreground">
190
- Installation counts are independent live measurements. Outcome
191
- counts are accepted reports, not unique devices or update
192
- attempts. Recovery reports identify the bundle recovered from.
218
+ />
219
+ </svg>
220
+ <span className="min-w-0 truncate font-mono">
221
+ {series.releaseId}
222
+ </span>
223
+ {series.points.some((point) => point.spike) ? (
224
+ <TriangleAlert
225
+ aria-label="Rollback spike"
226
+ className="size-3 shrink-0"
227
+ />
228
+ ) : null}
229
+ <span className="ml-auto tabular-nums">
230
+ {(metric === "active"
231
+ ? series.activeInstallations
232
+ : series.recoveredInstallations
233
+ ).toLocaleString()}
234
+ </span>
235
+ </button>
236
+ ))}
237
+ </div>
238
+ {selected ? (
239
+ <div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
240
+ <div className="min-w-0 flex-1">
241
+ <p className="break-all font-mono text-xs">
242
+ {selected.releaseId}
243
+ </p>
244
+ <p className="mt-1 text-xs text-muted-foreground">
245
+ Active {selected.activeInstallations.toLocaleString()} ·
246
+ Rollback {selected.recoveredInstallations.toLocaleString()} in
247
+ this period
248
+ </p>
249
+ {metric === "rollback" && lastSpike ? (
250
+ <p className="mt-2 flex items-center gap-2 text-xs">
251
+ <TriangleAlert
252
+ className="size-3 shrink-0"
253
+ aria-hidden="true"
254
+ />
255
+ Rollback spike on {dates.format(lastSpike.startMs)}. Review
256
+ this bundle’s delivery settings.
257
+ </p>
258
+ ) : null}
259
+ </div>
260
+ <Link
261
+ className={buttonVariants({ variant: "outline", size: "sm" })}
262
+ to="/"
263
+ search={{ releaseId: selected.releaseId }}
264
+ >
265
+ Review bundle
266
+ <ArrowUpRight aria-hidden="true" />
267
+ </Link>
268
+ </div>
269
+ ) : null}
270
+ <details className="text-xs text-muted-foreground">
271
+ <summary className="cursor-pointer">View chart data</summary>
272
+ <div className="mt-2 max-h-64 overflow-auto">
273
+ <table className="w-full text-left">
274
+ <caption className="sr-only">
275
+ {metric === "active"
276
+ ? "Active installations"
277
+ : "Recovered installations"}{" "}
278
+ by bundle ID, UTC
279
+ </caption>
280
+ <thead>
281
+ <tr>
282
+ <th scope="col" className="pr-4">
283
+ Date (UTC)
284
+ </th>
285
+ {report.series.map((series) => (
286
+ <th
287
+ scope="col"
288
+ className="px-2 font-mono"
289
+ key={series.releaseId}
290
+ >
291
+ {series.releaseId}
292
+ </th>
293
+ ))}
294
+ </tr>
295
+ </thead>
296
+ <tbody>
297
+ {data.map((point, i) => (
298
+ <tr key={point.startMs}>
299
+ <th
300
+ scope="row"
301
+ className="whitespace-nowrap pr-4 font-normal"
302
+ >
303
+ {times.format(point.startMs)}
304
+ </th>
305
+ {report.series.map((series) => (
306
+ <td
307
+ className="px-2 tabular-nums"
308
+ key={series.releaseId}
309
+ >
310
+ {(metric === "active"
311
+ ? series.points[i].active
312
+ : series.points[i].recoveredInstallations) ?? "—"}
313
+ </td>
314
+ ))}
315
+ </tr>
316
+ ))}
317
+ </tbody>
318
+ </table>
319
+ </div>
320
+ </details>
321
+ </>
322
+ )}
323
+ </>
324
+ );
325
+ }
326
+
327
+ export function InsightsOverview({ input }: { readonly input: RecoveryInput }) {
328
+ const query = useQuery({
329
+ queryKey: ["rollout-activity", input],
330
+ queryFn: () => getRecoveryReportRpc({ data: input }),
331
+ staleTime: 30_000,
332
+ });
333
+ return (
334
+ <Card className="min-w-0 overflow-hidden shadow-sm">
335
+ <CardHeader className="flex-row items-center justify-between gap-4 px-4 pt-4 pb-2 sm:px-6 sm:pt-6">
336
+ <CardTitle className="text-sm font-medium">
337
+ Bundle activity ·{" "}
338
+ {input.window === "30d"
339
+ ? "30 days"
340
+ : input.window === "7d"
341
+ ? "7 days"
342
+ : "24 hours"}
343
+ </CardTitle>
344
+ <Button
345
+ aria-label="Refresh bundle activity"
346
+ variant="ghost"
347
+ size="icon-sm"
348
+ disabled={query.isFetching}
349
+ onClick={() => void query.refetch()}
350
+ >
351
+ <RotateCw aria-hidden="true" />
352
+ </Button>
353
+ </CardHeader>
354
+ <CardContent className="flex flex-col gap-4 px-4 pb-4 sm:px-6 sm:pb-6">
355
+ {query.isPending ? (
356
+ <Skeleton
357
+ aria-label="Loading bundle activity"
358
+ className="h-64 w-full"
359
+ />
360
+ ) : query.error ? (
361
+ <InsightsErrorAlert
362
+ error={query.error}
363
+ fallbackTitle="Bundle activity unavailable"
364
+ />
365
+ ) : query.data ? (
366
+ <ActivityChart key={JSON.stringify(input)} report={query.data} />
367
+ ) : null}
368
+ </CardContent>
369
+ {query.data && !query.error ? (
370
+ <CardFooter className="flex-col items-start gap-2 border-t bg-muted/15 px-4 py-3 text-xs text-muted-foreground sm:px-6">
371
+ <p>
372
+ Active follows each installation’s last reported ID in this period.
373
+ Rollbacks count installations recovered from each ID per interval.
374
+ </p>
375
+ {query.data.unattributedInstallations > 0 ? (
376
+ <p>
377
+ {query.data.unattributedInstallations.toLocaleString()} reporting
378
+ installations have no observed bundle ID and are excluded.
379
+ </p>
380
+ ) : null}
381
+ {query.data.truncated ? (
382
+ <p>
383
+ Partial history — only reports since{" "}
384
+ {times.format(query.data.sinceMs)} UTC are included. Choose a
385
+ shorter period to see more complete counts.
193
386
  </p>
194
- </CardFooter>
195
- </Card>
387
+ ) : null}
388
+ </CardFooter>
196
389
  ) : null}
197
- <p className="text-xs text-muted-foreground">
198
- Offline installations, opt-outs, and reports that did not reach the
199
- server are absent.
200
- </p>
201
- </section>
390
+ </Card>
202
391
  );
203
392
  }
@@ -15,6 +15,7 @@ import { toast } from "sonner";
15
15
  import { normalizeRange } from "verkit";
16
16
 
17
17
  import { BundleIdDisplay } from "@/components/BundleIdDisplay";
18
+ import { BundleInsightsSummary } from "@/components/features/bundles/BundleInsightsSummary";
18
19
  import { BundleMetadata } from "@/components/features/bundles/BundleMetadata";
19
20
  import { RolloutCohortsDialog } from "@/components/features/bundles/RolloutCohortsDialog";
20
21
  import { PlatformIcon } from "@/components/PlatformIcon";
@@ -408,6 +409,15 @@ export function ReleaseEditorSheet({
408
409
 
409
410
  {release && draft ? (
410
411
  <div className="flex flex-col gap-6 px-4 pb-4 sm:px-6 sm:pb-6">
412
+ {channelName ? (
413
+ <BundleInsightsSummary
414
+ input={{
415
+ platform: release.platform,
416
+ channel: channelName,
417
+ releaseId: release.id,
418
+ }}
419
+ />
420
+ ) : null}
411
421
  <section
412
422
  aria-labelledby="delivery-settings-heading"
413
423
  className="flex flex-col gap-4"