ソースを参照

feat(ui): show estimated fall-off date per IP in policy preview

The policy edit Preview list now renders, alongside each blocklisted
IP, the date it would drop out of the list. For scored entries the
estimate is derived from the per-category score, threshold, and decay
function; for manual blocks it shows the configured `expires_at` (or
"never"). Also focuses the username input on the login page so a
keyboard user can sign in without grabbing the mouse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 週間 前
コミット
af197101b0

+ 80 - 2
api/src/Application/Admin/PoliciesController.php

@@ -9,9 +9,13 @@ use App\Domain\Audit\AuditEmitter;
 use App\Domain\Category\Category;
 use App\Domain\Policy\Policy;
 use App\Domain\Reputation\BlocklistBuilder;
+use App\Domain\Reputation\Decay;
+use App\Domain\Time\Clock;
 use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
 use App\Infrastructure\Policy\PolicyRepository;
 use App\Infrastructure\Reputation\BlocklistCache;
+use DateTimeImmutable;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -36,6 +40,8 @@ final class PoliciesController
         private readonly BlocklistBuilder $blocklistBuilder,
         private readonly BlocklistCache $blocklistCache,
         private readonly AuditEmitter $audit,
+        private readonly ManualBlockRepository $manualBlocks,
+        private readonly Clock $clock,
     ) {
     }
 
@@ -278,16 +284,88 @@ final class PoliciesController
 
         $blocklist = $this->blocklistBuilder->build($policy);
         $sample = array_slice($blocklist->entries, 0, 50);
-        $sampleStrings = array_map(static fn ($e) => $e->ipOrCidr, $sample);
+
+        $catBySlug = [];
+        foreach ($this->categories->listAll() as $cat) {
+            /** @var Category $cat */
+            $catBySlug[$cat->slug] = $cat;
+        }
+        $thresholdBySlug = [];
+        $slugByCategoryId = $this->slugByCategoryId();
+        foreach ($policy->thresholds as $catId => $threshold) {
+            $slug = $slugByCategoryId[$catId] ?? null;
+            if ($slug !== null) {
+                $thresholdBySlug[$slug] = $threshold;
+            }
+        }
+
+        $manualExpiryByKey = [];
+        foreach ($this->manualBlocks->listIps() as $mb) {
+            if ($mb->ip !== null) {
+                $manualExpiryByKey['ip:' . $mb->ip->text()] = $mb->expiresAt;
+            }
+        }
+        foreach ($this->manualBlocks->listSubnets() as $mb) {
+            if ($mb->cidr !== null) {
+                $manualExpiryByKey['cidr:' . $mb->cidr->text()] = $mb->expiresAt;
+            }
+        }
+
+        $now = $this->clock->now();
+        $sampleOut = [];
+        foreach ($sample as $entry) {
+            $expiresAt = null;
+            $estimated = false;
+            if ($entry->reason === 'scored') {
+                $maxDays = null;
+                foreach ($entry->categoryScores as $slug => $score) {
+                    if (!isset($thresholdBySlug[$slug], $catBySlug[$slug])) {
+                        continue;
+                    }
+                    $cat = $catBySlug[$slug];
+                    $days = Decay::daysUntilThreshold(
+                        $cat->decayFunction,
+                        $cat->decayParam,
+                        $score,
+                        $thresholdBySlug[$slug],
+                    );
+                    if ($days === null) {
+                        continue;
+                    }
+                    if ($maxDays === null || $days > $maxDays) {
+                        $maxDays = $days;
+                    }
+                }
+                if ($maxDays !== null) {
+                    $seconds = (int) round($maxDays * 86400.0);
+                    $expiresAt = $now->modify(($seconds >= 0 ? '+' : '') . $seconds . ' seconds');
+                    $estimated = true;
+                }
+            } elseif ($entry->reason === 'manual') {
+                $key = ($entry->isCidr ? 'cidr:' : 'ip:') . $entry->ipOrCidr;
+                $expiresAt = $manualExpiryByKey[$key] ?? null;
+            }
+            $sampleOut[] = [
+                'ip_or_cidr' => $entry->ipOrCidr,
+                'reason' => $entry->reason,
+                'expires_at' => self::isoOrNull($expiresAt),
+                'expires_estimated' => $estimated,
+            ];
+        }
 
         return self::json($response, 200, [
             'count' => $blocklist->count(),
-            'sample' => $sampleStrings,
+            'sample' => $sampleOut,
             'generated_at' => $blocklist->generatedAt->format('Y-m-d\TH:i:s\Z'),
             'policy' => $policy->name,
         ]);
     }
 
