Quellcode durchsuchen

feat(audit): label entities and surface before/after diffs on updates

Adds a frozen-at-write `target_label` column to `audit_log` carrying the
most descriptive identifier for each event (reporter/consumer/policy
name, category slug, IP/CIDR text, token kind+prefix, job name). Update
events now record `details.changes = {field: {from, to}}` instead of a
bare list of changed field names, so the audit reader can see what
actually moved without diffing manually. The audit page renders the
label next to the numeric id and a Before/After diff table for updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa vor 1 Woche
Ursprung
Commit
441f54393d

+ 30 - 0
api/db/migrations/20260501120000_add_audit_target_label.php

@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * The numeric `target_id` is rarely meaningful to a human reader of the
+ * audit log. Add a sibling `target_label` column carrying the most
+ * descriptive identifier for the entity at the moment the event was
+ * recorded (reporter/consumer/policy name, category slug, IP/CIDR text,
+ * job name, token prefix). Frozen at write time — renaming the entity
+ * later does not rewrite history.
+ */
+final class AddAuditTargetLabel extends BaseMigration
+{
+    public function up(): void
+    {
+        $this->table('audit_log')
+            ->addColumn('target_label', 'string', ['limit' => 255, 'null' => true, 'after' => 'target_id'])
+            ->update();
+    }
+
+    public function down(): void
+    {
+        $this->table('audit_log')
+            ->removeColumn('target_label')
+            ->update();
+    }
+}

+ 2 - 1
api/openapi.php

@@ -255,7 +255,8 @@ $components = [
                 'action' => ['type' => 'string', 'example' => 'manual_block.created'],
                 'entity_type' => ['type' => 'string', 'nullable' => true],
                 'entity_id' => ['type' => 'string', 'nullable' => true],
-                'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true],
+                'entity_label' => ['type' => 'string', 'nullable' => true, 'description' => 'Human-readable identifier for the target (name, slug, IP, CIDR, prefix). Frozen at write time.'],
+                'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true, 'description' => 'For update events, contains a `changes` map of `{field: {from, to}}` for every modified field.'],
                 'source_ip' => ['type' => 'string', 'nullable' => true],
             ],
         ],

+ 5 - 0
api/public/openapi.yaml

@@ -1614,10 +1614,15 @@ components:
         entity_id:
           type: string
           nullable: true
+        entity_label:
+          type: string
+          nullable: true
+          description: Human-readable identifier for the target (name, slug, IP, CIDR, prefix). Frozen at write time.
         details:
           type: object
           nullable: true
           additionalProperties: true
+          description: 'For update events, contains a `changes` map of `{field: {from, to}}` for every modified field.'
         source_ip:
           type: string
           nullable: true

+ 44 - 0
api/src/Application/Admin/AdminControllerSupport.php

@@ -94,4 +94,48 @@ trait AdminControllerSupport
 
         return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
     }
+
+    /**
+     * Compute a `{field: {from, to}}` diff between two snapshots, restricted
+     * to keys that actually appear in `$after`. The audit log uses this on
+     * update events so a reader sees both the previous value and the new
+     * value side by side instead of just a list of changed field names.
+     *
+     * Equality is loose: scalar comparisons after string-coercion. Values
+     * that round-trip identically through `(string)$x` are not reported.
+     *
+     * @param array<string, mixed> $before snapshot before mutation
+     * @param array<string, mixed> $after  fields submitted for change (only these keys are compared)
+     * @return array<string, array{from: mixed, to: mixed}>
+     */
+    private static function diffFields(array $before, array $after): array
+    {
+        $changes = [];
+        foreach ($after as $key => $newValue) {
+            $oldValue = $before[$key] ?? null;
+            if (self::scalarEquals($oldValue, $newValue)) {
+                continue;
+            }
+            $changes[$key] = ['from' => $oldValue, 'to' => $newValue];
+        }
+
+        return $changes;
+    }
+
+    private static function scalarEquals(mixed $a, mixed $b): bool
+    {
+        if ($a === $b) {
+            return true;
+        }
+        if ($a === null || $b === null) {
+            return false;
+        }
+        if (is_scalar($a) && is_scalar($b)) {
+            // Catches "1.00" vs 1.0, true vs 1, etc. — the kinds of
+            // round-trip differences that aren't really changes.
+            return (string) $a === (string) $b;
+        }
+
+        return false;
+    }
 }

