start project
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
bp = Blueprint("overview", __name__, template_folder="templates")
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@login_required
|
||||
def require_login():
|
||||
pass
|
||||
|
||||
|
||||
from app.blueprints.overview import routes # noqa: E402,F401 registers routes
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Context-builder query functions for the Overview tab (Chapter 08).
|
||||
|
||||
Every function here reads request_stats_hourly / request_stats_daily /
|
||||
referrer_stats_daily / browser_stats_daily — never log_entries — per
|
||||
Ch03 rule 6 / Ch08's own data-source rule.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import case, func
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.browser_stats import BrowserStatsDaily
|
||||
from app.models.referrer_stats import ReferrerStatsDaily
|
||||
from app.models.request_stats import RequestStatsDaily, RequestStatsHourly
|
||||
from app.utils.dates import day_bounds
|
||||
|
||||
# ASSUMPTION (flagged in Ch08): Ch08 doesn't give a numeric threshold for
|
||||
# "hourly vs daily depending on range width" — picked 3 days.
|
||||
HOURLY_GRANULARITY_THRESHOLD_DAYS = 3
|
||||
|
||||
|
||||
def get_kpis(from_date: date, to_date: date) -> dict:
|
||||
"""All six Ch08 KPI-card fields, plus peak-day (folded in — Ch11 has
|
||||
no dedicated route for it and Ch08 calls for only a 'simple max-lookup').
|
||||
"""
|
||||
row = (
|
||||
db.session.query(
|
||||
func.coalesce(func.sum(RequestStatsDaily.count), 0).label("total_requests"),
|
||||
func.coalesce(func.sum(RequestStatsDaily.unique_ips), 0).label("unique_ips_sum"),
|
||||
func.coalesce(func.sum(RequestStatsDaily.bytes_sum), 0).label("total_bandwidth"),
|
||||
func.coalesce(func.sum(RequestStatsDaily.error_count), 0).label("total_errors"),
|
||||
)
|
||||
.filter(RequestStatsDaily.date >= from_date, RequestStatsDaily.date <= to_date)
|
||||
.one()
|
||||
)
|
||||
num_days = (to_date - from_date).days + 1
|
||||
avg_response_size = (row.total_bandwidth / row.total_requests) if row.total_requests else 0.0
|
||||
error_rate_pct = (row.total_errors / row.total_requests * 100) if row.total_requests else 0.0
|
||||
avg_requests_per_day = row.total_requests / num_days if num_days else 0.0
|
||||
|
||||
peak_row = (
|
||||
db.session.query(RequestStatsDaily.date, RequestStatsDaily.count)
|
||||
.filter(RequestStatsDaily.date >= from_date, RequestStatsDaily.date <= to_date)
|
||||
.order_by(RequestStatsDaily.count.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_requests": row.total_requests,
|
||||
# APPROXIMATION (flagged in Ch08): sum of daily unique_ips over-counts
|
||||
# repeat visitors across days.
|
||||
"unique_ips": row.unique_ips_sum,
|
||||
"total_bandwidth_bytes": row.total_bandwidth,
|
||||
"avg_response_size_bytes": round(avg_response_size, 1),
|
||||
"error_rate_pct": round(error_rate_pct, 2),
|
||||
"avg_requests_per_day": round(avg_requests_per_day, 1),
|
||||
"peak_day": {"date": peak_row.date.isoformat(), "count": peak_row.count} if peak_row else None,
|
||||
}
|
||||
|
||||
|
||||
def get_traffic_chart_series(from_date: date, to_date: date) -> dict:
|
||||
span_days = (to_date - from_date).days + 1
|
||||
if span_days <= HOURLY_GRANULARITY_THRESHOLD_DAYS:
|
||||
start, end = day_bounds(from_date, to_date)
|
||||
rows = (
|
||||
db.session.query(RequestStatsHourly.date_hour, func.sum(RequestStatsHourly.count).label("count"))
|
||||
.filter(RequestStatsHourly.date_hour >= start, RequestStatsHourly.date_hour < end)
|
||||
.group_by(RequestStatsHourly.date_hour)
|
||||
.order_by(RequestStatsHourly.date_hour)
|
||||
.all()
|
||||
)
|
||||
return {"granularity": "hourly", "series": [{"t": r.date_hour.isoformat(), "count": r.count} for r in rows]}
|
||||
|
||||
rows = (
|
||||
db.session.query(RequestStatsDaily.date, RequestStatsDaily.count)
|
||||
.filter(RequestStatsDaily.date >= from_date, RequestStatsDaily.date <= to_date)
|
||||
.order_by(RequestStatsDaily.date)
|
||||
.all()
|
||||
)
|
||||
return {"granularity": "daily", "series": [{"t": r.date.isoformat(), "count": r.count} for r in rows]}
|
||||
|
||||
|
||||
def get_status_code_breakdown(from_date: date, to_date: date) -> dict:
|
||||
start, end = day_bounds(from_date, to_date)
|
||||
bucket = case(
|
||||
(RequestStatsHourly.status_code < 300, "2xx"),
|
||||
(RequestStatsHourly.status_code < 400, "3xx"),
|
||||
(RequestStatsHourly.status_code < 500, "4xx"),
|
||||
else_="5xx",
|
||||
)
|
||||
rows = (
|
||||
db.session.query(bucket.label("bucket"), func.sum(RequestStatsHourly.count).label("count"))
|
||||
.filter(RequestStatsHourly.date_hour >= start, RequestStatsHourly.date_hour < end)
|
||||
.group_by("bucket")
|
||||
.all()
|
||||
)
|
||||
breakdown = {"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0}
|
||||
for r in rows:
|
||||
breakdown[r.bucket] = r.count
|
||||
return breakdown
|
||||
|
||||
|
||||
def get_top_urls(from_date: date, to_date: date, page: int, per_page: int) -> tuple[list[list], int]:
|
||||
"""Top URLs by hits. NOTE (flagged in Ch08): only available within the
|
||||
~90-day hourly retention window (Ch06) — request_stats_daily has no
|
||||
path column, so a wider range returns nothing here.
|
||||
"""
|
||||
start, end = day_bounds(from_date, to_date)
|
||||
hits = func.sum(RequestStatsHourly.count)
|
||||
errors = func.sum(case((RequestStatsHourly.status_code >= 400, RequestStatsHourly.count), else_=0))
|
||||
bytes_sum = func.sum(RequestStatsHourly.bytes_sent_sum)
|
||||
|
||||
base_query = (
|
||||
db.session.query(RequestStatsHourly.path, hits.label("hits"), bytes_sum.label("bytes_sum"), errors.label("errors"))
|
||||
.filter(RequestStatsHourly.date_hour >= start, RequestStatsHourly.date_hour < end)
|
||||
.group_by(RequestStatsHourly.path)
|
||||
)
|
||||
total = base_query.count()
|
||||
rows = base_query.order_by(hits.desc()).offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
results = [
|
||||
[
|
||||
r.path,
|
||||
r.hits,
|
||||
round(r.bytes_sum / r.hits, 1) if r.hits else 0.0,
|
||||
round(r.errors / r.hits * 100, 2) if r.hits else 0.0,
|
||||
]
|
||||
for r in rows
|
||||
]
|
||||
return results, total
|
||||
|
||||
|
||||
def get_top_referrers(from_date: date, to_date: date, page: int, per_page: int) -> tuple[list[list], int]:
|
||||
"""Domain-bucketed referrers (Method A, Ch08 follow-up)."""
|
||||
hits = func.sum(ReferrerStatsDaily.count)
|
||||
base_query = (
|
||||
db.session.query(ReferrerStatsDaily.referrer_domain, hits.label("hits"))
|
||||
.filter(ReferrerStatsDaily.date >= from_date, ReferrerStatsDaily.date <= to_date)
|
||||
.group_by(ReferrerStatsDaily.referrer_domain)
|
||||
)
|
||||
total = base_query.count()
|
||||
rows = base_query.order_by(hits.desc()).offset((page - 1) * per_page).limit(per_page).all()
|
||||
return [[r.referrer_domain, r.hits] for r in rows], total
|
||||
|
||||
|
||||
def get_browser_breakdown(from_date: date, to_date: date) -> dict:
|
||||
"""Human-only (bots excluded at rollup-write time, Ch08)."""
|
||||
browser_rows = (
|
||||
db.session.query(BrowserStatsDaily.browser, func.sum(BrowserStatsDaily.count).label("count"))
|
||||
.filter(BrowserStatsDaily.date >= from_date, BrowserStatsDaily.date <= to_date)
|
||||
.group_by(BrowserStatsDaily.browser)
|
||||
.order_by(func.sum(BrowserStatsDaily.count).desc())
|
||||
.all()
|
||||
)
|
||||
os_rows = (
|
||||
db.session.query(BrowserStatsDaily.os, func.sum(BrowserStatsDaily.count).label("count"))
|
||||
.filter(BrowserStatsDaily.date >= from_date, BrowserStatsDaily.date <= to_date)
|
||||
.group_by(BrowserStatsDaily.os)
|
||||
.order_by(func.sum(BrowserStatsDaily.count).desc())
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"by_browser": [{"name": r.browser, "count": r.count} for r in browser_rows],
|
||||
"by_os": [{"name": r.os, "count": r.count} for r in os_rows],
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Overview blueprint routes (Chapter 08 / Chapter 11's route table)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import current_app, jsonify, request
|
||||
|
||||
from app.blueprints.overview import bp
|
||||
from app.blueprints.overview.queries import (
|
||||
get_browser_breakdown,
|
||||
get_kpis,
|
||||
get_status_code_breakdown,
|
||||
get_top_referrers,
|
||||
get_top_urls,
|
||||
get_traffic_chart_series,
|
||||
)
|
||||
from app.services.background import resume_incomplete_files
|
||||
from app.utils.dates import parse_date_range
|
||||
from app.utils.htmx import render_htmx_aware
|
||||
from app.utils.pagination import parse_pagination
|
||||
|
||||
|
||||
@bp.route("/overview")
|
||||
def overview():
|
||||
"""Full page on first load, HTMX partial on tab switch / range change (Ch04).
|
||||
|
||||
Also opportunistically resumes any log_files stuck in queued/processing
|
||||
(Ch12 simplification: the replacement for cron's "there's always a
|
||||
next tick" guarantee — see app/services/background.py).
|
||||
"""
|
||||
resume_incomplete_files(current_app._get_current_object())
|
||||
from_date, to_date = parse_date_range(request)
|
||||
return render_htmx_aware(
|
||||
request,
|
||||
full_template="overview/index.html",
|
||||
partial_template="overview/_content.html",
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/overview/kpis")
|
||||
def api_kpis():
|
||||
from_date, to_date = parse_date_range(request)
|
||||
return jsonify(data=get_kpis(from_date, to_date), meta={"from": from_date.isoformat(), "to": to_date.isoformat()})
|
||||
|
||||
|
||||
@bp.get("/api/overview/traffic-chart")
|
||||
def api_traffic_chart():
|
||||
from_date, to_date = parse_date_range(request)
|
||||
return jsonify(
|
||||
data=get_traffic_chart_series(from_date, to_date),
|
||||
meta={"from": from_date.isoformat(), "to": to_date.isoformat()},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/overview/status-codes")
|
||||
def api_status_codes():
|
||||
from_date, to_date = parse_date_range(request)
|
||||
return jsonify(
|
||||
data=get_status_code_breakdown(from_date, to_date),
|
||||
meta={"from": from_date.isoformat(), "to": to_date.isoformat()},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/overview/top-urls")
|
||||
def api_top_urls():
|
||||
from_date, to_date = parse_date_range(request)
|
||||
page, per_page = parse_pagination(request)
|
||||
rows, total = get_top_urls(from_date, to_date, page, per_page)
|
||||
return jsonify(
|
||||
data={"rows": rows, "total": total},
|
||||
meta={"from": from_date.isoformat(), "to": to_date.isoformat(), "page": page, "per_page": per_page},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/overview/top-referrers")
|
||||
def api_top_referrers():
|
||||
from_date, to_date = parse_date_range(request)
|
||||
page, per_page = parse_pagination(request)
|
||||
rows, total = get_top_referrers(from_date, to_date, page, per_page)
|
||||
return jsonify(
|
||||
data={"rows": rows, "total": total},
|
||||
meta={"from": from_date.isoformat(), "to": to_date.isoformat(), "page": page, "per_page": per_page},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/overview/browser-breakdown")
|
||||
def api_browser_breakdown():
|
||||
"""Chapter 11 addition (Method A, Ch08 follow-up) — not in the
|
||||
original route table; see docs/api-contract-final.md."""
|
||||
from_date, to_date = parse_date_range(request)
|
||||
return jsonify(
|
||||
data=get_browser_breakdown(from_date, to_date),
|
||||
meta={"from": from_date.isoformat(), "to": to_date.isoformat()},
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
<div id="overview-content"
|
||||
hx-get="{{ url_for('overview.overview') }}"
|
||||
hx-trigger="change from:#date-range-form"
|
||||
hx-include="#date-range-form"
|
||||
hx-target="#overview-content"
|
||||
hx-swap="outerHTML"
|
||||
data-from="{{ from_date.isoformat() }}"
|
||||
data-to="{{ to_date.isoformat() }}">
|
||||
|
||||
<div class="flex flex-wrap items-end justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="font-display font-bold text-xl">Overview</h1>
|
||||
<p class="text-sm text-muted dark:text-muted-dark">What happened on your site recently</p>
|
||||
</div>
|
||||
<form id="date-range-form" class="flex gap-3 items-end">
|
||||
<label class="text-sm text-muted dark:text-muted-dark">From
|
||||
<input type="date" name="from" value="{{ from_date.isoformat() }}"
|
||||
class="block border border-line dark:border-line-dark rounded-md px-2 py-1 mt-1 bg-surface dark:bg-surface-dark text-ink dark:text-ink-dark font-data text-sm">
|
||||
</label>
|
||||
<label class="text-sm text-muted dark:text-muted-dark">To
|
||||
<input type="date" name="to" value="{{ to_date.isoformat() }}"
|
||||
class="block border border-line dark:border-line-dark rounded-md px-2 py-1 mt-1 bg-surface dark:bg-surface-dark text-ink dark:text-ink-dark font-data text-sm">
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Upload panel — real byte-transfer progress via XHR (uploads.js), then
|
||||
a real parse-progress bar (Ch12) once the file is queued. No cron
|
||||
required: processing starts automatically in the background. -->
|
||||
<div class="mb-6 border border-line dark:border-line-dark rounded-xl p-4 bg-surface dark:bg-surface-dark">
|
||||
<h2 class="font-display font-semibold text-sm mb-3">Upload a log file</h2>
|
||||
<form id="upload-form" action="{{ url_for('uploads.upload_log_file') }}"
|
||||
class="flex flex-wrap gap-3 items-center">
|
||||
<input type="file" name="logfile" accept=".log,.txt,.gz" required
|
||||
class="text-sm text-muted dark:text-muted-dark file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-accent/10 file:text-accent dark:file:bg-accent-dark/15 dark:file:text-accent-dark file:text-sm file:font-medium hover:file:bg-accent/20 dark:hover:file:bg-accent-dark/25 file:cursor-pointer cursor-pointer">
|
||||
<select name="server_type" class="border border-line dark:border-line-dark rounded-md px-2 py-1.5 text-sm bg-surface dark:bg-surface-dark text-ink dark:text-ink-dark">
|
||||
<option value="apache">Apache</option>
|
||||
<option value="litespeed">LiteSpeed</option>
|
||||
</select>
|
||||
<input type="text" name="format_string" placeholder="LogFormat (optional — defaults to Combined)"
|
||||
class="border border-line dark:border-line-dark rounded-md px-2 py-1.5 text-sm flex-1 min-w-[220px] bg-surface dark:bg-surface-dark text-ink dark:text-ink-dark placeholder:text-muted dark:placeholder:text-muted-dark">
|
||||
<button type="submit" class="bg-accent dark:bg-accent-dark text-white dark:text-paper-dark text-sm font-medium rounded-md px-4 py-1.5 hover:opacity-90 transition-opacity">
|
||||
Upload
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="upload-progress-wrap" class="hidden mt-3">
|
||||
<div class="flex items-center justify-between text-sm mb-1">
|
||||
<span id="upload-progress-label" class="text-muted dark:text-muted-dark">Uploading…</span>
|
||||
</div>
|
||||
<div class="w-full h-2 rounded-full bg-line dark:bg-line-dark overflow-hidden">
|
||||
<div id="upload-progress-bar" class="h-full rounded-full bg-accent dark:bg-accent-dark transition-all duration-150" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-result" class="mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Uploaded files — select and delete (project-owner follow-up
|
||||
request). Independent of the date-range picker above: this lists
|
||||
uploads by when they arrived, not by which log dates they cover
|
||||
(a single file can span many dates). Selection is intentionally
|
||||
scoped to the currently-rendered page/sort/search view — it
|
||||
doesn't try to persist across a Grid.js re-render, which keeps
|
||||
"what's selected" always visually honest at the cost of losing
|
||||
selection if you page/sort/search mid-selection. -->
|
||||
<div class="mb-6 border border-line dark:border-line-dark rounded-xl bg-surface dark:bg-surface-dark overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 pb-3">
|
||||
<h2 class="font-display font-semibold text-sm">Uploaded files</h2>
|
||||
<button type="button" id="delete-selected-btn" disabled
|
||||
class="text-sm font-medium rounded-md px-3 py-1.5 border border-danger/40 dark:border-danger-dark/40 text-danger dark:text-danger-dark opacity-40 cursor-not-allowed disabled:opacity-40 enabled:opacity-100 enabled:hover:bg-danger/10 dark:enabled:hover:bg-danger-dark/10 transition-opacity">
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
<div id="uploaded-files-grid" class="px-4 pb-4" data-endpoint="{{ url_for('uploads.list_uploads') }}"></div>
|
||||
</div>
|
||||
|
||||
<!-- KPI readout — the one deliberate signature treatment: values set in
|
||||
tabular mono, like numbers straight off the log line, with a thin
|
||||
top rule that switches color when a metric needs attention
|
||||
(structure carries information, not just decoration). -->
|
||||
<div id="kpi-cards" class="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6" data-endpoint="{{ url_for('overview.api_kpis') }}"></div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<div class="border border-line dark:border-line-dark rounded-xl p-4 bg-surface dark:bg-surface-dark h-72">
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-muted dark:text-muted-dark mb-2">Traffic over time</h3>
|
||||
<div class="h-56"><canvas id="traffic-chart" data-endpoint="{{ url_for('overview.api_traffic_chart') }}"></canvas></div>
|
||||
</div>
|
||||
<div class="border border-line dark:border-line-dark rounded-xl p-4 bg-surface dark:bg-surface-dark h-72">
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-muted dark:text-muted-dark mb-2">Status codes</h3>
|
||||
<div class="h-56"><canvas id="status-code-chart" data-endpoint="{{ url_for('overview.api_status_codes') }}"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-muted dark:text-muted-dark mb-2">Top URLs</h3>
|
||||
<div id="top-urls-grid" data-endpoint="{{ url_for('overview.api_top_urls') }}"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-muted dark:text-muted-dark mb-2">Top referrers</h3>
|
||||
<div id="top-referrers-grid" data-endpoint="{{ url_for('overview.api_top_referrers') }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
|
||||
<div class="border border-line dark:border-line-dark rounded-xl p-4 bg-surface dark:bg-surface-dark h-64">
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-muted dark:text-muted-dark mb-2">Browsers</h3>
|
||||
<div class="h-48"><canvas id="browser-breakdown-chart" data-endpoint="{{ url_for('overview.api_browser_breakdown') }}"></canvas></div>
|
||||
</div>
|
||||
<div class="border border-line dark:border-line-dark rounded-xl p-4 bg-surface dark:bg-surface-dark h-64">
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-muted dark:text-muted-dark mb-2">Operating systems</h3>
|
||||
<div class="h-48"><canvas id="os-breakdown-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function initOverviewWidgets() {
|
||||
const root = document.getElementById('overview-content');
|
||||
const from = root.dataset.from, to = root.dataset.to;
|
||||
const withRange = (url) => `${url}?from=${from}&to=${to}`;
|
||||
|
||||
window.initUploadForm('upload-form');
|
||||
initUploadedFilesGrid();
|
||||
|
||||
fetch(withRange(document.getElementById('kpi-cards').dataset.endpoint))
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => renderKpiCards(data));
|
||||
|
||||
const trafficEl = document.getElementById('traffic-chart');
|
||||
fetch(withRange(trafficEl.dataset.endpoint))
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => window.initChart('traffic-chart', {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.series.map((p) => p.t),
|
||||
datasets: [{
|
||||
label: 'Requests', data: data.series.map((p) => p.count), tension: 0.3,
|
||||
borderColor: '#0E7C86', backgroundColor: '#0E7C8622', fill: true,
|
||||
}],
|
||||
},
|
||||
}));
|
||||
|
||||
const statusEl = document.getElementById('status-code-chart');
|
||||
fetch(withRange(statusEl.dataset.endpoint))
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => window.initChart('status-code-chart', {
|
||||
type: 'doughnut',
|
||||
data: { labels: Object.keys(data), datasets: [{ data: Object.values(data), backgroundColor: window.KAVOSH_CHART_PALETTE }] },
|
||||
}));
|
||||
|
||||
window.initGrid(
|
||||
'top-urls-grid',
|
||||
document.getElementById('top-urls-grid').dataset.endpoint,
|
||||
[{ name: 'Path' }, { name: 'Hits' }, { name: 'Avg Size (B)' }, { name: 'Error Rate %' }],
|
||||
{ from, to },
|
||||
);
|
||||
|
||||
window.initGrid(
|
||||
'top-referrers-grid',
|
||||
document.getElementById('top-referrers-grid').dataset.endpoint,
|
||||
[{ name: 'Referrer Domain' }, { name: 'Hits' }],
|
||||
{ from, to },
|
||||
);
|
||||
|
||||
const browserEl = document.getElementById('browser-breakdown-chart');
|
||||
fetch(withRange(browserEl.dataset.endpoint))
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => {
|
||||
window.initChart('browser-breakdown-chart', {
|
||||
type: 'bar',
|
||||
data: { labels: data.by_browser.map((r) => r.name), datasets: [{ label: 'Browser', data: data.by_browser.map((r) => r.count), backgroundColor: '#0E7C86' }] },
|
||||
});
|
||||
window.initChart('os-breakdown-chart', {
|
||||
type: 'bar',
|
||||
data: { labels: data.by_os.map((r) => r.name), datasets: [{ label: 'OS', data: data.by_os.map((r) => r.count), backgroundColor: '#B8860B' }] },
|
||||
});
|
||||
});
|
||||
|
||||
function renderKpiCards(kpi) {
|
||||
const errorTone = kpi.error_rate_pct >= 5 ? 'danger' : kpi.error_rate_pct >= 1 ? 'warn' : 'ok';
|
||||
const cards = [
|
||||
['Total requests', kpi.total_requests.toLocaleString(), 'accent'],
|
||||
['Unique IPs (approx.)', kpi.unique_ips.toLocaleString(), 'accent'],
|
||||
['Bandwidth', `${(kpi.total_bandwidth_bytes / 1e6).toFixed(1)} MB`, 'accent'],
|
||||
['Avg response size', `${kpi.avg_response_size_bytes} B`, 'accent'],
|
||||
['Error rate', `${kpi.error_rate_pct}%`, errorTone],
|
||||
['Avg requests / day', kpi.avg_requests_per_day.toLocaleString(), 'accent'],
|
||||
];
|
||||
const toneBorder = {
|
||||
accent: 'border-t-accent dark:border-t-accent-dark',
|
||||
ok: 'border-t-ok dark:border-t-ok-dark',
|
||||
warn: 'border-t-warn dark:border-t-warn-dark',
|
||||
danger: 'border-t-danger dark:border-t-danger-dark',
|
||||
};
|
||||
const el = document.getElementById('kpi-cards');
|
||||
el.innerHTML = cards.map(([label, value, tone]) => `
|
||||
<div class="bg-surface dark:bg-surface-dark rounded-lg border border-line dark:border-line-dark border-t-2 ${toneBorder[tone]} p-3">
|
||||
<p class="text-xs text-muted dark:text-muted-dark uppercase tracking-wide">${label}</p>
|
||||
<p class="font-data text-2xl font-medium mt-0.5">${value}</p>
|
||||
</div>`).join('');
|
||||
if (kpi.peak_day) {
|
||||
el.innerHTML += `
|
||||
<div class="bg-surface dark:bg-surface-dark rounded-lg border border-line dark:border-line-dark border-t-2 border-t-accent dark:border-t-accent-dark p-3 col-span-2 sm:col-span-3">
|
||||
<p class="text-xs text-muted dark:text-muted-dark uppercase tracking-wide">Peak day</p>
|
||||
<p class="font-data text-xl font-medium mt-0.5">${kpi.peak_day.date} <span class="text-muted dark:text-muted-dark text-sm">— ${kpi.peak_day.count.toLocaleString()} requests</span></p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function initUploadedFilesGrid() {
|
||||
const container = document.getElementById('uploaded-files-grid');
|
||||
const deleteBtn = document.getElementById('delete-selected-btn');
|
||||
const selectedIds = new Set();
|
||||
|
||||
function updateDeleteButton() {
|
||||
deleteBtn.disabled = selectedIds.size === 0;
|
||||
deleteBtn.textContent = selectedIds.size > 0 ? `Delete selected (${selectedIds.size})` : 'Delete selected';
|
||||
}
|
||||
|
||||
// Selection is intentionally NOT restored across a Grid.js
|
||||
// re-render (page/sort/search) — simpler and always visually
|
||||
// honest, at the cost of losing selection if you page away
|
||||
// mid-selection. See the comment above the HTML for this panel.
|
||||
container.addEventListener('change', (e) => {
|
||||
if (!e.target.matches('.file-select-checkbox')) return;
|
||||
const id = parseInt(e.target.value, 10);
|
||||
if (e.target.checked) selectedIds.add(id); else selectedIds.delete(id);
|
||||
updateDeleteButton();
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: '',
|
||||
formatter: (cell, row) => {
|
||||
const rawStatus = row.cells[6].data;
|
||||
const disabled = rawStatus === 'processing';
|
||||
return window.gridHtml(
|
||||
`<input type="checkbox" class="file-select-checkbox w-4 h-4 rounded border-line dark:border-line-dark text-accent focus:ring-accent cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
value="${cell}" ${disabled ? 'disabled title="Still analyzing — wait for it to finish"' : ''}>`
|
||||
);
|
||||
},
|
||||
},
|
||||
{ name: 'Filename' },
|
||||
{ name: 'Server' },
|
||||
{ name: 'Status' },
|
||||
{ name: 'Uploaded' },
|
||||
{ name: 'Size' },
|
||||
{ name: 'Raw Status', hidden: true },
|
||||
];
|
||||
|
||||
const filesGrid = window.initGrid(
|
||||
'uploaded-files-grid',
|
||||
container.dataset.endpoint,
|
||||
columns,
|
||||
{},
|
||||
);
|
||||
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`Delete ${selectedIds.size} file(s) and all data derived from them? This can't be undone.`)) return;
|
||||
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
fetch('{{ url_for("uploads.bulk_delete_uploads") }}', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': token || '' },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => {
|
||||
selectedIds.clear();
|
||||
updateDeleteButton();
|
||||
filesGrid.forceRender();
|
||||
if (data.skipped && data.skipped.length) {
|
||||
alert('Some files were skipped:\n' + data.skipped.map((s) => `#${s.id} — ${s.reason}`).join('\n'));
|
||||
}
|
||||
// Deleting a file can change rollups for any date it
|
||||
// touched — refresh the whole page's KPIs/charts via the
|
||||
// same mechanism the date-range picker itself uses.
|
||||
window.htmx.trigger(document.getElementById('date-range-form'), 'change');
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Overview — Kavosh{% endblock %}
|
||||
{% block content %}
|
||||
{% include "overview/_content.html" %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user