Enable multi-column table reports #50

Merged
wagesj45 merged 1 commit from codex/add-domain-grouping-to-reports into main 2025-07-19 18:20:08 -05:00
4 changed files with 127 additions and 33 deletions

View file

@ -75,47 +75,78 @@
label: Top Paths label: Top Paths
icon: map icon: map
chart: table chart: table
bucket: path buckets:
bucket_label: Path - domain
- path
bucket_label:
- Domain
- Path
query: | query: |
SELECT path AS path, WITH paths AS (
COUNT(*) AS value SELECT host AS domain,
FROM ( substr(substr(request, instr(request, ' ') + 1), 1,
SELECT substr(substr(request, instr(request, ' ') + 1), 1,
instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path
FROM logs FROM logs
), ranked AS (
SELECT domain, path, COUNT(*) AS value,
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
FROM paths
GROUP BY domain, path
) )
GROUP BY path SELECT domain, path, value
ORDER BY value DESC FROM ranked
LIMIT 20 WHERE rn <= 20
ORDER BY domain, value DESC
- name: user_agents - name: user_agents
label: User Agents label: User Agents
icon: user icon: user
chart: table chart: table
bucket: user_agent buckets:
bucket_label: User Agent - domain
- user_agent
bucket_label:
- Domain
- User Agent
query: | query: |
SELECT user_agent AS user_agent, WITH ua AS (
COUNT(*) AS value SELECT host AS domain, user_agent
FROM logs FROM logs
GROUP BY user_agent ), ranked AS (
ORDER BY value DESC SELECT domain, user_agent, COUNT(*) AS value,
LIMIT 20 ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
FROM ua
GROUP BY domain, user_agent
)
SELECT domain, user_agent, value
FROM ranked
WHERE rn <= 20
ORDER BY domain, value DESC
- name: referrers - name: referrers
label: Referrers label: Referrers
icon: link icon: link
chart: table chart: table
bucket: referrer buckets:
bucket_label: Referrer - domain
- referrer
bucket_label:
- Domain
- Referrer
query: | query: |
SELECT referer AS referrer, WITH ref AS (
COUNT(*) AS value SELECT host AS domain, referer AS referrer
FROM logs FROM logs
GROUP BY referrer ), ranked AS (
ORDER BY value DESC SELECT domain, referrer, COUNT(*) AS value,
LIMIT 20 ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
FROM ref
GROUP BY domain, referrer
)
SELECT domain, referrer, value
FROM ranked
WHERE rn <= 20
ORDER BY domain, value DESC
- name: status_distribution - name: status_distribution
label: HTTP Statuses label: HTTP Statuses

View file

@ -182,6 +182,8 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
entry["icon"] = definition["icon"] entry["icon"] = definition["icon"]
if "bucket" in definition: if "bucket" in definition:
entry["bucket"] = definition["bucket"] entry["bucket"] = definition["bucket"]
if "buckets" in definition:
entry["buckets"] = definition["buckets"]
if "bucket_label" in definition: if "bucket_label" in definition:
entry["bucket_label"] = definition["bucket_label"] entry["bucket_label"] = definition["bucket_label"]
if "color" in definition: if "color" in definition:
@ -268,6 +270,8 @@ def _generate_global() -> None:
entry["icon"] = definition["icon"] entry["icon"] = definition["icon"]
if "bucket" in definition: if "bucket" in definition:
entry["bucket"] = definition["bucket"] entry["bucket"] = definition["bucket"]
if "buckets" in definition:
entry["buckets"] = definition["buckets"]
if "bucket_label" in definition: if "bucket_label" in definition:
entry["bucket_label"] = definition["bucket_label"] entry["bucket_label"] = definition["bucket_label"]
if "color" in definition: if "color" in definition:

View file

@ -117,21 +117,24 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (token !== currentLoad) return; if (token !== currentLoad) return;
const bucketField = rep.bucket || 'bucket'; const bucketFields = rep.buckets || [rep.bucket || 'bucket'];
const labels = Array.isArray(rep.bucket_label)
? rep.bucket_label
: [rep.bucket_label || 'Bucket'];
if (rep.chart === 'table') { if (rep.chart === 'table') {
const rows = data.map(x => [x[bucketField], x.value]); const rows = data.map(x => bucketFields.map(f => x[f]).concat(x.value));
const columns = labels.map(l => ({ title: l }));
columns.push({ title: 'Value' });
const table = new DataTable('#table-' + rep.name, { const table = new DataTable('#table-' + rep.name, {
data: rows, data: rows,
columns: [ columns: columns
{ title: rep.bucket_label || 'Bucket' },
{ title: 'Value' }
]
}); });
registerChart(token, rep.name, table); registerChart(token, rep.name, table);
return; return;
} }
const labels = data.map(x => x[bucketField]); const bucketField = bucketFields[0];
const labelsArr = data.map(x => x[bucketField]);
const values = data.map(x => x.value); const values = data.map(x => x.value);
const chartType = rep.chart === 'stackedBar' ? 'bar' : rep.chart; const chartType = rep.chart === 'stackedBar' ? 'bar' : rep.chart;
const options = { scales: { y: { beginAtZero: true } } }; const options = { scales: { y: { beginAtZero: true } } };
@ -158,7 +161,7 @@
const chart = new Chart(document.getElementById('chart-' + rep.name), { const chart = new Chart(document.getElementById('chart-' + rep.name), {
type: chartType, type: chartType,
data: { data: {
labels: labels, labels: labelsArr,
datasets: [dataset] datasets: [dataset]
}, },
options: options options: options

View file

@ -253,3 +253,59 @@ def test_global_stats_file(tmp_path, sample_reports, monkeypatch):
assert stats["unique_domains"] == 1 assert stats["unique_domains"] == 1
assert isinstance(stats["generated_at"], str) assert isinstance(stats["generated_at"], str)
assert stats["generation_seconds"] >= 0 assert stats["generation_seconds"] >= 0
def test_multi_bucket_table(tmp_path, monkeypatch):
db_path = tmp_path / "database" / "ngxstat.db"
setup_db(db_path)
# add a second domain entry
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute(
"INSERT INTO logs (ip, host, time, request, status, bytes_sent, referer, user_agent, cache_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"127.0.0.1",
"foo.com",
"2024-01-01 10:10:00",
"GET /foo HTTP/1.1",
200,
100,
"-",
"curl",
"MISS",
),
)
conn.commit()
conn.close()
cfg = tmp_path / "reports.yml"
cfg.write_text(
"""
- name: multi
chart: table
global: true
buckets: [domain, agent]
bucket_label: [Domain, Agent]
query: |
SELECT host AS domain, user_agent AS agent, COUNT(*) AS value
FROM logs
GROUP BY host, agent
"""
)
monkeypatch.setattr(gr, "DB_PATH", db_path)
monkeypatch.setattr(gr, "OUTPUT_DIR", tmp_path / "output")
monkeypatch.setattr(gr, "REPORT_CONFIG", cfg)
monkeypatch.setattr(
gr, "TEMPLATE_DIR", Path(__file__).resolve().parents[1] / "templates"
)
gr._generate_global()
gr._generate_interval("hourly")
data = json.loads((tmp_path / "output" / "global" / "multi.json").read_text())
assert {"domain", "agent", "value"} <= data[0].keys()
reports = json.loads((tmp_path / "output" / "global" / "reports.json").read_text())
entry = next(r for r in reports if r["name"] == "multi")
assert entry["buckets"] == ["domain", "agent"]
assert entry["bucket_label"] == ["Domain", "Agent"]