+ 8 - 1
api/src/Application/Admin/AllowlistController.php

@@ -129,6 +129,7 @@ final class AllowlistController
                 $id,
                 ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason],
                 self::auditContext($request),
+                $ip->text(),
             );
 
             return self::json($response, 201, $created->toArray());
@@ -173,6 +174,7 @@ final class AllowlistController
             $id,
             ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason],
             self::auditContext($request),
+            $cidr->text(),
         );
 
         return self::json($response, 201, $payload);
@@ -196,12 +198,17 @@ final class AllowlistController
         $this->evaluator->invalidate();
         $this->blocklistCache->invalidateAll();
 
+        $label = $existing->kind === AllowlistEntry::KIND_IP
+            ? $existing->ip?->text()
+            : $existing->cidr?->text();
+
         $this->audit->emit(
             AuditAction::ALLOWLIST_DELETED,
             'allowlist',
             $id,
-            ['kind' => $existing->kind, 'reason' => $existing->reason],
+            ['kind' => $existing->kind, 'ip' => $existing->ip?->text(), 'cidr' => $existing->cidr?->text(), 'reason' => $existing->reason],
             self::auditContext($request),
+            $label,
         );
 
         return $response->withStatus(204);

+ 13 - 1
api/src/Application/Admin/CategoriesController.php

@@ -135,6 +135,7 @@ final class CategoriesController
             $id,
             ['slug' => $slug, 'name' => $name, 'decay_function' => $decayFunction->value, 'decay_param' => $decayParam],
             self::auditContext($request),
+            $slug,
         );
 
         return self::json($response, 201, $created->toArray());
@@ -222,6 +223,15 @@ final class CategoriesController
             return self::validationFailed($response, $errors);
         }
 
+        $beforeSnapshot = [
+            'slug' => $existing->slug,
+            'name' => $existing->name,
+            'description' => $existing->description,
+            'decay_function' => $existing->decayFunction->value,
+            'decay_param' => number_format($existing->decayParam, 4, '.', ''),
+            'is_active' => $existing->isActive ? 1 : 0,
+        ];
+
         $this->categories->update($id, $fields);
         $updated = $this->categories->findById($id);
         if ($updated === null) {
@@ -232,8 +242,9 @@ final class CategoriesController
             AuditAction::CATEGORY_UPDATED,
             'category',
             $id,
-            ['slug' => $existing->slug, 'changed' => array_keys($fields)],
+            ['slug' => $existing->slug, 'changes' => self::diffFields($beforeSnapshot, $fields)],
             self::auditContext($request),
+            $updated->slug,
         );
 
         return self::json($response, 200, $updated->toArray());
@@ -274,6 +285,7 @@ final class CategoriesController
             $id,
             ['slug' => $existing->slug, 'name' => $existing->name],
             self::auditContext($request),
+            $existing->slug,
         );
 
         return $response->withStatus(204);

+ 11 - 1
api/src/Application/Admin/ConsumersController.php

@@ -115,6 +115,7 @@ final class ConsumersController
             $id,
             ['name' => $name, 'policy_id' => $policyId, 'description' => $description],
             self::auditContext($request),
+            $name,
         );
 
         return self::json($response, 201, $created->toArray());
@@ -185,6 +186,13 @@ final class ConsumersController
             return self::validationFailed($response, $errors);
         }
 
