@hot-updater/console 1.1.3 → 1.1.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 (154) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/EventDetails-gbD_fCqc.js +1 -0
  3. package/.output/public/assets/{InsightsPageHeader-SYEOMClX.js → InsightsPageHeader-BRw8XkJW.js} +1 -1
  4. package/.output/public/assets/api-keys-D81VHHNM.js +1 -0
  5. package/.output/public/assets/api-keys-Dia6i8K1.js +1 -0
  6. package/.output/public/assets/badge-Bqf3UDee.js +1 -0
  7. package/.output/public/assets/circle-off-BsbZQtsF.js +1 -0
  8. package/.output/public/assets/field-GxF9SyUo.js +1 -0
  9. package/.output/public/assets/{index-C5S4DrO2.js → index-DrkdiRIn.js} +3 -3
  10. package/.output/public/assets/insights-C-5t8wh2.js +2 -0
  11. package/.output/public/assets/insights-D_ngQdiz.js +1 -0
  12. package/.output/public/assets/insights-KigRBDgC.js +42 -0
  13. package/.output/public/assets/insights-api-1ZjVu3Sb.js +1 -0
  14. package/.output/public/assets/insights-search-aT8BVCci.js +1 -0
  15. package/.output/public/assets/{insights-usage-rpc-DTL0_Rp8.js → insights-usage-rpc-DqTiXI1_.js} +1 -1
  16. package/.output/public/assets/insights_.distribution-Bzl6RFBR.js +2 -0
  17. package/.output/public/assets/insights_.distribution-Cthgttqb.js +1 -0
  18. package/.output/public/assets/insights_.distribution-D2c2v1bu.js +1 -0
  19. package/.output/public/assets/installations-2Nx8Htf-.js +1 -0
  20. package/.output/public/assets/{installations-we1IV0xp.js → installations-BskJ1xQr.js} +2 -2
  21. package/.output/public/assets/installations-K2iJ_r0h.js +1 -0
  22. package/.output/public/assets/{remote-config-uBgCaI-p.js → remote-config-D9X3NiwV.js} +4 -4
  23. package/.output/public/assets/{remote-config-DyTLPRKh.js → remote-config-DzOOiBIO.js} +1 -1
  24. package/.output/public/assets/remote-config-ikg7kak0.js +1 -0
  25. package/.output/public/assets/routes-9rb9P5Y7.js +2 -0
  26. package/.output/public/assets/routes-P7683vKQ.js +1 -0
  27. package/.output/public/assets/{select-CV8nkfsf.js → select-M3H2gxCc.js} +1 -1
  28. package/.output/public/assets/styles-CpiW_YdW.css +2 -0
  29. package/.output/public/assets/{switch-C8ycp9AH.js → switch-By_vfm8I.js} +1 -1
  30. package/.output/server/{__23tanstack-start-server-fn-resolver-Gb7cVpng.mjs → __23tanstack-start-server-fn-resolver-KpQ5Z_24.mjs} +40 -40
  31. package/.output/server/_libs/@base-ui/react+[...].mjs +77 -1
  32. package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
  33. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +2 -2
  34. package/.output/server/_libs/lucide-react.mjs +1 -1
  35. package/.output/server/_libs/next-themes.mjs +1 -1
  36. package/.output/server/_libs/react-redux+[...].mjs +1 -1
  37. package/.output/server/_libs/recharts+[...].mjs +1 -1
  38. package/.output/server/_libs/sonner.mjs +1 -1
  39. package/.output/server/_libs/tanstack__react-query.mjs +1 -1
  40. package/.output/server/_ssr/{BundleIdDisplay-DDiNTVT3.mjs → BundleIdDisplay-CjSnY65J.mjs} +3 -3
  41. package/.output/server/_ssr/{ConsoleFeatureUnavailable-DE65d293.mjs → ConsoleFeatureUnavailable-CHxn9f-w.mjs} +7 -7
  42. package/.output/server/_ssr/{EventDetails-Bg1V3JsJ.mjs → EventDetails-l6dF54zp.mjs} +8 -8
  43. package/.output/server/_ssr/{HashValueDisplay-BDHvSsxT.mjs → HashValueDisplay-Zs7ABoOi.mjs} +3 -3
  44. package/.output/server/_ssr/{InsightsPageHeader-Db-y_PRS.mjs → InsightsPageHeader-ZlAlDxQy.mjs} +8 -8
  45. package/.output/server/_ssr/{NotFoundPage-DHgL7Uzm.mjs → NotFoundPage-u28ndvp1.mjs} +3 -3
  46. package/.output/server/_ssr/{alert-dialog-MbJW0K9H.mjs → alert-dialog-B8AqlJwo.mjs} +2 -2
  47. package/.output/server/_ssr/{api-XOdKU3tw.mjs → api-C_0B7NXM.mjs} +3 -3
  48. package/.output/server/_ssr/{api-keys-v7wa2_rX.mjs → api-keys-CLH38u9B.mjs} +2 -2
  49. package/.output/server/_ssr/{api-keys-BHJdcnez.mjs → api-keys-pRNvn2-9.mjs} +9 -8
  50. package/.output/server/_ssr/{api-keys-rpc-B389z2LY.mjs → api-keys-rpc-wecWs0nA.mjs} +3 -3
  51. package/.output/server/_ssr/{api-rpc-T6dVPhPr.mjs → api-rpc-DWN8eOHS.mjs} +6 -41
  52. package/.output/server/_ssr/{auth-rpc-CzPT9U2N.mjs → auth-rpc-CLRArbIl.mjs} +4 -4
  53. package/.output/server/_ssr/{auth.server-LmCV7BTA.mjs → auth.server-DR9zc0EU.mjs} +2 -2
  54. package/.output/server/_ssr/badge-BpT7r-ng.mjs +108 -0
  55. package/.output/server/_ssr/{bundleActivity-M5gzBFNg.mjs → bundleActivity-0qFIcvqt.mjs} +1 -1
  56. package/.output/server/_ssr/{config.server-COvZCTA4.mjs → config.server-DTdsG47T.mjs} +4 -4
  57. package/.output/server/_ssr/{console-access-CRAcskRF.mjs → console-access-aUpQs49I.mjs} +2 -2
  58. package/.output/server/_ssr/{console-features-Oz0q20jJ.mjs → console-features-4xAGfZHw.mjs} +1 -1
  59. package/.output/server/_ssr/{console-features-api-qihYDNRo.mjs → console-features-api-DSSgCV3v.mjs} +5 -5
  60. package/.output/server/_ssr/{console-features-rpc-r1KcSohP.mjs → console-features-rpc-D6kMlR2A.mjs} +3 -3
  61. package/.output/server/_ssr/{dialog-CPbM7GQc.mjs → dialog-B4ALf7NJ.mjs} +2 -2
  62. package/.output/server/_ssr/{downloadBundle-CZysjmNW.mjs → downloadBundle-94jPBGSO.mjs} +1 -1
  63. package/.output/server/_ssr/{empty-8scgvztU.mjs → empty-Bh1FEeYF.mjs} +3 -3
  64. package/.output/server/_ssr/{failureReports-DKDhVsXA.mjs → failureReports-Bh22wois.mjs} +4 -4
  65. package/.output/server/_ssr/field-HKCaPH3e.mjs +89 -0
  66. package/.output/server/_ssr/{getBundleChildren-Cv9iz1As.mjs → getBundleChildren-DfxB2K8D.mjs} +1 -1
  67. package/.output/server/_ssr/{input-group-BTB34g3v.mjs → input-group-xm3LzDuq.mjs} +2 -2
  68. package/.output/server/_ssr/{insights-CGpn8W5V.mjs → insights-BKZpJ_K4.mjs} +590 -360
  69. package/.output/server/_ssr/{insights-BulsRVnt.mjs → insights-CCyTACIw.mjs} +5 -5
  70. package/.output/server/_ssr/{insights-Btnkt5-Y.mjs → insights-CNqWQLJR.mjs} +2 -2
  71. package/.output/server/_ssr/{insights-api-Dg3D7Vkg.mjs → insights-api-DtRFz5X3.mjs} +5 -5
  72. package/.output/server/_ssr/{insights-errors-BjnFUyZj.mjs → insights-errors-Iy-lBaM7.mjs} +2 -2
  73. package/.output/server/_ssr/{insights-failures-xaa41Buf.mjs → insights-failures-BJN3RfLu.mjs} +3 -3
  74. package/.output/server/_ssr/{insights-failures-rpc-DdK55wRd.mjs → insights-failures-rpc-DLZ91uOG.mjs} +12 -12
  75. package/.output/server/_ssr/{insights-recovery-BWQhiZaF.mjs → insights-recovery-BUuQdlA3.mjs} +1 -1
  76. package/.output/server/_ssr/{insights-recovery-rpc-D-dwlIPT.mjs → insights-recovery-rpc-BQHUhPi7.mjs} +6 -6
  77. package/.output/server/_ssr/{insights-rpc-Dn-jdkQK.mjs → insights-rpc-C6Vukyn7.mjs} +3 -3
  78. package/.output/server/_ssr/{insights-search-C8EWA0hu.mjs → insights-search-CBYoxT7v.mjs} +8 -7
  79. package/.output/server/_ssr/{insights-usage-BpBM0U0c.mjs → insights-usage-CHqvtehH.mjs} +1 -1
  80. package/.output/server/_ssr/{insights-usage-rpc-DIa-5vUO.mjs → insights-usage-rpc-BJ7a-xco.mjs} +6 -6
  81. package/.output/server/_ssr/{insights-usage-rpc-BCulD17c.mjs → insights-usage-rpc-C-lNUIsD.mjs} +5 -5
  82. package/.output/server/_ssr/{insightsUsage-CzhVjsc8.mjs → insightsUsage-COUG-_-U.mjs} +3 -3
  83. package/.output/server/_ssr/{insights_.distribution-CKe3T4k1.mjs → insights_.distribution-CTwilMPm.mjs} +5 -5
  84. package/.output/server/_ssr/{insights_.distribution-D7ZzykQb.mjs → insights_.distribution-DmLW8I8a.mjs} +2 -2
  85. package/.output/server/_ssr/{insights_.distribution-CE0ve_Rx.mjs → insights_.distribution-DztDyrBj.mjs} +14 -13
  86. package/.output/server/_ssr/{installations-CJHIYH0u.mjs → installations-B6MK5iA8.mjs} +2 -2
  87. package/.output/server/_ssr/{installations-Bu4Sk4W6.mjs → installations-Bct7ag5y.mjs} +4 -4
  88. package/.output/server/_ssr/{installations-CGMSRurj.mjs → installations-_N4PtYFl.mjs} +12 -11
  89. package/.output/server/_ssr/{release-adoption-005tQRpo.mjs → release-adoption-DuYJTGuX.mjs} +17 -7
  90. package/.output/server/_ssr/{release-adoption-rpc-DPMYNIlM.mjs → release-adoption-rpc-CHl7G_Zy.mjs} +8 -8
  91. package/.output/server/_ssr/{releaseAdoption-CtOFkwli.mjs → releaseAdoption-B39cpvfo.mjs} +42 -26
  92. package/.output/server/_ssr/releaseReachability-bFsuElSS.mjs +39 -0
  93. package/.output/server/_ssr/{remote-config-BCW6vPOD.mjs → remote-config-10-obSZ0.mjs} +3 -3
  94. package/.output/server/_ssr/{remote-config-DrC_3H7K.mjs → remote-config-Dj1eTYi4.mjs} +2 -2
  95. package/.output/server/_ssr/{remote-config-Ca-Wv5sF.mjs → remote-config-Mj05qVYZ.mjs} +18 -17
  96. package/.output/server/_ssr/{remote-config-rpc-CcGmTmvS.mjs → remote-config-rpc-lteqDSLn.mjs} +3 -3
  97. package/.output/server/_ssr/{router-C5eI7aq7.mjs → router-Dr4jjjhx.mjs} +17 -17
  98. package/.output/server/_ssr/{routes-Bd3qlRny.mjs → routes-B5KrCDwk.mjs} +17 -16
  99. package/.output/server/_ssr/{routes-DZoij0js.mjs → routes-BZbrY_qw.mjs} +17 -17
  100. package/.output/server/_ssr/{runtime.server-De0QVWc9.mjs → runtime.server-DsHqVqTr.mjs} +2 -2
  101. package/.output/server/_ssr/{select-usyW0pcU.mjs → select-BtdGNSmp.mjs} +2 -2
  102. package/.output/server/_ssr/{server-Bp9nEe3Z.mjs → server-BxaHSwHh.mjs} +4 -4
  103. package/.output/server/_ssr/{sidebar-Bqu4emj1.mjs → sidebar-CVsgosaD.mjs} +3 -3
  104. package/.output/server/_ssr/ssr.mjs +1 -1
  105. package/.output/server/_ssr/{switch-DJPcwvwD.mjs → switch-BmARgkUP.mjs} +3 -3
  106. package/.output/server/_ssr/{table-DEzdHi1t.mjs → table-M5P3YUfY.mjs} +2 -2
  107. package/.output/server/_ssr/{tabs-HzTSngsE.mjs → tabs-CrmfOlJj.mjs} +3 -3
  108. package/.output/server/_ssr/updateFailures-z1IQy_4q.mjs +103 -0
  109. package/.output/server/{_tanstack-start-manifest_v-DTOQk1l4.mjs → _tanstack-start-manifest_v-_BY9hYTm.mjs} +51 -50
  110. package/.output/server/index.mjs +198 -191
  111. package/package.json +7 -7
  112. package/src/components/features/insights/AppUsage.tsx +15 -2
  113. package/src/components/features/insights/InsightsControls.tsx +264 -246
  114. package/src/components/features/insights/InsightsOverview.tsx +90 -68
  115. package/src/components/features/insights/InsightsScope.tsx +39 -0
  116. package/src/components/features/insights/ReleaseActivity.tsx +4 -4
  117. package/src/components/features/insights/ReleaseCombobox.tsx +196 -0
  118. package/src/components/features/insights/RollbackButton.tsx +4 -0
  119. package/src/components/features/insights/UpdateFailures.tsx +7 -1
  120. package/src/lib/insights-failures-rpc.ts +3 -1
  121. package/src/lib/insights-failures.ts +5 -2
  122. package/src/lib/insights-recovery.ts +5 -0
  123. package/src/lib/insights-search.ts +10 -14
  124. package/src/lib/release-adoption-api.ts +24 -32
  125. package/src/lib/release-adoption.ts +45 -8
  126. package/src/lib/server/failureReports.ts +1 -1
  127. package/src/lib/server/releaseAdoption.ts +75 -26
  128. package/src/lib/server/updateFailures.ts +131 -10
  129. package/src/routes/insights.tsx +56 -24
  130. package/src/routes/insights_.distribution.tsx +2 -1
  131. package/.output/public/assets/EventDetails-CSX2ayg9.js +0 -1
  132. package/.output/public/assets/api-keys-BEdLKiib.js +0 -1
  133. package/.output/public/assets/api-keys-TdTtKm2b.js +0 -1
  134. package/.output/public/assets/chevron-right-BnvHIJdZ.js +0 -1
  135. package/.output/public/assets/field-DBMkBfkD.js +0 -1
  136. package/.output/public/assets/insights-BMKRCIg1.js +0 -2
  137. package/.output/public/assets/insights-CPyViuyC.js +0 -42
  138. package/.output/public/assets/insights-D0PbX-Rm.js +0 -1
  139. package/.output/public/assets/insights-api-Dmd_LgT3.js +0 -1
  140. package/.output/public/assets/insights-search-DAa0f7Dr.js +0 -1
  141. package/.output/public/assets/insights_.distribution-BaOQlBV9.js +0 -1
  142. package/.output/public/assets/insights_.distribution-CxXMIEb5.js +0 -2
  143. package/.output/public/assets/insights_.distribution-EE5OqxyL.js +0 -1
  144. package/.output/public/assets/installations-BgISglUQ.js +0 -1
  145. package/.output/public/assets/installations-DQbgRkSi.js +0 -1
  146. package/.output/public/assets/remote-config-AQmdmbT8.js +0 -1
  147. package/.output/public/assets/routes-Br26jzt9.js +0 -1
  148. package/.output/public/assets/routes-CyirpG2O.js +0 -2
  149. package/.output/public/assets/styles-DTGEAJFv.css +0 -2
  150. package/.output/server/_ssr/field-DHrweW4l.mjs +0 -189
  151. package/.output/server/_ssr/updateFailures-H6pnnYK4.mjs +0 -40
  152. /package/.output/public/assets/{ConsoleFeatureUnavailable-B-iNWpuE.js → ConsoleFeatureUnavailable-C0KfsKWL.js} +0 -0
  153. /package/.output/public/assets/{table-DgGD67Fx.js → table-ek1DmRHq.js} +0 -0
  154. /package/.output/public/assets/{valueToPercent-C4C1WZVn.js → valueToPercent-B44vDssN.js} +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/console",
