You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/08/23 14:54:15 UTC

(skywalking-horizon-ui) 09/11: fix(audit): snap a custom range to the bucket it will be shown in

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch refactor/audit-drop-token-use
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 2cc7ed237242085dc47e25eaf3155e163fc96730
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Aug 23 22:11:05 2026 +0800

    fix(audit): snap a custom range to the bucket it will be shown in
    
    A second adversarial pass over the three commits the first audit did not cover
    raised 31 findings; 14 survived verification.
    
    The custom range picker refused the range it had just seeded. Entering Custom
    from any preset filled the inputs with "N hours ending now" at whatever minute
    it happened to be, and widening that outward to whole hours makes N+1 of them —
    so a twelve-hour seed exceeded the twelve-hour cap before the operator touched
    anything. The seed now lands on bucket boundaries, which is also what the page
    will label the groups with.
    
    Snapping is done in UTC, because that is what a bucket is. Flooring on the
    local clock is only equivalent where the offset is a whole number of hours: on
    +05:30 a local :00 sits in the middle of a bucket, so the control snapped to
    the one place a group never begins, and the first half hour an operator asked
    for fell outside every group while a half hour they did not ask for was
    counted. Flooring in UTC yields a local time that IS a group edge — 11:47 IST
    becomes 11:30 IST, the start of the first group shown. `step="3600"` is gone
    with it: it forced the browser onto local :00, the wrong grid in exactly those
    zones. Groups still read on the operator's own clock, so on a fractional offset
    they run half or quarter past — an hour bucket has to begin somewhere, the
    heading always says where, and the docs now say so too.
    
    A malformed cursor answered 500 instead of 400. Zod runs every check in a
    chain, so the bigint bound was handed the same string the regex had already
    rejected, and `BigInt('def')` throws straight out of `safeParse`. The bound now
    defers to the regex's own message instead of failing on its input. Route tests
    cover both halves, and the page-number rule that replaced the arbitrary cap.
    
    Four comments described the read-before-write cycle the previous commit
    deleted, one of them the schema's, which stated the inverse of the DDL three
    lines below it: that `horizon_node` is attribution and deliberately not part of
    the key, when the key is exactly what it now is. The next backend's author
    would have read that and reintroduced the overwrite.
    
    The retention test read its row back out of a top-ten list, so a busy hour
    could have ranked it out and failed retention for something retention had no
    part in. It now uses an hour of its own.
---
 apps/bff/src/http/admin/audit.test.ts              | 99 ++++++++++++++++++++++
 apps/bff/src/http/admin/audit.ts                   | 12 ++-
 apps/bff/src/store/audit/postgres/live.test.ts     |  5 +-
 apps/bff/src/store/audit/postgres/schema.ts        | 11 +--
 apps/bff/src/store/audit/postgres/token-store.ts   |  6 +-
 apps/bff/src/store/audit/token-counters.test.ts    |  6 +-
 apps/bff/src/store/audit/token-counters.ts         |  9 +-
 .../ui/src/features/admin/audit/TokenUsageList.vue |  8 +-
 .../src/features/admin/audit/useTokenUsagePage.ts  | 37 +++++---
 apps/ui/src/i18n/locales/de.json                   |  2 +-
 apps/ui/src/i18n/locales/en.json                   |  2 +-
 apps/ui/src/i18n/locales/es.json                   |  2 +-
 apps/ui/src/i18n/locales/fr.json                   |  2 +-
 apps/ui/src/i18n/locales/ja.json                   |  2 +-
 apps/ui/src/i18n/locales/ko.json                   |  2 +-
 apps/ui/src/i18n/locales/pt.json                   |  2 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  2 +-
 docs/setup/login-audit.md                          |  2 +
 18 files changed, 171 insertions(+), 40 deletions(-)