+        $beforeSnapshot = [
+            'name' => $existing->name,
+            'description' => $existing->description,
+            'policy_id' => $existing->policyId,
+            'is_active' => $existing->isActive ? 1 : 0,
+        ];
+
         $this->consumers->update($id, $fields);
         $updated = $this->consumers->findById($id);
         if ($updated === null) {
@@ -195,8 +203,9 @@ final class ConsumersController
             AuditAction::CONSUMER_UPDATED,
             'consumer',
             $id,
-            ['changed' => array_keys($fields)],
+            ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
             self::auditContext($request),
+            $updated->name,
         );
 
         return self::json($response, 200, $updated->toArray());
@@ -224,6 +233,7 @@ final class ConsumersController
             $id,
             ['name' => $existing->name, 'soft' => true],
             self::auditContext($request),
+            $existing->name,
         );
 
         return $response->withStatus(204);

+ 1 - 0
api/src/Application/Admin/JobsAdminController.php

@@ -126,6 +126,7 @@ final class JobsAdminController
             $name,
             ['name' => $name, 'params' => $params, 'triggered_by' => 'manual'],
             self::auditContext($request),
+            $name,
         );
 
         $job = $this->registry->get($name);

+ 2 - 0
api/src/Application/Admin/MaintenanceController.php

@@ -263,6 +263,7 @@ final class MaintenanceController
             null,
             ['deleted' => $deleted],
             self::auditContext($request),
+            'purge',
         );
 
         return self::json($response, 200, [
@@ -492,6 +493,7 @@ final class MaintenanceController
             null,
             ['summary' => $summary],
             self::auditContext($request),
+            'seed-demo',
         );
 
         return self::json($response, 200, [

+ 8 - 1
api/src/Application/Admin/ManualBlocksController.php

@@ -153,6 +153,7 @@ final class ManualBlocksController
                 $id,
                 ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
                 self::auditContext($request),
+                $ip->text(),
             );
 
             return self::json($response, 201, $created->toArray());
@@ -197,6 +198,7 @@ final class ManualBlocksController
             $id,
             ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
             self::auditContext($request),
+            $cidr->text(),
         );
 
         return self::json($response, 201, $payload);
@@ -220,12 +222,17 @@ final class ManualBlocksController
         $this->evaluator->invalidate();
         $this->blocklistCache->invalidateAll();
 
+        $label = $existing->kind === ManualBlock::KIND_IP
+            ? $existing->ip?->text()
+            : $existing->cidr?->text();
+
         $this->audit->emit(
             AuditAction::MANUAL_BLOCK_DELETED,
             'manual_block',
             $id,
-            ['kind' => $existing->kind, 'reason' => $existing->reason],
+            ['kind' => $existing->kind, 'ip' => $existing->ip?->text(), 'cidr' => $existing->cidr?->text(), 'reason' => $existing->reason],
             self::auditContext($request),
+            $label,
         );
 
         return $response->withStatus(204);

+ 41 - 4
api/src/Application/Admin/PoliciesController.php

@@ -138,6 +138,7 @@ final class PoliciesController
             $id,
             ['name' => $name, 'include_manual_blocks' => $includeManualBlocks, 'threshold_count' => count($thresholds)],
             self::auditContext($request),
+            $name,
         );
 
         return self::json($response, 201, $created->toArray($this->slugByCategoryId()));
@@ -207,6 +208,16 @@ final class PoliciesController
             return self::validationFailed($response, $errors);
         }
 
+        $slugByCategoryId = $this->slugByCategoryId();
+
+        $beforeSnapshot = [
+            'name' => $existing->name,
+            'description' => $existing->description,
+            'include_manual_blocks' => $existing->includeManualBlocks ? 1 : 0,
+        ];
+
+        $beforeThresholds = self::thresholdsBySlug($existing->thresholds, $slugByCategoryId);
+
         if ($fields !== []) {
             $this->policies->update($id, $fields);
         }
@@ -219,19 +230,24 @@ final class PoliciesController
             return self::error($response, 500, 'update_failed');
         }
 
