Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/app/models/PageView.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class PageView(Base):
__table_args__ = (
Index("ix_page_view_created_at", "created_at"),
Index("ix_page_view_path", "path"),
# Matches the navigation-flow query's ORDER BY (see
# app/routers/admin/analytics.py), which reconstructs sessions per
# visitor and otherwise forces a full sort of the date-range scan.
Index("ix_page_view_visitor_created", "visitor_hash", "created_at", "id"),
)

def __repr__(self) -> str:
Expand Down
31 changes: 30 additions & 1 deletion backend/script/init_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
from __future__ import annotations

import os
import re
import sys

from sqlalchemy import inspect, or_, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.schema import CreateColumn
from sqlalchemy.schema import CreateColumn, CreateIndex

from app.database import (
Base,
Expand Down Expand Up @@ -67,6 +68,33 @@ def sync_missing_columns() -> None:
print(f"Added missing column {table.name}.{column.name}")


def sync_missing_indexes() -> None:
"""Add indexes present on the ORM models but missing from deployed tables.

create_all() only creates indexes when it creates the table itself; it
never alters a table that's already there (same gap sync_missing_columns()
covers for columns). Runs CREATE INDEX CONCURRENTLY so building the index
on a large existing table doesn't hold a lock against writes.
"""
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())

with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
for table in Base.metadata.sorted_tables:
if table.name not in existing_tables:
continue # just created by create_missing_tables()

existing_indexes = {ix["name"] for ix in inspector.get_indexes(table.name)}
for index in table.indexes:
if index.name in existing_indexes:
continue

index_ddl = str(CreateIndex(index).compile(dialect=engine.dialect))
index_ddl = re.sub(r"^CREATE( UNIQUE)? INDEX", r"CREATE\1 INDEX CONCURRENTLY", index_ddl)
conn.execute(text(index_ddl))
print(f"Added missing index {index.name} on {table.name}")


def bootstrap_initial_admin() -> None:
email = os.getenv("INITIAL_ADMIN_EMAIL")
onyen = os.getenv("INITIAL_ADMIN_ONYEN")
Expand Down Expand Up @@ -132,5 +160,6 @@ def bootstrap_initial_admin() -> None:
print("Initializing deployed database")
create_missing_tables()
sync_missing_columns()
sync_missing_indexes()
bootstrap_initial_admin()
print("Database initialization complete")
54 changes: 41 additions & 13 deletions frontend/src/app/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,58 @@ const ACTIVE_USERS_POLL_MS = 30_000;
export default function AdminAnalyticsPage() {
const [days, setDays] = useState(7);
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [navigationFlow, setNavigationFlow] = useState<NavigationFlow | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeUsers, setActiveUsers] = useState<number | null>(null);

const [navigationFlow, setNavigationFlow] = useState<NavigationFlow | null>(null);
const [isFlowLoading, setIsFlowLoading] = useState(true);