diff --git a/apps/bff/src/http/admin/audit.test.ts b/apps/bff/src/http/admin/audit.test.ts
new file mode 100644
index 00000000..077b8a4a
--- /dev/null
+++ b/apps/bff/src/http/admin/audit.test.ts
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * What the audit routes accept, at the edge.
+ *
+ * The cursor cases exist because a validator can FAIL rather than reject: zod
+ * runs every check in a chain, so a refine placed after a `.regex()` is still
+ * handed the string the regex just rejected. `BigInt('def')` throws there, and
+ * a throw out of `safeParse` is a 500 for what is plainly a bad request.
+ */
+
+import { describe, it, expect } from 'vitest';
+import Fastify from 'fastify';
+import { registerAuditRoutes } from './audit.js';
+import type { AuditRouteDeps } from './audit.js';
+
+/** Enough of the service for the routes to answer; the reads are not exercised. */
+const audit = {
+  query: async () => ({ rows: [], hasNext: false }),
+  queryStat: async () => ({ columns: [], overBudget: 0, horizonNodes: 0 }),
+  queryTokenUsage: async () => ({ hours: [] }),
+  health: async () => ({
+    horizonNode: 'n1', enabled: true, configured: true, available: true,
+    rowsThisHour: 0, overBudgetThisHour: 0,
+  }),
+} as unknown as AuditRouteDeps['audit'];
+
+async function app() {
+  const f = Fastify();
+  await registerAuditRoutes(f, { audit } as AuditRouteDeps);
+  return f;
+}
+
+async function get(url: string) {
+  const f = await app();
+  try {
+    return await f.inject({ method: 'GET', url });
+  } finally {
+    await f.close();
+  }
+}
+
+describe('the audit list cursor', () => {
+  it.each([
+    ['abc:def', 'a shape the regex rejects, whose id half is not a number'],
+    ['not-a-cursor', 'no separator at all'],
+    ['1:2:3', 'too many halves'],
+    ['', 'empty'],
+  ])('answers 400 rather than failing on %s (%s)', async (cursor) => {
+    const res = await get(`/api/admin/audit?cursor=${encodeURIComponent(cursor)}`);
+
+    // 400, never 500: the distinction is the whole point — a malformed
+    // parameter is the caller's fault and must not read as a server fault.
+    expect(res.statusCode).toBe(400);
+  });
+
+  it('refuses an id past what a signed bigint can hold', async () => {
+    const res = await get('/api/admin/audit?cursor=1700000000000:9999999999999999999');
+
+    expect(res.statusCode).toBe(400);
+  });
+
+  it('accepts an ordinary cursor', async () => {
+    const res = await get('/api/admin/audit?cursor=1700000000000:42');
+
+    expect(res.statusCode).toBe(200);
+  });
+});
+
+describe('the audit list page number', () => {
+  /** Keyset paging makes depth free, so the only rule is that it stays a
+   *  number the rest of the code can do arithmetic on. */
+  it('accepts a page far beyond any previous cap', async () => {
+    const res = await get('/api/admin/audit?pageNum=250000');
+
+    expect(res.statusCode).toBe(200);
+  });
+
+  it.each(['0', '-1', 'abc', '1.5'])('refuses %s', async (pageNum) => {
+    const res = await get(`/api/admin/audit?pageNum=${pageNum}`);
+
+    expect(res.statusCode).toBe(400);
+  });
+});
diff --git a/apps/bff/src/http/admin/audit.ts b/apps/bff/src/http/admin/audit.ts
index d381194c..5461dfd4 100644
--- a/apps/bff/src/http/admin/audit.ts
+++ b/apps/bff/src/http/admin/audit.ts
@@ -112,9 +112,15 @@ const listQuerySchema = z.object({
   cursor: z
     .string()
     .regex(/^\d{1,15}:\d{1,19}$/, 'cursor must be "<epochMs>:<id>"')
-    .refine((v) => BigInt(v.split(':')[1] ?? '0') <= PG_BIGINT_MAX, {
-      message: 'cursor id is larger than the column can hold',
-    })
+    // Shape-checked again rather than trusting the regex above it: zod runs
+    // every check in the chain, so a failed `.regex()` does NOT stop this one
+    // being handed the same bad string — and `BigInt('def')` THROWS, turning a
+    // malformed query parameter into a 500 instead of the 400 it earned.
+    // Malformed input returns true here and keeps the regex's own message.
+    .refine((v) => {
+      const id = v.split(':')[1];
+      return id === undefined || !/^\d+$/.test(id) || BigInt(id) <= PG_BIGINT_MAX;
+    }, { message: 'cursor id is larger than the column can hold' })
     .optional(),
 });
 