+    private static function isoOrNull(?DateTimeImmutable $dt): ?string
+    {
+        return $dt?->format('Y-m-d\TH:i:s\Z');
+    }
+
     /**
      * Resolve a body-supplied `{slug: number}` map to `[category_id => float]`.
      * Returns the integer-keyed map on success, or a single human-readable

+ 12 - 4
api/src/Domain/Reputation/BlocklistBuilder.php

@@ -70,6 +70,8 @@ final class BlocklistBuilder
         $categoriesByIp = [];
         /** @var array<string, float> $maxScoreByIp */
         $maxScoreByIp = [];
+        /** @var array<string, array<int, float>> $scoresByIp ip_bin => category_id => score */
+        $scoresByIp = [];
 
         foreach ($rows as $row) {
             $bin = (string) $row['ip_bin'];
@@ -79,10 +81,12 @@ final class BlocklistBuilder
                 $ipText[$bin] = (string) $row['ip_text'];
                 $categoriesByIp[$bin] = [$catId];
                 $maxScoreByIp[$bin] = $score;
+                $scoresByIp[$bin] = [$catId => $score];
 
                 continue;
             }
             $categoriesByIp[$bin][] = $catId;
+            $scoresByIp[$bin][$catId] = $score;
             if ($score > $maxScoreByIp[$bin]) {
                 $maxScoreByIp[$bin] = $score;
             }
@@ -97,14 +101,14 @@ final class BlocklistBuilder
         if ($allowIpHash !== []) {
             foreach ($ipText as $bin => $_) {
                 if (isset($allowIpHash[$bin])) {
-                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin]);
+                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin], $scoresByIp[$bin]);
                 }
             }
         }
         if ($allowSubnetsByPrefix !== []) {
             foreach ($ipText as $bin => $_) {
                 if (self::binaryInSubnetIndex($bin, $allowSubnetsByPrefix)) {
-                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin]);
+                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin], $scoresByIp[$bin]);
                 }
             }
         }
@@ -159,7 +163,7 @@ final class BlocklistBuilder
         if ($manualSubnetsByPrefix !== [] && $ipText !== []) {
             foreach ($ipText as $bin => $_) {
                 if (self::binaryInSubnetIndex($bin, $manualSubnetsByPrefix)) {
-                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin]);
+                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin], $scoresByIp[$bin]);
                 }
             }
         }
@@ -186,9 +190,12 @@ final class BlocklistBuilder
 
         foreach ($ipText as $bin => $text) {
             $categories = [];
+            $categoryScores = [];
             foreach ($categoriesByIp[$bin] as $catId) {
                 if (isset($slugByCategoryId[$catId])) {
-                    $categories[] = $slugByCategoryId[$catId];
+                    $slug = $slugByCategoryId[$catId];
+                    $categories[] = $slug;
+                    $categoryScores[$slug] = round($scoresByIp[$bin][$catId], 4);
                 }
             }
             sort($categories);
@@ -200,6 +207,7 @@ final class BlocklistBuilder
                 categories: $categories,
                 score: round($maxScoreByIp[$bin], 4),
                 reason: 'scored',
+                categoryScores: $categoryScores,
             );
             if ($isIpv4) {
                 $v4Bucket[$bin] = $entry;

+ 6 - 1
api/src/Domain/Reputation/BlocklistEntry.php

@@ -18,7 +18,11 @@ namespace App\Domain\Reputation;
 final class BlocklistEntry
 {
     /**
-     * @param list<string> $categories category slugs that matched (only for `scored`)
+     * @param list<string>             $categories     category slugs that matched (only for `scored`)
+     * @param array<string, float>     $categoryScores per-matched-category score, keyed by slug;
+     *                                                 empty for `manual`. Carried for the policy
+     *                                                 preview's expiry estimate; not exposed in the
+     *                                                 public blocklist JSON.
      */
     public function __construct(
         public readonly string $ipOrCidr,
@@ -27,6 +31,7 @@ final class BlocklistEntry
         public readonly array $categories,
         public readonly ?float $score,
         public readonly string $reason,
+        public readonly array $categoryScores = [],
     ) {
     }
 

+ 36 - 0
api/src/Domain/Reputation/Decay.php

@@ -30,4 +30,40 @@ final class Decay
             DecayFunction::Exponential => 0.5 ** ($age / $decayParam),
         };
     }