-        $changed = array_keys($fields);
+        $changes = self::diffFields($beforeSnapshot, $fields);
         if ($thresholds !== null) {
-            $changed[] = 'thresholds';
+            $afterThresholds = self::thresholdsBySlug($thresholds, $slugByCategoryId);
+            if ($beforeThresholds !== $afterThresholds) {
+                $changes['thresholds'] = ['from' => $beforeThresholds, 'to' => $afterThresholds];
+            }
         }
+
         $this->audit->emit(
             AuditAction::POLICY_UPDATED,
             'policy',
             $id,
-            ['changed' => $changed],
+            ['name' => $existing->name, 'changes' => $changes],
             self::auditContext($request),
+            $updated->name,
         );
 
-        return self::json($response, 200, $updated->toArray($this->slugByCategoryId()));
+        return self::json($response, 200, $updated->toArray($slugByCategoryId));
     }
 
     /**
@@ -265,6 +281,7 @@ final class PoliciesController
             $id,
             ['name' => $existing->name],
             self::auditContext($request),
+            $existing->name,
         );
 
         return $response->withStatus(204);
@@ -404,6 +421,26 @@ final class PoliciesController
         return $dt?->format('Y-m-d\TH:i:s\Z');
     }
 
+    /**
+     * Convert `[category_id => threshold]` to `[category_slug => threshold]` for
+     * audit-log readability, sorted by slug so before/after diffs compare cleanly.
+     *
+     * @param array<int, float> $byId
+     * @param array<int, string> $slugByCategoryId
+     * @return array<string, float>
+     */
+    private static function thresholdsBySlug(array $byId, array $slugByCategoryId): array
+    {
+        $out = [];
+        foreach ($byId as $catId => $threshold) {
+            $slug = $slugByCategoryId[$catId] ?? ('category_' . $catId);
+            $out[$slug] = $threshold;
+        }
+        ksort($out);
+
+        return $out;
+    }
+
     /**
      * Resolve a body-supplied `{slug: number}` map to `[category_id => float]`.
      * Returns the integer-keyed map on success, or a single human-readable

+ 12 - 1
api/src/Application/Admin/ReportersController.php

@@ -112,6 +112,7 @@ final class ReportersController
             $id,
             ['name' => $name, 'trust_weight' => $trustWeight, 'description' => $description],
             self::auditContext($request),
+            $name,
         );
 
         return self::json($response, 201, $created->toArray());
@@ -179,6 +180,13 @@ final class ReportersController
             return self::validationFailed($response, $errors);
         }
 
+        $beforeSnapshot = [
+            'name' => $existing->name,
+            'description' => $existing->description,
+            'trust_weight' => number_format($existing->trustWeight, 2, '.', ''),
+            'is_active' => $existing->isActive ? 1 : 0,
+        ];
+
         $this->reporters->update($id, $fields);
         $updated = $this->reporters->findById($id);
         if ($updated === null) {
@@ -189,8 +197,9 @@ final class ReportersController
             AuditAction::REPORTER_UPDATED,
             'reporter',
             $id,
-            ['changed' => array_keys($fields)],
+            ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
             self::auditContext($request),
+            $updated->name,
         );
 
         return self::json($response, 200, $updated->toArray());
@@ -219,6 +228,7 @@ final class ReportersController
                 $id,
                 ['name' => $existing->name, 'soft' => true, 'reason' => 'has_reports'],
                 self::auditContext($request),
+                $existing->name,
             );
 
             return self::json($response, 409, [
@@ -234,6 +244,7 @@ final class ReportersController
             $id,
             ['name' => $existing->name, 'soft' => true],
             self::auditContext($request),
+            $existing->name,
         );
 
         return $response->withStatus(204);

+ 14 - 0
api/src/Application/Admin/TokensController.php

@@ -195,6 +195,7 @@ final class TokensController
                 'expires_at' => $created->expiresAt?->format('c'),
             ],
             self::auditContext($request),
+            self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value),
         );
 
         return self::json($response, 201, [
@@ -234,6 +235,7 @@ final class TokensController
             $id,
             ['kind' => $token->kind->value, 'prefix' => $token->prefix],
             self::auditContext($request),
+            self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
         );
 
         return $response->withStatus(204);
@@ -264,4 +266,16 @@ final class TokensController
     {
         return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
     }
+
+    /**
+     * Compose a human-friendly label like "admin viewer (abc12345…)" or
+     * "consumer (zxcvbnm9…)". Includes the role for admin tokens so the
+     * audit reader can tell a viewer-token revoke from an admin-token revoke.
+     */
+    private static function tokenLabel(string $kind, string $prefix, ?string $role): string
+    {
+        $base = $kind === 'admin' && $role !== null ? sprintf('admin %s', $role) : $kind;
+
+        return sprintf('%s (%s…)', $base, $prefix);
+    }
 }

