ソースを参照

feat(ui): render dates and times in the browser locale

Templates previously emitted ISO 8601 UTC strings verbatim (Zulu time),
which forced operators to mentally convert. Now timestamps are wrapped
in <time class="irdb-dt" datetime="…"> elements; a small client-side
pass replaces the text content with Intl.DateTimeFormat output on load
and after htmx swaps. Browser locale wins; deployments can configure a
UI_LOCALE BCP 47 fallback that's appended after navigator.language.

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

+ 6 - 0
.env.example

@@ -97,3 +97,9 @@ LOCAL_ADMIN_ENABLED=true
 LOCAL_ADMIN_USERNAME=admin
 # Generate with: php -r "echo password_hash('s3cret', PASSWORD_ARGON2ID);"
 LOCAL_ADMIN_PASSWORD_HASH=
+
+# Optional BCP 47 locale fallback for date/time formatting (e.g. "de-CH",
+# "en-GB"). The browser's locale wins; this is appended as a fallback so
+# JavaScript's Intl.DateTimeFormat picks something sensible if the
+# browser's preference isn't supported. Empty = browser-only.
+UI_LOCALE=

+ 6 - 0
SPEC.md

@@ -419,6 +419,7 @@ The session contains: `user_id`, `display_name`, `role` (cached from the upsert
 - All forms server-validated by surfacing the API's validation errors; show inline.
 - No client-side framework heavier than Alpine.js.
 - API errors render as toast notifications, never raw JSON.
+- Dates and times render in the browser's locale (via `Intl.DateTimeFormat`). Templates emit ISO 8601 UTC inside `<time class="irdb-dt" datetime="…">…</time>`; a small client-side pass replaces the text content on load and after htmx swaps. Deployments can configure a `UI_LOCALE` BCP 47 fallback that's appended after the browser's preference.
 
 ### RBAC matrix
 
@@ -572,6 +573,11 @@ OIDC_REDIRECT_URI=https://reputation.example.com/oidc/callback
 LOCAL_ADMIN_ENABLED=true
 LOCAL_ADMIN_USERNAME=admin
 LOCAL_ADMIN_PASSWORD_HASH=
+
+# Optional BCP 47 locale fallback for date/time formatting (e.g. de-CH,
+# en-GB). Browser locale wins; this is the fallback when unsupported.
+# Empty = browser-only.
+UI_LOCALE=
 ```
 
 A complete `.env.example` documents every variable with comments. The README walks through generating the secrets.

+ 5 - 0
ui/config/settings.php

@@ -61,4 +61,9 @@ return [
     // attribution string for the IP-detail enrichment panel. The api
     // owns the actual provider config; this is a display-only mirror.
     'geoip_provider' => strtolower((string) (getenv('GEOIP_PROVIDER') ?: 'dbip')),
+
+    // Optional BCP 47 locale hint (e.g. "de-CH", "en-GB"). Browser locale
+    // wins; this is appended as a fallback when the browser's locale is
+    // unavailable or unsupported. Empty = browser-only.
+    'ui_locale' => trim((string) (getenv('UI_LOCALE') ?: '')),
 ];

+ 57 - 0
ui/resources/js/app.js

@@ -78,5 +78,62 @@ function renderReportsChart() {
 
 document.addEventListener('DOMContentLoaded', renderReportsChart);
 
+// Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
+// datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
+// string as a no-JS fallback. This pass replaces it with the user's
+// browser locale formatting, with an optional configured fallback (set
+// via UI_LOCALE on the html data attribute) appended so browser locale
+// wins but a deployment can still ensure something sensible if the
+// browser's preference isn't supported. `data-irdb-dt-format="date"`
+// switches to date-only output.
+function getDateLocales() {
+    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;
+}
+
+function buildDateFormatters() {
+    const locales = getDateLocales();
+    return {
+        datetime: new Intl.DateTimeFormat(locales, {
+            year: 'numeric', month: '2-digit', day: '2-digit',
+            hour: '2-digit', minute: '2-digit', second: '2-digit',
+        }),
+        date: new Intl.DateTimeFormat(locales, {
+            year: 'numeric', month: '2-digit', day: '2-digit',
+        }),
+    };
+}
+
+function formatTimes(root) {
+    const scope = root && root.querySelectorAll ? root : document;
+    const formatters = buildDateFormatters();
+    const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
+    elements.forEach((el) => {
+        const iso = el.getAttribute('datetime');
+        if (!iso) return;
+        const d = new Date(iso);
+        if (isNaN(d.getTime())) return;
+        const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
+        try {
+            el.textContent = fmt.format(d);
+            if (!el.hasAttribute('title')) {
+                el.setAttribute('title', iso);
+            }
+        } catch (e) {
+            /* leave the ISO fallback in place */
+        }
+    });
+}
+
+document.addEventListener('DOMContentLoaded', () => formatTimes(document));
+document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
+
 window.Alpine = Alpine;
 Alpine.start();

+ 1 - 1
ui/resources/views/layout.twig

@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="en" class="h-full">
+<html lang="en" class="h-full" data-irdb-locale-fallback="{{ ui_locale_fallback|default('') }}">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">

+ 1 - 1
ui/resources/views/pages/allowlist/index.twig

@@ -74,7 +74,7 @@
                         <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
                         <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
                         <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                         {% if can_write %}
                             <td class="px-4 py-2 text-right">
                                 {% include 'partials/confirm_form.twig' with {

+ 1 - 1
ui/resources/views/pages/audit/index.twig

@@ -99,7 +99,7 @@
                 <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                     {% for ev in list.items %}
                         <tr>
-                            <td class="px-4 py-2 align-top"><time class="font-mono text-xs text-slate-600 dark:text-slate-300" datetime="{{ ev.occurred_at }}" title="{{ ev.occurred_at }}">{{ ev.occurred_at }}</time></td>
+                            <td class="px-4 py-2 align-top"><time class="irdb-dt font-mono text-xs text-slate-600 dark:text-slate-300" datetime="{{ ev.occurred_at }}" title="{{ ev.occurred_at }}">{{ ev.occurred_at }}</time></td>
                             <td class="px-4 py-2 align-top text-xs">
                                 <span class="rounded bg-slate-100 px-1.5 py-0.5 font-mono uppercase tracking-tight text-slate-700 dark:bg-slate-800 dark:text-slate-300">{{ ev.actor_kind }}</span>
                                 {% if ev.actor_id %}<span class="ml-1 font-mono text-slate-500">#{{ ev.actor_id }}</span>{% endif %}

+ 1 - 1
ui/resources/views/pages/dashboard.twig

@@ -102,7 +102,7 @@
                                     <span class="rounded bg-amber-100 px-2 py-0.5 text-xs uppercase text-amber-800 dark:bg-amber-900 dark:text-amber-100">overdue</span>
                                 {% endif %}
                                 <span class="text-xs text-slate-500 dark:text-slate-400">
-                                    {{ job.last_finished_at|default('never') }}
+                                    {% if job.last_finished_at %}<time class="irdb-dt" datetime="{{ job.last_finished_at }}">{{ job.last_finished_at }}</time>{% else %}never{% endif %}
                                 </span>
                             </span>
                         </li>

+ 4 - 4
ui/resources/views/pages/ips/detail.twig

@@ -107,7 +107,7 @@
                     <dd class="col-span-2">{{ detail.enrichment.as_org|default('—') }}</dd>
                 </dl>
                 {% if detail.enrichment.enriched_at %}
-                    <p class="mt-3 text-xs text-slate-400">Enriched <time datetime="{{ detail.enrichment.enriched_at }}">{{ detail.enrichment.enriched_at }}</time> UTC</p>
+                    <p class="mt-3 text-xs text-slate-400">Enriched <time class="irdb-dt" datetime="{{ detail.enrichment.enriched_at }}">{{ detail.enrichment.enriched_at }}</time></p>
                 {% endif %}
             {% else %}
                 <p class="mt-3 text-sm text-slate-400">
@@ -126,12 +126,12 @@
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Override status</h2>
             {% if detail.allowlist %}
                 <p class="mt-3 text-sm">Allowlisted since
-                    <time class="font-mono">{{ detail.allowlist.created_at }}</time>.
+                    <time class="irdb-dt font-mono" datetime="{{ detail.allowlist.created_at }}">{{ detail.allowlist.created_at }}</time>.
                     {% if detail.allowlist.reason %}<br><span class="text-slate-500 dark:text-slate-400">Reason:</span> {{ detail.allowlist.reason }}{% endif %}
                 </p>
             {% elseif detail.manualBlock %}
                 <p class="mt-3 text-sm">Manually blocked since
-                    <time class="font-mono">{{ detail.manualBlock.created_at }}</time>.
+                    <time class="irdb-dt font-mono" datetime="{{ detail.manualBlock.created_at }}">{{ detail.manualBlock.created_at }}</time>.
                     {% if detail.manualBlock.reason %}<br><span class="text-slate-500 dark:text-slate-400">Reason:</span> {{ detail.manualBlock.reason }}{% endif %}
                 </p>
             {% else %}
@@ -173,7 +173,7 @@
                             <span class="font-mono text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">
                                 {{ ev.type }}
                             </span>
-                            <time class="font-mono text-xs text-slate-400">{{ ev.at }}</time>
+                            <time class="irdb-dt font-mono text-xs text-slate-400" datetime="{{ ev.at }}">{{ ev.at }}</time>
                         </div>
                         {% if ev.type == 'report' %}
                             <p class="mt-1">

+ 1 - 1
ui/resources/views/pages/ips/index.twig

@@ -121,7 +121,7 @@
                             <td class="px-4 py-2 font-mono text-slate-500">{{ item.enrichment.asn|default('—') }}</td>
                             <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ item.topCategory|default('—') }}</td>
                             <td class="px-4 py-2 text-right font-mono">{{ item.maxScore|number_format(2) }}</td>
-                            <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.lastReportAt|default('—') }}</td>
+                            <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.lastReportAt %}<time class="irdb-dt" datetime="{{ item.lastReportAt }}">{{ item.lastReportAt }}</time>{% else %}—{% endif %}</td>
                             <td class="px-4 py-2">{{ h.status_pill(item.status) }}</td>
                         </tr>
                     {% else %}

+ 2 - 2
ui/resources/views/pages/manual-blocks/index.twig

@@ -85,8 +85,8 @@
                         <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
                         <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
                         <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.expires_at|default('—') }}</td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.expires_at %}<time class="irdb-dt" datetime="{{ item.expires_at }}">{{ item.expires_at }}</time>{% else %}—{% endif %}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                         {% if can_write %}
                             <td class="px-4 py-2 text-right">
                                 {% include 'partials/confirm_form.twig' with {

+ 4 - 4
ui/resources/views/pages/search/index.twig

@@ -61,7 +61,7 @@
                                     <td class="px-4 py-2"><a href="/app/ips/{{ item.ip|url_encode }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ item.ip }}</a></td>
                                     <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ item.topCategory|default('—') }}</td>
                                     <td class="px-4 py-2 text-right font-mono">{{ item.maxScore|number_format(2) }}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.lastReportAt|default('—') }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.lastReportAt %}<time class="irdb-dt" datetime="{{ item.lastReportAt }}">{{ item.lastReportAt }}</time>{% else %}—{% endif %}</td>
                                     <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.status }}</td>
                                 </tr>
                             {% endfor %}
@@ -111,8 +111,8 @@
                                     <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
                                     <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
                                     <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.expires_at|default('—') }}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.expires_at %}<time class="irdb-dt" datetime="{{ item.expires_at }}">{{ item.expires_at }}</time>{% else %}—{% endif %}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                                 </tr>
                             {% endfor %}
                         </tbody>
@@ -154,7 +154,7 @@
                                     <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
                                     <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
                                     <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                                 </tr>
                             {% endfor %}
                         </tbody>

+ 1 - 1
ui/resources/views/pages/settings/index.twig

@@ -83,7 +83,7 @@
                                     {% endif %}
                                 </td>
                                 <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">
-                                    {{ info.last_run.finished_at|default('—') }}
+                                    {% if info.last_run.finished_at %}<time class="irdb-dt" datetime="{{ info.last_run.finished_at }}">{{ info.last_run.finished_at }}</time>{% else %}—{% endif %}
                                 </td>
                                 <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">
                                     {{ info.last_run.items_processed|default('—') }}

+ 1 - 1
ui/resources/views/pages/tokens/index.twig

@@ -83,7 +83,7 @@
                             {%- elseif t.kind == 'consumer' -%}consumer #{{ t.consumer_id }}
                             {%- else -%}—{%- endif -%}
                         </td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ t.last_used_at|default('never') }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if t.last_used_at %}<time class="irdb-dt" datetime="{{ t.last_used_at }}">{{ t.last_used_at }}</time>{% else %}never{% endif %}</td>
                         <td class="px-4 py-2">
                             {% if t.revoked_at %}
                                 <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs uppercase text-slate-500 dark:bg-slate-800 dark:text-slate-400">revoked</span>

+ 2 - 0
ui/src/App/Container.php

@@ -86,6 +86,7 @@ final class Container
             'settings.session_idle' => (int) ($settings['session_idle_seconds'] ?? 28800),
             'settings.session_absolute' => (int) ($settings['session_absolute_seconds'] ?? 86400),
             'settings.geoip_provider' => strtolower((string) ($settings['geoip_provider'] ?? 'dbip')),
+            'settings.ui_locale' => trim((string) ($settings['ui_locale'] ?? '')),
 
             LoggerInterface::class => factory(static function (ContainerInterface $c): LoggerInterface {
                 $logger = new Logger('ui');
@@ -186,6 +187,7 @@ final class Container
                     'local_admin_enabled' => (bool) $c->get('settings.local_admin_enabled'),
                     'app_env' => (string) $c->get('settings.app_env'),
                     'geoip_provider' => (string) $c->get('settings.geoip_provider'),
+                    'ui_locale_fallback' => (string) $c->get('settings.ui_locale'),
                 ]);
             }),