@@ -155,16 +165,20 @@ function KpiSkeletonGrid() {
);
}
-export function EntityKpiCards({ errorTag, source, labels }: EntityKpiCardsProps) {
+export function EntityKpiCards({ errorTag, source, labels, metrics }: EntityKpiCardsProps) {
const ErrorComponent = ({ error }: { error: Error }) => {
captureKpiErrorOnce(error, errorTag);
return null;
};
return (
- }>
-
-
+ {metrics != null ? (
+
+ ) : (
+ }>
+
+
+ )}
);
}
diff --git a/apps/dashboard/src/components/link.tsx b/apps/dashboard/src/components/link.tsx
index d2a72be7f..df8b08743 100644
--- a/apps/dashboard/src/components/link.tsx
+++ b/apps/dashboard/src/components/link.tsx
@@ -19,13 +19,14 @@ type LinkProps = {
title?: string,
};
-export const Link = React.forwardRef
(({ onClick, href, children, ...rest }, ref) => {
+export const Link = React.forwardRef(({ onClick, href, children, prefetch, ...rest }, ref) => {
const router = useRouter();
const { needConfirm } = useRouterConfirm();
return ) => {
if (needConfirm) {
@@ -38,7 +39,7 @@ export const Link = React.forwardRef(({ onClick, h
}
}}
>
-
+ {prefetch !== false ? : null}
{children}
;
diff --git a/apps/dashboard/src/components/user-dialog.tsx b/apps/dashboard/src/components/user-dialog.tsx
index 688562744..6660a5f91 100644
--- a/apps/dashboard/src/components/user-dialog.tsx
+++ b/apps/dashboard/src/components/user-dialog.tsx
@@ -2,6 +2,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
import { ServerUser } from "@hexclave/next";
import { KnownErrors } from "@hexclave/shared";
import { countryCodeSchema, emailSchema, jsonStringOrEmptySchema, passwordSchema } from "@hexclave/shared/dist/schema-fields";
+import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Typography } from "@/components/ui";
import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components";
import { WarningCircleIcon } from "@phosphor-icons/react";
@@ -18,6 +19,7 @@ const metadataDocsUrl = "https://docs.hexclave.com/guides/getting-started/user-f
export function UserDialog(props: {
open?: boolean,
onOpenChange?: (open: boolean) => void,
+ onUserMutated?: () => void | Promise,
trigger?: React.ReactNode,
} & ({
type: 'create',
@@ -146,6 +148,9 @@ export function UserDialog(props: {
}
throw error;
}
+ if (props.onUserMutated) {
+ runAsynchronouslyWithAlert(Promise.resolve().then(() => props.onUserMutated?.()));
+ }
}
return <>
diff --git a/apps/dashboard/src/components/user-dialogs.tsx b/apps/dashboard/src/components/user-dialogs.tsx
index 66cdcaa3d..4e8d20709 100644
--- a/apps/dashboard/src/components/user-dialogs.tsx
+++ b/apps/dashboard/src/components/user-dialogs.tsx
@@ -1,16 +1,21 @@
import { ServerUser } from '@hexclave/next';
import { ActionDialog, CopyField, Typography } from "@/components/ui";
+import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { deindent } from "@hexclave/shared/dist/utils/strings";
+import { Link } from './link';
import { useRouter } from './router';
export function DeleteUserDialog(props: {
user: ServerUser,
open: boolean,
+ profileHref: string,
redirectTo?: string,
onOpenChange: (open: boolean) => void,
+ onDeleted?: () => void | Promise,
}) {
const router = useRouter();
+ const userLabel = props.user.displayName?.trim() || props.user.primaryEmail?.trim() || props.user.id;
return {
await props.user.delete();
+ if (props.onDeleted) {
+ runAsynchronouslyWithAlert(Promise.resolve().then(() => props.onDeleted?.()));
+ }
if (props.redirectTo) {
router.push(props.redirectTo);
}
@@ -27,7 +35,18 @@ export function DeleteUserDialog(props: {
}}
confirmText="I understand that this action cannot be undone."
>
- {`Are you sure you want to delete the user ${props.user.displayName ? '"' + props.user.displayName + '"' : ''} with ID ${props.user.id}?`}
+
+ Are you sure you want to delete the user " {
+ props.onOpenChange(false);
+ }}
+ >
+ {userLabel}
+ "?
+
;
}
diff --git a/apps/dashboard/src/lib/hexclave-app-internals.ts b/apps/dashboard/src/lib/hexclave-app-internals.ts
index c1be230d2..45ab1985c 100644
--- a/apps/dashboard/src/lib/hexclave-app-internals.ts
+++ b/apps/dashboard/src/lib/hexclave-app-internals.ts
@@ -1,5 +1,7 @@
import {
+ MetricsResponseBodySchema,
type MetricsResponse,
+ MetricsUserCountsSchema,
type MetricsUserCounts,
type UserActivityResponse,
} from "@hexclave/shared/dist/interface/admin-metrics";
@@ -58,6 +60,7 @@ type AdminAppInternalsHooks = {
useMetrics: (includeAnonymous: boolean, filters?: AnalyticsOverviewFilters) => MetricsResponse,
useUserActivity: (userId: string) => UserActivityResponse,
useMetricsUserCounts: () => MetricsUserCounts,
+ sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise,
};
function getInternalsHookOrThrow(adminApp: object, hookName: K): AdminAppInternalsHooks[K] {
@@ -95,3 +98,68 @@ export function useUserActivityOrThrow(adminApp: object, userId: string): UserAc
export function useMetricsUserCountsOrThrow(adminApp: object): MetricsUserCounts {
return getInternalsHookOrThrow(adminApp, "useMetricsUserCounts")();
}
+
+function getMetricsQueryString(includeAnonymous: boolean, filters?: AnalyticsOverviewFilters): string {
+ const params = new URLSearchParams();
+ if (includeAnonymous) {
+ params.append("include_anonymous", "true");
+ }
+ if (filters?.country_code) params.append("filter_country_code", filters.country_code);
+ if (filters?.referrer) params.append("filter_referrer", filters.referrer);
+ if (filters?.browser) params.append("filter_browser", filters.browser);
+ if (filters?.os) params.append("filter_os", filters.os);
+ if (filters?.device) params.append("filter_device", filters.device);
+ if (filters?.since) params.append("filter_since", filters.since);
+ if (filters?.until) params.append("filter_until", filters.until);
+ return params.toString();
+}
+
+function applyMetricsResponseDefaults(body: MetricsResponse): MetricsResponse {
+ // Keep this in sync with HexclaveAdminInterface.getMetrics(). These defaults
+ // preserve one-release-cycle tolerance for dashboards talking to older servers.
+ const rawBody: Partial = body;
+ const rawAnalytics: Partial = body.analytics_overview;
+ return {
+ ...body,
+ live_users: rawBody.live_users ?? 0,
+ hourly_users: rawBody.hourly_users ?? [],
+ hourly_active_users: rawBody.hourly_active_users ?? [],
+ analytics_overview: {
+ ...body.analytics_overview,
+ hourly_page_views: rawAnalytics.hourly_page_views ?? [],
+ hourly_active_users: rawAnalytics.hourly_active_users ?? [],
+ hourly_visitors: rawAnalytics.hourly_visitors ?? [],
+ daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [],
+ anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0,
+ top_regions: rawAnalytics.top_regions ?? [],
+ bounce_rate: rawAnalytics.bounce_rate ?? 0,
+ daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [],
+ daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [],
+ top_browsers: rawAnalytics.top_browsers ?? [],
+ top_operating_systems: rawAnalytics.top_operating_systems ?? [],
+ top_devices: rawAnalytics.top_devices ?? [],
+ },
+ };
+}
+
+async function fetchJsonOrThrow(adminApp: object, path: string): Promise {
+ const response = await getInternalsHookOrThrow(adminApp, "sendRequest")(path, { method: "GET" }, "admin");
+ if (!response.ok) {
+ throw new HexclaveAssertionError(`Admin app internals request failed: ${path}`);
+ }
+ return await response.json();
+}
+
+export async function fetchMetricsOrThrow(
+ adminApp: object,
+ includeAnonymous: boolean,
+ filters?: AnalyticsOverviewFilters,
+): Promise {
+ const queryString = getMetricsQueryString(includeAnonymous, filters);
+ const path = `/internal/metrics${queryString ? `?${queryString}` : ""}`;
+ return applyMetricsResponseDefaults(await MetricsResponseBodySchema.validate(await fetchJsonOrThrow(adminApp, path)));
+}
+
+export async function fetchMetricsUserCountsOrThrow(adminApp: object): Promise {
+ return await MetricsUserCountsSchema.validate(await fetchJsonOrThrow(adminApp, "/internal/metrics/user-counts"));
+}