+
+    /**
+     * Inverse of `value()`: roughly how many days until a current score
+     * decays below `$threshold`, assuming no further reports.
+     *
+     * Exponential is exact (the sum scales by 0.5^(Δ/T) regardless of
+     * report ages). Linear treats the current score as a lump at "now"
+     * — an upper bound on time-to-threshold; the real curve drops faster
+     * as individual reports age out. Used by the policy preview to give
+     * operators an at-a-glance "when does this entry fall off?".
+     *
+     * Returns 0.0 if the score is already at or below the threshold,
+     * `null` if the threshold is zero/negative (entry would never fall
+     * off naturally).
+     */
+    public static function daysUntilThreshold(
+        DecayFunction $function,
+        float $decayParam,
+        float $currentScore,
+        float $threshold,
+    ): ?float {
+        if ($decayParam <= 0.0) {
+            return 0.0;
+        }
+        if ($threshold <= 0.0) {
+            return null;
+        }
+        if ($currentScore <= $threshold) {
+            return 0.0;
+        }
+
+        return match ($function) {
+            DecayFunction::Linear => $decayParam * (1.0 - $threshold / $currentScore),
+            DecayFunction::Exponential => $decayParam * (log($currentScore / $threshold) / log(2.0)),
+        };
+    }
 }

+ 39 - 0
api/tests/Unit/Reputation/DecayTest.php

@@ -58,4 +58,43 @@ final class DecayTest extends TestCase
         self::assertSame(0.0, Decay::value(DecayFunction::Linear, 5.0, 0.0));
         self::assertSame(0.0, Decay::value(DecayFunction::Exponential, 5.0, 0.0));
     }
+
+    public function testDaysUntilThresholdLinearHalfwayCase(): void
+    {
+        // Linear decay, T=30 days. Score=2.0, threshold=1.0 → score reaches
+        // threshold when 2.0 * (1 − Δ/30) = 1.0, i.e. Δ = 15.
+        self::assertEqualsWithDelta(
+            15.0,
+            Decay::daysUntilThreshold(DecayFunction::Linear, 30.0, 2.0, 1.0) ?? -1.0,
+            1e-9,
+        );
+    }
+
+    public function testDaysUntilThresholdExponentialHalfLife(): void
+    {
+        // Exponential half-life=14 days. Score=2.0, threshold=1.0 → Δ = 14.
+        self::assertEqualsWithDelta(
+            14.0,
+            Decay::daysUntilThreshold(DecayFunction::Exponential, 14.0, 2.0, 1.0) ?? -1.0,
+            1e-9,
+        );
+        // Two half-lives: 4.0 → 1.0 takes 28 days.
+        self::assertEqualsWithDelta(
+            28.0,
+            Decay::daysUntilThreshold(DecayFunction::Exponential, 14.0, 4.0, 1.0) ?? -1.0,
+            1e-9,
+        );
+    }
+
+    public function testDaysUntilThresholdReturnsZeroWhenAlreadyBelow(): void
+    {
+        self::assertSame(0.0, Decay::daysUntilThreshold(DecayFunction::Linear, 30.0, 0.5, 1.0));
+        self::assertSame(0.0, Decay::daysUntilThreshold(DecayFunction::Exponential, 14.0, 1.0, 1.0));
+    }
+
+    public function testDaysUntilThresholdReturnsNullForNonPositiveThreshold(): void
+    {
+        self::assertNull(Decay::daysUntilThreshold(DecayFunction::Linear, 30.0, 5.0, 0.0));
+        self::assertNull(Decay::daysUntilThreshold(DecayFunction::Exponential, 14.0, 5.0, -0.5));
+    }
 }

+ 3 - 1
ui/resources/views/pages/login.twig

@@ -34,13 +34,15 @@
                 {% endif %}
                 <form x-show="open"
                       x-cloak
+                      x-init="$watch('open', (v) => { if (v) $nextTick(() => $refs.usernameInput && $refs.usernameInput.focus()); })"
                       method="post"
                       action="/login/local"
                       class="mt-4 space-y-3">
                     {% include 'partials/csrf.twig' %}
                     <div>
                         <label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300">Username</label>
