Hover tooltip for signup rules

This commit is contained in:
Konstantin Wohlwend 2026-03-23 12:34:22 -07:00
parent d22593d535
commit 238ed06120
3 changed files with 118 additions and 33 deletions

View File

@ -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,
},

View File

@ -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={() => {

View File

@ -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,