diff --git a/run-reports.sh b/run-reports.sh
index ade36c6..a0d718f 100755
--- a/run-reports.sh
+++ b/run-reports.sh
@@ -24,6 +24,7 @@ python scripts/generate_reports.py hourly
python scripts/generate_reports.py daily
python scripts/generate_reports.py weekly
python scripts/generate_reports.py monthly
+python scripts/generate_reports.py global
# Generate reports for each individual domain
echo "[INFO] Generating per-domain reports..."
diff --git a/scripts/generate_reports.py b/scripts/generate_reports.py
index 8b9fad6..e7c42cb 100644
--- a/scripts/generate_reports.py
+++ b/scripts/generate_reports.py
@@ -101,6 +101,10 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
report_list = []
for definition in cfg:
+ if "{bucket}" not in definition["query"] or definition.get("global"):
+ # Global reports are generated separately
+ continue
+
name = definition["name"]
query = definition["query"].replace("{bucket}", bucket)
query = query.replace("FROM logs", "FROM logs_view")
@@ -154,6 +158,50 @@ def _generate_root_index() -> None:
typer.echo(f"Generated root index at {out_path}")
+def _generate_global() -> None:
+ """Generate reports that do not depend on an interval."""
+ cfg = _load_config()
+ if not cfg:
+ typer.echo("No report definitions found")
+ return
+
+ conn = sqlite3.connect(DB_PATH)
+ cur = conn.cursor()
+
+ out_dir = OUTPUT_DIR / "global"
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ report_list = []
+ for definition in cfg:
+ if "{bucket}" in definition["query"] and not definition.get("global"):
+ continue
+
+ name = definition["name"]
+ query = definition["query"]
+ cur.execute(query)
+ rows = cur.fetchall()
+ headers = [c[0] for c in cur.description]
+ data = [dict(zip(headers, row)) for row in rows]
+ json_path = out_dir / f"{name}.json"
+ _save_json(json_path, data)
+ entry = {
+ "name": name,
+ "label": definition.get("label", name.title()),
+ "chart": definition.get("chart", "line"),
+ "json": f"{name}.json",
+ "html": f"{name}.html",
+ }
+ if "color" in definition:
+ entry["color"] = definition["color"]
+ if "colors" in definition:
+ entry["colors"] = definition["colors"]
+ _render_snippet(entry, out_dir)
+ report_list.append(entry)
+
+ _save_json(out_dir / "reports.json", report_list)
+ typer.echo("Generated global reports")
+
+
@app.command()
def hourly(
domain: Optional[str] = typer.Option(
@@ -218,6 +266,12 @@ def monthly(
_generate_interval("monthly", domain)
+@app.command("global")
+def global_reports() -> None:
+ """Generate global reports."""
+ _generate_global()
+
+
@app.command()
def index() -> None:
"""Generate the root index page linking all reports."""
diff --git a/tests/test_reports.py b/tests/test_reports.py
index f1be1d0..d8259f2 100644
--- a/tests/test_reports.py
+++ b/tests/test_reports.py
@@ -80,6 +80,14 @@ def sample_reports(tmp_path):
FROM logs
GROUP BY bucket
ORDER BY bucket
+- name: domain_totals
+ global: true
+ query: |
+ SELECT host AS bucket,
+ COUNT(*) AS value
+ FROM logs
+ GROUP BY host
+ ORDER BY value DESC
"""
)
return cfg
@@ -161,3 +169,23 @@ def test_generate_root_index(tmp_path, sample_reports, monkeypatch):
# check for domain options
assert '