page analytics retention

This commit is contained in:
Salihu
2026-08-14 23:12:36 +01:00
parent e204184ad8
commit fd81cd281f
7 changed files with 218 additions and 34 deletions
+1 -17
View File
@@ -26,24 +26,8 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
import { daysToRetention, retentionToDays, RetentionUnit } from "@/ee/utils";
type RetentionUnit = "days" | "months" | "years";
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function AuditLogs() {
const { t } = useTranslation();
@@ -1,13 +1,18 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
Group,
NumberInput,
Popover,
Select,
SimpleGrid,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
@@ -16,34 +21,59 @@ import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
usePageAnalyticsRetentionQuery,
useUpdatePageAnalyticsRetentionMutation,
useWorkspacePageAnalyticsDailyStatsQuery,
useWorkspacePageAnalyticsTopPagesQuery,
useWorkspacePageAnalyticsTotalsQuery,
} from "@/ee/page-analytics/queries/page-analytics-query";
import { Link } from "react-router-dom";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { IconSettings } from "@tabler/icons-react";
import {
daysToRetention,
formatNumber,
retentionToDays,
RetentionUnit,
toISODate,
} from "@/ee/utils";
type RangePreset = "7" | "30" | "90";
const DAILY_PAGE_SIZE = 10;
function toISODate(daysAgo: number | string): string {
const daysNum = Number(daysAgo);
return new Date(Date.now() - daysNum * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
function formatNumber(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString();
}
export default function PageAnalytics() {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const [rangePreset, setRangePreset] = useState<RangePreset>("30");
const [topPagesLimit, setTopPagesLimit] = useState("10");
const [settingsOpen, setSettingsOpen] = useState(false);
const { data: retentionData } = usePageAnalyticsRetentionQuery();
const updateRetention = useUpdatePageAnalyticsRetentionMutation();
const parsed = useMemo(
() => daysToRetention(retentionData?.retentionDays ?? 365),
[retentionData]
);
useEffect(() => {
if (settingsOpen) return;
setRetentionAmount(parsed.amount);
setRetentionUnit(parsed.unit);
}, [parsed]);
const [retentionAmount, setRetentionAmount] = useState<number | string>(
parsed.amount
);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
const resetRetentionForm = useCallback(() => {
const { amount, unit } = daysToRetention(retentionData?.retentionDays ?? 365);
setRetentionAmount(amount);
setRetentionUnit(unit);
}, [setRetentionAmount, setRetentionUnit, retentionData]);
const {
cursor: topPagesCursor,
@@ -129,9 +159,6 @@ export default function PageAnalytics() {
<SettingsTitle title={t("Page analytics")} />
<Group justify="space-between" mb="md">
<Text c="dimmed" size="sm">
{t("Unique visitors and view volume for your workspace pages.")}
</Text>
<Select
value={rangePreset}
onChange={handleRangeChange}
@@ -144,6 +171,96 @@ export default function PageAnalytics() {
size="sm"
allowDeselect={false}
/>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon
variant="default"
size="input-sm"
ml="auto"
onClick={() => setSettingsOpen((o) => !o)}
>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num =
typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== (retentionData?.retentionDays ?? 365)) {
updateRetention.mutate({
pageAnalyticsRetentionDays: Number(days),
});
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} mb="md">
@@ -1,8 +1,10 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getPageAnalyticsRetention,
getWorkspacePageAnalyticsDailyStats,
getWorkspacePageAnalyticsTopPages,
getWorkspacePageAnalyticsTotals,
updatePageAnalyticsRetention,
} from "@/ee/page-analytics/services/page-analytics-service";
import type { IPagination } from "@/lib/types";
import type {
@@ -12,6 +14,8 @@ import type {
WorkspaceAnalyticsTopPage,
WorkspaceAnalyticsTotals,
} from "@/ee/page-analytics/types/page-analytics.types";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
export function useWorkspacePageAnalyticsTotalsQuery(
params?: WorkspaceAnalyticsParams,
@@ -42,3 +46,28 @@ export function useWorkspacePageAnalyticsTopPagesQuery(
placeholderData: keepPreviousData,
});
}
export function usePageAnalyticsRetentionQuery() {
return useQuery({
queryKey: ["page-analytics-retention"],
queryFn: () => getPageAnalyticsRetention(),
});
}
export function useUpdatePageAnalyticsRetentionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: { pageAnalyticsRetentionDays: number }) =>
updatePageAnalyticsRetention(data),
onSuccess: () => {
notifications.show({ message: t("Page analytics retention updated") });
queryClient.invalidateQueries({ queryKey: ["page-analytics-retention"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -28,3 +28,16 @@ export async function getWorkspacePageAnalyticsTopPages(
const req = await api.post("/page-analytics/workspace-top-pages", params);
return req.data;
}
export async function getPageAnalyticsRetention(): Promise<{ retentionDays: number }> {
const req = await api.post("/page-analytics/retention");
return req.data;
}
export async function updatePageAnalyticsRetention(data: {
pageAnalyticsRetentionDays: number;
}): Promise<{ retentionDays: number }> {
const req = await api.post("/page-analytics/retention/update", data);
return req.data;
}
+30
View File
@@ -14,3 +14,33 @@ export function exchangeTokenRedirectUrl(
) {
return getHostnameUrl(hostname) + "/api/auth/exchange?token=" + exchangeToken;
}
export type RetentionUnit = "days" | "months" | "years";
export function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
export function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export function toISODate(daysAgo: number | string): string {
const daysNum = Number(daysAgo);
return new Date(Date.now() - daysNum * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
export function formatNumber(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString();
}
@@ -47,8 +47,18 @@ export async function up(db: Kysely<any>): Promise<void> {
COALESCE(user_id::text, visitor_id)
)
`.execute(db);
await db.schema
.alterTable('workspaces')
.addColumn('page_analytics_retention_days', 'int8', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspaces')
.dropColumn('page_analytics_retention_days')
.execute();
await db.schema.dropTable('page_analytics').ifExists().execute();
}
+1
View File
@@ -437,6 +437,7 @@ export interface WorkspaceInvitations {
}
export interface Workspaces {
pageAnalyticsRetentionDays: Generated<number>;
auditRetentionDays: Generated<number>;
trashRetentionDays: Generated<number>;
billingEmail: string | null;