3
3
  "type": "module",
4
- "version": "1.1.3",
4
+ "version": "1.1.4",
5
5
  "files": [
6
6
  ".output",
7
7
  "lib",
@@ -58,10 +58,10 @@
58
58
  "tailwindcss": "^4.2.1",
59
59
  "tw-animate-css": "^1.4.0",
60
60
  "verkit": "0.4.0",
61
- "@hot-updater/bsdiff": "1.1.3",
62
- "@hot-updater/protocol": "1.1.3",
63
- "@hot-updater/server": "1.1.3",
64
- "@hot-updater/plugin-core": "1.1.3"
61
+ "@hot-updater/bsdiff": "1.1.4",
62
+ "@hot-updater/plugin-core": "1.1.4",
63
+ "@hot-updater/protocol": "1.1.4",
64
+ "@hot-updater/server": "1.1.4"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@tanstack/devtools-vite": "^0.6.0",
@@ -86,8 +86,8 @@
86
86
  "vite-tsconfig-paths": "^6.1.1",
87
87
  "vitest": "4.1.4",
88
88
  "web-vitals": "^5.1.0",
89
- "@hot-updater/cli-tools": "1.1.3",
90
- "@hot-updater/mock": "1.1.3"
89
+ "@hot-updater/cli-tools": "1.1.4",
90
+ "@hot-updater/mock": "1.1.4"
91
91
  },
