Added statistics
Build and Publish / Build and Publish Docker Image (push) Successful in 3m18s

This commit is contained in:
ab
2026-09-20 17:00:59 +03:00
parent 7f49b1f314
commit 5fd26c816a
16 changed files with 2125 additions and 5 deletions
Generated
+1 -1
View File
@@ -4763,7 +4763,7 @@ dependencies = [
[[package]]
name = "web-petting"
version = "1.0.5"
version = "1.0.7"
dependencies = [
"async-trait",
"aws-sdk-s3",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "web-petting"
version = "1.0.6"
version = "1.0.7"
edition = "2024"
default-run = "web-petting"
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""
One-off data migration from the old web-petting SQLite database to PostgreSQL.
The script intentionally lives outside the Rust application and does not affect
the app dependency graph. It migrates user-owned application data only and skips
Cot technical tables such as cot__migrations and cot__session.
Prerequisites:
1. Run the new app migrations against PostgreSQL first, so the target tables
already exist.
2. Install one PostgreSQL Python driver in the environment that runs this
script:
python -m pip install "psycopg[binary]"
psycopg2 is also supported if it is already installed.
Usage:
python scripts/migrate_sqlite_to_postgres.py --sqlite db.sqlite3 --postgres-url postgresql://...
python scripts/migrate_sqlite_to_postgres.py --dry-run
"""
from __future__ import annotations
import argparse
import os
import sqlite3
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
Transform = Callable[[Any], Any]
@dataclass(frozen=True)
class TableSpec:
name: str
columns: tuple[str, ...]
transforms: dict[str, Transform] | None = None
def optional_bool(value: Any) -> bool | None:
if value is None:
return None
if value in (0, "0", False):
return False
if value in (1, "1", True):
return True
raise ValueError(f"cannot convert {value!r} to bool")
TABLES: tuple[TableSpec, ...] = (
TableSpec(
"web_petting__user",
(
"id",
"login",
"password_hash",
"display_name",
"telegram_chat_id",
"telegram_notifications",
"status",
"created_at",
"updated_at",
),
transforms={"telegram_notifications": optional_bool},
),
TableSpec(
"web_petting__setting",
("id", "key", "value", "updated_at"),
),
TableSpec(
"web_petting__client",
(
"id",
"name",
"phone",
"email",
"address",
"notes",
"media_token",
"color",
"status",
"created_at",
"updated_at",
),
),
TableSpec(
"web_petting__testimonial",
("id", "text", "author_note", "image_path", "status", "sort_order", "created_at"),
),
TableSpec(
"web_petting__visit",
(
"id",
"client_id",
"user_id",
"visit_date",
"time_start",
"time_end",
"notes",
"public_notes",
"client_feedback",
"status",
"created_at",
"updated_at",
),
),
TableSpec(
"web_petting__media",
("id", "client_id", "visit_id", "file_path", "file_type", "caption", "status", "created_at"),
),
TableSpec(
"web_petting__lead",
("id", "name", "phone", "email", "comment", "status", "client_id", "created_at", "updated_at"),
),
)
FK_CHECKS: tuple[tuple[str, str, str, str], ...] = (
("web_petting__lead", "client_id", "web_petting__client", "id"),
("web_petting__visit", "client_id", "web_petting__client", "id"),
("web_petting__visit", "user_id", "web_petting__user", "id"),
("web_petting__media", "client_id", "web_petting__client", "id"),
("web_petting__media", "visit_id", "web_petting__visit", "id"),
)
def quote_ident(identifier: str) -> str:
return '"' + identifier.replace('"', '""') + '"'
def pg_table_name(table: str, schema: str | None) -> str:
if schema:
return f"{quote_ident(schema)}.{quote_ident(table)}"
return quote_ident(table)
def sequence_relation_name(table: str, schema: str | None) -> str:
if schema:
return f"{schema}.{table}"
return table
def sqlite_table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"select 1 from sqlite_schema where type = 'table' and name = ?",
(table,),
).fetchone()
return row is not None
def sqlite_columns(conn: sqlite3.Connection, table: str) -> set[str]:
return {row["name"] for row in conn.execute(f"pragma table_info({quote_ident(table)})")}
def sqlite_count(conn: sqlite3.Connection, table: str) -> int:
return int(conn.execute(f"select count(*) from {quote_ident(table)}").fetchone()[0])
def read_rows(conn: sqlite3.Connection, spec: TableSpec) -> list[tuple[Any, ...]]:
columns_sql = ", ".join(quote_ident(column) for column in spec.columns)
rows: list[tuple[Any, ...]] = []
for row in conn.execute(f"select {columns_sql} from {quote_ident(spec.name)} order by id"):
values = []
for column in spec.columns:
value = row[column]
if spec.transforms and column in spec.transforms:
value = spec.transforms[column](value)
values.append(value)
rows.append(tuple(values))
return rows
def validate_sqlite(conn: sqlite3.Connection) -> None:
for spec in TABLES:
if not sqlite_table_exists(conn, spec.name):
raise RuntimeError(f"SQLite table is missing: {spec.name}")
existing_columns = sqlite_columns(conn, spec.name)
missing_columns = [column for column in spec.columns if column not in existing_columns]
if missing_columns:
joined = ", ".join(missing_columns)
raise RuntimeError(f"SQLite table {spec.name} is missing columns: {joined}")
for source_table, source_column, target_table, target_column in FK_CHECKS:
rows = conn.execute(
f"""
select s.id, s.{quote_ident(source_column)}
from {quote_ident(source_table)} s
left join {quote_ident(target_table)} t
on s.{quote_ident(source_column)} = t.{quote_ident(target_column)}
where s.{quote_ident(source_column)} is not null
and t.{quote_ident(target_column)} is null
order by s.id
limit 20
"""
).fetchall()
if rows:
examples = ", ".join(
f"id={row[0]} {source_column}={row[1]}" for row in rows
)
raise RuntimeError(
f"SQLite FK check failed: {source_table}.{source_column} -> "
f"{target_table}.{target_column}; examples: {examples}"
)
def import_postgres_driver() -> Any:
try:
import psycopg # type: ignore
return psycopg
except ModuleNotFoundError:
pass
try:
import psycopg2 # type: ignore
return psycopg2
except ModuleNotFoundError as exc:
raise RuntimeError(
"No PostgreSQL Python driver found. Install one with: "
'python -m pip install "psycopg[binary]"'
) from exc
def connect_postgres(postgres_url: str) -> Any:
driver = import_postgres_driver()
return driver.connect(postgres_url)
def postgres_table_exists(cur: Any, table: str, schema: str | None) -> bool:
if schema:
cur.execute(
"""
select 1
from information_schema.tables
where table_schema = %s and table_name = %s
""",
(schema, table),
)
else:
cur.execute("select to_regclass(%s)", (table,))
row = cur.fetchone()
if not row:
return False
return bool(row[0])
def postgres_count(cur: Any, table: str, schema: str | None) -> int:
cur.execute(f"select count(*) from {pg_table_name(table, schema)}")
return int(cur.fetchone()[0])
def ensure_postgres_ready(cur: Any, schema: str | None, allow_existing: bool) -> None:
for spec in TABLES:
if not postgres_table_exists(cur, spec.name, schema):
raise RuntimeError(
f"PostgreSQL table is missing: {spec.name}. "
"Run the new app migrations before this script."
)
count = postgres_count(cur, spec.name, schema)
if count and not allow_existing:
raise RuntimeError(
f"PostgreSQL table {spec.name} already contains {count} rows. "
"Use --truncate-user-tables to replace user data."
)
def truncate_user_tables(cur: Any, schema: str | None) -> None:
tables = ", ".join(pg_table_name(spec.name, schema) for spec in TABLES)
cur.execute(f"truncate table {tables} restart identity")
def insert_table(cur: Any, spec: TableSpec, rows: list[tuple[Any, ...]], schema: str | None) -> None:
if not rows:
return
columns_sql = ", ".join(quote_ident(column) for column in spec.columns)
placeholders = ", ".join(["%s"] * len(spec.columns))
sql = f"insert into {pg_table_name(spec.name, schema)} ({columns_sql}) values ({placeholders})"
cur.executemany(sql, rows)
def reset_sequence(cur: Any, table: str, schema: str | None) -> None:
relation = sequence_relation_name(table, schema)
cur.execute(
"""
select setval(
pg_get_serial_sequence(%s, 'id'),
greatest(coalesce((select max(id) from %s), 0), 1),
coalesce((select max(id) from %s), 0) > 0
)
"""
% ("%s", pg_table_name(table, schema), pg_table_name(table, schema)),
(relation,),
)
def migrate(sqlite_path: Path, postgres_url: str, schema: str | None, truncate: bool) -> None:
sqlite_conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
sqlite_conn.row_factory = sqlite3.Row
try:
validate_sqlite(sqlite_conn)
source_rows = {spec.name: read_rows(sqlite_conn, spec) for spec in TABLES}
finally:
sqlite_conn.close()
pg_conn = connect_postgres(postgres_url)
try:
with pg_conn:
with pg_conn.cursor() as cur:
ensure_postgres_ready(cur, schema, allow_existing=truncate)
if truncate:
truncate_user_tables(cur, schema)
for spec in TABLES:
insert_table(cur, spec, source_rows[spec.name], schema)
print(f"inserted {len(source_rows[spec.name])} rows into {spec.name}")
for spec in TABLES:
reset_sequence(cur, spec.name, schema)
for spec in TABLES:
target_count = postgres_count(cur, spec.name, schema)
source_count = len(source_rows[spec.name])
if target_count != source_count:
raise RuntimeError(
f"count mismatch for {spec.name}: source={source_count}, target={target_count}"
)
finally:
pg_conn.close()
def dry_run(sqlite_path: Path) -> None:
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
validate_sqlite(conn)
print("SQLite source is valid.")
for spec in TABLES:
print(f"{spec.name}: {sqlite_count(conn, spec.name)} rows")
print("Cot technical tables are intentionally skipped.")
finally:
conn.close()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Migrate web-petting user data from SQLite to PostgreSQL.",
)
parser.add_argument(
"--sqlite",
default="db.sqlite3",
type=Path,
help="Path to the downloaded SQLite database (default: db.sqlite3).",
)
parser.add_argument(
"--postgres-url",
default=os.environ.get("WEB_PETTING_DATABASE_URL") or os.environ.get("DATABASE_URL"),
help="PostgreSQL URL. Defaults to WEB_PETTING_DATABASE_URL or DATABASE_URL.",
)
parser.add_argument(
"--schema",
default="public",
help="Target PostgreSQL schema (default: public). Use an empty string for current search_path.",
)
parser.add_argument(
"--truncate-user-tables",
action="store_true",
help="Delete existing rows from migrated application tables before inserting.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate and count SQLite rows without connecting to PostgreSQL.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
sqlite_path = args.sqlite
if not sqlite_path.exists():
print(f"SQLite file not found: {sqlite_path}", file=sys.stderr)
return 2
schema = args.schema or None
try:
if args.dry_run:
dry_run(sqlite_path)
return 0
if not args.postgres_url:
print(
"PostgreSQL URL is required. Pass --postgres-url or set WEB_PETTING_DATABASE_URL.",
file=sys.stderr,
)
return 2
migrate(sqlite_path, args.postgres_url, schema, args.truncate_user_tables)
print("Migration complete.")
return 0
except Exception as exc:
print(f"Migration failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+276
View File
@@ -1,3 +1,4 @@
use chrono::TimeZone;
use cot::Template;
use cot::db::{Auto, Database, ForeignKey, Model, query};
use cot::html::Html;
@@ -352,6 +353,18 @@ struct SettingsTemplate<'a> {
vapid_private_key_configured: bool,
r2_secret_configured: bool,
push_subscribers: Vec<PushSubscriberItem>,
analytics_landing_views: u64,
analytics_landing_unique: u64,
analytics_portal_opens: u64,
analytics_media_views: u64,
}
#[derive(Debug, Template)]
#[template(path = "admin/analytics.html")]
struct AnalyticsTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
}
fn setting_has_value(settings: &[Setting], key: &str) -> bool {
@@ -527,6 +540,47 @@ async fn admin_media_view(
})
}
/// Builds a JSON reference to a media file for the analytics UI: a preview link
/// if the file still exists and is active, otherwise a minimal placeholder.
async fn media_ref_json(
storage: &crate::uploads::Storage,
clients: &[Client],
media_list: &[Media],
media_id: i64,
) -> cot::Result<serde_json::Value> {
match media_list
.iter()
.find(|m| m.id.unwrap() == media_id && m.status == "active")
{
Some(m) => {
let cid = m.client_id.primary_key().unwrap();
let client_name = clients
.iter()
.find(|c| c.id.unwrap() == cid)
.map(|c| c.name.clone())
.unwrap_or_default();
let caption = m
.caption
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let file_type = m.file_type.clone();
let view = admin_media_view(storage, m.clone()).await?;
Ok(serde_json::json!({
"media_id": media_id,
"exists": true,
"file_type": file_type,
"url": view.url,
"thumbnail_url": view.thumbnail_url,
"client_name": client_name,
"caption": caption,
}))
}
None => Ok(serde_json::json!({ "media_id": media_id, "exists": false })),
}
}
// ---------------------------------------------------------------------------
// Auth Handlers
// ---------------------------------------------------------------------------
@@ -1339,6 +1393,27 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
let r2_secret_configured =
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
// Compact 30-day analytics summary shown on the settings page.
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
let summary_from = today - chrono::Duration::days(29);
let summary_events = crate::analytics::load_events(&db, summary_from, today).await?;
let summary = crate::analytics::aggregate(
&summary_events,
summary_from,
today,
crate::analytics::Granularity::Day,
);
let metric_total = |key: &str| {
summary
.metrics
.iter()
.find(|m| m.key == key)
.map(|m| m.total)
.unwrap_or(0)
};
let body = SettingsTemplate {
t: lang.t(),
lang,
@@ -1356,11 +1431,180 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
vapid_private_key_configured,
r2_secret_configured,
push_subscribers: load_push_subscribers(&db).await?,
analytics_landing_views: metric_total("landing_views"),
analytics_landing_unique: metric_total("landing_unique"),
analytics_portal_opens: metric_total("portal_opens"),
analytics_media_views: metric_total("media_views"),
}
.render()?;
html_response(body, lang)
}
async fn analytics_page(request: Request, session: Session) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let body = AnalyticsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
}
.render()?;
html_response(body, lang)
}
/// JSON time-series + rankings for the analytics dashboard.
/// Query params: `from`, `to` (YYYY-MM-DD), `granularity` (day|week|month).
async fn analytics_data(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
if require_auth(&session, lang).await.is_err() {
return Html::new("{}")
.with_header("content-type", "application/json")
.into_response();
}
let query_str = request.uri().query().unwrap_or("");
let mut from_str = "";
let mut to_str = "";
let mut gran_str = "day";
for pair in query_str.split('&') {
if let Some(v) = pair.strip_prefix("from=") {
from_str = v;
} else if let Some(v) = pair.strip_prefix("to=") {
to_str = v;
} else if let Some(v) = pair.strip_prefix("granularity=") {
gran_str = v;
}
}
let tz = crate::tz::load_tz(&db).await;
let tz_today = crate::tz::today_in_tz(tz);
let to = chrono::NaiveDate::parse_from_str(to_str, "%Y-%m-%d").unwrap_or(tz_today);
let from = chrono::NaiveDate::parse_from_str(from_str, "%Y-%m-%d")
.unwrap_or_else(|_| to - chrono::Duration::days(29));
// Guard against an inverted range.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
let granularity = crate::analytics::Granularity::from_code(gran_str);
let events = crate::analytics::load_events(&db, from, to).await?;
let mut report = crate::analytics::aggregate(&events, from, to, granularity);
let clients = Client::objects().all(&db).await?;
let media = Media::objects().all(&db).await?;
let client_name = |id: i64| {
clients
.iter()
.find(|c| c.id.unwrap() == id)
.map(|c| c.name.clone())
.unwrap_or_else(|| format!("#{id}"))
};
report.top_clients = crate::analytics::top_entries(
crate::analytics::portal_opens_by_client(&events),
10,
client_name,
);
// Top media, enriched with preview links so they can be opened in-page.
let storage = crate::uploads::Storage::load(&db).await?;
let mut media_counts: Vec<(i64, u64)> = crate::analytics::media_views_by_id(&events)
.into_iter()
.collect();
media_counts.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
media_counts.truncate(10);
let mut top_media = Vec::with_capacity(media_counts.len());
for (media_id, count) in media_counts {
let mut entry = media_ref_json(&storage, &clients, &media, media_id).await?;
entry["count"] = serde_json::json!(count);
top_media.push(entry);
}
let json = serde_json::json!({
"labels": report.labels,
"metrics": report.metrics,
"top_clients": report.top_clients,
"top_media": top_media,
});
Html::new(json.to_string())
.with_header("content-type", "application/json")
.into_response()
}
/// JSON page of recent events for the activity log, newest first.
/// Query params: `before` (id cursor for older pages), `limit`.
async fn analytics_events(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if require_auth(&session, lang).await.is_err() {
return Html::new("{}")
.with_header("content-type", "application/json")
.into_response();
}
let query_str = request.uri().query().unwrap_or("");
let mut before: Option<i64> = None;
let mut limit: usize = 40;
for pair in query_str.split('&') {
if let Some(v) = pair.strip_prefix("before=") {
before = v.parse().ok();
} else if let Some(v) = pair.strip_prefix("limit=")
&& let Ok(n) = v.parse::<usize>()
{
limit = n.clamp(1, 100);
}
}
let page = crate::analytics::recent_events(&db, before, limit).await?;
let next_cursor = if page.len() == limit {
page.last().map(|e| e.id.unwrap())
} else {
None
};
let tz = crate::tz::load_tz(&db).await;
let clients = Client::objects().all(&db).await?;
let media = Media::objects().all(&db).await?;
let storage = crate::uploads::Storage::load(&db).await?;
let mut out = Vec::with_capacity(page.len());
for e in &page {
let client_name = e.client_id.as_ref().map(|fk| {
let cid = fk.primary_key().unwrap();
clients
.iter()
.find(|c| c.id.unwrap() == cid)
.map(|c| c.name.clone())
.unwrap_or_else(|| format!("#{cid}"))
});
let media_ref = match e.media_id {
Some(mid) => Some(media_ref_json(&storage, &clients, &media, mid).await?),
None => None,
};
let local = tz.from_utc_datetime(&e.created_at);
out.push(serde_json::json!({
"id": e.id.unwrap(),
"created_at": local.format("%Y-%m-%d %H:%M").to_string(),
"event_type": e.event_type,
"client_name": client_name,
"visitor": e.visitor_hash.chars().take(8).collect::<String>(),
"media": media_ref,
"user_agent": e.user_agent,
"referer": e.referer,
"path": e.path,
}));
}
let json = serde_json::json!({ "events": out, "next_cursor": next_cursor });
Html::new(json.to_string())
.with_header("content-type", "application/json")
.into_response()
}
// ---------------------------------------------------------------------------
// POST Handlers (protected)
// ---------------------------------------------------------------------------
@@ -1636,6 +1880,27 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
let r2_secret_configured =
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
// Compact 30-day analytics summary shown on the settings page.
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
let summary_from = today - chrono::Duration::days(29);
let summary_events = crate::analytics::load_events(&db, summary_from, today).await?;
let summary = crate::analytics::aggregate(
&summary_events,
summary_from,
today,
crate::analytics::Granularity::Day,
);
let metric_total = |key: &str| {
summary
.metrics
.iter()
.find(|m| m.key == key)
.map(|m| m.total)
.unwrap_or(0)
};
let rendered = SettingsTemplate {
t: lang.t(),
lang,
@@ -1653,6 +1918,10 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
vapid_private_key_configured,
r2_secret_configured,
push_subscribers: load_push_subscribers(&db).await?,
analytics_landing_views: metric_total("landing_views"),
analytics_landing_unique: metric_total("landing_unique"),
analytics_portal_opens: metric_total("portal_opens"),
analytics_media_views: metric_total("media_views"),
}
.render()?;
html_response(rendered, lang)
@@ -3251,6 +3520,13 @@ pub fn admin_router() -> Router {
),
Route::with_handler_and_name("/settings", settings_page, "admin-settings-get"),
Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"),
Route::with_handler_and_name("/analytics", analytics_page, "admin-analytics"),
Route::with_handler_and_name("/analytics/data", analytics_data, "admin-analytics-data"),
Route::with_handler_and_name(
"/analytics/events",
analytics_events,
"admin-analytics-events",
),
])
}
+501
View File
@@ -0,0 +1,501 @@
//! Visit analytics: recording anonymous events and aggregating them for the
//! admin statistics dashboard.
//!
//! Events are stored append-only in [`AnalyticsEvent`]. Three things are
//! tracked:
//! - `landing_view` — a (non-bot) visit to the public landing page,
//! - `portal_open` — a client opening their private portal page,
//! - `media_view` — a client opening a full-size photo/video from the archive.
//!
//! Uniqueness is counted per anonymous first-party cookie (`vid`), never by IP.
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime};
use cot::db::query;
use cot::db::{Auto, Database, ForeignKey, Model};
use cot::request::Request;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use crate::models::{AnalyticsEvent, EventType};
/// Name of the anonymous first-party visitor cookie.
pub const VISITOR_COOKIE: &str = "vid";
// ---------------------------------------------------------------------------
// Recording
// ---------------------------------------------------------------------------
/// Technical request context stored alongside an event (never includes IP).
#[derive(Debug, Clone, Default)]
pub struct RequestMeta {
pub user_agent: Option<String>,
pub referer: Option<String>,
pub path: Option<String>,
}
impl RequestMeta {
/// Extracts User-Agent, Referer and path+query from the request.
pub fn from_request(request: &Request) -> Self {
let header = |name: &str| {
request
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.filter(|value| !value.is_empty())
};
let path = request
.uri()
.path_and_query()
.map(|pq| pq.as_str().to_string());
Self {
user_agent: header("user-agent"),
referer: header("referer"),
path,
}
}
}
/// Records an analytics event. Failures are logged but never surfaced to the
/// caller, so instrumentation can never break a user-facing request.
pub async fn record(
db: &Database,
event_type: EventType,
client_id: Option<i64>,
media_id: Option<i64>,
visitor_hash: String,
meta: RequestMeta,
) {
let mut event = AnalyticsEvent {
id: Auto::auto(),
event_type: event_type.as_str().to_string(),
client_id: client_id.map(|id| ForeignKey::PrimaryKey(Auto::fixed(id))),
media_id,
visitor_hash,
user_agent: meta.user_agent,
referer: meta.referer,
path: meta.path,
created_at: chrono::Utc::now().naive_utc(),
};
if let Err(error) = event.save(db).await {
tracing::warn!(target: "analytics", %error, "failed to record analytics event");
}
}
// ---------------------------------------------------------------------------
// Visitor identity (anonymous cookie) + bot filtering
// ---------------------------------------------------------------------------
/// Reads the `vid` cookie from the request, if present.
pub fn visitor_id_from_request(request: &Request) -> Option<String> {
let cookies = request.headers().get("cookie")?.to_str().ok()?;
for part in cookies.split(';') {
let part = part.trim();
if let Some(value) = part.strip_prefix("vid=") {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
/// A fresh random visitor id.
pub fn new_visitor_id() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
/// `Set-Cookie` header value for a visitor id (1 year, lax).
pub fn visitor_cookie(id: &str) -> String {
format!("{VISITOR_COOKIE}={id}; Path=/; SameSite=Lax; Max-Age=31536000")
}
/// Returns the visitor id and whether a new cookie needs to be set on the
/// response (i.e. the request had no `vid` cookie yet).
pub fn ensure_visitor_id(request: &Request) -> (String, bool) {
match visitor_id_from_request(request) {
Some(id) => (id, false),
None => (new_visitor_id(), true),
}
}
/// Heuristic check whether a User-Agent string belongs to a crawler/bot.
pub fn is_bot(user_agent: &str) -> bool {
let ua = user_agent.to_ascii_lowercase();
if ua.is_empty() || ua == "-" {
return true;
}
const MARKERS: &[&str] = &[
"bot",
"crawl",
"spider",
"slurp",
"curl",
"wget",
"python-requests",
"httpclient",
"http-client",
"scrapy",
"headless",
"phantomjs",
"monitor",
"uptime",
"pingdom",
"facebookexternalhit",
"embedly",
"preview",
"fetch",
"google-",
"yandex",
"bingpreview",
"semrush",
"ahrefs",
"mj12",
"dotbot",
"petalbot",
"gptbot",
"ccbot",
];
MARKERS.iter().any(|marker| ua.contains(marker))
}
// ---------------------------------------------------------------------------
// Aggregation
// ---------------------------------------------------------------------------
/// Time bucketing granularity for reports.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Granularity {
Day,
Week,
Month,
}
impl Granularity {
pub fn from_code(code: &str) -> Self {
match code {
"week" => Self::Week,
"month" => Self::Month,
_ => Self::Day,
}
}
/// The label for the bucket a given date falls into.
fn bucket_label(self, date: NaiveDate) -> String {
match self {
Self::Day => date.format("%Y-%m-%d").to_string(),
Self::Week => {
let monday =
date - Duration::days(i64::from(date.weekday().num_days_from_monday()));
monday.format("%Y-%m-%d").to_string()
}
Self::Month => date.format("%Y-%m").to_string(),
}
}
}
/// One time-series metric.
#[derive(Debug, Serialize)]
pub struct Metric {
pub key: &'static str,
pub values: Vec<u64>,
pub total: u64,
}
/// An entry in a "top" ranking (top clients / top media).
#[derive(Debug, Serialize)]
pub struct TopEntry {
pub label: String,
pub count: u64,
}
/// Full aggregated report returned to the dashboard as JSON.
#[derive(Debug, Serialize)]
pub struct Report {
pub labels: Vec<String>,
pub metrics: Vec<Metric>,
pub top_clients: Vec<TopEntry>,
pub top_media: Vec<TopEntry>,
}
/// Builds the ordered list of bucket labels covering `[from, to]` (inclusive),
/// plus a lookup from label to its index.
fn build_buckets(
from: NaiveDate,
to: NaiveDate,
granularity: Granularity,
) -> (Vec<String>, HashMap<String, usize>) {
let mut labels = Vec::new();
let mut index = HashMap::new();
let mut day = from;
while day <= to {
let label = granularity.bucket_label(day);
if !index.contains_key(&label) {
index.insert(label.clone(), labels.len());
labels.push(label);
}
day += Duration::days(1);
}
(labels, index)
}
/// Aggregates raw events into a time-series report. Pure and deterministic so it
/// can be unit-tested without a database.
pub fn aggregate(
events: &[AnalyticsEvent],
from: NaiveDate,
to: NaiveDate,
granularity: Granularity,
) -> Report {
let (labels, index) = build_buckets(from, to, granularity);
let n = labels.len();
let mut landing_views = vec![0u64; n];
let mut landing_unique_sets: Vec<HashSet<&str>> = vec![HashSet::new(); n];
let mut portal_opens = vec![0u64; n];
let mut media_views = vec![0u64; n];
for event in events {
let date = event.created_at.date();
if date < from || date > to {
continue;
}
let Some(&bucket) = index.get(&granularity.bucket_label(date)) else {
continue;
};
match event.event_type.as_str() {
"landing_view" => {
landing_views[bucket] += 1;
landing_unique_sets[bucket].insert(event.visitor_hash.as_str());
}
"portal_open" => portal_opens[bucket] += 1,
"media_view" => media_views[bucket] += 1,
_ => {}
}
}
let landing_unique: Vec<u64> = landing_unique_sets
.iter()
.map(|set| set.len() as u64)
.collect();
let metric = |key: &'static str, values: Vec<u64>| Metric {
total: values.iter().sum(),
key,
values,
};
Report {
labels,
metrics: vec![
metric("landing_views", landing_views),
metric("landing_unique", landing_unique),
metric("portal_opens", portal_opens),
metric("media_views", media_views),
],
top_clients: Vec::new(),
top_media: Vec::new(),
}
}
/// Loads events in `[from, to]` (inclusive of the whole `to` day) from the
/// database.
pub async fn load_events(
db: &Database,
from: NaiveDate,
to: NaiveDate,
) -> cot::Result<Vec<AnalyticsEvent>> {
let start: NaiveDateTime = from.and_hms_opt(0, 0, 0).unwrap();
let end: NaiveDateTime = to.and_hms_opt(23, 59, 59).unwrap();
Ok(
query!(AnalyticsEvent, $created_at >= start && $created_at <= end)
.all(db)
.await?,
)
}
/// Loads a page of recent events, newest first, for the event log.
///
/// Cursor pagination by descending id: pass the id of the oldest event already
/// shown as `before` to fetch the next (older) page. `None` starts from newest.
///
/// The ORM exposes neither `ORDER BY` nor raw row reads, so ordering and slicing
/// happen in Rust. This is fine for the expected event volume of this site; if
/// the table ever grows very large, add a descending index and raw SQL here.
pub async fn recent_events(
db: &Database,
before: Option<i64>,
limit: usize,
) -> cot::Result<Vec<AnalyticsEvent>> {
// `Auto<i64>` is not `Ord`, so the id cursor can't be pushed into the query;
// filter and sort in Rust instead (acceptable for this site's volume).
let mut events = AnalyticsEvent::objects().all(db).await?;
events.sort_by_key(|e| std::cmp::Reverse(e.id.unwrap()));
Ok(events
.into_iter()
.filter(|e| before.is_none_or(|cursor| e.id.unwrap() < cursor))
.take(limit)
.collect())
}
/// Counts `portal_open` events per client id within the event slice.
pub fn portal_opens_by_client(events: &[AnalyticsEvent]) -> HashMap<i64, u64> {
let mut counts: HashMap<i64, u64> = HashMap::new();
for event in events {
if event.event_type == "portal_open"
&& let Some(fk) = &event.client_id
{
let id = fk.primary_key().unwrap();
*counts.entry(id).or_default() += 1;
}
}
counts
}
/// Counts `media_view` events per media id within the event slice.
pub fn media_views_by_id(events: &[AnalyticsEvent]) -> HashMap<i64, u64> {
let mut counts: HashMap<i64, u64> = HashMap::new();
for event in events {
if event.event_type == "media_view"
&& let Some(id) = event.media_id
{
*counts.entry(id).or_default() += 1;
}
}
counts
}
/// Turns an id->count map into a descending top-N ranking using the supplied
/// label resolver.
pub fn top_entries<F>(counts: HashMap<i64, u64>, limit: usize, label_of: F) -> Vec<TopEntry>
where
F: Fn(i64) -> String,
{
let mut entries: Vec<(i64, u64)> = counts.into_iter().collect();
entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
entries.truncate(limit);
entries
.into_iter()
.map(|(id, count)| TopEntry {
label: label_of(id),
count,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, day: &str, visitor: &str) -> AnalyticsEvent {
AnalyticsEvent {
id: Auto::fixed(0),
event_type: kind.to_string(),
client_id: None,
media_id: None,
visitor_hash: visitor.to_string(),
user_agent: None,
referer: None,
path: None,
created_at: NaiveDate::parse_from_str(day, "%Y-%m-%d")
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap(),
}
}
fn date(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
#[test]
fn bot_detection() {
assert!(is_bot("Googlebot/2.1"));
assert!(is_bot("curl/8.0"));
assert!(is_bot(""));
assert!(is_bot("-"));
assert!(!is_bot(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
));
}
#[test]
fn daily_buckets_cover_full_range() {
let events = vec![
event("landing_view", "2026-09-01", "a"),
event("landing_view", "2026-09-01", "a"), // repeat visitor -> not unique
event("landing_view", "2026-09-01", "b"),
event("landing_view", "2026-09-03", "c"),
event("portal_open", "2026-09-03", "c"),
event("media_view", "2026-09-03", "c"),
];
let report = aggregate(
&events,
date("2026-09-01"),
date("2026-09-03"),
Granularity::Day,
);
assert_eq!(report.labels, ["2026-09-01", "2026-09-02", "2026-09-03"]);
let landing = &report.metrics[0];
assert_eq!(landing.key, "landing_views");
assert_eq!(landing.values, [3, 0, 1]);
assert_eq!(landing.total, 4);
let unique = &report.metrics[1];
assert_eq!(unique.key, "landing_unique");
assert_eq!(unique.values, [2, 0, 1]);
assert_eq!(unique.total, 3);
assert_eq!(report.metrics[2].values, [0, 0, 1]); // portal opens
assert_eq!(report.metrics[3].values, [0, 0, 1]); // media views
}
#[test]
fn events_outside_range_are_ignored() {
let events = vec![
event("landing_view", "2026-08-31", "a"),
event("landing_view", "2026-09-02", "b"),
];
let report = aggregate(
&events,
date("2026-09-01"),
date("2026-09-02"),
Granularity::Day,
);
assert_eq!(report.metrics[0].total, 1);
}
#[test]
fn weekly_bucketing_groups_by_monday() {
// 2026-09-01 is a Tuesday; its week's Monday is 2026-08-31.
let events = vec![
event("landing_view", "2026-09-01", "a"),
event("landing_view", "2026-09-06", "b"), // Sunday, same ISO week
event("landing_view", "2026-09-07", "c"), // Monday, next week
];
let report = aggregate(
&events,
date("2026-09-01"),
date("2026-09-07"),
Granularity::Week,
);
assert_eq!(report.labels, ["2026-08-31", "2026-09-07"]);
assert_eq!(report.metrics[0].values, [2, 1]);
}
#[test]
fn top_entries_are_sorted_desc_and_truncated() {
let mut counts = HashMap::new();
counts.insert(1, 5);
counts.insert(2, 9);
counts.insert(3, 9);
let top = top_entries(counts, 2, |id| format!("c{id}"));
assert_eq!(top.len(), 2);
// ties broken by ascending id -> c2 before c3
assert_eq!(top[0].label, "c2");
assert_eq!(top[0].count, 9);
assert_eq!(top[1].label, "c3");
}
}
+106
View File
@@ -187,6 +187,42 @@ pub struct Translations {
pub landing_contact_label: &'static str,
pub landing_pricing_title: &'static str,
// Analytics
pub nav_analytics: &'static str,
pub analytics_title: &'static str,
pub analytics_intro: &'static str,
pub analytics_metric_landing_views: &'static str,
pub analytics_metric_landing_unique: &'static str,
pub analytics_metric_portal_opens: &'static str,
pub analytics_metric_media_views: &'static str,
pub analytics_range_from: &'static str,
pub analytics_range_to: &'static str,
pub analytics_granularity: &'static str,
pub analytics_gran_day: &'static str,
pub analytics_gran_week: &'static str,
pub analytics_gran_month: &'static str,
pub analytics_preset_7: &'static str,
pub analytics_preset_30: &'static str,
pub analytics_preset_90: &'static str,
pub analytics_preset_365: &'static str,
pub analytics_metrics_label: &'static str,
pub analytics_top_clients: &'static str,
pub analytics_top_media: &'static str,
pub analytics_count: &'static str,
pub analytics_no_data: &'static str,
pub analytics_loading: &'static str,
pub analytics_load_error: &'static str,
pub analytics_summary_title: &'static str,
pub analytics_summary_period: &'static str,
pub analytics_open_dashboard: &'static str,
pub analytics_log_title: &'static str,
pub analytics_log_empty: &'static str,
pub analytics_log_end: &'static str,
pub analytics_log_visitor: &'static str,
pub analytics_ev_landing_view: &'static str,
pub analytics_ev_portal_open: &'static str,
pub analytics_ev_media_view: &'static str,
// Dashboard
pub dashboard_title: &'static str,
pub dashboard_today_visits: &'static str,
@@ -473,6 +509,41 @@ static RU: Translations = Translations {
landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость",
nav_analytics: "Статистика",
analytics_title: "Статистика посещений",
analytics_intro: "Уникальные заходы на сайт, открытия клиентских страниц и просмотры фото из архива.",
analytics_metric_landing_views: "Заходы на сайт",
analytics_metric_landing_unique: "Уникальные посетители",
analytics_metric_portal_opens: "Открытия страниц клиентов",
analytics_metric_media_views: "Просмотры фото",
analytics_range_from: "С",
analytics_range_to: "По",
analytics_granularity: "Разбивка",
analytics_gran_day: "По дням",
analytics_gran_week: "По неделям",
analytics_gran_month: "По месяцам",
analytics_preset_7: "7 дней",
analytics_preset_30: "30 дней",
analytics_preset_90: "90 дней",
analytics_preset_365: "Год",
analytics_metrics_label: "Метрики",
analytics_top_clients: "Топ клиентов по открытиям страницы",
analytics_top_media: "Топ фото по просмотрам",
analytics_count: "Количество",
analytics_no_data: "Нет данных за выбранный период.",
analytics_loading: "Загрузка...",
analytics_load_error: "Не удалось загрузить данные. Обновите страницу.",
analytics_summary_title: "Статистика посещений",
analytics_summary_period: "за последние 30 дней",
analytics_open_dashboard: "Открыть подробную статистику",
analytics_log_title: "Последние события",
analytics_log_empty: "Событий пока нет.",
analytics_log_end: "Это все события.",
analytics_log_visitor: "Посетитель",
analytics_ev_landing_view: "Заход на сайт",
analytics_ev_portal_open: "Открытие страницы клиента",
analytics_ev_media_view: "Просмотр фото",
dashboard_title: "Главная",
dashboard_today_visits: "Визиты на сегодня",
dashboard_no_visits: "На сегодня визитов нет.",
@@ -749,6 +820,41 @@ static EN: Translations = Translations {
landing_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing",
nav_analytics: "Analytics",
analytics_title: "Visit analytics",
analytics_intro: "Unique landing-page visits, client portal opens, and archive photo views.",
analytics_metric_landing_views: "Landing visits",
analytics_metric_landing_unique: "Unique visitors",
analytics_metric_portal_opens: "Client portal opens",
analytics_metric_media_views: "Photo views",
analytics_range_from: "From",
analytics_range_to: "To",
analytics_granularity: "Group by",
analytics_gran_day: "Day",
analytics_gran_week: "Week",
analytics_gran_month: "Month",
analytics_preset_7: "7 days",
analytics_preset_30: "30 days",
analytics_preset_90: "90 days",
analytics_preset_365: "Year",
analytics_metrics_label: "Metrics",
analytics_top_clients: "Top clients by portal opens",
analytics_top_media: "Top photos by views",
analytics_count: "Count",
analytics_no_data: "No data for the selected period.",
analytics_loading: "Loading...",
analytics_load_error: "Failed to load data. Please refresh the page.",
analytics_summary_title: "Visit analytics",
analytics_summary_period: "over the last 30 days",
analytics_open_dashboard: "Open detailed analytics",
analytics_log_title: "Recent events",
analytics_log_empty: "No events yet.",
analytics_log_end: "That's all events.",
analytics_log_visitor: "Visitor",
analytics_ev_landing_view: "Landing visit",
analytics_ev_portal_open: "Client portal open",
analytics_ev_media_view: "Photo view",
dashboard_title: "Home",
dashboard_today_visits: "Today's visits",
dashboard_no_visits: "No visits for today.",
+1
View File
@@ -1,4 +1,5 @@
mod admin;
mod analytics;
mod i18n;
mod migrations;
pub mod models;
+4
View File
@@ -4,8 +4,12 @@
pub mod m_0001_initial;
pub mod m_0002_push_subscription;
pub mod m_0003_analytics_event;
pub mod m_0004_analytics_event_meta;
/// The list of migrations for current app.
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration,
&m_0002_push_subscription::Migration,
&m_0003_analytics_event::Migration,
&m_0004_analytics_event_meta::Migration,
];
+61
View File
@@ -0,0 +1,61 @@
//! Append-only analytics events (landing visits, portal opens, media views).
#[derive(Debug, Copy, Clone)]
pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0003_analytics_event";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
&[::cot::db::migrations::MigrationDependency::migration(
"web-petting",
"m_0001_initial",
)];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
.fields(&[
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("id"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("event_type"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("client_id"),
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::TYPE,
)
.foreign_key(
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
)
.set_null(
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("media_id"),
<Option<i64> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<i64> as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("visitor_hash"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
])
.build(),
];
}
@@ -0,0 +1,46 @@
//! Add request metadata columns (user agent, referer, path) to analytics events.
#[derive(Debug, Copy, Clone)]
pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0004_analytics_event_meta";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
&[::cot::db::migrations::MigrationDependency::migration(
"web-petting",
"m_0003_analytics_event",
)];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("user_agent"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
)
.build(),
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("referer"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
)
.build(),
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("path"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
)
.build(),
];
}
+46
View File
@@ -206,6 +206,52 @@ pub struct Testimonial {
pub created_at: chrono::NaiveDateTime,
}
/// Analytics event type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventType {
/// A visit to the public landing page.
LandingView,
/// A client opening their private portal page via media token.
PortalOpen,
/// A client opening a full-size photo/video from their archive.
MediaView,
}
impl EventType {
pub fn as_str(&self) -> &'static str {
match self {
Self::LandingView => "landing_view",
Self::PortalOpen => "portal_open",
Self::MediaView => "media_view",
}
}
}
/// An append-only analytics event (landing visits, portal opens, media views).
///
/// Never soft-deleted; aggregated on demand for the admin statistics dashboard.
#[derive(Debug, Clone)]
#[model]
pub struct AnalyticsEvent {
#[model(primary_key)]
pub id: Auto<i64>,
/// landing_view | portal_open | media_view
pub event_type: String,
/// Client this event belongs to (portal/media events); None for landing views.
pub client_id: Option<ForeignKey<Client>>,
/// Media file this event refers to (media views only).
pub media_id: Option<i64>,
/// Anonymous first-party visitor id (random cookie value), used to count uniques.
pub visitor_hash: String,
/// Raw User-Agent string of the request (no IP is ever stored).
pub user_agent: Option<String>,
/// Referer header, if any (where the visitor came from).
pub referer: Option<String>,
/// Request path + query string (useful for landing UTM/referral params).
pub path: Option<String>,
pub created_at: chrono::NaiveDateTime,
}
/// Global key-value settings (telegram_bot_token, telegram_chat_id, etc.).
#[derive(Debug, Clone)]
#[model]
+69 -3
View File
@@ -14,7 +14,9 @@ use tracing::info;
use cot::db::query;
use crate::i18n::{Lang, Translations};
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
use crate::models::{
Client, EventType, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit,
};
use crate::telegram;
fn detect_lang(request: &Request) -> Lang {
@@ -132,6 +134,22 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
ua = ua,
"landing visit"
);
// Analytics: count unique landing visits (skip bots/crawlers).
let (visitor_id, set_visitor_cookie) = crate::analytics::ensure_visitor_id(&request);
if !crate::analytics::is_bot(ua) {
let meta = crate::analytics::RequestMeta::from_request(&request);
crate::analytics::record(
&db,
EventType::LandingView,
None,
None,
visitor_id.clone(),
meta,
)
.await;
}
let key = "contact_info".to_string();
let contact_info = query!(Setting, $key == key)
.get(&db)
@@ -193,7 +211,16 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
turnstile_site_key,
}
.render()?;
html_response(body, lang)
let mut resp = html_response(body, lang)?;
if set_visitor_cookie {
resp.headers_mut().append(
"set-cookie",
crate::analytics::visitor_cookie(&visitor_id)
.parse()
.unwrap(),
);
}
Ok(resp)
}
#[derive(Deserialize)]
@@ -408,6 +435,19 @@ async fn client_portal(
};
let client_id = client.id.unwrap();
// Analytics: count how clients open their private portal page.
let (visitor_id, set_visitor_cookie) = crate::analytics::ensure_visitor_id(&request);
crate::analytics::record(
&db,
EventType::PortalOpen,
Some(client_id),
None,
visitor_id.clone(),
crate::analytics::RequestMeta::from_request(&request),
)
.await;
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
@@ -622,7 +662,16 @@ async fn client_portal(
has_next_page: page < total_pages,
}
.render()?;
html_response(body, lang)
let mut resp = html_response(body, lang)?;
if set_visitor_cookie {
resp.headers_mut().append(
"set-cookie",
crate::analytics::visitor_cookie(&visitor_id)
.parse()
.unwrap(),
);
}
Ok(resp)
}
#[derive(Deserialize)]
@@ -894,6 +943,22 @@ async fn portal_media(
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
// Analytics: count full-size media opens. Only count the initial request,
// not subsequent HTTP range/seek requests for the same file.
if range.is_none() {
let visitor_id = crate::analytics::visitor_id_from_request(&request)
.unwrap_or_else(crate::analytics::new_visitor_id);
crate::analytics::record(
&db,
EventType::MediaView,
Some(client_id),
Some(media_id),
visitor_id,
crate::analytics::RequestMeta::from_request(&request),
)
.await;
}
let storage = crate::uploads::Storage::load(&db).await?;
let display_path =
crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
@@ -1082,6 +1147,7 @@ async fn serve_static(_request: Request, Path(filename): Path<String>) -> cot::R
};
let content_type = match ext {
"css" => "text/css; charset=utf-8",
"js" => "text/javascript; charset=utf-8",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
+20
View File
File diff suppressed because one or more lines are too long
+543
View File
@@ -0,0 +1,543 @@
{% extends "admin/layout.html" %}
{% let active_page = "analytics" %}
{% block title %}{{ t.analytics_title }}{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>{{ t.analytics_title }}</h1>
<p>{{ t.analytics_intro }}</p>
</div>
</div>
<div class="analytics-controls"
data-labels='{
"landing_views": "{{ t.analytics_metric_landing_views }}",
"landing_unique": "{{ t.analytics_metric_landing_unique }}",
"portal_opens": "{{ t.analytics_metric_portal_opens }}",
"media_views": "{{ t.analytics_metric_media_views }}",
"no_data": "{{ t.analytics_no_data }}",
"loading": "{{ t.analytics_loading }}",
"error": "{{ t.analytics_load_error }}",
"log_empty": "{{ t.analytics_log_empty }}",
"log_end": "{{ t.analytics_log_end }}",
"visitor": "{{ t.analytics_log_visitor }}",
"landing_view": "{{ t.analytics_ev_landing_view }}",
"portal_open": "{{ t.analytics_ev_portal_open }}",
"media_view": "{{ t.analytics_ev_media_view }}"
}'>
<div class="analytics-presets">
<button type="button" class="button is-small preset-btn" data-days="7">{{ t.analytics_preset_7 }}</button>
<button type="button" class="button is-small preset-btn" data-days="30">{{ t.analytics_preset_30 }}</button>
<button type="button" class="button is-small preset-btn" data-days="90">{{ t.analytics_preset_90 }}</button>
<button type="button" class="button is-small preset-btn" data-days="365">{{ t.analytics_preset_365 }}</button>
</div>
<div class="analytics-range">
<label>{{ t.analytics_range_from }}
<input type="date" id="anFrom" class="input is-small">
</label>
<label>{{ t.analytics_range_to }}
<input type="date" id="anTo" class="input is-small">
</label>
<label>{{ t.analytics_granularity }}
<select id="anGran" class="input is-small">
<option value="day">{{ t.analytics_gran_day }}</option>
<option value="week">{{ t.analytics_gran_week }}</option>
<option value="month">{{ t.analytics_gran_month }}</option>
</select>
</label>
</div>
</div>
<div class="analytics-kpis" id="anKpis"></div>
<div class="analytics-metric-toggles">
<span class="toggles-label">{{ t.analytics_metrics_label }}:</span>
<label><input type="checkbox" class="metric-toggle" data-key="landing_views" checked> {{ t.analytics_metric_landing_views }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="landing_unique" checked> {{ t.analytics_metric_landing_unique }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="portal_opens" checked> {{ t.analytics_metric_portal_opens }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="media_views" checked> {{ t.analytics_metric_media_views }}</label>
</div>
<div class="analytics-chart-wrap">
<div class="analytics-chart-box">
<canvas id="anChart"></canvas>
</div>
</div>
<div class="analytics-tops">
<div class="analytics-top-card">
<h2>{{ t.analytics_top_clients }}</h2>
<table class="analytics-top-table" id="anTopClients">
<thead><tr><th>{{ t.analytics_top_clients }}</th><th>{{ t.analytics_count }}</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="analytics-top-card">
<h2>{{ t.analytics_top_media }}</h2>
<table class="analytics-top-table" id="anTopMedia">
<thead><tr><th>{{ t.analytics_top_media }}</th><th>{{ t.analytics_count }}</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="analytics-log-card">
<h2>{{ t.analytics_log_title }}</h2>
<div class="analytics-log" id="anLog"></div>
</div>
<style>
.analytics-controls { display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end; margin-bottom:18px; }
.analytics-presets { display:flex; gap:6px; flex-wrap:wrap; }
.analytics-range { display:flex; gap:12px; flex-wrap:wrap; }
.analytics-range label { display:flex; flex-direction:column; font-size:.8rem; color:#666; gap:3px; }
.analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:18px; }
.analytics-kpi { background:#fff; border-radius:12px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.06); border-left:4px solid var(--kpi-color,#7c6ed4); }
.analytics-kpi .kpi-value { font-size:1.7rem; font-weight:700; line-height:1.1; }
.analytics-kpi .kpi-label { font-size:.8rem; color:#777; margin-top:2px; }
.analytics-metric-toggles { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-bottom:12px; font-size:.85rem; }
.analytics-metric-toggles .toggles-label { color:#777; font-weight:600; }
.analytics-metric-toggles label { display:flex; align-items:center; gap:5px; cursor:pointer; }
.analytics-chart-wrap { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-bottom:20px; }
.analytics-chart-box { position:relative; height:340px; width:100%; }
@media (max-width:600px) { .analytics-chart-box { height:260px; } }
.analytics-tops { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px; }
.analytics-top-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
.analytics-top-card h2 { font-size:1rem; margin:0 0 10px; }
.analytics-top-table { width:100%; border-collapse:collapse; font-size:.9rem; }
.analytics-top-table th { text-align:left; color:#888; font-weight:600; font-size:.78rem; border-bottom:1px solid #eee; padding:6px 4px; }
.analytics-top-table td { padding:6px 4px; border-bottom:1px solid #f4f4f4; }
.analytics-top-table td:last-child, .analytics-top-table th:last-child { text-align:right; width:70px; }
.analytics-top-empty { color:#aaa; padding:10px 4px; }
.an-media-link { display:flex; align-items:center; gap:8px; color:inherit; text-decoration:none; }
.an-media-link:hover { text-decoration:underline; }
.an-media-thumb { width:40px; height:40px; border-radius:6px; object-fit:cover; background:#eceaf5; flex:0 0 auto; }
.an-media-missing { width:40px; height:40px; border-radius:6px; background:#f0f0f3; display:inline-flex; align-items:center; justify-content:center; color:#bbb; flex:0 0 auto; }
.analytics-log-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-top:20px; }
.analytics-log-card h2 { font-size:1rem; margin:0 0 10px; }
.analytics-log { max-height:460px; overflow-y:auto; border:1px solid #f0f0f0; border-radius:8px; }
.an-log-row { display:flex; gap:12px; align-items:flex-start; padding:10px 12px; border-bottom:1px solid #f5f5f5; font-size:.85rem; }
.an-log-row:last-child { border-bottom:none; }
.an-log-icon { font-size:1.1rem; line-height:1.4; flex:0 0 auto; }
.an-log-main { flex:1 1 auto; min-width:0; }
.an-log-head { display:flex; flex-wrap:wrap; gap:8px; align-items:baseline; }
.an-log-event { font-weight:600; }
.an-log-client { color:#7c6ed4; }
.an-log-time { color:#999; font-size:.78rem; margin-left:auto; white-space:nowrap; }
.an-log-meta { color:#888; font-size:.78rem; margin-top:3px; word-break:break-word; }
.an-log-meta span { margin-right:12px; }
.an-log-meta code { background:rgba(0,0,0,.04); padding:1px 4px; border-radius:3px; }
.an-log-thumb { width:44px; height:44px; border-radius:6px; object-fit:cover; background:#eceaf5; flex:0 0 auto; }
.an-log-status { text-align:center; color:#aaa; padding:14px; font-size:.85rem; }
</style>
<script src="/static/chart.min.js?v={{ t.app_version() }}"></script>
<script>
(function () {
var controls = document.querySelector('.analytics-controls');
var L = JSON.parse(controls.getAttribute('data-labels'));
var META = [
{ key: 'landing_views', color: '#7c6ed4' },
{ key: 'landing_unique', color: '#00b496' },
{ key: 'portal_opens', color: '#ff8c26' },
{ key: 'media_views', color: '#ff5287' }
];
var fromInput = document.getElementById('anFrom');
var toInput = document.getElementById('anTo');
var granSelect = document.getElementById('anGran');
var kpisEl = document.getElementById('anKpis');
var chart = null;
// Draws a centered status message ("loading", "no data", error) directly on
// the chart canvas, so state changes never shift the page layout.
var messageOverlay = {
id: 'messageOverlay',
afterDraw: function (c) {
var msg = c.$message;
if (!msg) { return; }
var ctx = c.ctx;
var a = c.chartArea;
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.fillRect(a.left, a.top, a.right - a.left, a.bottom - a.top);
ctx.fillStyle = '#8a8a8a';
ctx.font = '600 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(msg, (a.left + a.right) / 2, (a.top + a.bottom) / 2);
ctx.restore();
}
};
function setMessage(msg) {
if (!chart) { return; }
chart.$message = msg;
chart.update('none');
}
function fmt(d) {
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function setRange(days) {
var to = new Date();
var from = new Date();
from.setDate(from.getDate() - (days - 1));
fromInput.value = fmt(from);
toInput.value = fmt(to);
}
function metricLabel(key) { return L[key] || key; }
function hexToRgba(hex, a) {
var n = parseInt(hex.slice(1), 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
}
function renderKpis(report) {
kpisEl.replaceChildren();
META.forEach(function (m) {
var metric = report.metrics.find(function (x) { return x.key === m.key; });
var total = metric ? metric.total : 0;
var card = document.createElement('div');
card.className = 'analytics-kpi';
card.style.setProperty('--kpi-color', m.color);
var v = document.createElement('div');
v.className = 'kpi-value';
v.textContent = total.toLocaleString();
var lbl = document.createElement('div');
lbl.className = 'kpi-label';
lbl.textContent = metricLabel(m.key);
card.appendChild(v);
card.appendChild(lbl);
kpisEl.appendChild(card);
});
}
function initChart() {
var ctx = document.getElementById('anChart').getContext('2d');
var datasets = META.map(function (m) {
return {
label: metricLabel(m.key),
data: [],
borderColor: m.color,
backgroundColor: hexToRgba(m.color, 0.12),
borderWidth: 2,
pointRadius: 2,
tension: 0.25,
fill: true,
hidden: false,
_key: m.key
};
});
chart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
},
plugins: [messageOverlay]
});
}
function applyData(report) {
chart.data.labels = report.labels;
chart.data.datasets.forEach(function (ds) {
var metric = report.metrics.find(function (x) { return x.key === ds._key; });
ds.data = metric ? metric.values : [];
});
chart.update('none');
}
function renderTop(tableId, rows) {
var tbody = document.querySelector('#' + tableId + ' tbody');
tbody.replaceChildren();
if (!rows || rows.length === 0) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 2;
td.className = 'analytics-top-empty';
td.textContent = L.no_data;
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
rows.forEach(function (row) {
var tr = document.createElement('tr');
var name = document.createElement('td');
name.textContent = row.label;
var cnt = document.createElement('td');
cnt.textContent = row.count.toLocaleString();
tr.appendChild(name);
tr.appendChild(cnt);
tbody.appendChild(tr);
});
}
function mediaLabel(m) {
if (!m.exists) { return '#' + m.media_id; }
var base = m.client_name || '';
if (m.caption) { return base ? base + ': ' + m.caption : m.caption; }
return base ? base + ' #' + m.media_id : '#' + m.media_id;
}
// Builds a clickable preview (opens the shared lightbox) for a media ref,
// or a placeholder if the file was deleted.
function mediaLink(m, thumbClass) {
if (m && m.exists && m.url) {
var a = document.createElement('a');
a.href = m.url;
a.setAttribute('data-lightbox', m.file_type === 'video' ? 'video' : 'photo');
a.className = 'an-media-link';
var img = document.createElement('img');
img.className = thumbClass;
img.src = m.thumbnail_url;
img.alt = '';
img.loading = 'lazy';
a.appendChild(img);
var span = document.createElement('span');
span.textContent = mediaLabel(m);
a.appendChild(span);
return a;
}
var wrap = document.createElement('span');
wrap.className = 'an-media-link';
var ph = document.createElement('span');
ph.className = 'an-media-missing';
ph.textContent = '🗑';
wrap.appendChild(ph);
var lbl = document.createElement('span');
lbl.textContent = mediaLabel(m);
wrap.appendChild(lbl);
return wrap;
}
function renderTopMedia(rows) {
var tbody = document.querySelector('#anTopMedia tbody');
tbody.replaceChildren();
if (!rows || rows.length === 0) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 2;
td.className = 'analytics-top-empty';
td.textContent = L.no_data;
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
rows.forEach(function (m) {
var tr = document.createElement('tr');
var td1 = document.createElement('td');
td1.appendChild(mediaLink(m, 'an-media-thumb'));
var td2 = document.createElement('td');
td2.textContent = (m.count || 0).toLocaleString();
tr.appendChild(td1);
tr.appendChild(td2);
tbody.appendChild(tr);
});
}
function hasData(report) {
return report.metrics.some(function (m) { return m.total > 0; });
}
function load() {
var params = new URLSearchParams({
from: fromInput.value,
to: toInput.value,
granularity: granSelect.value
});
setMessage(L.loading);
fetch('/admin/analytics/data?' + params.toString(), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (report) {
renderKpis(report);
applyData(report);
renderTop('anTopClients', report.top_clients);
renderTopMedia(report.top_media);
setMessage(hasData(report) ? '' : L.no_data);
})
.catch(function () { setMessage(L.error); });
}
document.querySelectorAll('.preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
setRange(parseInt(btn.getAttribute('data-days'), 10));
load();
});
});
fromInput.addEventListener('change', load);
toInput.addEventListener('change', load);
granSelect.addEventListener('change', load);
document.querySelectorAll('.metric-toggle').forEach(function (cb) {
cb.addEventListener('change', function () {
if (!chart) { return; }
chart.data.datasets.forEach(function (ds) {
if (ds._key === cb.getAttribute('data-key')) { ds.hidden = !cb.checked; }
});
chart.update();
});
});
// --- Event log (newest first, infinite scroll of older events) ---------
var logEl = document.getElementById('anLog');
var logCursor = null;
var logLoading = false;
var logDone = false;
function logStatus(text) {
var s = document.createElement('div');
s.className = 'an-log-status';
s.textContent = text;
return s;
}
function eventIcon(type) {
if (type === 'landing_view') { return '🌐'; }
if (type === 'portal_open') { return '👤'; }
if (type === 'media_view') { return '🖼️'; }
return '•';
}
function shortUa(ua) {
if (!ua) { return ''; }
var browser = '';
if (/Edg\//.test(ua)) { browser = 'Edge'; }
else if (/OPR\/|Opera/.test(ua)) { browser = 'Opera'; }
else if (/Chrome\//.test(ua)) { browser = 'Chrome'; }
else if (/Firefox\//.test(ua)) { browser = 'Firefox'; }
else if (/Safari\//.test(ua)) { browser = 'Safari'; }
var os = '';
if (/Windows/.test(ua)) { os = 'Windows'; }
else if (/iPhone|iPad|iPod/.test(ua)) { os = 'iOS'; }
else if (/Android/.test(ua)) { os = 'Android'; }
else if (/Mac OS X|Macintosh/.test(ua)) { os = 'macOS'; }
else if (/Linux/.test(ua)) { os = 'Linux'; }
return [browser, os].filter(Boolean).join(' · ');
}
function metaSpan(text) {
var s = document.createElement('span');
s.textContent = text;
return s;
}
function renderLogRow(e) {
var row = document.createElement('div');
row.className = 'an-log-row';
var icon = document.createElement('div');
icon.className = 'an-log-icon';
icon.textContent = eventIcon(e.event_type);
row.appendChild(icon);
if (e.media && e.media.exists && e.media.url) {
var a = document.createElement('a');
a.href = e.media.url;
a.setAttribute('data-lightbox', e.media.file_type === 'video' ? 'video' : 'photo');
var img = document.createElement('img');
img.className = 'an-log-thumb';
img.src = e.media.thumbnail_url;
img.alt = '';
img.loading = 'lazy';
a.appendChild(img);
row.appendChild(a);
}
var main = document.createElement('div');
main.className = 'an-log-main';
var head = document.createElement('div');
head.className = 'an-log-head';
var ev = document.createElement('span');
ev.className = 'an-log-event';
ev.textContent = L[e.event_type] || e.event_type;
head.appendChild(ev);
if (e.client_name) {
var cl = document.createElement('span');
cl.className = 'an-log-client';
cl.textContent = e.client_name;
head.appendChild(cl);
}
var tm = document.createElement('span');
tm.className = 'an-log-time';
tm.textContent = e.created_at;
head.appendChild(tm);
main.appendChild(head);
var meta = document.createElement('div');
meta.className = 'an-log-meta';
if (e.visitor) { meta.appendChild(metaSpan(L.visitor + ': ' + e.visitor)); }
var ua = shortUa(e.user_agent);
if (ua) { meta.appendChild(metaSpan(ua)); }
if (e.path && e.path !== '/') {
var p = document.createElement('span');
var code = document.createElement('code');
code.textContent = e.path;
p.appendChild(code);
meta.appendChild(p);
}
if (e.referer) { meta.appendChild(metaSpan('← ' + e.referer)); }
if (meta.childNodes.length) { main.appendChild(meta); }
row.appendChild(main);
return row;
}
function loadLog() {
if (logLoading || logDone) { return; }
logLoading = true;
var busy = logStatus(L.loading);
logEl.appendChild(busy);
var params = new URLSearchParams({ limit: '40' });
if (logCursor) { params.set('before', logCursor); }
fetch('/admin/analytics/events?' + params.toString(), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
busy.remove();
(data.events || []).forEach(function (e) { logEl.appendChild(renderLogRow(e)); });
logCursor = data.next_cursor;
logLoading = false;
if (!logEl.querySelector('.an-log-row')) {
logEl.appendChild(logStatus(L.log_empty));
logDone = true;
return;
}
if (!logCursor) {
logDone = true;
logEl.appendChild(logStatus(L.log_end));
return;
}
// Keep loading until the scroll container is actually scrollable.
if (logEl.scrollHeight <= logEl.clientHeight + 20) { loadLog(); }
})
.catch(function () {
busy.remove();
logLoading = false;
logEl.appendChild(logStatus(L.error));
});
}
logEl.addEventListener('scroll', function () {
if (logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 40) { loadLog(); }
});
initChart();
setRange(30);
load();
loadLog();
})();
</script>
{% endblock %}
+4
View File
@@ -21,6 +21,7 @@
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>{{ t.nav_media }}</a>
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>{{ t.nav_testimonials }}</a>
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>{{ t.nav_users }}</a>
<a href="/admin/analytics?lang={{ lang.code() }}" {% if active_page == "analytics" %}class="is-active"{% endif %}>{{ t.nav_analytics }}</a>
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>{{ t.nav_settings }}</a>
</nav>
<div class="top-header-right">
@@ -58,6 +59,9 @@
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>
<span class="tab-icon">🔑</span><span class="tab-label">{{ t.nav_users }}</span>
</a>
<a href="/admin/analytics?lang={{ lang.code() }}" {% if active_page == "analytics" %}class="is-active"{% endif %}>
<span class="tab-icon">📊</span><span class="tab-label">{{ t.nav_analytics }}</span>
</a>
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>
<span class="tab-icon">⚙️</span><span class="tab-label">{{ t.nav_settings }}</span>
</a>
+38
View File
@@ -18,6 +18,44 @@
<div class="notification is-danger is-light admin-message">{{ message }}</div>
{% endif %}
<section class="admin-section" style="margin-bottom:1rem;">
<header class="admin-section-head">
<span class="admin-section-icon" aria-hidden="true">📊</span>
<div>
<h2>{{ t.analytics_summary_title }}</h2>
<p>{{ t.analytics_summary_period }}</p>
</div>
</header>
<div class="admin-section-body">
<div class="settings-analytics-kpis">
<div class="settings-analytics-kpi" style="--kpi-color:#7c6ed4">
<span class="kpi-value">{{ analytics_landing_views }}</span>
<span class="kpi-label">{{ t.analytics_metric_landing_views }}</span>
</div>
<div class="settings-analytics-kpi" style="--kpi-color:#00b496">
<span class="kpi-value">{{ analytics_landing_unique }}</span>
<span class="kpi-label">{{ t.analytics_metric_landing_unique }}</span>
</div>
<div class="settings-analytics-kpi" style="--kpi-color:#ff8c26">
<span class="kpi-value">{{ analytics_portal_opens }}</span>
<span class="kpi-label">{{ t.analytics_metric_portal_opens }}</span>
</div>
<div class="settings-analytics-kpi" style="--kpi-color:#ff5287">
<span class="kpi-value">{{ analytics_media_views }}</span>
<span class="kpi-label">{{ t.analytics_metric_media_views }}</span>
</div>
</div>
<a class="button is-light" href="/admin/analytics?lang={{ lang.code() }}">{{ t.analytics_open_dashboard }} →</a>
</div>
</section>
<style>
.settings-analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:14px; }
.settings-analytics-kpi { border-left:4px solid var(--kpi-color,#7c6ed4); padding:8px 14px; background:rgba(0,0,0,.02); border-radius:8px; display:flex; flex-direction:column; }
.settings-analytics-kpi .kpi-value { font-size:1.6rem; font-weight:700; line-height:1.1; }
.settings-analytics-kpi .kpi-label { font-size:.78rem; color:#777; margin-top:2px; }
</style>
<form id="settingsForm" class="admin-form" method="post" action="/admin/settings/save">
<section class="admin-section">
<header class="admin-section-head">