First Commit
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Frigate Counter Dashboard
|
||||
A beautiful, interactive Flask web app for viewing daily batch counting data.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import csv
|
||||
from io import StringIO
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Flask, render_template, jsonify, request, Response
|
||||
from werkzeug.serving import WSGIRequestHandler
|
||||
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "frigate-counter-dashboard")
|
||||
|
||||
DB_PATH = os.getenv("DB_PATH", "frigate_counter.db")
|
||||
CURRENT_BATCH_PATH = os.getenv("CURRENT_BATCH_PATH", "current_batch.json")
|
||||
CUTOFF_TIME = os.getenv("CUTOFF_TIME", "17:00")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Database helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# def get_counting_date(dt=None, cutoff_str="17:00"):
|
||||
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
||||
"""Replicate the service logic for determining the counting date."""
|
||||
if dt is None:
|
||||
dt = datetime.now()
|
||||
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
|
||||
if dt.time() < cutoff:
|
||||
return dt.date().isoformat()
|
||||
return (dt.date() + timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Routes
|
||||
# ------------------------------------------------------------------ #
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Main dashboard page."""
|
||||
return render_template("dashboard.html")
|
||||
|
||||
|
||||
@app.route("/api/current-batch")
|
||||
def api_current_batch():
|
||||
"""Get real-time data from the current active batch file."""
|
||||
try:
|
||||
with open(CURRENT_BATCH_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"counting_date": data.get("counting_date"),
|
||||
"batch_number": data.get("batch_number"),
|
||||
"count": data.get("count", 0),
|
||||
"start_time": data.get("start_time"),
|
||||
"last_detection_time": data.get("last_detection_time"),
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "No active batch",
|
||||
"count": 0,
|
||||
"batch_number": None,
|
||||
"counting_date": None,
|
||||
}
|
||||
), 404
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"count": 0,
|
||||
"batch_number": None,
|
||||
"counting_date": None,
|
||||
}
|
||||
), 500
|
||||
|
||||
|
||||
@app.route("/api/summary")
|
||||
def api_summary():
|
||||
"""Get summary statistics for the dashboard cards."""
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Today's counting date (based on cutoff)
|
||||
today = get_counting_date()
|
||||
|
||||
# Today's stats
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(total_count, 0) as total_count,
|
||||
COALESCE(total_batches, 0) as total_batches
|
||||
FROM daily_summaries
|
||||
WHERE counting_date = ?
|
||||
""",
|
||||
(today,),
|
||||
)
|
||||
today_row = cur.fetchone()
|
||||
|
||||
# Yesterday's stats
|
||||
yesterday = (
|
||||
datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)
|
||||
).isoformat()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(total_count, 0) as total_count,
|
||||
COALESCE(total_batches, 0) as total_batches
|
||||
FROM daily_summaries
|
||||
WHERE counting_date = ?
|
||||
""",
|
||||
(yesterday,),
|
||||
)
|
||||
yesterday_row = cur.fetchone()
|
||||
|
||||
# All-time totals
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
||||
COALESCE(SUM(total_batches), 0) as grand_batches,
|
||||
COUNT(DISTINCT counting_date) as total_days
|
||||
FROM daily_summaries
|
||||
""")
|
||||
all_time = cur.fetchone()
|
||||
|
||||
# Average per day
|
||||
cur.execute("""
|
||||
SELECT ROUND(AVG(total_count), 1) as avg_per_day
|
||||
FROM daily_summaries
|
||||
""")
|
||||
avg = cur.fetchone()
|
||||
|
||||
# Best day
|
||||
cur.execute("""
|
||||
SELECT counting_date, total_count
|
||||
FROM daily_summaries
|
||||
ORDER BY total_count DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
best = cur.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"today": {
|
||||
"date": today,
|
||||
"total_count": today_row["total_count"] if today_row else 0,
|
||||
"total_batches": today_row["total_batches"] if today_row else 0,
|
||||
},
|
||||
"yesterday": {
|
||||
"date": yesterday,
|
||||
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
||||
"total_batches": yesterday_row["total_batches"] if yesterday_row else 0,
|
||||
},
|
||||
"all_time": {
|
||||
"grand_total": all_time["grand_total"],
|
||||
"grand_batches": all_time["grand_batches"],
|
||||
"total_days": all_time["total_days"],
|
||||
},
|
||||
"average_per_day": avg["avg_per_day"] or 0,
|
||||
"best_day": {
|
||||
"date": best["counting_date"] if best else None,
|
||||
"count": best["total_count"] if best else 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/daily-data")
|
||||
def api_daily_data():
|
||||
"""Get daily data for charts and table."""
|
||||
days = request.args.get("days", 30, type=int)
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Daily summaries for chart
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_batches,
|
||||
ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch
|
||||
FROM daily_summaries
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(date_from,),
|
||||
)
|
||||
|
||||
daily_data = []
|
||||
for row in cur.fetchall():
|
||||
daily_data.append(
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_batches": row["total_batches"],
|
||||
"avg_per_batch": row["avg_per_batch"] or 0,
|
||||
}
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return jsonify(daily_data)
|
||||
|
||||
|
||||
@app.route("/api/day-detail/<date>")
|
||||
def api_day_detail(date):
|
||||
"""Get detailed batch information for a specific day."""
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Batches for this day
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT batch_number, count, start_time, end_time,
|
||||
ROUND(
|
||||
(julianday(end_time) - julianday(start_time)) * 24 * 60, 1
|
||||
) as duration_minutes
|
||||
FROM batches
|
||||
WHERE counting_date = ?
|
||||
ORDER BY batch_number ASC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
|
||||
batches = []
|
||||
total_duration = 0
|
||||
for row in cur.fetchall():
|
||||
duration = row["duration_minutes"] or 0
|
||||
total_duration += duration
|
||||
batches.append(
|
||||
{
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": duration,
|
||||
}
|
||||
)
|
||||
|
||||
# Summary for the day
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT total_count, total_batches
|
||||
FROM daily_summaries
|
||||
WHERE counting_date = ?
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
summary = cur.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"date": date,
|
||||
"total_count": summary["total_count"] if summary else 0,
|
||||
"total_batches": summary["total_batches"] if summary else 0,
|
||||
"total_duration_minutes": round(total_duration, 1),
|
||||
"avg_duration_minutes": round(total_duration / len(batches), 1)
|
||||
if batches
|
||||
else 0,
|
||||
"batches": batches,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/recent-batches")
|
||||
def api_recent_batches():
|
||||
"""Get the most recent batches across all days."""
|
||||
limit = request.args.get("limit", 10, type=int)
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||
ROUND(
|
||||
(julianday(end_time) - julianday(start_time)) * 24 * 60, 1
|
||||
) as duration_minutes
|
||||
FROM batches
|
||||
ORDER BY end_time DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
batches = []
|
||||
for row in cur.fetchall():
|
||||
batches.append(
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": row["duration_minutes"] or 0,
|
||||
}
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return jsonify(batches)
|
||||
|
||||
|
||||
@app.route("/api/available-dates")
|
||||
def api_available_dates():
|
||||
"""Get list of all available counting dates."""
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT counting_date, total_count, total_batches
|
||||
FROM daily_summaries
|
||||
ORDER BY counting_date DESC
|
||||
""")
|
||||
|
||||
dates = []
|
||||
for row in cur.fetchall():
|
||||
dates.append(
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_batches": row["total_batches"],
|
||||
}
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return jsonify(dates)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CSV Export Routes
|
||||
# ------------------------------------------------------------------ #
|
||||
@app.route("/api/export-daily-csv")
|
||||
def export_daily_csv():
|
||||
"""Export daily summary records as CSV."""
|
||||
days = request.args.get("days", 30, type=int)
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_batches,
|
||||
ROUND(CAST(total_count AS FLOAT) / NULLIF(total_batches, 0), 1) as avg_per_batch
|
||||
FROM daily_summaries
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(date_from,),
|
||||
)
|
||||
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["Date", "Total Count", "Total Batches", "Avg per Batch"])
|
||||
|
||||
for row in cur.fetchall():
|
||||
writer.writerow(
|
||||
[
|
||||
row["counting_date"],
|
||||
row["total_count"],
|
||||
row["total_batches"],
|
||||
row["avg_per_batch"] or 0,
|
||||
]
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
|
||||
filename = f"daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return Response(
|
||||
csv_data,
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/export-day-csv/<date>")
|
||||
def export_day_csv(date):
|
||||
"""Export batch details for a specific day as CSV."""
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Batches for this day
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT batch_number, count, start_time, end_time,
|
||||
ROUND(
|
||||
(julianday(end_time) - julianday(start_time)) * 24 * 60, 1
|
||||
) as duration_minutes
|
||||
FROM batches
|
||||
WHERE counting_date = ?
|
||||
ORDER BY batch_number ASC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
["Batch Number", "Count", "Start Time", "End Time", "Duration (min)"]
|
||||
)
|
||||
|
||||
for row in cur.fetchall():
|
||||
writer.writerow(
|
||||
[
|
||||
row["batch_number"],
|
||||
row["count"],
|
||||
row["start_time"],
|
||||
row["end_time"],
|
||||
row["duration_minutes"] or 0,
|
||||
]
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
|
||||
filename = f"day_detail_{date}.csv"
|
||||
return Response(
|
||||
csv_data,
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Run
|
||||
# ------------------------------------------------------------------ #
|
||||
if __name__ == "__main__":
|
||||
# Suppress Flask's default request log spam
|
||||
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
||||
|
||||
port = int(os.getenv("DASHBOARD_PORT", 80))
|
||||
host = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||
debug = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
||||
|
||||
print(f"🚀 Dashboard running at http://{host}:{port}")
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
Reference in New Issue
Block a user