@hot-updater/console 0.36.6 → 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 +249 -46
- 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,377 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlertTriangle,
|
|
3
|
+
Check,
|
|
4
|
+
Clipboard,
|
|
5
|
+
KeyRound,
|
|
6
|
+
Loader2,
|
|
7
|
+
Plus,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
ShieldOff,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { type FormEvent, type MouseEvent, useState } from "react";
|
|
12
|
+
import { toast } from "sonner";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
Alert,
|
|
16
|
+
AlertAction,
|
|
17
|
+
AlertDescription,
|
|
18
|
+
AlertTitle,
|
|
19
|
+
} from "@/components/ui/alert";
|
|
20
|
+
import {
|
|
21
|
+
AlertDialog,
|
|
22
|
+
AlertDialogAction,
|
|
23
|
+
AlertDialogCancel,
|
|
24
|
+
AlertDialogContent,
|
|
25
|
+
AlertDialogDescription,
|
|
26
|
+
AlertDialogFooter,
|
|
27
|
+
AlertDialogHeader,
|
|
28
|
+
AlertDialogMedia,
|
|
29
|
+
AlertDialogTitle,
|
|
30
|
+
AlertDialogTrigger,
|
|
31
|
+
} from "@/components/ui/alert-dialog";
|
|
32
|
+
import { Badge } from "@/components/ui/badge";
|
|
33
|
+
import { Button } from "@/components/ui/button";
|
|
34
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
35
|
+
import {
|
|
36
|
+
Dialog,
|
|
37
|
+
DialogContent,
|
|
38
|
+
DialogDescription,
|
|
39
|
+
DialogFooter,
|
|
40
|
+
DialogHeader,
|
|
41
|
+
DialogTitle,
|
|
42
|
+
} from "@/components/ui/dialog";
|
|
43
|
+
import {
|
|
44
|
+
Field,
|
|
45
|
+
FieldDescription,
|
|
46
|
+
FieldError,
|
|
47
|
+
FieldGroup,
|
|
48
|
+
FieldLabel,
|
|
49
|
+
} from "@/components/ui/field";
|
|
50
|
+
import { Input } from "@/components/ui/input";
|
|
51
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
52
|
+
import {
|
|
53
|
+
Table,
|
|
54
|
+
TableBody,
|
|
55
|
+
TableCell,
|
|
56
|
+
TableHead,
|
|
57
|
+
TableHeader,
|
|
58
|
+
TableRow,
|
|
59
|
+
} from "@/components/ui/table";
|
|
60
|
+
import {
|
|
61
|
+
useApiKeysQuery,
|
|
62
|
+
useCreateApiKeyMutation,
|
|
63
|
+
useRevokeApiKeyMutation,
|
|
64
|
+
} from "@/lib/api-keys-api";
|
|
65
|
+
import type { ApiKeyView } from "@/lib/api-keys-rpc";
|
|
66
|
+
|
|
67
|
+
const formatCreatedAt = (createdAt: number): string =>
|
|
68
|
+
new Date(createdAt).toISOString().slice(0, 10);
|
|
69
|
+
|
|
70
|
+
function CreateApiKeyDialog() {
|
|
71
|
+
const [open, setOpen] = useState(false);
|
|
72
|
+
const [name, setName] = useState("");
|
|
73
|
+
const [apiKey, setApiKey] = useState<string | null>(null);
|
|
74
|
+
const create = useCreateApiKeyMutation();
|
|
75
|
+
const normalizedName = name.trim();
|
|
76
|
+
const nameError =
|
|
77
|
+
normalizedName.length > 64 ? "Name must be 64 characters or fewer." : null;
|
|
78
|
+
|
|
79
|
+
const reset = () => {
|
|
80
|
+
setName("");
|
|
81
|
+
setApiKey(null);
|
|
82
|
+
create.reset();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleOpenChange = (nextOpen: boolean) => {
|
|
86
|
+
setOpen(nextOpen);
|
|
87
|
+
if (!nextOpen) reset();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
if (normalizedName.length === 0 || nameError !== null) return;
|
|
93
|
+
try {
|
|
94
|
+
const result = await create.mutateAsync(normalizedName);
|
|
95
|
+
setApiKey(result.apiKey);
|
|
96
|
+
toast.success("API key created");
|
|
97
|
+
} catch (error) {
|
|
98
|
+
toast.error(
|
|
99
|
+
error instanceof Error ? error.message : "Unable to create API key",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const copyKey = async () => {
|
|
105
|
+
if (apiKey === null) return;
|
|
106
|
+
try {
|
|
107
|
+
await navigator.clipboard.writeText(apiKey);
|
|
108
|
+
toast.success("API key copied");
|
|
109
|
+
} catch {
|
|
110
|
+
toast.error("Unable to copy API key");
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
<Button onClick={() => setOpen(true)}>
|
|
117
|
+
<Plus data-icon="inline-start" />
|
|
118
|
+
Create API key
|
|
119
|
+
</Button>
|
|
120
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
121
|
+
<DialogContent showCloseButton={apiKey === null}>
|
|
122
|
+
<DialogHeader>
|
|
123
|
+
<DialogTitle>
|
|
124
|
+
{apiKey === null ? "Create API key" : "Save your API key"}
|
|
125
|
+
</DialogTitle>
|
|
126
|
+
<DialogDescription>
|
|
127
|
+
{apiKey === null
|
|
128
|
+
? "Name the app or environment that will use this key."
|
|
129
|
+
: "This key is shown once. Store it securely before closing."}
|
|
130
|
+
</DialogDescription>
|
|
131
|
+
</DialogHeader>
|
|
132
|
+
{apiKey === null ? (
|
|
133
|
+
<form onSubmit={handleSubmit}>
|
|
134
|
+
<FieldGroup>
|
|
135
|
+
<Field data-invalid={nameError !== null || undefined}>
|
|
136
|
+
<FieldLabel htmlFor="api-key-name">Name</FieldLabel>
|
|
137
|
+
<Input
|
|
138
|
+
id="api-key-name"
|
|
139
|
+
autoComplete="off"
|
|
140
|
+
maxLength={65}
|
|
141
|
+
onChange={(event) => setName(event.target.value)}
|
|
142
|
+
placeholder="Production app"
|
|
143
|
+
value={name}
|
|
144
|
+
/>
|
|
145
|
+
<FieldDescription>
|
|
146
|
+
Use a name you can identify before revoking the key.
|
|
147
|
+
</FieldDescription>
|
|
148
|
+
<FieldError>{nameError}</FieldError>
|
|
149
|
+
</Field>
|
|
150
|
+
</FieldGroup>
|
|
151
|
+
<DialogFooter className="mt-4">
|
|
152
|
+
<Button
|
|
153
|
+
disabled={
|
|
154
|
+
create.isPending ||
|
|
155
|
+
normalizedName.length === 0 ||
|
|
156
|
+
nameError !== null
|
|
157
|
+
}
|
|
158
|
+
type="submit"
|
|
159
|
+
>
|
|
160
|
+
{create.isPending ? (
|
|
161
|
+
<Loader2
|
|
162
|
+
data-icon="inline-start"
|
|
163
|
+
className="animate-spin"
|
|
164
|
+
/>
|
|
165
|
+
) : (
|
|
166
|
+
<KeyRound data-icon="inline-start" />
|
|
167
|
+
)}
|
|
168
|
+
Create API key
|
|
169
|
+
</Button>
|
|
170
|
+
</DialogFooter>
|
|
171
|
+
</form>
|
|
172
|
+
) : (
|
|
173
|
+
<FieldGroup>
|
|
174
|
+
<Field>
|
|
175
|
+
<FieldLabel htmlFor="created-api-key">API key</FieldLabel>
|
|
176
|
+
<div className="flex gap-2">
|
|
177
|
+
<Input
|
|
178
|
+
id="created-api-key"
|
|
179
|
+
className="font-mono"
|
|
180
|
+
readOnly
|
|
181
|
+
value={apiKey}
|
|
182
|
+
/>
|
|
183
|
+
<Button onClick={copyKey} type="button" variant="outline">
|
|
184
|
+
<Clipboard data-icon="inline-start" />
|
|
185
|
+
Copy
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
</Field>
|
|
189
|
+
<DialogFooter>
|
|
190
|
+
<Button onClick={() => handleOpenChange(false)}>
|
|
191
|
+
<Check data-icon="inline-start" />
|
|
192
|
+
Done
|
|
193
|
+
</Button>
|
|
194
|
+
</DialogFooter>
|
|
195
|
+
</FieldGroup>
|
|
196
|
+
)}
|
|
197
|
+
</DialogContent>
|
|
198
|
+
</Dialog>
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function RevokeApiKeyDialog({ record }: { record: ApiKeyView }) {
|
|
204
|
+
const [open, setOpen] = useState(false);
|
|
205
|
+
const revoke = useRevokeApiKeyMutation();
|
|
206
|
+
|
|
207
|
+
const handleRevoke = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
try {
|
|
210
|
+
await revoke.mutateAsync(record.id);
|
|
211
|
+
setOpen(false);
|
|
212
|
+
toast.success("API key revoked");
|
|
213
|
+
} catch (error) {
|
|
214
|
+
toast.error(
|
|
215
|
+
error instanceof Error ? error.message : "Unable to revoke API key",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<AlertDialog
|
|
222
|
+
open={open}
|
|
223
|
+
onOpenChange={(nextOpen) => {
|
|
224
|
+
if (!revoke.isPending) setOpen(nextOpen);
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<AlertDialogTrigger render={<Button size="sm" variant="ghost" />}>
|
|
228
|
+
<ShieldOff data-icon="inline-start" />
|
|
229
|
+
Revoke
|
|
230
|
+
</AlertDialogTrigger>
|
|
231
|
+
<AlertDialogContent>
|
|
232
|
+
<AlertDialogHeader>
|
|
233
|
+
<AlertDialogMedia>
|
|
234
|
+
<ShieldOff />
|
|
235
|
+
</AlertDialogMedia>
|
|
236
|
+
<AlertDialogTitle>Revoke {record.name}?</AlertDialogTitle>
|
|
237
|
+
<AlertDialogDescription>
|
|
238
|
+
Apps using {record.prefix}… will immediately lose OTA update and
|
|
239
|
+
analytics access. This action cannot be undone.
|
|
240
|
+
</AlertDialogDescription>
|
|
241
|
+
</AlertDialogHeader>
|
|
242
|
+
<AlertDialogFooter>
|
|
243
|
+
<AlertDialogCancel disabled={revoke.isPending}>
|
|
244
|
+
Cancel
|
|
245
|
+
</AlertDialogCancel>
|
|
246
|
+
<AlertDialogAction
|
|
247
|
+
disabled={revoke.isPending}
|
|
248
|
+
onClick={(event) => void handleRevoke(event)}
|
|
249
|
+
variant="destructive"
|
|
250
|
+
>
|
|
251
|
+
Revoke API key
|
|
252
|
+
</AlertDialogAction>
|
|
253
|
+
</AlertDialogFooter>
|
|
254
|
+
</AlertDialogContent>
|
|
255
|
+
</AlertDialog>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function ApiKeysPage() {
|
|
260
|
+
const apiKeys = useApiKeysQuery();
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<Card className="overflow-hidden shadow-sm">
|
|
264
|
+
<CardHeader className="flex-row items-center justify-between space-y-0 border-b px-4 py-3">
|
|
265
|
+
<CardTitle className="text-sm">
|
|
266
|
+
<h2>API keys</h2>
|
|
267
|
+
</CardTitle>
|
|
268
|
+
<CreateApiKeyDialog />
|
|
269
|
+
</CardHeader>
|
|
270
|
+
<CardContent className="p-0">
|
|
271
|
+
{apiKeys.isError ? (
|
|
272
|
+
<div className="p-4">
|
|
273
|
+
<Alert variant="destructive">
|
|
274
|
+
<AlertTriangle />
|
|
275
|
+
<AlertTitle>API keys couldn't be loaded</AlertTitle>
|
|
276
|
+
<AlertDescription>
|
|
277
|
+
Check your connection and try again.
|
|
278
|
+
</AlertDescription>
|
|
279
|
+
<AlertAction>
|
|
280
|
+
<Button
|
|
281
|
+
onClick={() => void apiKeys.refetch()}
|
|
282
|
+
size="xs"
|
|
283
|
+
variant="outline"
|
|
284
|
+
>
|
|
285
|
+
<RefreshCw data-icon="inline-start" />
|
|
286
|
+
Retry
|
|
287
|
+
</Button>
|
|
288
|
+
</AlertAction>
|
|
289
|
+
</Alert>
|
|
290
|
+
</div>
|
|
291
|
+
) : (
|
|
292
|
+
<Table aria-label="API keys" className="table-fixed">
|
|
293
|
+
<TableHeader>
|
|
294
|
+
<TableRow>
|
|
295
|
+
<TableHead>Key</TableHead>
|
|
296
|
+
<TableHead className="w-32">Created</TableHead>
|
|
297
|
+
<TableHead className="w-24 text-right">
|
|
298
|
+
<span className="sr-only">Actions</span>
|
|
299
|
+
</TableHead>
|
|
300
|
+
</TableRow>
|
|
301
|
+
</TableHeader>
|
|
302
|
+
<TableBody>
|
|
303
|
+
{apiKeys.isLoading ? (
|
|
304
|
+
Array.from({ length: 3 }, (_, index) => (
|
|
305
|
+
<TableRow key={index}>
|
|
306
|
+
<TableCell className="space-y-2 py-3">
|
|
307
|
+
{index === 0 ? (
|
|
308
|
+
<span className="sr-only">Loading API keys</span>
|
|
309
|
+
) : null}
|
|
310
|
+
<Skeleton className="h-3.5 w-32" />
|
|
311
|
+
<Skeleton className="h-3 w-20" />
|
|
312
|
+
</TableCell>
|
|
313
|
+
<TableCell>
|
|
314
|
+
<Skeleton className="h-3.5 w-20" />
|
|
315
|
+
</TableCell>
|
|
316
|
+
<TableCell>
|
|
317
|
+
<Skeleton className="ml-auto h-6 w-16" />
|
|
318
|
+
</TableCell>
|
|
319
|
+
</TableRow>
|
|
320
|
+
))
|
|
321
|
+
) : apiKeys.data?.length === 0 ? (
|
|
322
|
+
<TableRow>
|
|
323
|
+
<TableCell
|
|
324
|
+
colSpan={3}
|
|
325
|
+
className="h-40 text-center text-muted-foreground"
|
|
326
|
+
>
|
|
327
|
+
<div className="mx-auto flex max-w-xs flex-col items-center gap-2">
|
|
328
|
+
<KeyRound className="size-5" />
|
|
329
|
+
<p className="font-medium text-foreground">No API keys</p>
|
|
330
|
+
<p>Create an API key to connect an app.</p>
|
|
331
|
+
</div>
|
|
332
|
+
</TableCell>
|
|
333
|
+
</TableRow>
|
|
334
|
+
) : (
|
|
335
|
+
apiKeys.data?.map((apiKey) => (
|
|
336
|
+
<TableRow
|
|
337
|
+
className={
|
|
338
|
+
apiKey.revoked_at_ms === null
|
|
339
|
+
? undefined
|
|
340
|
+
: "text-muted-foreground"
|
|
341
|
+
}
|
|
342
|
+
key={apiKey.id}
|
|
343
|
+
>
|
|
344
|
+
<TableCell className="whitespace-normal py-3">
|
|
345
|
+
<div className="min-w-0 space-y-1">
|
|
346
|
+
<p className="break-words font-medium text-foreground">
|
|
347
|
+
{apiKey.name}
|
|
348
|
+
</p>
|
|
349
|
+
<p className="font-mono text-xs text-muted-foreground">
|
|
350
|
+
{apiKey.prefix}…
|
|
351
|
+
</p>
|
|
352
|
+
</div>
|
|
353
|
+
</TableCell>
|
|
354
|
+
<TableCell className="text-xs text-muted-foreground">
|
|
355
|
+
<time
|
|
356
|
+
dateTime={new Date(apiKey.created_at_ms).toISOString()}
|
|
357
|
+
>
|
|
358
|
+
{formatCreatedAt(apiKey.created_at_ms)}
|
|
359
|
+
</time>
|
|
360
|
+
</TableCell>
|
|
361
|
+
<TableCell className="text-right">
|
|
362
|
+
{apiKey.revoked_at_ms === null ? (
|
|
363
|
+
<RevokeApiKeyDialog record={apiKey} />
|
|
364
|
+
) : (
|
|
365
|
+
<Badge variant="outline">Revoked</Badge>
|
|
366
|
+
)}
|
|
367
|
+
</TableCell>
|
|
368
|
+
</TableRow>
|
|
369
|
+
))
|
|
370
|
+
)}
|
|
371
|
+
</TableBody>
|
|
372
|
+
</Table>
|
|
373
|
+
)}
|
|
374
|
+
</CardContent>
|
|
375
|
+
</Card>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { ActiveInstallationWindow } from "@hot-updater/server";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ChartConfig,
|
|
7
|
+
ChartContainer,
|
|
8
|
+
ChartTooltip,
|
|
9
|
+
ChartTooltipContent,
|
|
10
|
+
} from "@/components/ui/chart";
|
|
11
|
+
|
|
12
|
+
interface SeriesPoint {
|
|
13
|
+
readonly bucketStartMs: number;
|
|
14
|
+
readonly value: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface BundleActivityChartProps {
|
|
18
|
+
readonly installed: readonly SeriesPoint[];
|
|
19
|
+
readonly recovered: readonly SeriesPoint[];
|
|
20
|
+
readonly window: ActiveInstallationWindow;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ActivityChartPoint {
|
|
24
|
+
readonly bucketStartMs: number;
|
|
25
|
+
readonly installed: number;
|
|
26
|
+
readonly recovered: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const chartConfig = {
|
|
30
|
+
installed: {
|
|
31
|
+
label: "Applied",
|
|
32
|
+
color: "var(--chart-2)",
|
|
33
|
+
},
|
|
34
|
+
recovered: {
|
|
35
|
+
label: "Recovered",
|
|
36
|
+
color: "var(--muted-foreground)",
|
|
37
|
+
},
|
|
38
|
+
} satisfies ChartConfig;
|
|
39
|
+
|
|
40
|
+
const dateFormatter = new Intl.DateTimeFormat("en", {
|
|
41
|
+
day: "numeric",
|
|
42
|
+
month: "short",
|
|
43
|
+
timeZone: "UTC",
|
|
44
|
+
});
|
|
45
|
+
const hourFormatter = new Intl.DateTimeFormat("en", {
|
|
46
|
+
day: "numeric",
|
|
47
|
+
hour: "numeric",
|
|
48
|
+
month: "short",
|
|
49
|
+
timeZone: "UTC",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const windowLabels = {
|
|
53
|
+
"24h": "24 hours",
|
|
54
|
+
"7d": "7 days",
|
|
55
|
+
"30d": "30 days",
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
const mergeActivitySeries = (
|
|
59
|
+
installed: readonly SeriesPoint[],
|
|
60
|
+
recovered: readonly SeriesPoint[],
|
|
61
|
+
): readonly ActivityChartPoint[] => {
|
|
62
|
+
const installedByBucket = new Map(
|
|
63
|
+
installed.map((point) => [point.bucketStartMs, point.value] as const),
|
|
64
|
+
);
|
|
65
|
+
const recoveredByBucket = new Map(
|
|
66
|
+
recovered.map((point) => [point.bucketStartMs, point.value] as const),
|
|
67
|
+
);
|
|
68
|
+
const buckets = new Set([
|
|
69
|
+
...installedByBucket.keys(),
|
|
70
|
+
...recoveredByBucket.keys(),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
return [...buckets]
|
|
74
|
+
.sort((left, right) => left - right)
|
|
75
|
+
.map((bucketStartMs) => ({
|
|
76
|
+
bucketStartMs,
|
|
77
|
+
installed: installedByBucket.get(bucketStartMs) ?? 0,
|
|
78
|
+
recovered: recoveredByBucket.get(bucketStartMs) ?? 0,
|
|
79
|
+
}));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const formatBucket = (
|
|
83
|
+
value: number,
|
|
84
|
+
window: ActiveInstallationWindow,
|
|
85
|
+
): string =>
|
|
86
|
+
(window === "24h" ? hourFormatter : dateFormatter).format(new Date(value));
|
|
87
|
+
|
|
88
|
+
export function BundleActivityChart({
|
|
89
|
+
installed,
|
|
90
|
+
recovered,
|
|
91
|
+
window,
|
|
92
|
+
}: BundleActivityChartProps) {
|
|
93
|
+
const captionId = useId();
|
|
94
|
+
const chartData = mergeActivitySeries(installed, recovered);
|
|
95
|
+
const hasWindowActivity = chartData.some(
|
|
96
|
+
(point) => point.installed > 0 || point.recovered > 0,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!hasWindowActivity) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex h-16 items-center justify-center border-t px-4 pt-3 text-center text-xs text-muted-foreground sm:h-20 sm:pt-4">
|
|
102
|
+
No device activity in the last 30 days.
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="border-t pt-3 sm:pt-4">
|
|
109
|
+
<ChartContainer
|
|
110
|
+
aria-describedby={captionId}
|
|
111
|
+
aria-label={`Bundle movement over ${windowLabels[window]}`}
|
|
112
|
+
className="h-24 w-full aspect-auto sm:h-32"
|
|
113
|
+
config={chartConfig}
|
|
114
|
+
role="img"
|
|
115
|
+
>
|
|
116
|
+
<AreaChart
|
|
117
|
+
accessibilityLayer
|
|
118
|
+
data={chartData}
|
|
119
|
+
margin={{ left: -16, right: 4, top: 4 }}
|
|
120
|
+
>
|
|
121
|
+
<CartesianGrid vertical={false} />
|
|
122
|
+
<XAxis
|
|
123
|
+
axisLine={false}
|
|
124
|
+
dataKey="bucketStartMs"
|
|
125
|
+
minTickGap={28}
|
|
126
|
+
tickFormatter={(value: number) => formatBucket(value, window)}
|
|
127
|
+
tickLine={false}
|
|
128
|
+
/>
|
|
129
|
+
<YAxis
|
|
130
|
+
allowDecimals={false}
|
|
131
|
+
axisLine={false}
|
|
132
|
+
tickLine={false}
|
|
133
|
+
width={32}
|
|
134
|
+
/>
|
|
135
|
+
<ChartTooltip
|
|
136
|
+
content={
|
|
137
|
+
<ChartTooltipContent
|
|
138
|
+
labelFormatter={(_, payload) => {
|
|
139
|
+
const bucket = payload[0]?.payload.bucketStartMs;
|
|
140
|
+
return typeof bucket === "number"
|
|
141
|
+
? formatBucket(bucket, window)
|
|
142
|
+
: "";
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
<Area
|
|
148
|
+
dataKey="installed"
|
|
149
|
+
fill="var(--color-installed)"
|
|
150
|
+
fillOpacity={0.12}
|
|
151
|
+
isAnimationActive={false}
|
|
152
|
+
stroke="var(--color-installed)"
|
|
153
|
+
strokeWidth={2}
|
|
154
|
+
type="monotone"
|
|
155
|
+
/>
|
|
156
|
+
<Area
|
|
157
|
+
dataKey="recovered"
|
|
158
|
+
fill="var(--color-recovered)"
|
|
159
|
+
fillOpacity={0.04}
|
|
160
|
+
isAnimationActive={false}
|
|
161
|
+
stroke="var(--color-recovered)"
|
|
162
|
+
strokeWidth={1.5}
|
|
163
|
+
type="monotone"
|
|
164
|
+
/>
|
|
165
|
+
</AreaChart>
|
|
166
|
+
</ChartContainer>
|
|
167
|
+
<div className="sr-only">
|
|
168
|
+
<table>
|
|
169
|
+
<caption id={captionId}>
|
|
170
|
+
Distinct bundle movement in each bucket over {windowLabels[window]}.
|
|
171
|
+
Times are shown in UTC.
|
|
172
|
+
</caption>
|
|
173
|
+
<thead>
|
|
174
|
+
<tr>
|
|
175
|
+
<th scope="col">Date</th>
|
|
176
|
+
<th scope="col">Applied</th>
|
|
177
|
+
<th scope="col">Recovered</th>
|
|
178
|
+
</tr>
|
|
179
|
+
</thead>
|
|
180
|
+
<tbody>
|
|
181
|
+
{chartData.map((point) => (
|
|
182
|
+
<tr key={point.bucketStartMs}>
|
|
183
|
+
<th scope="row">{formatBucket(point.bucketStartMs, window)}</th>
|
|
184
|
+
<td>{point.installed}</td>
|
|
185
|
+
<td>{point.recovered}</td>
|
|
186
|
+
</tr>
|
|
187
|
+
))}
|
|
188
|
+
</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useAnalyticsCapability } from "@/components/features/analytics/AnalyticsCapabilityContext";
|
|
2
|
+
import { AnalyticsErrorAlert } from "@/components/features/analytics/AnalyticsErrorAlert";
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
5
|
+
import { useBundleEventAnalyticsQuery } from "@/lib/api";
|
|
6
|
+
|
|
7
|
+
import { BundleActivityChart } from "./BundleActivityChart";
|
|
8
|
+
|
|
9
|
+
interface BundleAnalyticsSummaryProps {
|
|
10
|
+
readonly bundleId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function Metric({
|
|
14
|
+
label,
|
|
15
|
+
tone,
|
|
16
|
+
value,
|
|
17
|
+
}: {
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly tone: "applied" | "recovered";
|
|
20
|
+
readonly value: number;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex flex-col gap-1">
|
|
24
|
+
<dt className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
25
|
+
<span
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
className={
|
|
28
|
+
tone === "applied"
|
|
29
|
+
? "size-2 rounded-full bg-chart-2"
|
|
30
|
+
: "size-2 rounded-full bg-muted-foreground"
|
|
31
|
+
}
|
|
32
|
+
/>
|
|
33
|
+
{label}
|
|
34
|
+
</dt>
|
|
35
|
+
<dd className="text-xl font-semibold tracking-tight tabular-nums">
|
|
36
|
+
{value}
|
|
37
|
+
</dd>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function SupportedBundleAnalyticsSummary({
|
|
43
|
+
bundleId,
|
|
44
|
+
}: BundleAnalyticsSummaryProps) {
|
|
45
|
+
const { data, error, isLoading } = useBundleEventAnalyticsQuery(
|
|
46
|
+
{
|
|
47
|
+
bundleId,
|
|
48
|
+
window: "30d",
|
|
49
|
+
limit: 1,
|
|
50
|
+
offset: 0,
|
|
51
|
+
},
|
|
52
|
+
true,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Card>
|
|
57
|
+
<CardHeader className="p-4 pb-3">
|
|
58
|
+
<CardTitle className="text-sm font-medium">
|
|
59
|
+
Activity · 30 days
|
|
60
|
+
</CardTitle>
|
|
61
|
+
</CardHeader>
|
|
62
|
+
<CardContent className="flex flex-col gap-3 px-4 pb-4">
|
|
63
|
+
{isLoading ? (
|
|
64
|
+
<div
|
|
65
|
+
aria-label="Loading reported bundle outcomes"
|
|
66
|
+
className="flex flex-col gap-3"
|
|
67
|
+
>
|
|
68
|
+
<div className="grid grid-cols-2 gap-4">
|
|
69
|
+
<Skeleton className="h-10 w-full" />
|
|
70
|
+
<Skeleton className="h-10 w-full" />
|
|
71
|
+
</div>
|
|
72
|
+
<Skeleton className="h-24 w-full sm:h-32" />
|
|
73
|
+
</div>
|
|
74
|
+
) : error ? (
|
|
75
|
+
<AnalyticsErrorAlert
|
|
76
|
+
error={
|
|
77
|
+
error instanceof Error
|
|
78
|
+
? error
|
|
79
|
+
: new Error("Failed to load bundle analytics.")
|
|
80
|
+
}
|
|
81
|
+
fallbackTitle="Analytics unavailable"
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<>
|
|
85
|
+
<dl className="grid grid-cols-2 divide-x divide-border/70">
|
|
86
|
+
<div className="pr-4">
|
|
87
|
+
<Metric
|
|
88
|
+
label="Applied"
|
|
89
|
+
tone="applied"
|
|
90
|
+
value={data?.summary.installed ?? 0}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="pl-4">
|
|
94
|
+
<Metric
|
|
95
|
+
label="Recovered"
|
|
96
|
+
tone="recovered"
|
|
97
|
+
value={data?.summary.recovered ?? 0}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</dl>
|
|
101
|
+
<BundleActivityChart
|
|
102
|
+
installed={data?.series.installed ?? []}
|
|
103
|
+
recovered={data?.series.recovered ?? []}
|
|
104
|
+
window="30d"
|
|
105
|
+
/>
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</CardContent>
|
|
109
|
+
</Card>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function BundleAnalyticsSummary({
|
|
114
|
+
bundleId,
|
|
115
|
+
}: BundleAnalyticsSummaryProps) {
|
|
116
|
+
const capability = useAnalyticsCapability();
|
|
117
|
+
|
|
118
|
+
if (capability.status !== "supported") {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return <SupportedBundleAnalyticsSummary bundleId={bundleId} />;
|
|
123
|
+
}
|