@hot-updater/console 0.36.7 → 1.0.0-rc.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/.output/nitro.json +1 -1
- package/.output/public/assets/AnalyticsCapabilityContext-BCke57-7.js +1 -0
- package/.output/public/assets/BundleActivityChart-CnsBe_1J.js +42 -0
- package/.output/public/assets/BundleIdDisplay-Dky75zLU.js +1 -0
- package/.output/public/assets/alert-C-1KvDW8.js +1 -0
- package/.output/public/assets/analytics-Bqz0c-BT.js +1 -0
- package/.output/public/assets/analytics-api-CJdrTLLO.js +1 -0
- package/.output/public/assets/api-B_Rh-uzC.js +1 -0
- package/.output/public/assets/api-keys-BurCFhzH.js +1 -0
- package/.output/public/assets/api-keys-api-BEDJdQg3.js +1 -0
- package/.output/public/assets/badge-Bf0fZ-Gx.js +1 -0
- package/.output/public/assets/chart-no-axes-combined-FaYMGmQS.js +1 -0
- package/.output/public/assets/createServerFn-gM2-LFBy.js +3 -0
- package/.output/public/assets/dialog-BS60BRr2.js +1 -0
- package/.output/public/assets/dist-DspNKfMj.js +1 -0
- package/.output/public/assets/download-O-yNBc9H.js +1 -0
- package/.output/public/assets/field-Buq148-A.js +1 -0
- package/.output/public/assets/index-kFhd2S78.js +10 -0
- package/.output/public/assets/installations-BrQ-od41.js +2 -0
- package/.output/public/assets/installations-CGlZ3ROz.js +1 -0
- package/.output/public/assets/lazyRouteComponent-rPzgA7S-.js +1 -0
- package/.output/public/assets/loader-circle-DCYF-wJl.js +1 -0
- package/.output/public/assets/preload-helper-TKOBYGYD.js +1 -0
- package/.output/public/assets/refresh-cw-n8XYbPPj.js +1 -0
- package/.output/public/assets/routes-CrdokJ80.js +1 -0
- package/.output/public/assets/routes-DcQnVajO.js +2 -0
- package/.output/public/assets/search-DLhbWw3W.js +1 -0
- package/.output/public/assets/shield-check-DE_hVuHu.js +1 -0
- package/.output/public/assets/signing-HRexfjsH.js +1 -0
- package/.output/public/assets/styles-eB4EtvFS.css +2 -0
- package/.output/public/assets/table-D8CLieHX.js +1 -0
- package/.output/public/assets/textarea-YJLTVAoq.js +1 -0
- package/.output/public/assets/useMutation-JsGVjQMY.js +1 -0
- package/.output/public/assets/useOpenInteractionType-4cRD5xO0.js +1 -0
- package/.output/server/__23tanstack-start-server-fn-resolver-DBP9TQ7K.mjs +146 -0
- package/.output/server/_libs/@base-ui/react+[...].mjs +16124 -0
- package/.output/server/_libs/@reduxjs/toolkit+[...].mjs +1554 -0
- package/.output/server/_libs/@tanstack/react-router+[...].mjs +41 -10
- package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +132 -0
- package/.output/server/_libs/d3-array.mjs +231 -0
- package/.output/server/_libs/d3-color.mjs +330 -0
- package/.output/server/_libs/d3-format.mjs +252 -0
- package/.output/server/_libs/d3-interpolate.mjs +207 -0
- package/.output/server/_libs/d3-path.mjs +77 -0
- package/.output/server/_libs/d3-scale+[...].mjs +1907 -0
- package/.output/server/_libs/d3-shape.mjs +818 -0
- package/.output/server/_libs/decimal.js-light.mjs +884 -0
- package/.output/server/_libs/es-toolkit.mjs +1134 -0
- package/.output/server/_libs/eventemitter3.mjs +236 -0
- package/.output/server/_libs/immer.mjs +532 -0
- package/.output/server/_libs/lucide-react.mjs +141 -13
- package/.output/server/_libs/next-themes.mjs +2 -2
- package/.output/server/_libs/react-redux+[...].mjs +205 -0
- package/.output/server/_libs/recharts+[...].mjs +14117 -0
- package/.output/server/_libs/sonner.mjs +2 -2
- package/.output/server/_libs/tanstack__query-core.mjs +114 -1
- package/.output/server/_libs/tanstack__react-query.mjs +3 -4
- package/.output/server/_runtime.mjs +10 -1
- package/.output/server/_ssr/AnalyticsCapabilityContext-ui4DX8GO.mjs +15 -0
- package/.output/server/_ssr/BundleActivityChart-BiB9ZORH.mjs +288 -0
- package/.output/server/_ssr/BundleIdDisplay-Czk6iR6p.mjs +13 -0
- package/.output/server/_ssr/alert-Dim3MGY5.mjs +86 -0
- package/.output/server/_ssr/analytics-DtMTn8fn.mjs +858 -0
- package/.output/server/_ssr/analytics-api-Cg1M5v_H.mjs +71 -0
- package/.output/server/_ssr/analytics-input-fRRRn0uA.mjs +96 -0
- package/.output/server/_ssr/analytics-rpc-DY5uXvHT.mjs +155 -0
- package/.output/server/_ssr/api-BTSv1tZb.mjs +282 -0
- package/.output/server/_ssr/api-keys-BnM_o2nS.mjs +300 -0
- package/.output/server/_ssr/api-keys-api-CIywsSF5.mjs +60 -0
- package/.output/server/_ssr/api-keys-rpc-BSp1wkIV.mjs +136 -0
- package/.output/server/_ssr/api-rpc-cG5wd_tO.mjs +1468 -0
- package/.output/server/_ssr/auth-BF8mbAgF.mjs +21 -0
- package/.output/server/_ssr/auth-rpc-BrJ6obHJ.mjs +24 -0
- package/.output/server/_ssr/auth.server-DwKoZoQM.mjs +50 -0
- package/.output/server/_ssr/badge-DCxbEeof.mjs +26 -0
- package/.output/server/_ssr/bundle-signing-rpc-COoQhW8E.mjs +16 -0
- package/.output/server/_ssr/bundle-signing.server-D5u8KkMp.mjs +51 -0
- package/.output/server/_ssr/config-Dv8qLDZ0.mjs +13 -0
- package/.output/server/_ssr/config.server-DEiLhKmy.mjs +361 -0
- package/.output/server/_ssr/{createServerFn-DzU0k62V.mjs → createServerFn-B2hcXoPM.mjs} +1 -1
- package/.output/server/_ssr/createServerRpc-DB29C_5p.mjs +12 -0
- package/.output/server/_ssr/{sidebar-Cv_H0_Jv.mjs → createSsrRpc-9P0S0lJ3.mjs} +68 -50
- package/.output/server/_ssr/databaseRows-CwbNwYSl.mjs +166 -0
- package/.output/server/_ssr/deleteBundle-Cu_XOMt0.mjs +66 -0
- package/.output/server/_ssr/dialog-CQuRMNZ4.mjs +170 -0
- package/.output/server/_ssr/dist-BVSLTpoP.mjs +250 -0
- package/.output/server/_ssr/downloadBundle-CNnYxYO2.mjs +24 -0
- package/.output/server/_ssr/{empty-plugin-adapters-B4HCzgmM.mjs → empty-plugin-adapters-B5QvGpEl.mjs} +1 -1
- package/.output/server/_ssr/field-DRIXKjZG.mjs +74 -0
- package/.output/server/_ssr/{getBundleChildren-CVtSiH5t.mjs → getBundleChildren-BcRMFoDF.mjs} +10 -18
- package/.output/server/_ssr/installations-B-azgAvX.mjs +19 -0
- package/.output/server/_ssr/installations-Ctvzp2JH.mjs +535 -0
- package/.output/server/_ssr/router-C-KUBfOi.mjs +513 -0
- package/.output/server/_ssr/routes-BM4ht9jb.mjs +2286 -0
- package/.output/server/_ssr/routes-Z7TwC6nC.mjs +37 -0
- package/.output/server/_ssr/runtime.server-awTLiiXW.mjs +527 -0
- package/.output/server/_ssr/server-BjzmvXnO.mjs +7468 -0
- package/.output/server/_ssr/signing-k8GxyBDF.mjs +202 -0
- package/.output/server/_ssr/ssr.mjs +4 -5740
- package/.output/server/_ssr/start-DpViQGnt.mjs +4 -0
- package/.output/server/_ssr/table-v1yCvYo7.mjs +53 -0
- package/.output/server/_ssr/textarea-DYHnskhb.mjs +14 -0
- package/.output/server/_ssr/uuidv7-BbJ-9D_I.mjs +31 -0
- package/.output/server/_tanstack-start-manifest_v-CH4StzJu.mjs +108 -0
- package/.output/server/index.mjs +256 -53
- package/README.md +29 -8
- package/lib/index.d.mts +36 -0
- package/lib/index.mjs +4 -0
- package/lib/vite-internal.mjs +127 -0
- package/lib/vite.d.mts +10 -0
- package/lib/vite.mjs +5 -0
- package/package.json +51 -29
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/logo.svg +1 -0
- package/public/manifest.json +30 -0
- package/public/robots.txt +3 -0
- package/src/components/AppSidebar.tsx +159 -0
- package/src/components/BundleIdDisplay.tsx +18 -0
- package/src/components/ChannelBadge.tsx +31 -0
- package/src/components/ConsoleAccessPage.tsx +122 -0
- package/src/components/EnabledStatusIcon.tsx +54 -0
- package/src/components/HashValueDisplay.tsx +85 -0
- package/src/components/HotUpdaterLogo.tsx +30 -0
- package/src/components/NotFoundPage.tsx +33 -0
- package/src/components/PlatformIcon.tsx +39 -0
- package/src/components/RolloutPercentageBadge.tsx +28 -0
- package/src/components/ThemeProvider.tsx +116 -0
- package/src/components/TimestampDisplay.tsx +41 -0
- package/src/components/features/analytics/ActivityChart.tsx +150 -0
- package/src/components/features/analytics/AnalyticsCapabilityContext.tsx +24 -0
- package/src/components/features/analytics/AnalyticsControls.tsx +115 -0
- package/src/components/features/analytics/AnalyticsErrorAlert.tsx +21 -0
- package/src/components/features/analytics/AnalyticsOverview.tsx +208 -0
- package/src/components/features/analytics/BundleDistribution.tsx +133 -0
- package/src/components/features/analytics/BundleSelector.tsx +114 -0
- package/src/components/features/analytics/InstallationHistoryCard.tsx +248 -0
- package/src/components/features/analytics/InstallationMatchesCard.tsx +132 -0
- package/src/components/features/analytics/InstallationPageHeader.tsx +138 -0
- package/src/components/features/analytics/InstallationPagination.tsx +48 -0
- package/src/components/features/analytics/InstallationSearch.tsx +230 -0
- package/src/components/features/analytics/UpdateOutcomes.tsx +147 -0
- package/src/components/features/api-keys/ApiKeysPage.tsx +377 -0
- package/src/components/features/bundles/BundleActivityChart.tsx +193 -0
- package/src/components/features/bundles/BundleAnalyticsSummary.tsx +123 -0
- package/src/components/features/bundles/BundleChildrenPanel.tsx +201 -0
- package/src/components/features/bundles/BundleMetadata.tsx +136 -0
- package/src/components/features/bundles/RolloutCohortsDialog.tsx +201 -0
- package/src/components/features/channels/ChannelManagementDialog.tsx +228 -0
- package/src/components/features/releases/ReleaseEditorSheet.tsx +879 -0
- package/src/components/features/releases/ReleaseStateBadge.tsx +20 -0
- package/src/components/features/signing/BundleSigningPage.tsx +247 -0
- package/src/components/ui/alert-dialog.tsx +185 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +89 -0
- package/src/components/ui/chart.tsx +371 -0
- package/src/components/ui/combobox.tsx +303 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/dropdown-menu.tsx +270 -0
- package/src/components/ui/field.tsx +235 -0
- package/src/components/ui/input-group.tsx +147 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/label.tsx +25 -0
- package/src/components/ui/progress.tsx +84 -0
- package/src/components/ui/select.tsx +205 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +132 -0
- package/src/components/ui/sidebar.tsx +779 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +56 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/switch.tsx +25 -0
- package/src/components/ui/table.tsx +114 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +66 -0
- package/src/hooks/use-mobile.ts +30 -0
- package/src/index.ts +48 -0
- package/src/lib/analytics-api.ts +145 -0
- package/src/lib/analytics-error.ts +19 -0
- package/src/lib/analytics-input.ts +187 -0
- package/src/lib/analytics-overview.ts +170 -0
- package/src/lib/analytics-rpc.ts +218 -0
- package/src/lib/api-keys-api.ts +69 -0
- package/src/lib/api-keys-rpc.ts +87 -0
- package/src/lib/api-rpc.ts +478 -0
- package/src/lib/api.ts +444 -0
- package/src/lib/auth-rpc.ts +16 -0
- package/src/lib/bundle-signing-api.ts +14 -0
- package/src/lib/bundle-signing-rpc.ts +16 -0
- package/src/lib/constants.ts +1 -0
- package/src/lib/git.ts +51 -0
- package/src/lib/server/auth.server.ts +43 -0
- package/src/lib/server/bundle-signing.server.ts +100 -0
- package/src/lib/server/config.server.ts +109 -0
- package/src/lib/server/console-runtime.server.ts +70 -0
- package/src/lib/server/deleteBundle.ts +145 -0
- package/src/lib/server/downloadBundle.ts +46 -0
- package/src/lib/server/getBundleChildren.ts +130 -0
- package/src/lib/server/listReleases.ts +61 -0
- package/src/lib/server/releaseActivity.ts +27 -0
- package/src/lib/server/releaseReachability.ts +95 -0
- package/src/lib/server/runtime.server.ts +172 -0
- package/src/lib/utils.ts +9 -0
- package/src/logo.svg +1 -0
- package/src/routeTree.gen.ts +199 -0
- package/src/router.tsx +21 -0
- package/src/routes/-installations-search.ts +19 -0
- package/src/routes/-releases-search.ts +74 -0
- package/src/routes/__root.tsx +174 -0
- package/src/routes/analytics.tsx +139 -0
- package/src/routes/api/auth/$.ts +19 -0
- package/src/routes/api/bundles/$bundleId/download.ts +17 -0
- package/src/routes/api-keys.tsx +37 -0
- package/src/routes/index.tsx +982 -0
- package/src/routes/installations.tsx +194 -0
- package/src/routes/signing.tsx +37 -0
- package/src/styles.css +152 -0
- package/src/virtual-modules.d.ts +21 -0
- package/src/vite-env.d.ts +1 -0
- package/src/vite-internal.ts +195 -0
- package/src/vite.ts +12 -0
- package/.output/public/assets/dist-DJX53faP.js +0 -3
- package/.output/public/assets/index-Iylis5kY.js +0 -10
- package/.output/public/assets/routes-HT21hs0C.js +0 -54
- package/.output/public/assets/styles-C0Re4quB.css +0 -2
- package/.output/server/__23tanstack-start-server-fn-resolver-CgwgZ0-i.mjs +0 -66
- package/.output/server/_libs/@floating-ui/core+[...].mjs +0 -698
- package/.output/server/_libs/@floating-ui/dom+[...].mjs +0 -644
- package/.output/server/_libs/@floating-ui/react-dom+[...].mjs +0 -839
- package/.output/server/_libs/@radix-ui/react-alert-dialog+[...].mjs +0 -2138
- package/.output/server/_libs/@radix-ui/react-popper+[...].mjs +0 -308
- package/.output/server/_libs/@radix-ui/react-select+[...].mjs +0 -1038
- package/.output/server/_libs/@tanstack/devtools-event-client+[...].mjs +0 -196
- package/.output/server/_libs/@tanstack/form-core+[...].mjs +0 -3228
- package/.output/server/_libs/@tanstack/react-form+[...].mjs +0 -430
- package/.output/server/_libs/@tanstack/react-table+[...].mjs +0 -2382
- package/.output/server/_libs/radix-ui__number.mjs +0 -6
- package/.output/server/_libs/radix-ui__primitive.mjs +0 -9
- package/.output/server/_libs/radix-ui__react-arrow.mjs +0 -23
- package/.output/server/_libs/radix-ui__react-collection.mjs +0 -78
- package/.output/server/_libs/radix-ui__react-direction.mjs +0 -13
- package/.output/server/_libs/radix-ui__react-label.mjs +0 -22
- package/.output/server/_libs/radix-ui__react-separator.mjs +0 -30
- package/.output/server/_libs/radix-ui__react-slider.mjs +0 -510
- package/.output/server/_libs/radix-ui__react-switch.mjs +0 -157
- package/.output/server/_libs/radix-ui__react-tooltip.mjs +0 -493
- package/.output/server/_ssr/api-rpc-BIbDUKSz.mjs +0 -292
- package/.output/server/_ssr/config.server-BSS366KT.mjs +0 -41
- package/.output/server/_ssr/deleteBundle-BgbZTtpW.mjs +0 -141
- package/.output/server/_ssr/dist-KboydRVk.mjs +0 -120
- package/.output/server/_ssr/router-C3hqEy3k.mjs +0 -307
- package/.output/server/_ssr/routes-B0lQvvlC.mjs +0 -3048
- package/.output/server/_ssr/start-02sLteYG.mjs +0 -4
- package/.output/server/_ssr/storageProfile-FVkjPoGb.mjs +0 -9
- package/.output/server/_tanstack-start-manifest_v-Bpx52WOU.mjs +0 -20
- package/.output/server/node_modules/tslib/modules/index.js +0 -70
- package/.output/server/node_modules/tslib/modules/package.json +0 -3
- package/.output/server/node_modules/tslib/package.json +0 -47
- package/.output/server/node_modules/tslib/tslib.js +0 -484
- package/.output/server/package.json +0 -9
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActiveInstallationOverview,
|
|
3
|
+
ActiveInstallationWindow,
|
|
4
|
+
} from "@hot-updater/server";
|
|
5
|
+
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ChartConfig,
|
|
9
|
+
ChartContainer,
|
|
10
|
+
ChartTooltip,
|
|
11
|
+
ChartTooltipContent,
|
|
12
|
+
} from "@/components/ui/chart";
|
|
13
|
+
|
|
14
|
+
type ActivitySeries = ActiveInstallationOverview["series"];
|
|
15
|
+
|
|
16
|
+
const dayFormatter = new Intl.DateTimeFormat("en", {
|
|
17
|
+
day: "numeric",
|
|
18
|
+
month: "short",
|
|
19
|
+
timeZone: "UTC",
|
|
20
|
+
});
|
|
21
|
+
const hourFormatter = new Intl.DateTimeFormat("en", {
|
|
22
|
+
day: "numeric",
|
|
23
|
+
hour: "numeric",
|
|
24
|
+
month: "short",
|
|
25
|
+
timeZone: "UTC",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const formatBucket = (
|
|
29
|
+
bucketStartMs: number,
|
|
30
|
+
window: ActiveInstallationWindow,
|
|
31
|
+
): string =>
|
|
32
|
+
(window === "24h" ? hourFormatter : dayFormatter).format(
|
|
33
|
+
new Date(bucketStartMs),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const chartConfig = {
|
|
37
|
+
installations: {
|
|
38
|
+
label: "Active installations",
|
|
39
|
+
color: "var(--chart-2)",
|
|
40
|
+
},
|
|
41
|
+
} satisfies ChartConfig;
|
|
42
|
+
|
|
43
|
+
export function ActivityChart({
|
|
44
|
+
series,
|
|
45
|
+
window,
|
|
46
|
+
}: {
|
|
47
|
+
readonly series: ActivitySeries;
|
|
48
|
+
readonly window: ActiveInstallationWindow;
|
|
49
|
+
}) {
|
|
50
|
+
const chartData = series.map((point) => ({
|
|
51
|
+
bucketStartMs: point.bucketStartMs,
|
|
52
|
+
installations: point.value,
|
|
53
|
+
}));
|
|
54
|
+
const hasReports = series.some(({ value }) => value > 0);
|
|
55
|
+
const bucketName = window === "24h" ? "hour" : "day";
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex min-w-0 flex-col gap-3">
|
|
59
|
+
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
|
60
|
+
<div>
|
|
61
|
+
<h3 className="text-sm font-medium">Activity over time</h3>
|
|
62
|
+
<p className="text-xs text-muted-foreground">
|
|
63
|
+
Unique active installations per {bucketName}
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
<span className="text-xs text-muted-foreground">UTC</span>
|
|
67
|
+
</div>
|
|
68
|
+
{!hasReports && (
|
|
69
|
+
<p className="text-sm text-muted-foreground">
|
|
70
|
+
No installations reported during this period.
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
<ChartContainer
|
|
74
|
+
aria-label={`Active installations per ${bucketName}`}
|
|
75
|
+
className="h-64 w-full aspect-auto"
|
|
76
|
+
config={chartConfig}
|
|
77
|
+
role="img"
|
|
78
|
+
>
|
|
79
|
+
<AreaChart
|
|
80
|
+
accessibilityLayer
|
|
81
|
+
data={chartData}
|
|
82
|
+
margin={{ left: -16, right: 4, top: 8 }}
|
|
83
|
+
>
|
|
84
|
+
<CartesianGrid vertical={false} />
|
|
85
|
+
<XAxis
|
|
86
|
+
axisLine={false}
|
|
87
|
+
dataKey="bucketStartMs"
|
|
88
|
+
minTickGap={28}
|
|
89
|
+
tickFormatter={(value: number) => formatBucket(value, window)}
|
|
90
|
+
tickLine={false}
|
|
91
|
+
/>
|
|
92
|
+
<YAxis
|
|
93
|
+
allowDecimals={false}
|
|
94
|
+
axisLine={false}
|
|
95
|
+
tickLine={false}
|
|
96
|
+
width={32}
|
|
97
|
+
/>
|
|
98
|
+
<ChartTooltip
|
|
99
|
+
content={
|
|
100
|
+
<ChartTooltipContent
|
|
101
|
+
labelFormatter={(_, payload) => {
|
|
102
|
+
const bucket = payload[0]?.payload.bucketStartMs;
|
|
103
|
+
return typeof bucket === "number"
|
|
104
|
+
? formatBucket(bucket, window)
|
|
105
|
+
: "";
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
}
|
|
109
|
+
/>
|
|
110
|
+
<Area
|
|
111
|
+
dataKey="installations"
|
|
112
|
+
fill="var(--color-installations)"
|
|
113
|
+
fillOpacity={0.12}
|
|
114
|
+
isAnimationActive={false}
|
|
115
|
+
stroke="var(--color-installations)"
|
|
116
|
+
strokeWidth={2}
|
|
117
|
+
type="monotone"
|
|
118
|
+
/>
|
|
119
|
+
</AreaChart>
|
|
120
|
+
</ChartContainer>
|
|
121
|
+
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
122
|
+
Each point counts an installation once in that {bucketName}. The total
|
|
123
|
+
above counts it once across the whole period, so the points do not add
|
|
124
|
+
up to the total.
|
|
125
|
+
</p>
|
|
126
|
+
<div className="sr-only">
|
|
127
|
+
<table aria-label={`Exact active installations per ${bucketName}`}>
|
|
128
|
+
<caption>
|
|
129
|
+
Unique installations that reported an update status in each UTC
|
|
130
|
+
{` ${bucketName}`}.
|
|
131
|
+
</caption>
|
|
132
|
+
<thead>
|
|
133
|
+
<tr>
|
|
134
|
+
<th scope="col">UTC {bucketName}</th>
|
|
135
|
+
<th scope="col">Active installations</th>
|
|
136
|
+
</tr>
|
|
137
|
+
</thead>
|
|
138
|
+
<tbody>
|
|
139
|
+
{chartData.map((point) => (
|
|
140
|
+
<tr key={point.bucketStartMs}>
|
|
141
|
+
<th scope="row">{formatBucket(point.bucketStartMs, window)}</th>
|
|
142
|
+
<td>{point.installations}</td>
|
|
143
|
+
</tr>
|
|
144
|
+
))}
|
|
145
|
+
</tbody>
|
|
146
|
+
</table>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { AnalyticsCapabilityState } from "@/lib/analytics-api";
|
|
4
|
+
|
|
5
|
+
const AnalyticsCapabilityContext = createContext<AnalyticsCapabilityState>({
|
|
6
|
+
status: "unresolved",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function AnalyticsCapabilityProvider({
|
|
10
|
+
children,
|
|
11
|
+
value,
|
|
12
|
+
}: {
|
|
13
|
+
readonly children: ReactNode;
|
|
14
|
+
readonly value: AnalyticsCapabilityState;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<AnalyticsCapabilityContext.Provider value={value}>
|
|
18
|
+
{children}
|
|
19
|
+
</AnalyticsCapabilityContext.Provider>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const useAnalyticsCapability = (): AnalyticsCapabilityState =>
|
|
24
|
+
useContext(AnalyticsCapabilityContext);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ActiveInstallationWindow } from "@hot-updater/server";
|
|
2
|
+
import { Search, X } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
7
|
+
import {
|
|
8
|
+
InputGroup,
|
|
9
|
+
InputGroupAddon,
|
|
10
|
+
InputGroupButton,
|
|
11
|
+
InputGroupInput,
|
|
12
|
+
} from "@/components/ui/input-group";
|
|
13
|
+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
14
|
+
|
|
15
|
+
const windows = [
|
|
16
|
+
{ value: "24h", label: "24 hours", shortLabel: "24h" },
|
|
17
|
+
{ value: "7d", label: "7 days", shortLabel: "7d" },
|
|
18
|
+
{ value: "30d", label: "30 days", shortLabel: "30d" },
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export function AnalyticsControls({
|
|
22
|
+
onInstallationSearch,
|
|
23
|
+
onWindowChange,
|
|
24
|
+
window,
|
|
25
|
+
}: {
|
|
26
|
+
readonly onInstallationSearch: (query: string) => void;
|
|
27
|
+
readonly onWindowChange: (window: ActiveInstallationWindow) => void;
|
|
28
|
+
readonly window: ActiveInstallationWindow;
|
|
29
|
+
}) {
|
|
30
|
+
const [draft, setDraft] = useState("");
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section
|
|
34
|
+
aria-label="Analytics controls"
|
|
35
|
+
className="rounded-xl border bg-background px-4 py-3"
|
|
36
|
+
>
|
|
37
|
+
<form
|
|
38
|
+
aria-label="Filter analytics"
|
|
39
|
+
className="min-w-0"
|
|
40
|
+
onSubmit={(event) => {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
const query = draft.trim();
|
|
43
|
+
if (query) onInstallationSearch(query);
|
|
44
|
+
}}
|
|
45
|
+
role="search"
|
|
46
|
+
>
|
|
47
|
+
<FieldGroup className="gap-3 md:grid md:grid-cols-[auto_minmax(18rem,1fr)] md:items-end">
|
|
48
|
+
<Field className="min-w-0">
|
|
49
|
+
<FieldLabel>Reporting period</FieldLabel>
|
|
50
|
+
<ToggleGroup
|
|
51
|
+
aria-label="Reporting period"
|
|
52
|
+
className="w-full lg:w-fit"
|
|
53
|
+
onValueChange={(value) => {
|
|
54
|
+
if (value[0]) {
|
|
55
|
+
onWindowChange(value[0] as ActiveInstallationWindow);
|
|
56
|
+
}
|
|
57
|
+
}}
|
|
58
|
+
spacing={0}
|
|
59
|
+
size="lg"
|
|
60
|
+
value={[window]}
|
|
61
|
+
variant="outline"
|
|
62
|
+
>
|
|
63
|
+
{windows.map((item) => (
|
|
64
|
+
<ToggleGroupItem
|
|
65
|
+
aria-label={item.label}
|
|
66
|
+
className="flex-1 md:flex-none"
|
|
67
|
+
key={item.value}
|
|
68
|
+
value={item.value}
|
|
69
|
+
>
|
|
70
|
+
{item.shortLabel}
|
|
71
|
+
</ToggleGroupItem>
|
|
72
|
+
))}
|
|
73
|
+
</ToggleGroup>
|
|
74
|
+
</Field>
|
|
75
|
+
<Field className="min-w-0">
|
|
76
|
+
<FieldLabel htmlFor="installation-history-search">
|
|
77
|
+
Installation history
|
|
78
|
+
</FieldLabel>
|
|
79
|
+
<div className="flex min-w-0 flex-col gap-2 sm:flex-row">
|
|
80
|
+
<InputGroup className="h-8 flex-1">
|
|
81
|
+
<InputGroupAddon>
|
|
82
|
+
<Search aria-hidden="true" />
|
|
83
|
+
</InputGroupAddon>
|
|
84
|
+
<InputGroupInput
|
|
85
|
+
aria-label="User or install ID"
|
|
86
|
+
id="installation-history-search"
|
|
87
|
+
maxLength={1024}
|
|
88
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
89
|
+
placeholder="User ID or install ID"
|
|
90
|
+
type="search"
|
|
91
|
+
value={draft}
|
|
92
|
+
/>
|
|
93
|
+
{draft && (
|
|
94
|
+
<InputGroupAddon align="inline-end">
|
|
95
|
+
<InputGroupButton
|
|
96
|
+
aria-label="Clear installation search"
|
|
97
|
+
onClick={() => setDraft("")}
|
|
98
|
+
size="icon-xs"
|
|
99
|
+
>
|
|
100
|
+
<X aria-hidden="true" />
|
|
101
|
+
</InputGroupButton>
|
|
102
|
+
</InputGroupAddon>
|
|
103
|
+
)}
|
|
104
|
+
</InputGroup>
|
|
105
|
+
<Button className="w-full sm:w-auto" size="lg" type="submit">
|
|
106
|
+
<Search aria-hidden="true" data-icon="inline-start" />
|
|
107
|
+
Search
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
</Field>
|
|
111
|
+
</FieldGroup>
|
|
112
|
+
</form>
|
|
113
|
+
</section>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TriangleAlert } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
4
|
+
import { getAnalyticsErrorCopy } from "@/lib/analytics-error";
|
|
5
|
+
|
|
6
|
+
export function AnalyticsErrorAlert({
|
|
7
|
+
error,
|
|
8
|
+
fallbackTitle,
|
|
9
|
+
}: {
|
|
10
|
+
readonly error: Error;
|
|
11
|
+
readonly fallbackTitle: string;
|
|
12
|
+
}) {
|
|
13
|
+
const copy = getAnalyticsErrorCopy(error, fallbackTitle);
|
|
14
|
+
return (
|
|
15
|
+
<Alert variant="destructive">
|
|
16
|
+
<TriangleAlert aria-hidden="true" />
|
|
17
|
+
<AlertTitle>{copy.title}</AlertTitle>
|
|
18
|
+
<AlertDescription>{copy.description}</AlertDescription>
|
|
19
|
+
</Alert>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { ActiveInstallationOverview } from "@hot-updater/server";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardFooter,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
} from "@/components/ui/card";
|
|
12
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
13
|
+
import type { AnalyticsOverview as CatalogOverview } from "@/lib/analytics-overview";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
import { ActivityChart } from "./ActivityChart";
|
|
17
|
+
import { AnalyticsErrorAlert } from "./AnalyticsErrorAlert";
|
|
18
|
+
import { BundleSelector } from "./BundleSelector";
|
|
19
|
+
import { UpdateOutcomes, type UpdateOutcomeState } from "./UpdateOutcomes";
|
|
20
|
+
|
|
21
|
+
type AnalyticsOverviewProps =
|
|
22
|
+
| { readonly status: "loading" }
|
|
23
|
+
| { readonly status: "error"; readonly error: Error }
|
|
24
|
+
| {
|
|
25
|
+
readonly status: "success";
|
|
26
|
+
readonly active: ActiveInstallationOverview;
|
|
27
|
+
readonly bundleId: string;
|
|
28
|
+
readonly bundles: readonly {
|
|
29
|
+
readonly bundleId: string;
|
|
30
|
+
readonly description: string;
|
|
31
|
+
}[];
|
|
32
|
+
readonly catalog: CatalogOverview;
|
|
33
|
+
readonly onBundleChange: (bundleId: string) => void;
|
|
34
|
+
readonly outcomes: UpdateOutcomeState;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const asOfFormatter = new Intl.DateTimeFormat("en", {
|
|
38
|
+
dateStyle: "medium",
|
|
39
|
+
timeStyle: "short",
|
|
40
|
+
timeZone: "UTC",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const activityWindowCopy = {
|
|
44
|
+
"24h": {
|
|
45
|
+
label: "Daily active installations",
|
|
46
|
+
period: "last 24 hours",
|
|
47
|
+
},
|
|
48
|
+
"7d": {
|
|
49
|
+
label: "Weekly active installations",
|
|
50
|
+
period: "last 7 days",
|
|
51
|
+
},
|
|
52
|
+
"30d": {
|
|
53
|
+
label: "Monthly active installations",
|
|
54
|
+
period: "last 30 days",
|
|
55
|
+
},
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
function LoadingCard({
|
|
59
|
+
children,
|
|
60
|
+
className,
|
|
61
|
+
label,
|
|
62
|
+
}: {
|
|
63
|
+
readonly children: ReactNode;
|
|
64
|
+
readonly className?: string;
|
|
65
|
+
readonly label: string;
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<Card aria-label={label} className={cn("min-w-0", className)}>
|
|
69
|
+
<CardHeader>
|
|
70
|
+
<Skeleton className="h-4 w-32" />
|
|
71
|
+
<Skeleton className="h-3 w-4/5" />
|
|
72
|
+
</CardHeader>
|
|
73
|
+
<CardContent>{children}</CardContent>
|
|
74
|
+
</Card>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function AnalyticsOverview(props: AnalyticsOverviewProps) {
|
|
79
|
+
if (props.status === "loading") {
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
aria-label="Loading reporting analytics"
|
|
83
|
+
className="flex min-w-0 flex-col gap-8"
|
|
84
|
+
>
|
|
85
|
+
<LoadingCard label="Loading installation activity">
|
|
86
|
+
<Skeleton className="h-10 w-24" />
|
|
87
|
+
<Skeleton className="mt-5 h-64 w-full" />
|
|
88
|
+
<div className="mt-5 grid gap-4 border-t pt-5 sm:grid-cols-3">
|
|
89
|
+
<Skeleton className="h-10 w-full" />
|
|
90
|
+
<Skeleton className="h-10 w-full" />
|
|
91
|
+
<Skeleton className="h-10 w-full" />
|
|
92
|
+
</div>
|
|
93
|
+
</LoadingCard>
|
|
94
|
+
<LoadingCard label="Loading bundle detail">
|
|
95
|
+
<div className="flex flex-col gap-4">
|
|
96
|
+
<Skeleton className="h-10 w-full max-w-md" />
|
|
97
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
98
|
+
<Skeleton className="h-14 w-full" />
|
|
99
|
+
<Skeleton className="h-14 w-full" />
|
|
100
|
+
<Skeleton className="h-14 w-full" />
|
|
101
|
+
<Skeleton className="h-14 w-full" />
|
|
102
|
+
</div>
|
|
103
|
+
<Skeleton className="h-32 w-full" />
|
|
104
|
+
</div>
|
|
105
|
+
</LoadingCard>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (props.status === "error") {
|
|
111
|
+
return (
|
|
112
|
+
<AnalyticsErrorAlert
|
|
113
|
+
error={props.error}
|
|
114
|
+
fallbackTitle="Reporting analytics unavailable"
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { active, bundleId, bundles, catalog, onBundleChange, outcomes } =
|
|
120
|
+
props;
|
|
121
|
+
const activityCopy = activityWindowCopy[active.window];
|
|
122
|
+
const selectedBundleId =
|
|
123
|
+
outcomes.status === "idle" ? null : outcomes.bundleId;
|
|
124
|
+
const latestBundleInstallations =
|
|
125
|
+
active.bundles.find(({ bundleId }) => bundleId === selectedBundleId)
|
|
126
|
+
?.installations ?? 0;
|
|
127
|
+
const configuredPercentage =
|
|
128
|
+
catalog.configuredRollouts.find(
|
|
129
|
+
({ bundleId }) => bundleId === selectedBundleId,
|
|
130
|
+
)?.configuredPercentage ?? null;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex min-w-0 flex-col gap-8">
|
|
134
|
+
<section aria-label="Installation activity">
|
|
135
|
+
<Card className="min-w-0 overflow-hidden shadow-sm">
|
|
136
|
+
<CardHeader className="pb-4">
|
|
137
|
+
<CardTitle className="text-sm font-medium">
|
|
138
|
+
<h2>{activityCopy.label}</h2>
|
|
139
|
+
</CardTitle>
|
|
140
|
+
<CardDescription>
|
|
141
|
+
Unique app installations that reported an update status in the{" "}
|
|
142
|
+
{activityCopy.period}.
|
|
143
|
+
</CardDescription>
|
|
144
|
+
</CardHeader>
|
|
145
|
+
<CardContent className="flex flex-col gap-5">
|
|
146
|
+
<div className="flex flex-col gap-1">
|
|
147
|
+
<p className="text-xs font-medium text-muted-foreground">
|
|
148
|
+
Active installations
|
|
149
|
+
</p>
|
|
150
|
+
<div className="flex items-end gap-2">
|
|
151
|
+
<span className="text-4xl font-semibold tracking-tight tabular-nums">
|
|
152
|
+
{active.activeInstallations.toLocaleString()}
|
|
153
|
+
</span>
|
|
154
|
+
<span className="pb-1 text-xs text-muted-foreground">
|
|
155
|
+
unique in {activityCopy.period}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="border-t pt-5">
|
|
160
|
+
<ActivityChart series={active.series} window={active.window} />
|
|
161
|
+
</div>
|
|
162
|
+
</CardContent>
|
|
163
|
+
<CardFooter className="border-t bg-muted/15 p-0">
|
|
164
|
+
<dl className="grid w-full sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
|
165
|
+
<div className="flex flex-col gap-1 px-5 py-4">
|
|
166
|
+
<dt className="text-xs text-muted-foreground">
|
|
167
|
+
Reported bundles
|
|
168
|
+
</dt>
|
|
169
|
+
<dd className="text-lg font-semibold tabular-nums">
|
|
170
|
+
{active.bundles.length.toLocaleString()}
|
|
171
|
+
</dd>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex flex-col gap-1 border-t px-5 py-4 sm:border-t-0">
|
|
174
|
+
<dt className="text-xs text-muted-foreground">
|
|
175
|
+
Reporting window
|
|
176
|
+
</dt>
|
|
177
|
+
<dd className="text-sm font-medium">{activityCopy.period}</dd>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex flex-col gap-1 border-t px-5 py-4 sm:border-t-0">
|
|
180
|
+
<dt className="text-xs text-muted-foreground">As of</dt>
|
|
181
|
+
<dd className="text-xs font-medium tabular-nums">
|
|
182
|
+
{asOfFormatter.format(new Date(active.asOfMs))} UTC
|
|
183
|
+
</dd>
|
|
184
|
+
</div>
|
|
185
|
+
</dl>
|
|
186
|
+
</CardFooter>
|
|
187
|
+
</Card>
|
|
188
|
+
</section>
|
|
189
|
+
|
|
190
|
+
<section aria-labelledby="bundle-detail-heading">
|
|
191
|
+
<UpdateOutcomes
|
|
192
|
+
bundleSelector={
|
|
193
|
+
<BundleSelector
|
|
194
|
+
bundleId={bundleId}
|
|
195
|
+
bundles={bundles}
|
|
196
|
+
onBundleChange={onBundleChange}
|
|
197
|
+
/>
|
|
198
|
+
}
|
|
199
|
+
configuredPercentage={configuredPercentage}
|
|
200
|
+
latestBundleInstallations={latestBundleInstallations}
|
|
201
|
+
reportingInstallations={active.activeInstallations}
|
|
202
|
+
state={outcomes}
|
|
203
|
+
window={active.window}
|
|
204
|
+
/>
|
|
205
|
+
</section>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ActiveInstallationOverview } from "@hot-updater/server";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Progress } from "@/components/ui/progress";
|
|
6
|
+
import {
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from "@/components/ui/table";
|
|
14
|
+
import type { AnalyticsOverview } from "@/lib/analytics-overview";
|
|
15
|
+
|
|
16
|
+
const PAGE_SIZE = 8;
|
|
17
|
+
|
|
18
|
+
const percentage = (value: number): string =>
|
|
19
|
+
new Intl.NumberFormat(undefined, {
|
|
20
|
+
maximumFractionDigits: 1,
|
|
21
|
+
style: "percent",
|
|
22
|
+
}).format(value);
|
|
23
|
+
|
|
24
|
+
export function BundleDistribution({
|
|
25
|
+
active,
|
|
26
|
+
catalog,
|
|
27
|
+
}: {
|
|
28
|
+
readonly active: ActiveInstallationOverview;
|
|
29
|
+
readonly catalog: AnalyticsOverview;
|
|
30
|
+
}) {
|
|
31
|
+
const [page, setPage] = useState(1);
|
|
32
|
+
const metadata = new Map(
|
|
33
|
+
catalog.configuredRollouts.map(({ bundle, bundleId }) => [
|
|
34
|
+
bundleId,
|
|
35
|
+
bundle,
|
|
36
|
+
]),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (active.bundles.length === 0) {
|
|
40
|
+
return (
|
|
41
|
+
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
42
|
+
No observed installations in this range.
|
|
43
|
+
</p>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const totalPages = Math.ceil(active.bundles.length / PAGE_SIZE);
|
|
48
|
+
const currentPage = Math.min(page, totalPages);
|
|
49
|
+
const start = (currentPage - 1) * PAGE_SIZE;
|
|
50
|
+
const end = Math.min(start + PAGE_SIZE, active.bundles.length);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex min-w-0 flex-col gap-4">
|
|
54
|
+
<div className="overflow-x-auto">
|
|
55
|
+
<Table aria-label="Observed installations by bundle">
|
|
56
|
+
<TableHeader>
|
|
57
|
+
<TableRow className="hover:bg-transparent">
|
|
58
|
+
<TableHead>Bundle</TableHead>
|
|
59
|
+
<TableHead className="hidden w-2/5 sm:table-cell">
|
|
60
|
+
Activity
|
|
61
|
+
</TableHead>
|
|
62
|
+
<TableHead className="text-right">Observed</TableHead>
|
|
63
|
+
<TableHead className="text-right">Share</TableHead>
|
|
64
|
+
</TableRow>
|
|
65
|
+
</TableHeader>
|
|
66
|
+
<TableBody>
|
|
67
|
+
{active.bundles.slice(start, end).map((row) => {
|
|
68
|
+
const bundle = metadata.get(row.bundleId);
|
|
69
|
+
const share = row.installations / active.activeInstallations;
|
|
70
|
+
const shareLabel = percentage(share);
|
|
71
|
+
return (
|
|
72
|
+
<TableRow key={row.bundleId}>
|
|
73
|
+
<TableCell className="max-w-48 whitespace-normal">
|
|
74
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
75
|
+
<code className="break-all text-xs">{row.bundleId}</code>
|
|
76
|
+
<span className="text-muted-foreground">
|
|
77
|
+
{bundle
|
|
78
|
+
? `${bundle.platform} · ${bundle.channel}`
|
|
79
|
+
: "Unknown bundle metadata"}
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
</TableCell>
|
|
83
|
+
<TableCell className="hidden sm:table-cell">
|
|
84
|
+
<Progress
|
|
85
|
+
aria-label={`${row.bundleId} observed share ${shareLabel}`}
|
|
86
|
+
aria-valuetext={shareLabel}
|
|
87
|
+
className="h-2"
|
|
88
|
+
value={share * 100}
|
|
89
|
+
/>
|
|
90
|
+
</TableCell>
|
|
91
|
+
<TableCell className="text-right tabular-nums">
|
|
92
|
+
{row.installations.toLocaleString()}
|
|
93
|
+
</TableCell>
|
|
94
|
+
<TableCell className="text-right tabular-nums">
|
|
95
|
+
{shareLabel}
|
|
96
|
+
</TableCell>
|
|
97
|
+
</TableRow>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</TableBody>
|
|
101
|
+
</Table>
|
|
102
|
+
</div>
|
|
103
|
+
{totalPages > 1 && (
|
|
104
|
+
<div className="flex flex-wrap items-center justify-between gap-2 px-4 pb-4">
|
|
105
|
+
<p className="text-xs text-muted-foreground tabular-nums">
|
|
106
|
+
Showing {start + 1}–{end} of{" "}
|
|
107
|
+
{active.bundles.length.toLocaleString()}
|
|
108
|
+
</p>
|
|
109
|
+
<div className="flex gap-2">
|
|
110
|
+
<Button
|
|
111
|
+
disabled={currentPage === 1}
|
|
112
|
+
onClick={() => setPage(currentPage - 1)}
|
|
113
|
+
size="sm"
|
|
114
|
+
type="button"
|
|
115
|
+
variant="outline"
|
|
116
|
+
>
|
|
117
|
+
Previous
|
|
118
|
+
</Button>
|
|
119
|
+
<Button
|
|
120
|
+
disabled={currentPage === totalPages}
|
|
121
|
+
onClick={() => setPage(currentPage + 1)}
|
|
122
|
+
size="sm"
|
|
123
|
+
type="button"
|
|
124
|
+
variant="outline"
|
|
125
|
+
>
|
|
126
|
+
Next
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|