92
92
  "description": "React Native OTA solution for self-hosted",
93
93
  "license": "SEE LICENSE IN LICENSE",
@@ -42,12 +42,16 @@ import {
42
42
  type InsightsRetentionDays,
43
43
  keepsTwelveMonths,
44
44
  } from "@/lib/insights-retention";
45
- import type { InsightsSearch } from "@/lib/insights-search";
45
+ import {
46
+ DEFAULT_INSIGHTS_CHANNEL,
47
+ type InsightsSearch,
48
+ } from "@/lib/insights-search";
46
49
  import type { AppUsageReport, UsageWindow } from "@/lib/insights-usage";
47
50
 
48
51
  import { EstimatedCount } from "./EstimatedCount";
49
52
  import { InsightsErrorAlert } from "./InsightsErrorAlert";
50
53
  import { UsagePeriodSelector } from "./InsightsPeriodSelector";
54
+ import { InsightsScope } from "./InsightsScope";
51
55
  import { ReportingDevicesSummary } from "./ReportingDevicesSummary";
52
56
 
53
57
  const dates = new Intl.DateTimeFormat("en", {
@@ -98,7 +102,16 @@ export function AppUsage({
98
102
  <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(16rem,1fr)]">
99
103
  <Card aria-label="App usage" role="region" className="min-w-0 shadow-sm">
100
104
  <CardHeader className="flex-row flex-wrap items-center justify-between gap-4">
101
- <CardTitle>App usage</CardTitle>
105
+ <div className="flex min-w-0 flex-col gap-1">
106
+ <CardTitle>App usage</CardTitle>
107
+ <InsightsScope
108
+ channel={search.channel ?? DEFAULT_INSIGHTS_CHANNEL}
109
+ platform={search.platform ?? "all"}
110
+ narrower={
111
+ search.appVersion ? [`App version ${search.appVersion}`] : []
112
+ }
113
+ />
114
+ </div>
102
115
  <UsagePeriodSelector
103
116
  window={window}
104
117
  onWindowChange={onWindowChange}
@@ -1,14 +1,8 @@
1
- import { useState } from "react";
1
+ import { RotateCcw, X } from "lucide-react";
2
+ import type { ReactNode } from "react";
2
3
 
3
4
  import { PlatformIcon } from "@/components/PlatformIcon";
4
5
  import { Button } from "@/components/ui/button";
5
- import {
6
- Card,
7
- CardContent,
8
- CardDescription,
9
- CardHeader,
10
- CardTitle,
11
- } from "@/components/ui/card";
12
6
  import {
13
7
  Combobox,
14
8
  ComboboxContent,
@@ -16,9 +10,8 @@ import {
16
10
  ComboboxInput,
17
11
  ComboboxItem,
18
12
  ComboboxList,
13
+ ComboboxTrigger,
19
14
  } from "@/components/ui/combobox";
20
- import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
21
- import { Input } from "@/components/ui/input";
22
15
  import {
23
16
  Select,
24
17
  SelectContent,
@@ -28,257 +21,282 @@ import {
28
21
  SelectValue,
29
22
  } from "@/components/ui/select";
30
23
  import { useChannelsQuery } from "@/lib/api";
31
- import type { RecoveryInput } from "@/lib/insights-recovery";
32
- import type { AppUsageScope } from "@/lib/insights-usage";
24
+ import { DEFAULT_INSIGHTS_CHANNEL } from "@/lib/insights-search";
25
+ import type { AdoptionRelease, HealthPlatform } from "@/lib/release-adoption";
26
+
27
+ import { platformLabel } from "./InsightsScope";
28
+ import { ReleaseCombobox } from "./ReleaseCombobox";
29
+
30
+ /** The Insights filters as the URL holds them. */
31
+ export type InsightsFilters = {
32
+ readonly channel: string;
33
+ readonly platform: HealthPlatform;
34
+ readonly appVersion?: string;
35
+ readonly releaseId?: string;
36
+ };
37
+
38
+ const DEFAULT_FILTERS = {
39
+ channel: DEFAULT_INSIGHTS_CHANNEL,
40
+ platform: "all",
41
+ appVersion: undefined,
42
+ releaseId: undefined,
43
+ } as const satisfies InsightsFilters;
44
+
45
+ // One look for every filter; a filter narrower than its default is filled.
46
+ const CHIP =
47
+ "h-11 shrink-0 gap-1.5 px-3 text-sm data-[active=true]:border-primary/60 data-[active=true]:bg-primary/10 sm:h-8 sm:text-xs";
48
+ const SELECT_CHIP =
49
+ "shrink-0 gap-1.5 px-3 text-sm data-[active=true]:border-primary/60 data-[active=true]:bg-primary/10 data-[size=default]:h-11 sm:text-xs sm:data-[size=default]:h-8";
50
+
51
+ /** A filter with a button that returns it to its default when it is narrowed. */
52
+ function Clearable({
53
+ name,
54
+ active,
55
+ onClear,
56
+ children,
57
+ }: {
58
+ readonly name: string;
59
+ readonly active: boolean;
60
+ readonly onClear: () => void;
61
+ readonly children: ReactNode;
62
+ }) {
63
+ return (
64
+ <div className="flex shrink-0 items-center">
65
+ {children}
66
+ {active ? (
67
+ <Button
68
+ aria-label={`Clear ${name}`}
69
+ className="size-11 sm:size-8"
70
+ size="icon"
71
+ variant="ghost"
72
+ onClick={onClear}
73
+ >
74
+ <X aria-hidden="true" />
75
+ </Button>
76
+ ) : null}
77
+ </div>
78
+ );
79
+ }
33
80
 
81
+ /**
82
+ * The Insights filters, above every report. Each applies as it changes, so
83
+ * what they show is what the reports read; one narrower than its default is
84
+ * filled and can be cleared on its own, and Reset filters clears them all.
85
+ */
34
86
  export function InsightsControls({
35
- onFiltersChange,
36
- scope,
37
- releaseScope,
87
+ filters,
38
88
  appVersions,
89
+ releases,
90
+ onFiltersChange,
39
91
  }: {
40
- readonly onFiltersChange: (filters: {
41
- readonly scope: AppUsageScope;
42
- readonly releaseScope: Pick<
43
- RecoveryInput,
44
- "platform" | "channel" | "releaseId"
45
- >;
46
- }) => void;
47
- readonly scope: AppUsageScope;
48
- readonly releaseScope: Pick<
49
- RecoveryInput,
50
- "platform" | "channel" | "releaseId"
51
- >;
92
+ readonly filters: InsightsFilters;
52
93
  readonly appVersions: readonly string[];
94
+ /** The channel's bundle deployments a release filter offers. */
95
+ readonly releases: readonly AdoptionRelease[];
96
+ readonly onFiltersChange: (change: Partial<InsightsFilters>) => void;
53
97
  }) {
54
- const [platform, setPlatform] = useState(scope.platform);
55
- const [channel, setChannel] = useState(scope.channel);
56
- const [appVersion, setAppVersion] = useState(scope.appVersion ?? "");
57
- const [releasePlatform, setReleasePlatform] = useState(releaseScope.platform);
58
- const [releaseId, setReleaseId] = useState(releaseScope.releaseId ?? "");
59
98
  const versions = [
60
- ...new Set([...appVersions, ...(appVersion ? [appVersion] : [])]),
99
+ ...new Set([
100
+ ...appVersions,
101
+ ...(filters.appVersion ? [filters.appVersion] : []),
102
+ ]),
61
103
  ];
62
104
  const channelsQuery = useChannelsQuery();
63
105
  const channels = channelsQuery.data?.map((item) => item.name) ?? [];
106
+ const narrowed = {
107
+ channel: filters.channel !== DEFAULT_FILTERS.channel,
108
+ platform: filters.platform !== DEFAULT_FILTERS.platform,
109
+ appVersion: filters.appVersion !== undefined,
110
+ releaseId: filters.releaseId !== undefined,
111
+ };
64
112
 
65
113
  return (
66
- <Card className="shadow-sm">
67
- <CardHeader className="gap-1 border-b px-5 py-4">
68
- <CardTitle>Filters</CardTitle>
69
- <CardDescription>
70
- Channel applies to all reports. Other filters affect the report named
71
- in their label.
72
- </CardDescription>
73
- </CardHeader>
74
- <CardContent className="px-5 py-4">
75
- <form
76
- aria-label="Insights filters"
77
- onSubmit={(event) => {
78
- event.preventDefault();
79
- onFiltersChange({
80
- scope: {
81
- platform,
82
- channel,
83
- ...(appVersion ? { appVersion } : {}),
84
- },
85
- releaseScope: {
86
- platform: releasePlatform,
87
- channel,
88
- ...(releaseId.trim() ? { releaseId: releaseId.trim() } : {}),
89
- },
90
- });
91
- }}
114
+ <div
115
+ aria-label="Insights filters"
116
+ className="flex shrink-0 items-center gap-2 overflow-x-auto border-b bg-background px-3 py-2 sm:flex-wrap sm:overflow-visible sm:px-4"
117
+ role="group"
118
+ >
119
+ <Combobox
120
+ items={channels}
121
+ value={filters.channel}
122
+ onValueChange={(value) => {
123
+ if (value !== null && value !== filters.channel)
124
+ onFiltersChange({ channel: value });
125
+ }}
126
+ >
127
+ <ComboboxTrigger
128
+ aria-label={`Channel: ${filters.channel}`}
129
+ data-active={narrowed.channel}
130
+ render={<Button className={CHIP} variant="outline" />}
92
131
  >
93
- <FieldGroup className="grid grid-cols-1 items-end gap-4 sm:grid-cols-2 xl:grid-cols-[minmax(12rem,1.25fr)_9rem_minmax(10rem,1fr)_9rem_minmax(12rem,1fr)_auto]">
94
- <Field className="min-w-0">
95
- <FieldLabel htmlFor="insights-channel">Channel</FieldLabel>
96
- <Combobox
97
- items={channels}
98
- value={channel}
99
- onValueChange={(value) => {
100
- if (value !== null) setChannel(value);
101
- }}
102
- >
103
- <ComboboxInput
104
- className="h-11 w-full sm:h-9 [&_input]:h-full"
105
- id="insights-channel"
106
- placeholder="Search channels…"
107
- maxLength={1024}
108
- />
109
- <ComboboxContent>
110
- <ComboboxEmpty>
111
- {channelsQuery.isPending ? (
112
- "Loading channels…"
113
- ) : channelsQuery.isError ? (
114
- <div className="flex flex-col items-center gap-2 p-2">
115
- Couldn't load channels.
116
- <Button
117
- onClick={() => void channelsQuery.refetch()}
118
- size="sm"
119
- variant="outline"
120
- >
121
- Retry channels
122
- </Button>
123
- </div>
124
- ) : (
125
- "No channels found."
126
- )}
127
- </ComboboxEmpty>
128
- <ComboboxList>
129
- {(item: string) => (
130
- <ComboboxItem
131
- className="min-h-11 pr-8 sm:min-h-8"
132
- key={item}
133
- value={item}
134
- >
135
- <span className="truncate">{item}</span>
136
- </ComboboxItem>
137
- )}
138
- </ComboboxList>
139
- </ComboboxContent>
140
- </Combobox>
141
- </Field>
142
- <Field>
143
- <FieldLabel htmlFor="insights-platform">
144
- Usage platform
145
- </FieldLabel>
146
- <Select
147
- items={{ all: "All platforms", ios: "iOS", android: "Android" }}
148
- value={platform}
149
- onValueChange={(value) => {
150
- if (value === "all" || value === "ios" || value === "android")
151
- setPlatform(value);
152
- }}
153
- >
154
- <SelectTrigger
155
- id="insights-platform"
156
- className="min-h-11 w-full sm:min-h-9"
132
+ <span className="text-muted-foreground">Channel</span>
133
+ <span className="max-w-48 truncate">{filters.channel}</span>
134
+ </ComboboxTrigger>
135
+ <ComboboxContent className="w-64">
136
+ <ComboboxInput
137
+ aria-label="Search channels"
138
+ placeholder="Search channels…"
139
+ showTrigger={false}
140
+ maxLength={1024}
141
+ />
142
+ <ComboboxEmpty>
143
+ {channelsQuery.isPending ? (
144
+ "Loading channels…"
145
+ ) : channelsQuery.isError ? (
146
+ <div className="flex flex-col items-center gap-2 p-2">
147
+ Couldn't load channels.
148
+ <Button
149
+ onClick={() => void channelsQuery.refetch()}
150
+ size="sm"
151
+ variant="outline"
157
152
  >
158
- <SelectValue>
159
- {platform !== "all" ? (
160
- <PlatformIcon platform={platform} className="size-3.5" />
161
- ) : null}
162
- {platform === "all"
163
- ? "All platforms"
164
- : platform === "ios"
165
- ? "iOS"
166
- : "Android"}
167
- </SelectValue>
168
- </SelectTrigger>
169
- <SelectContent>
170
- <SelectGroup>
171
- <SelectItem value="all">All platforms</SelectItem>
172
- <SelectItem value="ios">
173
- <PlatformIcon platform="ios" className="size-3.5" />
174
- iOS
175
- </SelectItem>
176
- <SelectItem value="android">
177
- <PlatformIcon platform="android" className="size-3.5" />
178
- Android
179
- </SelectItem>
180
- </SelectGroup>
181
- </SelectContent>
182
- </Select>
183
- </Field>
184
- <Field className="min-w-0">
185
- <FieldLabel htmlFor="insights-app-version">
186
- App version
187
- </FieldLabel>
188
- <Select
189
- items={Object.fromEntries([
190
- ["all", "All versions"],
191
- ...versions.map((version) => [`version:${version}`, version]),
192
- ])}
193
- value={appVersion ? `version:${appVersion}` : "all"}
194
- onValueChange={(value) => {
195
- if (value !== null)
196
- setAppVersion(value === "all" ? "" : value.slice(8));
197
- }}
153
+ Retry channels
154
+ </Button>
155
+ </div>
156
+ ) : (
157
+ "No channels found."
158
+ )}
159
+ </ComboboxEmpty>
160
+ <ComboboxList>
161
+ {(item: string) => (
162
+ <ComboboxItem
163
+ className="min-h-11 pr-8 sm:min-h-8"
164
+ key={item}
165
+ value={item}
198
166
  >
199
- <SelectTrigger
200
- id="insights-app-version"
201
- className="min-h-11 w-full sm:min-h-9"
202
- >
203
- <SelectValue />
204
- </SelectTrigger>
205
- <SelectContent>
206
- <SelectGroup>
207
- <SelectItem value="all">All versions</SelectItem>
208
- {versions.map((version) => (
209
- <SelectItem key={version} value={`version:${version}`}>
210
- {version}
211
- </SelectItem>
212
- ))}
213
- </SelectGroup>
214
- </SelectContent>
215
- </Select>
216
- </Field>
217
- <Field>
218
- <FieldLabel htmlFor="insights-release-platform">
219
- Health platform
220
- </FieldLabel>
221
- <Select
222
- items={{ ios: "iOS", android: "Android" }}
223
- value={releasePlatform}
224
- onValueChange={(value) => {
225
- if (value === "ios" || value === "android")
226
- setReleasePlatform(value);
227
- }}
228
- >
229
- <SelectTrigger
230
- id="insights-release-platform"
231
- className="min-h-11 w-full sm:min-h-9"
232
- >
233
- <SelectValue>
234
- <PlatformIcon
235
- platform={releasePlatform}
236
- className="size-3.5"
237
- />
238
- {releasePlatform === "ios" ? "iOS" : "Android"}
239
- </SelectValue>
240
- </SelectTrigger>
241
- <SelectContent>
242
- <SelectGroup>
243
- <SelectItem value="ios">
244
- <PlatformIcon platform="ios" className="size-3.5" />
245
- iOS
246
- </SelectItem>
247
- <SelectItem value="android">
248
- <PlatformIcon platform="android" className="size-3.5" />
249
- Android
250
- </SelectItem>
251
- </SelectGroup>
252
- </SelectContent>
253
- </Select>
254
- </Field>
255
- <Field className="min-w-0">
256
- <FieldLabel htmlFor="insights-release-id">
257
- Release ID{" "}
258
- <span className="text-muted-foreground">(optional)</span>
259
- </FieldLabel>
260
- <Input
261
- autoComplete="off"
262
- className="h-11 sm:h-9"
263
- id="insights-release-id"
264
- maxLength={1024}
265
- name="releaseId"
266
- placeholder="All releases…"
267
- value={releaseId}
268
- onChange={(event) => setReleaseId(event.target.value)}
269
- />
270
- </Field>
271
- <Button
272
- className="h-11 w-full sm:h-9"
273
- size="lg"
274
- type="submit"
275
- variant="outline"
167
+ <span className="truncate">{item}</span>
168
+ </ComboboxItem>
169
+ )}
170
+ </ComboboxList>
171
+ </ComboboxContent>
172
+ </Combobox>
173
+ <Clearable
174
+ name="platform"
175
+ active={narrowed.platform}
176
+ onClear={() => onFiltersChange({ platform: DEFAULT_FILTERS.platform })}
177
+ >
178
+ <Select
179
+ items={{ all: "All platforms", ios: "iOS", android: "Android" }}
180
+ value={filters.platform}
181
+ onValueChange={(value) => {
182
+ if (
183
+ (value === "all" || value === "ios" || value === "android") &&
184
+ value !== filters.platform
185
+ )
186
+ onFiltersChange({ platform: value });
187
+ }}
188
+ >
189
+ <SelectTrigger
190
+ aria-label="Platform"
191
+ className={SELECT_CHIP}
192
+ data-active={narrowed.platform}
193
+ >
194
+ <span className="text-muted-foreground">Platform</span>
195
+ <SelectValue>
196
+ {filters.platform === "all" ? null : (
197
+ <PlatformIcon
198
+ platform={filters.platform}
199
+ className="size-3.5"
200
+ />
201
+ )}
202
+ {platformLabel(filters.platform)}
203
+ </SelectValue>
204
+ </SelectTrigger>
205
+ <SelectContent>
206
+ <SelectGroup>
207
+ <SelectItem value="all">All platforms</SelectItem>
208
+ <SelectItem value="ios">
209
+ <PlatformIcon platform="ios" className="size-3.5" />
210
+ iOS
211
+ </SelectItem>
212
+ <SelectItem value="android">
213
+ <PlatformIcon platform="android" className="size-3.5" />
214
+ Android
215
+ </SelectItem>
216
+ </SelectGroup>
217
+ </SelectContent>
218
+ </Select>
219
+ </Clearable>
220
+ <Clearable
221
+ name="app version"
222
+ active={narrowed.appVersion}
223
+ onClear={() => onFiltersChange({ appVersion: undefined })}
224
+ >
225
+ <Select
226
+ items={Object.fromEntries([
227
+ ["all", "All versions"],
228
+ ...versions.map((version) => [`version:${version}`, version]),
229
+ ])}
230
+ value={filters.appVersion ? `version:${filters.appVersion}` : "all"}
231
+ onValueChange={(value) => {
232
+ if (value === null) return;
233
+ const appVersion = value === "all" ? undefined : value.slice(8);
234
+ if (appVersion !== filters.appVersion)
235
+ onFiltersChange({ appVersion });
236
+ }}
237
+ >
238
+ <SelectTrigger
239
+ aria-label="App version"
240
+ className={SELECT_CHIP}
241
+ data-active={narrowed.appVersion}
242
+ >
243
+ <span className="text-muted-foreground">App version</span>
244
+ <SelectValue />
245
+ </SelectTrigger>
246
+ <SelectContent>
247
+ <SelectGroup>
248
+ <SelectItem value="all">All versions</SelectItem>
249
+ {versions.map((version) => (
250
+ <SelectItem key={version} value={`version:${version}`}>
251
+ {version}
252
+ </SelectItem>
253
+ ))}
254
+ </SelectGroup>
255
+ </SelectContent>
256
+ </Select>
257
+ </Clearable>
258
+ <Clearable
259
+ name="release"
260
+ active={narrowed.releaseId}
261
+ onClear={() => onFiltersChange({ releaseId: undefined })}
262
+ >
263
+ <ReleaseCombobox
264
+ scope={{ channel: filters.channel, platform: filters.platform }}
265
+ candidates={releases}
266
+ excluded={filters.releaseId ? [filters.releaseId] : []}
267
+ label="Search releases"
268
+ placeholder="Search by ID or app version…"
269
+ onPick={(release) =>
270
+ onFiltersChange({ releaseId: release.releaseId })
271
+ }
272
+ trigger={
273
+ <ComboboxTrigger
274
+ aria-label={`Release: ${filters.releaseId ?? "All"}`}
275
+ data-active={narrowed.releaseId}
276
+ render={<Button className={CHIP} variant="outline" />}
276
277
  >
277
- Apply filters
278
- </Button>
279
- </FieldGroup>
280
- </form>
281
- </CardContent>
282
- </Card>
278
+ <span className="text-muted-foreground">Release</span>
279
+ {filters.releaseId ? (
280
+ <span className="max-w-36 truncate font-mono" translate="no">
281
+ {filters.releaseId}
282
+ </span>
283
+ ) : (
284
+ "All"
285
+ )}
286
+ </ComboboxTrigger>
287
+ }
288
+ />
289
+ </Clearable>
290
+ {Object.values(narrowed).some(Boolean) ? (
291
+ <Button
292
+ className="h-11 shrink-0 sm:h-8"
293
+ variant="ghost"
294
+ onClick={() => onFiltersChange(DEFAULT_FILTERS)}
295
+ >
296
+ <RotateCcw aria-hidden="true" data-icon="inline-start" />
297
+ Reset filters
298
+ </Button>
299
+ ) : null}
300
+ </div>
283
301
  );
284
302
  }