diff --git a/apps/bff/src/store/audit/postgres/live.test.ts b/apps/bff/src/store/audit/postgres/live.test.ts
index 5eae7d22..13404715 100644
--- a/apps/bff/src/store/audit/postgres/live.test.ts
+++ b/apps/bff/src/store/audit/postgres/live.test.ts
@@ -174,7 +174,10 @@ describe.skipIf(!URL)('the audit log against a real PostgreSQL', () => {
   it('expires token usage past retention, and keeps what is inside it', async () => {
     const tokens = store.tokenUsage();
     const outside = hourBucket(AT - (pg.retentionDays + 1) * 86_400_000);
-    const inside = hourBucket(AT);
+    // An hour inside retention but NOT the current one: `top` is capped at
+    // TOP_TOKENS_PER_HOUR, so a busy current hour could rank this row out of
+    // the list and fail the assertion for a reason retention had no part in.
+    const inside = hourBucket(AT - 2 * 86_400_000);
     await tokens.writeUsage([
       { hourBucket: outside, tokenId: 'expired', username: 'sre', count: 4, horizonNode: NODE },
       { hourBucket: inside, tokenId: 'current', username: 'sre', count: 6, horizonNode: NODE },
diff --git a/apps/bff/src/store/audit/postgres/schema.ts b/apps/bff/src/store/audit/postgres/schema.ts
index 45e80b8b..118b4707 100644
--- a/apps/bff/src/store/audit/postgres/schema.ts
+++ b/apps/bff/src/store/audit/postgres/schema.ts
@@ -104,11 +104,12 @@ export const SCHEMA_STATEMENTS: readonly string[] = [
   // Token usage. A STATISTIC, not an audit record: presenting a token is not
   // a sign-in, so it has no place in `horizon_audit` and no row per request.
   //
-  // One row per credential per hour, CLUSTER-WIDE — `horizon_node` is
-  // attribution, deliberately NOT part of the key, because the count is the
-  // deployment's, not one process's. A writer loads the stored total, adds
-  // what it has seen, and writes the total back, so a restarted process
-  // resumes rather than replacing a real total with a partial one.
+  // One row per credential per hour PER NODE, and the node is in the KEY.
+  // Each process writes only its own running total, so no node can overwrite
+  // another's share and no read is needed before a write; the deployment's
+  // count for the hour is the SUM across these rows, taken when the page asks.
+  // The node id carries a boot id, so a restart starts a fresh row rather than
+  // replacing a real total with a partial one.
   `CREATE TABLE IF NOT EXISTS horizon_token_usage (
      hour_bucket   bigint NOT NULL,
      token_id      text   NOT NULL,
diff --git a/apps/bff/src/store/audit/postgres/token-store.ts b/apps/bff/src/store/audit/postgres/token-store.ts
index 7ae0c8c5..7be7ab1b 100644
--- a/apps/bff/src/store/audit/postgres/token-store.ts
+++ b/apps/bff/src/store/audit/postgres/token-store.ts
@@ -18,8 +18,10 @@
 /**
  * The Postgres half of token usage — the FIRST implementation of
  * `TokenUsageStore`, not the only one. Everything backend-specific stops
- * here: the load-append-update cycle itself lives in `TokenCounters`, so a
- * second backend inherits the correctness rather than re-deriving it.
+ * here: what a row means lives in `TokenCounters`, and how a window is
+ * grouped lives in `summarizeWindow`, so a second backend inherits both
+ * rather than re-deriving them. All this file owes is an idempotent upsert
+ * keyed on `(hour_bucket, token_id, horizon_node)` and a range read.
  */
 
 import { hourBucketStart } from '../counters.js';
diff --git a/apps/bff/src/store/audit/token-counters.test.ts b/apps/bff/src/store/audit/token-counters.test.ts
index 48ff0975..1811be41 100644
--- a/apps/bff/src/store/audit/token-counters.test.ts
+++ b/apps/bff/src/store/audit/token-counters.test.ts
@@ -16,8 +16,10 @@
  */
 
 /**
- * The load-append-update cycle, which is the whole reason this is a mechanism
- * rather than a store detail. Both backends inherit these properties.
+ * The counting rules, which are the whole reason this is a mechanism rather
+ * than a store detail: a process counts only its own share, writes a running
+ * total rather than a delta, and never reads before writing. Both backends
+ * inherit these properties.
  */
 
 import { describe, it, expect } from 'vitest';
diff --git a/apps/bff/src/store/audit/token-counters.ts b/apps/bff/src/store/audit/token-counters.ts
index 25c9ec96..6412b731 100644
--- a/apps/bff/src/store/audit/token-counters.ts
+++ b/apps/bff/src/store/audit/token-counters.ts
@@ -61,10 +61,11 @@ export interface TokenUse {
 /**
  * Counts token use, and produces the rows a flush should write.
  *
- * The load-append-update cycle lives here rather than in the store, so both
- * backends inherit it: a store that forgot to load would silently overwrite,
- * and that is not a mistake worth letting two implementations make
- * independently.
+ * A row is this PROCESS's running total for one credential in one hour, and
+ * the node is part of the key it is written under — so a store never has to
+ * read before writing, and cannot overwrite another node's share by doing so.
+ * The deployment's total is the sum across those rows, taken at read time.
+ * Both backends inherit that rule from here rather than re-deriving it.
  */
 export class TokenCounters {
   private readonly cells = new Map<string, Cell>();
diff --git a/apps/ui/src/features/admin/audit/TokenUsageList.vue b/apps/ui/src/features/admin/audit/TokenUsageList.vue
index 63569bca..e106f6a7 100644
--- a/apps/ui/src/features/admin/audit/TokenUsageList.vue
+++ b/apps/ui/src/features/admin/audit/TokenUsageList.vue
@@ -95,14 +95,14 @@ function share(total: number): string {
         <template v-if="isCustom">
           <div class="cf-range">
             <input
-              :value="props.customStart" type="datetime-local" step="3600"
-              class="cf-input cf-range-num" :title="t('Whole hours — a group is an hour')"
+              :value="props.customStart" type="datetime-local"
+              class="cf-input cf-range-num" :title="t('Snaps to a group boundary — a group is one hour')"
               @input="emit('update:customStart', ($event.target as HTMLInputElement).value)"
             />
             <span class="cf-range-sep">–</span>
             <input
-              :value="props.customEnd" type="datetime-local" step="3600"
-              class="cf-input cf-range-num" :title="t('Whole hours — a group is an hour')"
+              :value="props.customEnd" type="datetime-local"
+              class="cf-input cf-range-num" :title="t('Snaps to a group boundary — a group is one hour')"
               @input="emit('update:customEnd', ($event.target as HTMLInputElement).value)"
             />
             <button class="sw-btn small primary" type="button" @click="emit('apply')">{{ t('Query') }}</button>
diff --git a/apps/ui/src/features/admin/audit/useTokenUsagePage.ts b/apps/ui/src/features/admin/audit/useTokenUsagePage.ts
index 2f8e9667..2673c06f 100644
--- a/apps/ui/src/features/admin/audit/useTokenUsagePage.ts
+++ b/apps/ui/src/features/admin/audit/useTokenUsagePage.ts
@@ -52,12 +52,18 @@ export function useTokenUsagePage() {
 
   const HOUR_MS = 3_600_000;
 
-/** Local-time hour start. Local, because the picker is a local-time control
- *  and snapping in UTC would move the value the operator typed. */
+/**
+ * The start of the hour BUCKET an instant falls in.
+ *
+ * Floored in UTC, because that is what a bucket is — and doing it on the local
+ * clock instead is only equivalent where the offset is a whole number of
+ * hours. On +05:30 a local :00 lands in the middle of a bucket, so the picker
+ * would snap to the one place a group never begins. Flooring here produces a
+ * local time that IS a group edge: 11:47 IST becomes 11:30 IST, which is the
+ * first group the page then shows.
+ */
 function floorHour(ms: number): number {
-  const d = new Date(ms);
-  d.setMinutes(0, 0, 0);
-  return d.getTime();
+  return Math.floor(ms / HOUR_MS) * HOUR_MS;
 }
 
 /** The span to ask for, or the complaint that stops the query. */
@@ -108,9 +114,16 @@ function floorHour(ms: number): number {
     }
   }
 
-  /** Entering custom mode seeds both ends from the span that was showing, so
-   *  the inputs are never empty — an empty `datetime-local` paints the
-   *  browser's own locale placeholder. */
+  /**
+   * Entering custom mode seeds both ends from the span that was showing, so
+   * the inputs are never empty — an empty `datetime-local` paints the
+   * browser's own locale placeholder.
+   *
+   * Seeded ON BUCKET BOUNDARIES, which is what keeps the seed legal. `resolve`
+   * widens a range outward to whole buckets, so seeding "twelve hours ending
+   * now" at an arbitrary minute would widen to thirteen and the page would
+   * refuse the very range it had just filled in.
+   */
   function setSpan(next: number): void {
     const previous = spanHours.value;
     spanHours.value = next;
@@ -121,10 +134,12 @@ function floorHour(ms: number): number {
       return;
     }
     if (customStart.value && customEnd.value) return;
-    const end = new Date();
     const span = previous > 0 ? previous : DEFAULT_TOKEN_USAGE_HOURS;
-    customStart.value = toLocalInput(new Date(end.getTime() - span * 3_600_000));
-    customEnd.value = toLocalInput(end);
+    // The end of the bucket in progress, so the newest group is the one the
+    // presets also end on.
+    const end = floorHour(Date.now()) + HOUR_MS;
+    customStart.value = toLocalInput(new Date(end - span * HOUR_MS));
+    customEnd.value = toLocalInput(new Date(end));
   }
 
   return {
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index dea1ffeb..a4d46196 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -223,7 +223,7 @@
   "Back to Overview": "Zurück zur Übersicht",
   "Back to list": "Zurück zur Liste",
   "Back to presets": "Zurück zu Voreinstellungen",
-  "Whole hours — a group is an hour": "Ganze Stunden – eine Gruppe ist eine Stunde",
+  "Snaps to a group boundary — a group is one hour": "Rastet auf eine Gruppengrenze ein – eine Gruppe ist eine Stunde",
   "Backend": "Backend",
   "Backend unreachable.": "Backend nicht erreichbar.",
   "Beacon": "Beacon",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 53c5ea29..31e1e940 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -223,7 +223,7 @@
   "Back to Overview": "Back to Overview",
   "Back to list": "Back to list",
   "Back to presets": "Back to presets",
-  "Whole hours — a group is an hour": "Whole hours — a group is an hour",
+  "Snaps to a group boundary — a group is one hour": "Snaps to a group boundary — a group is one hour",
   "Backend": "Backend",
   "Backend unreachable.": "Backend unreachable.",
   "Beacon": "Beacon",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 474e4e43..4711351e 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -223,7 +223,7 @@
   "Back to Overview": "Volver al resumen",
   "Back to list": "Volver a la lista",
   "Back to presets": "Volver a los presets",
-  "Whole hours — a group is an hour": "Horas completas: cada grupo es una hora",
+  "Snaps to a group boundary — a group is one hour": "Se ajusta al límite del grupo: cada grupo dura una hora",
   "Backend": "Backend",
   "Backend unreachable.": "Backend no accesible.",
   "Beacon": "Baliza",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index cbe37a15..b2d58daf 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -223,7 +223,7 @@
   "Back to Overview": "Retour à la vue d'ensemble",
   "Back to list": "Retour à la liste",
   "Back to presets": "Retour aux préréglages",
-  "Whole hours — a group is an hour": "Heures entières : un groupe correspond à une heure",
+  "Snaps to a group boundary — a group is one hour": "S'aligne sur une limite de groupe : un groupe dure une heure",
   "Backend": "Backend",
   "Backend unreachable.": "Backend injoignable.",
   "Beacon": "Balise",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 498e9c6d..35447703 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -223,7 +223,7 @@
   "Back to Overview": "概要に戻る",
   "Back to list": "一覧に戻る",
   "Back to presets": "プリセットに戻る",
-  "Whole hours — a group is an hour": "1 時間単位 — 1 グループが 1 時間です",
+  "Snaps to a group boundary — a group is one hour": "グループの境界に合わせます — 1 グループは 1 時間です",
   "Backend": "バックエンド",
   "Backend unreachable.": "バックエンドに到達できません。",
   "Beacon": "ビーコン",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 446f3f8e..102ded01 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -223,7 +223,7 @@
   "Back to Overview": "개요로 돌아가기",
   "Back to list": "목록으로 돌아가기",
   "Back to presets": "프리셋으로 돌아가기",
-  "Whole hours — a group is an hour": "정시 단위 — 한 그룹이 한 시간입니다",
+  "Snaps to a group boundary — a group is one hour": "그룹 경계에 맞춰집니다 — 한 그룹은 한 시간입니다",
   "Backend": "백엔드",
   "Backend unreachable.": "백엔드에 연결할 수 없습니다.",
   "Beacon": "비컨",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 9dd1f6d2..d890b473 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -223,7 +223,7 @@
   "Back to Overview": "Voltar à visão geral",
   "Back to list": "Voltar à lista",
   "Back to presets": "Voltar aos pré-ajustes",
-  "Whole hours — a group is an hour": "Horas inteiras: cada grupo é uma hora",
+  "Snaps to a group boundary — a group is one hour": "Ajusta ao limite do grupo: cada grupo dura uma hora",
   "Backend": "Backend",
   "Backend unreachable.": "Backend inacessível.",
   "Beacon": "Farol",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index aadae0b5..79cc2453 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -223,7 +223,7 @@
   "Back to Overview": "返回总览",
   "Back to list": "返回列表",
   "Back to presets": "返回预设",
-  "Whole hours — a group is an hour": "按整小时选择 —— 每组即一小时",
+  "Snaps to a group boundary — a group is one hour": "自动对齐到分组边界 —— 每组为一小时",
   "Backend": "后端",
   "Backend unreachable.": "后端不可达。",
   "Beacon": "信标",
diff --git a/docs/setup/login-audit.md b/docs/setup/login-audit.md
index 7bf68a1a..57346dbd 100644
--- a/docs/setup/login-audit.md
+++ b/docs/setup/login-audit.md
@@ -129,6 +129,8 @@ Presenting an API token is not a sign-in, so it produces no audit row — what a
 
 Pick a **Time range** — the last 2, 6 or 12 hours, or **Custom…** for an explicit start and end. Twelve hours is the widest span the page will read; a custom range longer than that is refused rather than silently trimmed, and a request that reaches the server for more is clamped to the last twelve.
 
+**A group is one whole hour, and the hours are UTC shown on your clock.** Where your offset is a whole number of hours the groups fall on the hour, as you would expect. Where it is not — India at +05:30, Adelaide at +09:30, Nepal at +05:45 — a group runs from half or quarter past to half or quarter past, because an hour bucket has to begin somewhere and that somewhere is the top of the UTC hour. The group headings always state the span they cover, and a custom range snaps to those same b [...]
+
 Each hour is one group. Its header gives the hour, a bar showing that hour against the busiest in the range, and two figures that always describe the **whole** hour: **Uses**, every request whose token was accepted, and **Credentials**, how many distinct tokens made them.
 
 **Accepted means the credential was recognised, not that the request succeeded.** A token is counted the moment it resolves to a live account — before permissions are consulted — so a request that is then refused for lack of a role, or that fails further on, is still a use. That is deliberate: the tab answers "what is this credential doing", and a token hammering endpoints it may not touch is exactly the thing you want visible. A token that fails to resolve at all is never counted, so th [...]