|
|
@@ -3,9 +3,9 @@
|
|
|
{% block title %}{{ detail.ip }} — IRDB{% endblock %}
|
|
|
|
|
|
{% macro flag(country) %}
|
|
|
- {%- if country and country|length == 2 -%}
|
|
|
- {%- set code = country|upper -%}
|
|
|
- {{- '🇦' ~ code[0:1] ~ '🇦' ~ code[1:2] -}}
|
|
|
+ {%- set emoji = flag_emoji(country) -%}
|
|
|
+ {%- if emoji -%}
|
|
|
+ <span class="text-base leading-none">{{- emoji -}}</span>
|
|
|
{%- else -%}
|
|
|
<span class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.65rem] text-slate-500 dark:bg-slate-800 dark:text-slate-400">??</span>
|
|
|
{%- endif -%}
|
|
|
@@ -163,6 +163,76 @@
|
|
|
{% endif %}
|
|
|
</section>
|
|
|
|
|
|
+ <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
|
|
+ x-data="scoreOverTime({
|
|
|
+ reports: {{ score_chart.reports|json_encode|e('html_attr') }},
|
|
|
+ categories: {{ score_chart.categories|json_encode|e('html_attr') }},
|
|
|
+ now: '{{ score_chart.now }}'
|
|
|
+ })">
|
|
|
+ <div class="flex flex-wrap items-center justify-between gap-3">
|
|
|
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Score over time</h2>
|
|
|
+ <div class="inline-flex overflow-hidden rounded-md border border-slate-300 text-xs dark:border-slate-700">
|
|
|
+ <template x-for="opt in ranges" :key="opt.id">
|
|
|
+ <button type="button"
|
|
|
+ x-on:click="range = opt.id"
|
|
|
+ :class="range === opt.id
|
|
|
+ ? 'bg-indigo-600 text-white'
|
|
|
+ : 'bg-white text-slate-600 hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800'"
|
|
|
+ class="border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700"
|
|
|
+ x-text="opt.label"></button>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template x-if="!hasReports">
|
|
|
+ <p class="mt-3 text-sm text-slate-400">No reports yet — nothing to plot.</p>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template x-if="hasReports">
|
|
|
+ <div>
|
|
|
+ <svg viewBox="0 0 660 240" class="mt-3 w-full rounded border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-950" preserveAspectRatio="none">
|
|
|
+ <g class="stroke-slate-200 dark:stroke-slate-800" stroke-width="1">
|
|
|
+ <line x1="50" y1="20" x2="640" y2="20"/>
|
|
|
+ <line x1="50" y1="65" x2="640" y2="65"/>
|
|
|
+ <line x1="50" y1="110" x2="640" y2="110"/>
|
|
|
+ <line x1="50" y1="155" x2="640" y2="155"/>
|
|
|
+ </g>
|
|
|
+ <line x1="50" y1="200" x2="640" y2="200" class="stroke-slate-300 dark:stroke-slate-700" stroke-width="1"/>
|
|
|
+ <line x1="50" y1="20" x2="50" y2="200" class="stroke-slate-300 dark:stroke-slate-700" stroke-width="1"/>
|
|
|
+
|
|
|
+ <template x-if="range === 'future'">
|
|
|
+ <rect x="50" y="20" width="590" height="180" class="fill-amber-50 dark:fill-amber-900/20" />
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <g font-size="10" text-anchor="end" class="fill-slate-500 dark:fill-slate-400">
|
|
|
+ <text x="46" y="23" x-text="yLabel(1.0)"></text>
|
|
|
+ <text x="46" y="68" x-text="yLabel(0.75)"></text>
|
|
|
+ <text x="46" y="113" x-text="yLabel(0.5)"></text>
|
|
|
+ <text x="46" y="158" x-text="yLabel(0.25)"></text>
|
|
|
+ <text x="46" y="203">0</text>
|
|
|
+ </g>
|
|
|
+
|
|
|
+ <g font-size="10" text-anchor="middle" class="fill-slate-500 dark:fill-slate-400">
|
|
|
+ <text x="50" y="216" x-text="xLabel(0)"></text>
|
|
|
+ <text x="197" y="216" x-text="xLabel(0.25)"></text>
|
|
|
+ <text x="345" y="216" x-text="xLabel(0.5)"></text>
|
|
|
+ <text x="492" y="216" x-text="xLabel(0.75)"></text>
|
|
|
+ <text x="640" y="216" x-text="xLabel(1)"></text>
|
|
|
+ </g>
|
|
|
+ <text x="345" y="232" font-size="10" text-anchor="middle" class="fill-slate-500 dark:fill-slate-400" x-text="xAxisCaption()"></text>
|
|
|
+
|
|
|
+ <path :d="path()" stroke="currentColor" class="text-indigo-500" fill="none" stroke-width="2"/>
|
|
|
+ </svg>
|
|
|
+ <p class="mt-2 text-xs text-slate-400">
|
|
|
+ <span x-text="rangeLabel()"></span> · max: <span x-text="maxScoreDisplay.toFixed(2)"></span>
|
|
|
+ <template x-if="range === 'future'">
|
|
|
+ <span class="ml-2 text-amber-600 dark:text-amber-400">forecast assumes no new reports</span>
|
|
|
+ </template>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </section>
|
|
|
+
|
|
|
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
|
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">History</h2>
|
|
|
{% if detail.history|length > 0 %}
|
|
|
@@ -200,4 +270,124 @@
|
|
|
{% endif %}
|
|
|
</section>
|
|
|
</div>
|
|
|
+
|
|
|
+<script>
|
|
|
+window.scoreOverTime = function (initial) {
|
|
|
+ const DAY_MS = 24 * 3600 * 1000;
|
|
|
+ const NOW = new Date(initial.now).getTime();
|
|
|
+ const REPORTS = (initial.reports || [])
|
|
|
+ .map(r => ({
|
|
|
+ t: new Date(r.at).getTime(),
|
|
|
+ category: r.category,
|
|
|
+ weight: Number(r.weight) || 1,
|
|
|
+ }))
|
|
|
+ .filter(r => Number.isFinite(r.t));
|
|
|
+ const CATEGORIES = (initial.categories || []).reduce((acc, c) => {
|
|
|
+ if (c && c.slug) {
|
|
|
+ acc[c.slug] = {
|
|
|
+ fn: c.decay_function === 'linear' ? 'linear' : 'exponential',
|
|
|
+ param: Math.max(0.1, Number(c.decay_param) || 14),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return acc;
|
|
|
+ }, {});
|
|
|
+ const DEFAULT = { fn: 'exponential', param: 14 };
|
|
|
+
|
|
|
+ function decayFor(report, t) {
|
|
|
+ const cat = CATEGORIES[report.category] || DEFAULT;
|
|
|
+ const ageDays = (t - report.t) / DAY_MS;
|
|
|
+ if (ageDays < 0) return 0;
|
|
|
+ if (cat.fn === 'linear') return Math.max(0, 1 - ageDays / cat.param);
|
|
|
+ return Math.pow(0.5, ageDays / cat.param);
|
|
|
+ }
|
|
|
+ function totalScoreAt(t) {
|
|
|
+ let total = 0;
|
|
|
+ for (const r of REPORTS) total += r.weight * decayFor(r, t);
|
|
|
+ return total;
|
|
|
+ }
|
|
|
+ function fmtDate(ts) {
|
|
|
+ const d = new Date(ts);
|
|
|
+ if (isNaN(d.getTime())) return '';
|
|
|
+ try {
|
|
|
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
|
+ } catch (e) {
|
|
|
+ return d.toISOString().slice(0, 10);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ ranges: [
|
|
|
+ { id: 'week', label: 'Week' },
|
|
|
+ { id: 'month', label: 'Month' },
|
|
|
+ { id: 'all', label: 'All' },
|
|
|
+ { id: 'future', label: '+30d' },
|
|
|
+ ],
|
|
|
+ range: 'month',
|
|
|
+ get hasReports() { return REPORTS.length > 0; },
|
|
|
+ get bounds() {
|
|
|
+ switch (this.range) {
|
|
|
+ case 'week': return { start: NOW - 7 * DAY_MS, end: NOW };
|
|
|
+ case 'future': return { start: NOW, end: NOW + 30 * DAY_MS };
|
|
|
+ case 'all': {
|
|
|
+ const earliest = REPORTS.length
|
|
|
+ ? Math.min.apply(null, REPORTS.map(r => r.t))
|
|
|
+ : NOW - 30 * DAY_MS;
|
|
|
+ return { start: Math.min(earliest, NOW - DAY_MS), end: NOW };
|
|
|
+ }
|
|
|
+ case 'month':
|
|
|
+ default: return { start: NOW - 30 * DAY_MS, end: NOW };
|
|
|
+ }
|
|
|
+ },
|
|
|
+ get points() {
|
|
|
+ const { start, end } = this.bounds;
|
|
|
+ const N = 120;
|
|
|
+ const span = end - start;
|
|
|
+ const out = [];
|
|
|
+ for (let i = 0; i <= N; i++) {
|
|
|
+ const t = start + (span * i) / N;
|
|
|
+ out.push({ t, v: totalScoreAt(t) });
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ },
|
|
|
+ get maxScoreDisplay() {
|
|
|
+ let max = 0;
|
|
|
+ for (const p of this.points) if (p.v > max) max = p.v;
|
|
|
+ return max;
|
|
|
+ },
|
|
|
+ path() {
|
|
|
+ const pts = this.points;
|
|
|
+ if (pts.length === 0) return '';
|
|
|
+ const x0 = 50, y0 = 20, w = 590, h = 180;
|
|
|
+ const max = Math.max(this.maxScoreDisplay, 1e-6);
|
|
|
+ const out = [];
|
|
|
+ for (let i = 0; i < pts.length; i++) {
|
|
|
+ const x = x0 + (i / (pts.length - 1)) * w;
|
|
|
+ const y = y0 + (1 - pts[i].v / max) * h;
|
|
|
+ out.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
|
|
|
+ }
|
|
|
+ return out.join(' ');
|
|
|
+ },
|
|
|
+ yLabel(frac) {
|
|
|
+ return (this.maxScoreDisplay * frac).toFixed(2);
|
|
|
+ },
|
|
|
+ xLabel(frac) {
|
|
|
+ const { start, end } = this.bounds;
|
|
|
+ return fmtDate(start + (end - start) * frac);
|
|
|
+ },
|
|
|
+ xAxisCaption() {
|
|
|
+ switch (this.range) {
|
|
|
+ case 'week': return 'last 7 days';
|
|
|
+ case 'month': return 'last 30 days';
|
|
|
+ case 'all': return 'all reported activity';
|
|
|
+ case 'future': return 'next 30 days (forecast)';
|
|
|
+ default: return '';
|
|
|
+ }
|
|
|
+ },
|
|
|
+ rangeLabel() {
|
|
|
+ const opt = this.ranges.find(r => r.id === this.range);
|
|
|
+ return opt ? opt.label : '';
|
|
|
+ },
|
|
|
+ };
|
|
|
+};
|
|
|
+</script>
|
|
|
{% endblock %}
|