// Kept separate from the navigation-flow fetch below: the flow query scans
// and sorts every pageview in range, so it can lag well behind the summary
// stats. Gating the whole page on both left visitors staring at a blank
// spinner for the slower of the two; the summary and line chart should
// render as soon as they're ready.
useEffect(() => {
let isMounted = true;

async function fetchData() {
async function fetchSummary() {
setIsLoading(true);
setError(null);
try {
const [summaryData, flowData] = await Promise.all([
getAnalyticsSummary(days),
getNavigationFlow(days),
]);
if (isMounted) {
setSummary(summaryData);
setNavigationFlow(flowData);
}
const summaryData = await getAnalyticsSummary(days);
if (isMounted) setSummary(summaryData);
} catch (err) {
console.error("Failed to fetch analytics data:", err);
console.error("Failed to fetch analytics summary:", err);
if (isMounted) setError("Failed to load analytics data.");
} finally {
if (isMounted) setIsLoading(false);
}
}

fetchData();
fetchSummary();

return () => {
isMounted = false;
};
}, [days]);

useEffect(() => {
let isMounted = true;

async function fetchNavigationFlow() {
setIsFlowLoading(true);
try {
const flowData = await getNavigationFlow(days);
if (isMounted) setNavigationFlow(flowData);
} catch (err) {
console.error("Failed to fetch navigation flow:", err);
} finally {
if (isMounted) setIsFlowLoading(false);
}
}

fetchNavigationFlow();

return () => {
isMounted = false;
Expand Down Expand Up @@ -109,7 +132,12 @@ export default function AdminAnalyticsPage() {
) : error ? (
<div className="py-20 text-center text-rose-600">{error}</div>
) : summary ? (
<AnalyticsCharts summary={summary} activeUsers={activeUsers} navigationFlow={navigationFlow} />
<AnalyticsCharts
summary={summary}
activeUsers={activeUsers}
navigationFlow={navigationFlow}
isNavigationFlowLoading={isFlowLoading}
/>
) : null}
</AdminCard>
</AdminPageShell>
Expand Down
129 changes: 18 additions & 111 deletions frontend/src/components/admin/AnalyticsCharts.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"use client";

import type { AnalyticsSummary, DailyPageViewCount, NavigationFlow, NavigationFlowLink } from "@/types/admin";
import {
CartesianGrid,
Layer,
Line,
LineChart,
Rectangle,
ResponsiveContainer,
Sankey,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { AnalyticsSummary, DailyPageViewCount, NavigationFlow } from "@/types/admin";
import dynamic from "next/dynamic";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";

// Code-split from the initial analytics bundle: the flow diagram fetches and
// computes independently of the summary stats above it (see
// app/admin/analytics/page.tsx), so it shouldn't block their first paint.
const NavigationFlowChart = dynamic(() => import("./NavigationFlowChart"), {
ssr: false,
loading: () => (
<div className="rounded-lg border border-slate-200 p-2 py-20 text-center text-sm text-slate-500">
Loading flow…
</div>
),
});

function StatTile({ label, value }: { label: string; value: number | null }) {
return (
Expand Down Expand Up @@ -113,111 +115,16 @@ function AnalyticsTooltip({
);
}

const SANKEY_WIDTH = 760;
const SANKEY_HEIGHT = 420;
const SESSION_START_SENTINEL = "__start__";
const SESSION_START_LABEL = "Session Start";

function truncateLabel(name: string, max = 28): string {
return name.length > max ? `${name.slice(0, max - 1)}…` : name;
}

function buildSankeyData(links: NavigationFlowLink[]) {
const nodeIndex = new Map<string, number>();
const nodes: { name: string }[] = [];

function indexFor(rawName: string): number {
let index = nodeIndex.get(rawName);
if (index === undefined) {
index = nodes.length;
nodeIndex.set(rawName, index);
nodes.push({ name: rawName === SESSION_START_SENTINEL ? SESSION_START_LABEL : rawName });
}
return index;
}

return {
nodes,
links: links.map((link) => ({
source: indexFor(link.source),
target: indexFor(link.target),
value: link.count,
})),
};
}

function renderFlowNode({
x,
y,
width,
height,
payload,
}: {
x: number;
y: number;
width: number;
height: number;
payload: { name: string };
}) {
const isRightHalf = x + width / 2 > SANKEY_WIDTH / 2;
return (
<Layer>
<Rectangle x={x} y={y} width={width} height={height} fill="#2563eb" fillOpacity={0.85} />
<text
x={isRightHalf ? x - 6 : x + width + 6}
y={y + height / 2}
textAnchor={isRightHalf ? "end" : "start"}
dominantBaseline="middle"
fontSize={12}
fill="#334155"
>
{truncateLabel(payload.name)}
</text>
</Layer>
);
}

function NavigationFlowChart({ data }: { data: NavigationFlow | null }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
Navigation Flow
{data && data.total_sessions > 0 && (
<span className="ml-2 text-xs font-normal text-slate-400">
({data.total_sessions.toLocaleString()} sessions analyzed)
</span>
)}
</h3>
{!data || data.links.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-500">No data yet.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-200 p-2">
<Sankey
width={SANKEY_WIDTH}
height={SANKEY_HEIGHT}
data={buildSankeyData(data.links)}
node={renderFlowNode}
link={{ stroke: "#94a3b8", strokeOpacity: 0.35 }}
nodePadding={20}
nodeWidth={10}
margin={{ top: 8, right: 140, bottom: 8, left: 8 }}
>
<Tooltip />
</Sankey>
</div>
)}
</div>
);
}

export function AnalyticsCharts({
summary,
activeUsers,
navigationFlow,
isNavigationFlowLoading,
}: {
summary: AnalyticsSummary;
activeUsers: number | null;
navigationFlow: NavigationFlow | null;
isNavigationFlowLoading?: boolean;
}) {
const hourly = summary.range_days <= 1;

Expand Down Expand Up @@ -269,7 +176,7 @@ export function AnalyticsCharts({
/>
</div>

<NavigationFlowChart data={navigationFlow} />
<NavigationFlowChart data={navigationFlow} isLoading={isNavigationFlowLoading} />
</div>
);
}
Loading
Loading