-                        <input type="text" name="username" id="username" required autocomplete="username"
+                        <input type="text" name="username" id="username" x-ref="usernameInput" required autocomplete="username"
+                               {% if not oidc_enabled %}autofocus{% endif %}
                                class="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950">
                     </div>
                     <div>

+ 63 - 6
ui/resources/views/pages/policies/edit.twig

@@ -102,17 +102,73 @@
                 <span><span class="font-mono" x-text="count"></span> entries</span>
             </template>
         </p>
-        <ul class="mt-3 max-h-60 space-y-1 overflow-y-auto font-mono text-xs text-slate-700 dark:text-slate-300">
-            <template x-for="entry in sample" :key="entry">
-                <li x-text="entry"></li>
+        <ul class="mt-3 max-h-60 divide-y divide-slate-100 overflow-y-auto text-xs dark:divide-slate-800">
+            <template x-for="entry in sample" :key="entry.key">
+                <li class="flex items-baseline justify-between gap-3 py-1">
+                    <span class="font-mono text-slate-700 dark:text-slate-300" x-text="entry.label"></span>
+                    <span class="shrink-0 text-slate-500 dark:text-slate-400" :title="entry.tooltip" x-text="entry.expiry"></span>
+                </li>
             </template>
         </ul>
-        <p class="mt-2 text-xs text-slate-400">Sample = first 50 entries from the rendered blocklist.</p>
+        <p class="mt-2 text-xs text-slate-400">
+            Sample = first 50 entries from the rendered blocklist. Expiry for scored
+            entries is an estimate assuming no further reports; manual entries show
+            the configured expiry.
+        </p>
     </section>
 </div>
 
 <script>
 window.policyPreview = function (id) {
+    function localeFallback() {
+        const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
+        const locales = [];
+        if (typeof navigator !== 'undefined' && navigator.language) {
+            locales.push(navigator.language);
+        }
+        if (fallback && fallback.trim()) {
+            locales.push(fallback.trim());
+        }
+        return locales.length > 0 ? locales : undefined;
+    }
+    let formatter;
+    try {
+        formatter = new Intl.DateTimeFormat(localeFallback(), {
+            year: 'numeric', month: '2-digit', day: '2-digit',
+            hour: '2-digit', minute: '2-digit',
+        });
+    } catch (e) {
+        formatter = null;
+    }
+    function formatExpiry(iso) {
+        if (!iso) return '';
+        const d = new Date(iso);
+        if (isNaN(d.getTime())) return iso;
+        if (!formatter) return iso;
+        try { return formatter.format(d); } catch (_) { return iso; }
+    }
+    function shapeEntry(raw, idx) {
+        if (typeof raw === 'string') {
+            return { key: 'r' + idx, label: raw, expiry: '', tooltip: '' };
+        }
+        const ip = raw.ip_or_cidr || '';
+        let expiry = '';
+        let tooltip = '';
+        if (raw.expires_at) {
+            const formatted = formatExpiry(raw.expires_at);
+            expiry = (raw.expires_estimated ? '~ ' : '') + formatted;
+            tooltip = (raw.expires_estimated
+                ? 'Estimated falls-off date (assumes no further reports). ISO: '
+                : 'Configured manual block expiry. ISO: ') + raw.expires_at;
+        } else if (raw.reason === 'manual') {
+            expiry = 'never';
+            tooltip = 'Manual block has no configured expiry';
+        } else {
+            expiry = '—';
+            tooltip = 'Score does not decay below threshold (threshold ≤ 0)';
+        }
+        return { key: 'r' + idx + ':' + ip, label: ip, expiry: expiry, tooltip: tooltip };
+    }
     return {
         loading: true,
         count: 0,
@@ -124,10 +180,11 @@ window.policyPreview = function (id) {
                 if (!res.ok) throw new Error('preview ' + res.status);
                 const data = await res.json();
                 this.count = data.count || 0;
-                this.sample = data.sample || [];
+                const items = Array.isArray(data.sample) ? data.sample : [];
+                this.sample = items.map(shapeEntry);
             } catch (e) {
                 this.count = 0;
-                this.sample = ['(preview unavailable)'];
+                this.sample = [{ key: 'err', label: '(preview unavailable)', expiry: '', tooltip: '' }];
             } finally {
                 this.loading = false;
             }