v1.6.29 : chemins IP triés par date, un par ligne avec compteur

- Drill-down IP : articles/livres affichés un par ligne (compteur entre parenthèses), triés par date de dernier accès desc
- AccessLogParser : ipPathTs trace le dernier timestamp par chemin/IP
- ip_top_paths : structure {n, ts} au lieu de count simple

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:09:44 +02:00
parent 40656631ba
commit e3d7e433e0
4 changed files with 64 additions and 45 deletions
+27 -26
View File
@@ -20,7 +20,7 @@ function esc(s) {
function flag(code) {
if (!code || code.length !== 2) { return ''; }
var cp = Array.from(code.toUpperCase()).map(function (c) { return 0x1F1E6 + c.charCodeAt(0) - 65; });
return String.fromCodePoint(cp[0], cp[1]) + ' ';
return String.fromCodePoint(cp[0], cp[1]) + ' ';
}
// Index IPs par ASN pour le drill-down
@@ -35,7 +35,7 @@ function esc(s) {
ipsByAsn[k].sort(function (a, b) { return b.hits - a.hits; });
});
// Mini sparkline (80×20px polyline) pour chaque IP
// Mini sparkline (80x20px polyline) pour chaque IP
function ipSparkline(daily) {
if (!daily || !daily.length) { return ''; }
var W = 80, H = 20, padX = 1, padY = 2;
@@ -85,44 +85,45 @@ function esc(s) {
var asnKey = n.asn || '__unknown__';
var ips = ipsByAsn[asnKey] || [];
// Lignes IP avec mini sparkline + chemins
// Lignes IP avec mini sparkline + chemins triés par date desc
var ipRows = ips.slice(0, 20).map(function (ipInfo) {
var articles = [], books = [];
Object.keys(ipInfo.paths || {}).forEach(function (path) {
var cnt = ipInfo.paths[path];
if (path.indexOf('/post/') === 0) { articles.push({ path: path, cnt: cnt }); }
else if (path.indexOf('/book/') === 0) { books.push({ path: path, cnt: cnt }); }
var p = ipInfo.paths[path];
var cnt = (p && typeof p === 'object') ? p.n : p;
var ts = (p && typeof p === 'object') ? p.ts : 0;
if (path.indexOf('/post/') === 0) { articles.push({ path: path, cnt: cnt, ts: ts }); }
else if (path.indexOf('/book/') === 0) { books.push({ path: path, cnt: cnt, ts: ts }); }
});
articles.sort(function (a, b) { return b.cnt - a.cnt; });
books.sort(function (a, b) { return b.cnt - a.cnt; });
articles.sort(function (a, b) { return b.ts - a.ts; });
books.sort(function (a, b) { return b.ts - a.ts; });
function pathLine(p, prefix) {
var slug = decodeURIComponent(p.path.replace(prefix, ''));
var label = slug.length > 40 ? slug.slice(0, 40) + '…' : slug;
return '<div style="font-size:.75rem;line-height:1.5">'
+ '<a href="' + esc(p.path) + '" target="_blank" style="color:#495057">'
+ esc(label) + '</a>'
+ ' <span style="color:#adb5bd">(' + p.cnt + ')</span></div>';
}
var pathsHtml = '';
if (articles.length) {
pathsHtml += '<div style="font-size:.75rem;color:#6c757d">Articles : '
+ articles.slice(0, 3).map(function (p) {
var slug = decodeURIComponent(p.path.replace('/post/', ''));
return '<a href="' + esc(p.path) + '" target="_blank" style="color:inherit">'
+ esc(slug.length > 28 ? slug.slice(0, 28) + '…' : slug)
+ '</a>(' + p.cnt + ')';
}).join(', ') + '</div>';
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Articles</div>'
+ articles.map(function (p) { return pathLine(p, '/post/'); }).join('');
}
if (books.length) {
pathsHtml += '<div style="font-size:.75rem;color:#6c757d">Livres : '
+ books.slice(0, 3).map(function (p) {
var slug = decodeURIComponent(p.path.replace('/book/', ''));
return '<a href="' + esc(p.path) + '" target="_blank" style="color:inherit">'
+ esc(slug.length > 28 ? slug.slice(0, 28) + '…' : slug)
+ '</a>(' + p.cnt + ')';
}).join(', ') + '</div>';
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Livres</div>'
+ books.map(function (p) { return pathLine(p, '/book/'); }).join('');
}
if (!pathsHtml) { pathsHtml = '<span style="font-size:.75rem;color:#adb5bd">—</span>'; }
return '<div class="d-flex align-items-center gap-2 py-1 border-bottom">'
+ '<code style="width:9rem;flex-shrink:0;font-size:.72rem;color:#6c757d">'
return '<div class="d-flex gap-2 py-2 border-bottom align-items-start">'
+ '<code style="width:9rem;flex-shrink:0;font-size:.72rem;color:#6c757d;padding-top:2px">'
+ esc(ipInfo.ip) + '</code>'
+ ipSparkline(ipInfo.daily || [])
+ '<div style="flex-shrink:0;padding-top:2px">' + ipSparkline(ipInfo.daily || []) + '</div>'
+ '<div class="flex-grow-1">' + pathsHtml + '</div>'
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0">'
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0;padding-top:2px">'
+ (ipInfo.hits || 0).toLocaleString('fr-FR') + '</div>'
+ '</div>';
}).join('');