+ 15 - 1
api/src/Application/Jobs/CleanupExpiredManualBlocksJob.php

@@ -10,6 +10,7 @@ use App\Domain\Audit\AuditEmitter;
 use App\Domain\Jobs\Job;
 use App\Domain\Jobs\JobContext;
 use App\Domain\Jobs\JobResult;
+use App\Domain\ManualBlock\ManualBlock;
 use App\Infrastructure\ManualBlock\ManualBlockRepository;
 use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
@@ -62,6 +63,18 @@ final class CleanupExpiredManualBlocksJob implements Job
             return new JobResult(itemsProcessed: 0);
         }
 
+        // Snapshot labels before the rows disappear so the audit row carries
+        // the IP/CIDR text rather than just an opaque numeric id.
+        $labels = [];
+        foreach ($expiredIds as $id) {
+            $row = $this->manualBlocks->findById($id);
+            $labels[$id] = $row === null
+                ? null
+                : ($row->kind === ManualBlock::KIND_IP
+                    ? $row->ip?->text()
+                    : $row->cidr?->text());
+        }
+
         $deleted = $this->manualBlocks->deleteExpired($now);
 
         $auditCtx = AuditContext::system();
@@ -70,8 +83,9 @@ final class CleanupExpiredManualBlocksJob implements Job
                 AuditAction::MANUAL_BLOCK_DELETED,
                 'manual_block',
                 (string) $id,
-                ['reason' => 'expired', 'job' => self::NAME],
+                ['reason' => 'expired', 'job' => self::NAME, 'target' => $labels[$id] ?? null],
                 $auditCtx,
+                $labels[$id] ?? null,
             );
         }
 

+ 4 - 0
api/src/Domain/Audit/AuditEmitter.php

@@ -17,6 +17,9 @@ interface AuditEmitter
     /**
      * @param array<string, mixed> $payload Free-form event payload, JSON-encoded into details_json.
      *     MUST NOT contain raw secrets (raw tokens, passwords).
+     * @param string|null $entityLabel Human-readable identifier (name, slug, IP, CIDR, prefix).
+     *     Frozen at write time — later renames don't rewrite history. Pass null only when no
+     *     meaningful label exists (system-wide actions like maintenance.purged).
      */
     public function emit(
         string $action,
@@ -24,5 +27,6 @@ interface AuditEmitter
         int|string|null $entityId,
         array $payload,
         AuditContext $context,
+        ?string $entityLabel = null,
     ): void;
 }

+ 5 - 2
api/src/Infrastructure/Audit/AuditRepository.php

@@ -28,6 +28,7 @@ class AuditRepository extends RepositoryBase
         string $action,
         ?string $entityType,
         int|string|null $entityId,
