start project

This commit is contained in:
Hemmat
2026-08-07 21:17:17 +03:30
commit ea1e1eead6
121 changed files with 8108 additions and 0 deletions
+13
View File
@@ -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
+167
View File
@@ -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],
}
+94
View File
@@ -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 %}