This commit is contained in:
Generated
+1
-1
@@ -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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
edition = "2024"
|
||||
default-run = "web-petting"
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
mod admin;
|
||||
mod analytics;
|
||||
mod i18n;
|
||||
mod migrations;
|
||||
pub mod models;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+20
File diff suppressed because one or more lines are too long
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user