+        ?string $entityLabel,
         string $detailsJson,
         AuditContext $context,
     ): int {
@@ -38,6 +39,7 @@ class AuditRepository extends RepositoryBase
             'action' => $action,
             'target_type' => $entityType,
             'target_id' => $entityId !== null ? (string) $entityId : null,
+            'target_label' => $entityLabel,
             'details_json' => $detailsJson,
             'ip_address' => $context->sourceIp,
             'created_at' => $now,
@@ -57,7 +59,7 @@ class AuditRepository extends RepositoryBase
      *     to?: ?string,
      * } $filters
      * @return array{
-     *     items: list<array{id: int, occurred_at: string, actor_kind: string, actor_id: ?string, action: string, entity_type: ?string, entity_id: ?string, details: array<string, mixed>|null, source_ip: ?string}>,
+     *     items: list<array{id: int, occurred_at: string, actor_kind: string, actor_id: ?string, action: string, entity_type: ?string, entity_id: ?string, entity_label: ?string, details: array<string, mixed>|null, source_ip: ?string}>,
      *     total: int,
      * }
      */
@@ -103,7 +105,7 @@ class AuditRepository extends RepositoryBase
         $itemTypes = ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER];
 
         $rows = $this->connection()->fetchAllAssociative(
-            'SELECT id, actor_kind, actor_id, action, target_type, target_id, details_json, ip_address, created_at '
+            'SELECT id, actor_kind, actor_id, action, target_type, target_id, target_label, details_json, ip_address, created_at '
             . 'FROM audit_log' . $whereSql . ' ORDER BY id DESC LIMIT :limit OFFSET :offset',
             $itemsParams,
             $itemTypes,
@@ -132,6 +134,7 @@ class AuditRepository extends RepositoryBase
                 'action' => (string) $row['action'],
                 'entity_type' => $row['target_type'] !== null ? (string) $row['target_type'] : null,
                 'entity_id' => $row['target_id'] !== null ? (string) $row['target_id'] : null,
+                'entity_label' => $row['target_label'] !== null ? (string) $row['target_label'] : null,
                 'details' => $details,
                 'source_ip' => $row['ip_address'] !== null ? (string) $row['ip_address'] : null,
             ];

+ 3 - 1
api/src/Infrastructure/Audit/DbAuditEmitter.php

@@ -30,17 +30,19 @@ final class DbAuditEmitter implements AuditEmitter
         int|string|null $entityId,
         array $payload,
         AuditContext $context,
