mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Hover tooltip for signup rules
This commit is contained in:
parent
d22593d535
commit
238ed06120
@ -1,6 +1,7 @@
|
||||
import { getClickhouseAdminClient } from "@/lib/clickhouse";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
|
||||
const ANALYTICS_HOURS = 48;
|
||||
|
||||
@ -22,11 +23,13 @@ export const GET = createSmartRouteHandler({
|
||||
rule_triggers: yupArray(yupObject({
|
||||
rule_id: yupString().defined(),
|
||||
total_count: yupNumber().integer().defined(),
|
||||
all_time_count: yupNumber().integer().defined(),
|
||||
hourly_counts: yupArray(yupObject({
|
||||
hour: yupString().defined(),
|
||||
count: yupNumber().integer().defined(),
|
||||
}).defined()).defined(),
|
||||
}).defined()).defined(),
|
||||
analytics_hours: yupNumber().integer().defined(),
|
||||
// Summary stats
|
||||
total_triggers: yupNumber().integer().defined(),
|
||||
triggers_by_action: yupObject({
|
||||
@ -119,20 +122,67 @@ export const GET = createSmartRouteHandler({
|
||||
}
|
||||
|
||||
// Build hourly breakdown for each rule
|
||||
const ruleTriggers = Array.from(ruleTriggersMap.entries()).map(([ruleId, data]) => ({
|
||||
rule_id: ruleId,
|
||||
total_count: data.totalCount,
|
||||
hourly_counts: hourKeys.map((hour) => ({
|
||||
hour,
|
||||
count: data.hourlyMap.get(hour) ?? 0,
|
||||
})),
|
||||
}));
|
||||
const allTimeResult = await client.query({
|
||||
query: `
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(CAST(data.rule_id, 'Nullable(String)'), ''),
|
||||
NULLIF(CAST(data.ruleId, 'Nullable(String)'), '')
|
||||
) as rule_id,
|
||||
count() as total_count
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$sign-up-rule-trigger'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
GROUP BY rule_id
|
||||
`,
|
||||
query_params: {
|
||||
projectId,
|
||||
branchId,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
const rawAllTimeRows: {
|
||||
rule_id: string | null,
|
||||
total_count: number | string,
|
||||
}[] = await allTimeResult.json();
|
||||
const allTimeCountMap = new Map<string, number>();
|
||||
for (const row of rawAllTimeRows) {
|
||||
if (row.rule_id == null || row.rule_id === "") {
|
||||
continue;
|
||||
}
|
||||
allTimeCountMap.set(row.rule_id, Number(row.total_count));
|
||||
}
|
||||
|
||||
// Build hourly breakdown for each rule and merge with all-time counts so
|
||||
// rules that had no recent matches still surface their lifetime trigger count.
|
||||
const allRuleIds = new Set<string>([
|
||||
...ruleTriggersMap.keys(),
|
||||
...allTimeCountMap.keys(),
|
||||
]);
|
||||
const ruleTriggers = Array.from(allRuleIds)
|
||||
.map((ruleId) => {
|
||||
const recentRuleData = ruleTriggersMap.get(ruleId);
|
||||
const recentCount = recentRuleData?.totalCount ?? 0;
|
||||
|
||||
return {
|
||||
rule_id: ruleId,
|
||||
total_count: recentCount,
|
||||
all_time_count: allTimeCountMap.get(ruleId) ?? recentCount,
|
||||
hourly_counts: hourKeys.map((hour) => ({
|
||||
hour,
|
||||
count: recentRuleData?.hourlyMap.get(hour) ?? 0,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => stringCompare(a.rule_id, b.rule_id));
|
||||
|
||||
return {
|
||||
statusCode: 200 as const,
|
||||
bodyType: "json" as const,
|
||||
body: {
|
||||
rule_triggers: ruleTriggers,
|
||||
analytics_hours: ANALYTICS_HOURS,
|
||||
total_triggers: rows.length,
|
||||
triggers_by_action: actionCounts,
|
||||
},
|
||||
|
||||
@ -21,6 +21,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Typography,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
@ -55,7 +58,8 @@ import { validateRiskScore } from "@/lib/risk-score-utils";
|
||||
// Analytics types
|
||||
type RuleAnalytics = {
|
||||
ruleId: string,
|
||||
totalCount: number,
|
||||
countInTimespan: number,
|
||||
allTimeCount: number,
|
||||
hourlyCounts: { hour: string, count: number }[],
|
||||
};
|
||||
|
||||
@ -120,11 +124,15 @@ type ConfigWithSignUpRules = CompleteConfig & {
|
||||
// Compact sparkline component for rule analytics (inline next to buttons)
|
||||
function RuleSparkline({
|
||||
data,
|
||||
totalCount,
|
||||
countInTimespan,
|
||||
allTimeCount,
|
||||
timespanHours,
|
||||
isLoading,
|
||||
}: {
|
||||
data: { hour: string, count: number }[],
|
||||
totalCount: number,
|
||||
countInTimespan: number,
|
||||
allTimeCount: number,
|
||||
timespanHours: number,
|
||||
isLoading: boolean,
|
||||
}) {
|
||||
// Show skeleton while loading
|
||||
@ -141,26 +149,37 @@ function RuleSparkline({
|
||||
const chartData = data.length >= 2 ? data : [{ hour: '0', count: 0 }, { hour: '1', count: 0 }];
|
||||
// Calculate max for Y domain - use at least 1 to avoid divide-by-zero
|
||||
const maxCount = Math.max(1, ...chartData.map(d => d.count));
|
||||
const timespanLabel = `Last ${timespanHours}h`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1" title="past 48h">
|
||||
<ResponsiveContainer width={40} height={16}>
|
||||
<AreaChart data={chartData} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
|
||||
<YAxis hide domain={[0, maxCount]} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.15}
|
||||
className="text-muted-foreground"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">{totalCount}</span>
|
||||
</div>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 cursor-help">
|
||||
<ResponsiveContainer width={40} height={16}>
|
||||
<AreaChart data={chartData} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
|
||||
<YAxis hide domain={[0, maxCount]} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.15}
|
||||
className="text-muted-foreground"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">{countInTimespan}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-[11px]">
|
||||
<div className="space-y-0.5">
|
||||
<div>{timespanLabel}: {countInTimespan.toLocaleString()}</div>
|
||||
<div>All-time: {allTimeCount.toLocaleString()}</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@ -319,6 +338,7 @@ function RuleEditor({
|
||||
function SortableRuleRow({
|
||||
entry,
|
||||
analytics,
|
||||
analyticsTimespanHours,
|
||||
isAnalyticsLoading,
|
||||
isEditing,
|
||||
onEdit,
|
||||
@ -329,6 +349,7 @@ function SortableRuleRow({
|
||||
}: {
|
||||
entry: SignUpRuleEntry,
|
||||
analytics?: RuleAnalytics,
|
||||
analyticsTimespanHours: number,
|
||||
isAnalyticsLoading: boolean,
|
||||
isEditing: boolean,
|
||||
onEdit: () => void,
|
||||
@ -444,7 +465,9 @@ function SortableRuleRow({
|
||||
<div className="hidden sm:flex items-center mr-1">
|
||||
<RuleSparkline
|
||||
data={analytics?.hourlyCounts ?? []}
|
||||
totalCount={analytics?.totalCount ?? 0}
|
||||
countInTimespan={analytics?.countInTimespan ?? 0}
|
||||
allTimeCount={analytics?.allTimeCount ?? 0}
|
||||
timespanHours={analyticsTimespanHours}
|
||||
isLoading={isAnalyticsLoading}
|
||||
/>
|
||||
</div>
|
||||
@ -991,6 +1014,7 @@ function DeleteRuleDialog({
|
||||
function useSignUpRulesAnalytics() {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const [analytics, setAnalytics] = useState<Map<string, RuleAnalytics>>(new Map());
|
||||
const [timespanHours, setTimespanHours] = useState(48);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -1010,12 +1034,14 @@ function useSignUpRulesAnalytics() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTimespanHours(data.analytics_hours);
|
||||
|
||||
const analyticsMap = new Map<string, RuleAnalytics>();
|
||||
for (const trigger of data.rule_triggers ?? []) {
|
||||
analyticsMap.set(trigger.rule_id, {
|
||||
ruleId: trigger.rule_id,
|
||||
totalCount: trigger.total_count,
|
||||
countInTimespan: trigger.total_count,
|
||||
allTimeCount: trigger.all_time_count,
|
||||
hourlyCounts: trigger.hourly_counts ?? [],
|
||||
});
|
||||
}
|
||||
@ -1031,7 +1057,7 @@ function useSignUpRulesAnalytics() {
|
||||
};
|
||||
}, [stackAdminApp]);
|
||||
|
||||
return { analytics, isLoading };
|
||||
return { analytics, timespanHours, isLoading };
|
||||
}
|
||||
|
||||
export default function PageClient() {
|
||||
@ -1047,7 +1073,11 @@ export default function PageClient() {
|
||||
const [ruleToDelete, setRuleToDelete] = useState<SignUpRuleEntry | null>(null);
|
||||
|
||||
// Fetch analytics data
|
||||
const { analytics: ruleAnalytics, isLoading: isAnalyticsLoading } = useSignUpRulesAnalytics();
|
||||
const {
|
||||
analytics: ruleAnalytics,
|
||||
timespanHours: analyticsTimespanHours,
|
||||
isLoading: isAnalyticsLoading,
|
||||
} = useSignUpRulesAnalytics();
|
||||
|
||||
// Type assertion needed because schema changes take effect at build time
|
||||
const configWithRules = config as ConfigWithSignUpRules;
|
||||
@ -1269,6 +1299,7 @@ export default function PageClient() {
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
analytics={ruleAnalytics.get(entry.id)}
|
||||
analyticsTimespanHours={analyticsTimespanHours}
|
||||
isAnalyticsLoading={isAnalyticsLoading}
|
||||
isEditing={editingRuleId === entry.id}
|
||||
onEdit={() => {
|
||||
@ -1306,6 +1337,7 @@ export default function PageClient() {
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
analytics={ruleAnalytics.get(entry.id)}
|
||||
analyticsTimespanHours={analyticsTimespanHours}
|
||||
isAnalyticsLoading={isAnalyticsLoading}
|
||||
isEditing={editingRuleId === entry.id}
|
||||
onEdit={() => {
|
||||
|
||||
@ -100,6 +100,7 @@ describe("with admin access", () => {
|
||||
const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-stats", { accessType: "admin" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('rule_triggers');
|
||||
expect(response.body).toHaveProperty('analytics_hours');
|
||||
expect(response.body).toHaveProperty('total_triggers');
|
||||
expect(response.body).toHaveProperty('triggers_by_action');
|
||||
expect(response.body.triggers_by_action).toHaveProperty('allow');
|
||||
@ -141,8 +142,10 @@ describe("with admin access", () => {
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"analytics_hours": 48,
|
||||
"rule_triggers": [
|
||||
{
|
||||
"all_time_count": 1,
|
||||
"hourly_counts": <stripped field 'hourly_counts'>,
|
||||
"rule_id": "test-rule",
|
||||
"total_count": 1,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user