Ver Fonte

feat(ui): fix country flags + add score-over-time chart on IP detail

- Add `flag_emoji` Twig function that maps a 2-letter ISO country
  code into the corresponding regional-indicator pair, replacing the
  garbled "🇦C🇦N CN" output the previous macro produced on the IPs list
  and detail pages.
- Render an estimated reputation chart between "Score per category"
  and "History" with switchable Week / Month / All / +30d ranges. The
  +30d range projects the existing reports forward under their per-
  category decay function so users can see when the score will fall
  off if no new reports arrive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa há 1 semana atrás
pai
commit
fda0b27f58

+ 193 - 3
ui/resources/views/pages/ips/detail.twig

@@ -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 %}

+ 3 - 5
ui/resources/views/pages/ips/index.twig

@@ -3,11 +3,9 @@
 {% block title %}IPs — IRDB{% endblock %}
 
 {% macro flag(country) %}
-    {%- if country and country|length == 2 -%}
-        {%- set code = country|upper -%}
-        {%- set first = code[0:1] -%}
-        {%- set second = code[1:2] -%}
-        {{- '🇦' ~ first ~ '🇦' ~ second -}}
+    {%- 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 -%}

+ 18 - 1
ui/src/App/Container.php

@@ -106,11 +106,28 @@ final class Container
                 /** @var bool $isDev */
                 $isDev = $c->get('settings.is_dev');
 
-                return Twig::create(__DIR__ . '/../../resources/views', [
+                $twig = Twig::create(__DIR__ . '/../../resources/views', [
                     'cache' => false,
                     'auto_reload' => $isDev,
                     'strict_variables' => false,
                 ]);
+                $twig->getEnvironment()->addFunction(
+                    new \Twig\TwigFunction('flag_emoji', static function (mixed $code): string {
+                        if (!is_string($code)) {
+                            return '';
+                        }
+                        $code = strtoupper(trim($code));
+                        if (strlen($code) !== 2 || !ctype_alpha($code)) {
+                            return '';
+                        }
+                        $a = mb_chr(0x1F1E6 + (ord($code[0]) - ord('A')), 'UTF-8');
+                        $b = mb_chr(0x1F1E6 + (ord($code[1]) - ord('A')), 'UTF-8');
+
+                        return $a . $b;
+                    })
+                );
+
+                return $twig;
             }),
 
             SessionManager::class => factory(static function (ContainerInterface $c): SessionManager {

+ 43 - 0
ui/src/Controllers/IpsController.php

@@ -108,10 +108,53 @@ final class IpsController
             );
         }
 
+        // Decay parameters drive the score-over-time chart on the detail
+        // page. If the API can't provide them we fall through to JS defaults
+        // (exponential, 14-day half-life) — chart degrades, page still renders.
+        $categories = [];
+        try {
+            $catList = $this->admin->listCategories($user->userId);
+            $items = $catList['items'] ?? [];
+            if (is_array($items)) {
+                foreach ($items as $c) {
+                    if (!is_array($c) || !isset($c['slug'])) {
+                        continue;
+                    }
+                    $categories[] = [
+                        'slug' => (string) $c['slug'],
+                        'decay_function' => (string) ($c['decay_function'] ?? 'exponential'),
+                        'decay_param' => (float) ($c['decay_param'] ?? 14.0),
+                    ];
+                }
+            }
+        } catch (\Throwable) {
+            // ignore — chart falls back to defaults
+        }
+
+        $reportEvents = [];
+        foreach ($detail->history as $ev) {
+            if (($ev['type'] ?? '') !== 'report') {
+                continue;
+            }
+            if (!isset($ev['at'], $ev['category'])) {
+                continue;
+            }
+            $reportEvents[] = [
+                'at' => (string) $ev['at'],
+                'category' => (string) $ev['category'],
+                'weight' => (float) ($ev['weight'] ?? 1.0),
+            ];
+        }
+
         return $this->twig->render($response, 'pages/ips/detail.twig', [
             'active_section' => 'ips',
             'detail' => $detail,
             'can_write' => in_array($user->role, ['operator', 'admin'], true),
+            'score_chart' => [
+                'reports' => $reportEvents,
+                'categories' => $categories,
+                'now' => gmdate('c'),
+            ],
         ]);
     }
 

+ 7 - 0
ui/tests/Integration/App/IpsPageTest.php

@@ -106,6 +106,12 @@ final class IpsPageTest extends AppTestCase
             ],
             'has_more' => false,
         ]);
+        $this->enqueueApiResponse(200, [
+            'items' => [
+                ['slug' => 'brute_force', 'decay_function' => 'exponential', 'decay_param' => 14],
+            ],
+            'total' => 1,
+        ]);
 
         $response = $this->request('GET', '/app/ips/203.0.113.10');
 
@@ -115,6 +121,7 @@ final class IpsPageTest extends AppTestCase
         self::assertStringContainsString('brute_force', $body);
         self::assertStringContainsString('web-prod-01', $body);
         self::assertStringContainsString('Score per category', $body);
+        self::assertStringContainsString('Score over time', $body);
         self::assertStringContainsString('History', $body);
     }
 

+ 2 - 0
ui/tests/Integration/Crud/CrudPagesTest.php

@@ -291,6 +291,7 @@ final class CrudPagesTest extends AppTestCase
             'history' => [],
             'has_more' => false,
         ]);
+        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
 
         $response = $this->request('GET', '/app/ips/203.0.113.10');
         $body = (string) $response->getBody();
@@ -312,6 +313,7 @@ final class CrudPagesTest extends AppTestCase
             'history' => [],
             'has_more' => false,
         ]);
+        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
 
         $response = $this->request('GET', '/app/ips/203.0.113.10');
         $body = (string) $response->getBody();