+        ?string $entityLabel = null,
     ): void {
         $type = $entityType ?? AuditAction::entityTypeFor($action);
 
         try {
             $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
-            $this->repo->insert($action, $type, $entityId, $json, $context);
+            $this->repo->insert($action, $type, $entityId, $entityLabel, $json, $context);
         } catch (Throwable $e) {
             $this->logger->error('audit_emit_failed', [
                 'action' => $action,
                 'entity_type' => $type,
                 'entity_id' => $entityId,
+                'entity_label' => $entityLabel,
                 'error' => $e->getMessage(),
             ]);
         }

+ 50 - 3
ui/resources/views/pages/audit/index.twig

@@ -106,9 +106,14 @@
                                 {% if ev.actor_id %}<span class="ml-1 font-mono text-slate-500">#{{ ev.actor_id }}</span>{% endif %}
                             </td>
                             <td class="px-4 py-2 align-top" data-sort-value="{{ ev.action }}">{{ h.action_pill(ev.action) }}</td>
-                            <td class="px-4 py-2 align-top text-xs" data-sort-value="{{ ev.entity_type|default('') }} {{ ev.entity_id|default('') }}">
+                            <td class="px-4 py-2 align-top text-xs" data-sort-value="{{ ev.entity_type|default('') }} {{ ev.entity_label|default('') }} {{ ev.entity_id|default('') }}">
                                 <span class="font-mono text-slate-600 dark:text-slate-300">{{ ev.entity_type|default('—') }}</span>
-                                {% if ev.entity_id %}<span class="ml-1 font-mono text-slate-500">#{{ ev.entity_id }}</span>{% endif %}
+                                {% if ev.entity_label %}
+                                    <span class="ml-1 font-medium text-slate-800 dark:text-slate-100">{{ ev.entity_label }}</span>
+                                    {% if ev.entity_id %}<span class="ml-1 font-mono text-[0.7rem] text-slate-400">#{{ ev.entity_id }}</span>{% endif %}
+                                {% elseif ev.entity_id %}
+                                    <span class="ml-1 font-mono text-slate-500">#{{ ev.entity_id }}</span>
+                                {% endif %}
                             </td>
                             <td class="px-4 py-2 align-top font-mono text-xs text-slate-500" data-sort-value="{{ ev.source_ip|default('') }}">{{ ev.source_ip|default('—') }}</td>
                             <td class="px-4 py-2 align-top text-right">
@@ -122,7 +127,49 @@
                         {% if ev.details %}
                             <tr x-show="open === {{ ev.id }}" x-cloak data-sort-row-detail>
                                 <td colspan="6" class="bg-slate-50 px-4 py-3 dark:bg-slate-950">
-                                    <pre class="overflow-x-auto rounded bg-white p-3 text-xs dark:bg-slate-900">{{ ev.details|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
+                                    {% if ev.details.changes is defined and ev.details.changes is iterable and ev.details.changes|length > 0 %}
+                                        <div class="mb-3">
+                                            <div class="mb-1 text-[0.7rem] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Changes</div>
+                                            <table class="w-full text-xs">
+                                                <thead class="text-left text-[0.7rem] uppercase text-slate-500 dark:text-slate-400">
+                                                    <tr>
+                                                        <th class="px-2 py-1 font-medium">Field</th>
+                                                        <th class="px-2 py-1 font-medium">Before</th>
+                                                        <th class="px-2 py-1 font-medium">After</th>
+                                                    </tr>
+                                                </thead>
+                                                <tbody class="divide-y divide-slate-200 bg-white dark:divide-slate-800 dark:bg-slate-900">
+                                                    {% for field, change in ev.details.changes %}
+                                                        <tr>
+                                                            <td class="px-2 py-1 font-mono text-slate-700 dark:text-slate-200">{{ field }}</td>
+                                                            <td class="px-2 py-1 align-top">
+                                                                {% if change.from is null %}
+                                                                    <span class="text-slate-400">∅</span>
+                                                                {% elseif change.from is iterable %}
+                                                                    <pre class="overflow-x-auto rounded bg-rose-50 px-2 py-1 font-mono text-[0.7rem] text-rose-900 dark:bg-rose-950 dark:text-rose-200">{{ change.from|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
+                                                                {% else %}
+                                                                    <span class="rounded bg-rose-50 px-1.5 py-0.5 font-mono text-rose-900 dark:bg-rose-950 dark:text-rose-200">{{ change.from }}</span>
+                                                                {% endif %}
+                                                            </td>
+                                                            <td class="px-2 py-1 align-top">
+                                                                {% if change.to is null %}
+                                                                    <span class="text-slate-400">∅</span>
+                                                                {% elseif change.to is iterable %}
+                                                                    <pre class="overflow-x-auto rounded bg-emerald-50 px-2 py-1 font-mono text-[0.7rem] text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ change.to|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
+                                                                {% else %}
+                                                                    <span class="rounded bg-emerald-50 px-1.5 py-0.5 font-mono text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ change.to }}</span>
+                                                                {% endif %}
+                                                            </td>
+                                                        </tr>
+                                                    {% endfor %}
+                                                </tbody>
+                                            </table>
+                                        </div>
+                                    {% endif %}
+                                    <details class="text-xs" {% if ev.details.changes is not defined %}open{% endif %}>
+                                        <summary class="cursor-pointer text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200">Raw payload</summary>
+                                        <pre class="mt-2 overflow-x-auto rounded bg-white p-3 dark:bg-slate-900">{{ ev.details|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
+                                    </details>
                                 </td>
                             </tr>
                         {% endif %}