Skip to content

Rework the dashboard overview and split settings by editability - #61

Merged
maiphucgiang merged 15 commits into
mainfrom
feat/dashboard-overview
Oct 14, 2026
Merged

maiphucgiang merged 15 commits into
mainfrom
feat/dashboard-overview

Conversation

@maiphucgiang

@maiphucgiang maiphucgiang commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

The overview page mixed a plain request trend with the rest of the dashboard, credential and storage pages lacked structured channel/usage views, and the settings page mixed model-routing guards and retired log knobs with general settings.

What changed

Dashboard overview

  • KPI card row: requests, success rate (P95 sub-item), failures, Credit cost, and total/input/output/cache tokens with share and hit-rate sub-items; values abbreviated (K/M/B) with the exact number in a hover bubble, and requests/credit show the change against the previous period (previous block in /admin/dashboard, plus summary.p95_duration_ms guarded against partial detail samples)
  • Usage trend spans the full row: monotone cubic curves, three independent axes, vertical tooltip rows, measured-width SVG viewBox, and wheel zoom with double-click/Esc reset — zooming into a window under 48h (with ≥4 hourly buckets) swaps to hourly buckets and back out to daily
  • Credential health, model ranking and official balance panels scroll internally with sticky table headers

Credentials workspace

  • Top-level 4-card region/category strip (全部账号, 国内区域, 国际区域, 企业账号 as an independent category) showing ready ratios and remaining official credits
  • Top-right action bar consolidates 刷新列表, 同步全部余额, a 批量操作 menu (批量签到, 导入文件, 导出已选), and 添加凭证
  • Card / List dual-view toggle and pagination (10 / 20 / 50 / 100 / 500 / 1000 / 2000 per page, default 20), both persisted in localStorage when changed
  • Unified card and table aggregation: top-right health badge + enable/disable pill switch, official balance with inline circular sync icon button, unified catalog/sync/cooldown block, per-credential request & token usage telemetry (credential_stats() in AuditStore), and 任务配置 + 详情 opening a 4-tab detail drawer (概览与请求, 自动化配置, 绑定与冷却, 原始诊断)

Storage management (new page, sidebar entry)

  • 4-card KPI strip (数据库文件, WAL 文件, 日志逻辑占用, 可回收空间) and balanced 2-column panels (归档旧日志 + 归档文件 and 压缩数据库 + 在线清理)
  • Batched JSONL export under auth/archives/ with fsync durability, short lock slices that do not block live record_request calls, and preserved ingest deduplication keys

Model routing & Settings

  • 表外模型拦截 (model_guard) and 模型能力预检 (model_capability_guard) moved from Settings to the Model Routing toolbar as two pill switches
  • Remaining settings split into editable / externally locked / system read-only tabs; retired text-log knobs hidden; session-scoped resource cache renders the last model routing snapshot immediately on entry

The header breadcrumb rendered the raw navigation key (nav.settings)
instead of the translated label. The two disabled legacy text-log
settings no longer appear in the management UI (hidden in the schema,
startup parsing unchanged). The settings list is split into tabs for
editable items, externally locked ones (CLI/env/.env) and internal
read-only values, so locked entries stop crowding the editable form.
…rend

The summary row becomes eight cards (requests, success rate with P95
latency, failures with anomalous-point count, Credit used, total/input/
output/cached tokens with shares and hit rate), each with a sparkline
and a comparison against the previous period returned by a new
previous aggregate plus a p95_duration_ms computed from retained
request rows. The request trend becomes a three-series chart
(requests, tokens, Credit) with independent axes, hover and keyboard
tooltips, a legend and the existing data-table fallback. The
credential health, model ranking and official balance panels cap
their height and scroll internally so long lists no longer stretch
the page.
@sourcery-ai

sourcery-ai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR substantially reshapes the admin console by adding KPI-driven, zoomable dashboard analytics; separating storage operations into a dedicated archival and maintenance page; and clarifying settings ownership and administrative controls, with corresponding backend, UI, accessibility, and test coverage.

Sequence diagram for verified audit log archiving

sequenceDiagram
    actor Admin
    participant Storage as Storage page
    participant API as Admin API
    participant Audit as Audit store
    participant DB as SQLite
    participant Archive as JSONL archive

    Admin->>Storage: Submit archive window
    Storage->>API: POST /admin/storage/archive
    API->>Audit: archive(days)
    Audit->>DB: BEGIN IMMEDIATE
    Audit->>DB: Read old requests and events in batches
    Audit->>Archive: Write exported JSONL
    Audit->>DB: Delete exported rows in batches
    Audit->>DB: COMMIT
    Audit-->>API: Archive counts and file
    API-->>Storage: JSON response
    Storage-->>Admin: Show archive result
Loading

Sequence diagram for zoomable dashboard usage trend

sequenceDiagram
    participant Dashboard as Dashboard page
    participant API as Admin API
    participant Store as Audit store
    participant Chart as UsageTrend

    Dashboard->>API: GET /admin/dashboard
    API->>Store: fetch()
    Store-->>API: summary, previous, daily series
    API-->>Dashboard: Dashboard data
    Dashboard->>API: GET /admin/dashboard?granularity=hour
    API->>Store: fetch()
    Store-->>API: hourly series
    API-->>Dashboard: Hourly data
    Chart->>Chart: Wheel zoom
    Chart->>Chart: Swap to hourly rows within 48h
    Chart->>Chart: Double-click or Escape reset
Loading

Flow diagram for settings ownership groups

flowchart TD
    Settings[Settings schema]
    Resolve[resolve_settings]
    Hidden[Hide retired settings]
    Source{Configuration source}
    Editable[Editable tab]
    External[Externally locked tab]
    System[System read-only tab]

    Settings --> Resolve
    Resolve --> Hidden
    Hidden --> Source
    Source -->|default and unlocked| Editable
    Source -->|CLI, environment, dotenv| External
    Source -->|sensitive or other locked source| System
Loading

File-Level Changes

Change Details Files
Reworked dashboard data and presentation around KPI summaries and an interactive multi-metric usage trend.
  • Added previous-period aggregates and request P95 duration to dashboard responses.
  • Added KPI cards for request, success, failure, Credit, and token metrics with abbreviated values, exact-value tooltips, deltas, shares, and sparklines.
  • Replaced the standalone trend with a full-width requests/tokens/Credit chart supporting monotone curves, independent axes, tooltips, keyboard controls, zoom/reset, and automatic hourly detail.
  • Added bounded scrolling to dashboard health, model, and balance panels.
app/audit_store.py
web/src/pages/Dashboard.tsx
web/src/UsageOverview.tsx
web/src/dashboard.test.tsx
web/src/ui.module.scss
web/src/theme.scss
web/src/i18n.tsx
Introduced storage management for audit database inspection, archival, cleanup, and compaction guidance.
  • Added storage status and archive API endpoints with validation, degraded-state handling, archive listing, and audit events.
  • Added SQLite page/free-list accounting and reclaimable-byte reporting.
  • Implemented locked, batched, export-before-delete JSONL archival with restrictive file permissions, transaction rollback, and cleanup of temporary files.
  • Added a storage page for database/WAL/log sizes, reclaimable space, archive execution and listing, existing cleanup flows, and stop/VACUUM/restart instructions.
  • Added backend and web coverage for storage reporting, validation, archival, and cached refresh behavior.
app/admin_api.py
app/audit_store.py
web/src/pages/Storage.tsx
web/src/App.tsx
web/src/storage.test.tsx
tests/test_admin_api.py
tests/test_audit_store.py
web/src/i18n.tsx
web/src/ui.module.scss
Reorganized settings and administration controls to distinguish configuration ownership and reduce UI clutter.
  • Marked retired text-log settings hidden and excluded them from resolved settings responses.
  • Grouped settings into editable, externally locked, and system read-only tabs.
  • Moved language selection into the appearance drawer with explicit Chinese, English, and follow-system preferences.
  • Collapsed credential maintenance actions into scoped More menus and standardized detail-dialog label widths.
  • Made model and credential resources render cached snapshots immediately while refreshing.
app/settings.py
web/src/pages/Settings.tsx
web/src/appearance.tsx
web/src/i18n.tsx
web/src/pages/Credentials.tsx
web/src/api.ts
web/src/pages/Models.tsx
web/src/components.tsx
web/src/copy.test.tsx
web/src/i18n.test.tsx
web/src/automation.test.tsx
web/src/buddy.test.tsx
web/src/enhancements.test.tsx
web/src/trial.test.tsx
web/src/modelMetadata.test.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 11 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="web/src/pages/Models.tsx" line_range="496" />
<code_context>
 export function Models() {
-  const resource = useResource("/models", modelResponse);
-  const credentials = useResource("/credentials", credentialResponse);
+  const resource = useResource("/models", modelResponse, { cache: true });
+  const credentials = useResource("/credentials", credentialResponse, { cache: true });
   const [query, setQuery] = useState("");
</code_context>
<issue_to_address>
**Concurrent model edits overwrite changes**

When a model changes in another tab after this page's cached snapshot is shown but before the open editor is saved, a cached model can open in `ModelEditor` before its refresh completes. If another tab changes that model and the refresh returns while the editor is open, the editor retains its old `rule` but receives the new `revision`; saving then passes the new revision with stale fields, overwriting the other tab's changes.

Keep the editor's base revision tied to the snapshot used to initialize its form, or refresh/reconcile the form before allowing a save.
</issue_to_address>

### Comment 2
<location path="app/audit_store.py" line_range="653-688" />
<code_context>
+                with open(tmp, "x", encoding="utf-8") as handle:
</code_context>
<issue_to_address>
**Archived logs are lost**

When the host loses power or crashes after the database commit but before the archive file and rename are durable, `archive` commits deletion without first syncing the JSONL file and its directory entry, so SQLite preserves the deletions while the archive is lost or truncated; the logs disappear from both the database and archive.

Sync the archive file and containing directory to durable storage before committing deletion of the database rows.

Also at `app/audit_store.py:695-704`.
</issue_to_address>

### Comment 3
<location path="app/audit_store.py" line_range="631" />
<code_context>
+        cutoff = time.time() - days * 86400
+        # Export-before-delete with a generous deadline: the shared 1s SQL budget
+        # cannot cover large windows, and the lock is held for the whole archive.
+        if not self._lock.acquire(timeout=0.25):
+            self._fault(TimeoutError())
+            return {"ok": False}
</code_context>
<issue_to_address>
**Audit records are dropped**

When requests or events are recorded while an archive holds the store lock for more than 250 ms, `archive` holds the lock during export and deletion, while `_run` gives `record_request` and `event` only 250 ms to acquire it; they return the dropped-record fallback, so those audit records are missing.

Change the archive and recording lock strategy so recording requests do not time out and get dropped during an archive.

Also at `app/audit_store.py:638`.
</issue_to_address>

### Comment 4
<location path="app/audit_store.py" line_range="703" />
<code_context>
+                    "DELETE FROM events WHERE id IN (SELECT id FROM events WHERE started_at<? LIMIT 2000)",
+                    (cutoff,)).rowcount:
+                pass
+            self._db.execute("DELETE FROM ingest WHERE created_at<?", (cutoff,))
+            self._db.execute("COMMIT")
+            return {"ok": True, "archived_requests": exported["request"],
</code_context>
<issue_to_address>
**Replayed records inflate aggregates**

When an archived request or event is redelivered after its ingest row was deleted, `archive` deletes the archived IDs from `ingest`; if a request or event with one of those IDs is later redelivered, `record_request` or `event` no longer recognizes it as a duplicate and aggregates it again. Dashboard totals then count the same record twice.

Preserve deduplication IDs for the applicable replay window, or make aggregate ingestion idempotent independently of the ingest rows removed by archiving.
</issue_to_address>

### Comment 5
<location path="web/src/pages/Storage.tsx" line_range="28" />
<code_context>
+  return `${(n / 1024 ** 3).toFixed(2)} GB`;
+}
+
+function ArchivePanel({ reload }: { reload: () => void }) {
+  const [days, setDays] = useState(30);
+  const [busy, setBusy] = useState(false);
</code_context>
<issue_to_address>
**Default archive finds no old logs**

When the configured retention is 30 days and the page is opened before archiving, the storage-page load calls `/admin/storage`, whose `audit.storage()` runs `_prune()` using the configured retention period; with the default 30-day retention, this removes expired details before the default 30-day archive request can export them, so the archive action reports no logs.

Choose an archive default shorter than the configured retention, or provide a way to archive expired details before the storage read prunes them.
</issue_to_address>

### Comment 6
<location path="web/src/UsageOverview.tsx" line_range="333-336" />
<code_context>
+    if (bucketsValid && b !== null) return LEFT + ((b - ds) * (RIGHT - LEFT)) / (de - ds);
+    return LEFT + (index * (RIGHT - LEFT)) / (rows.length - 1);
+  };
+  const yAt = (key: string, index: number) =>
+    BOTTOM - ((number(rows[index][key]) ?? 0) / (maxima[key] ?? 1)) * (BOTTOM - TOP);
+  const pathOf = (key: string) =>
+    smoothPath(rows.map((_, i): [number, number] => [xAt(i), yAt(key, i)]));
+  const choose = (index: number) =>
+    setSelection({ rows, index: Math.max(0, Math.min(rows.length - 1, index)) });
</code_context>
<issue_to_address>
**Hourly chart values are wrong**

When a daily trend is zoomed to a window of 48 hours or less and hourly rows are available, `yAt` and `pathOf` read `rows` while the chart renders `displayRows`, so hourly positions use daily values and can throw when hourly indices exceed the daily rows. `choose` also stores `rows`, so hourly selections do not match and tooltips do not activate.

Use `displayRows` consistently for chart values, paths, and selection state.

Also at `web/src/UsageOverview.tsx:318`.
</issue_to_address>

### Comment 7
<location path="web/src/api.ts" line_range="180-184" />
<code_context>
-        if (!controller.signal.aborted)
-          setState({ data: null, loading: false, error: errorMessage(error) });
+        if (controller.signal.aborted) return;
+        setState((prev) =>
+          prev.data === null
+            ? { data: null, loading: false, error: errorMessage(error) }
+            : { ...prev, loading: false },
+        );
       });
     return () => controller.abort();
</code_context>
<issue_to_address>
**Old results appear current**

When a resource path changes or a refresh fails after data has loaded, `useResource` keeps `prev.data` without checking which path it belongs to, and its catch handler suppresses the error whenever data exists; callers therefore show the old response under the new selection without an error notice.

Associate retained data with its path, clear it when the path changes, and report refresh failures even when same-path stale data remains visible.

Also at `web/src/api.ts:170`.
</issue_to_address>

### Comment 8
<location path="app/audit_store.py" line_range="568-569" />
<code_context>
+                "SELECT json_extract(payload,'$.duration_ms') FROM requests WHERE started_at>=? AND started_at<=?",
+                (start, now)).fetchall() if r[0] is not None)
+            # Detail rows are pruned by retention; without them the percentile is unknown.
+            summary["p95_duration_ms"] = (durations[max(0, math.ceil(len(durations) * 0.95) - 1)]
+                                          if durations else None)
+            return {"summary": summary, "previous": previous, "series": series,
                     "models": [{"model": key, **value} for key, value in models.items()],
</code_context>
<issue_to_address>
**P95 misrepresents the selected period**

When retention or size-based pruning removes some request details within the selected range while other details remain, `dashboard` calculates P95 from only the retained request details, while the summary covers the full selected range; users see a partial-sample percentile presented as the period's P95.

Mark P95 unknown when request details do not cover the full selected range.

Also at `app/audit_store.py:564-567`.
</issue_to_address>

### Comment 9
<location path="web/src/pages/Storage.tsx" line_range="146" />
<code_context>
+docker compose down        # 本地部署则停止 converter 进程
+
+# 2. 压缩(在部署目录执行)
+sqlite3 auth/logs.sqlite3 "PRAGMA wal_checkpoint(TRUNCATE); VACUUM;"
+
+# 3. 重启网关
</code_context>
<issue_to_address>
**Compression targets the wrong database**

When the deployment uses a custom managed auth directory or is a local non-Compose install, the instructions always target `auth/logs.sqlite3` and prescribe a Docker Compose restart, so operators can vacuum the wrong database and leave the live database uncompressed or be unable to follow the instructions.

Document how to locate the configured live database and give restart instructions appropriate to the deployment type.
</issue_to_address>

### Comment 10
<location path="web/src/storage.test.tsx" line_range="39" />
<code_context>
+    fireEvent.click(screen.getByRole("button", { name: "归档并删除" }));
+    await waitFor(() => expect(post).toHaveBeenCalledWith("/storage/archive", { days: 30 }));
+    expect(await screen.findByText(/已归档 6 条/)).toBeTruthy();
+    expect(get).toHaveBeenCalledWith("/storage", expect.objectContaining({}));
+  });
+});
</code_context>
<issue_to_address>
**Refresh is never verified**

When the code omits the post-archive reload or the remount refresh, when the archive succeeds but either follow-up refresh is omitted, `storage page` still passes because line 39 matches the initial GET, and `cached resources` still passes because lines 65–66 only observe the cached snapshot. Both tests pass without verifying a refresh occurred or completed.

Assert that the GET count increases after archiving and that the remount issues a second GET whose fresh response replaces the cached snapshot.

Also at `web/src/storage.test.tsx:65-66`.
</issue_to_address>

### Comment 11
<location path="web/src/i18n.test.tsx" line_range="58" />
<code_context>
-    fireEvent.click(screen.getByRole("button"));
-    // eslint-disable-next-line no-console
+    expect(screen.getByTestId("interp").textContent).toBe("尝试 3");
+    expect(localStorage.getItem("codebuddy2api.lang")).toBe("zh");
+    act(() => chooseLanguage("en"));
     expect(screen.getByTestId("probe").textContent).toBe("Overview");
</code_context>
<issue_to_address>
**Drawer language change goes unverified**

When a drawer choice is persisted without updating the active language, when the drawer handler stores the selected preference but does not notify the language provider, `applies language choices made in the appearance drawer` still passes: line 53 checks only localStorage, so the displayed language can remain unchanged.

Assert that visible translated text changes after selecting a language in the appearance drawer.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 11 findings to address first, and archiving removes old request and event rows from SQLite after exporting them to JSONL. Reverting the code will not put those rows back in the database, but the archived data remains available for a bounded manual restore.

Blocking findings: web/src/pages/Models.tsx:496, app/audit_store.py:688, app/audit_store.py:631, app/audit_store.py:703, web/src/pages/Storage.tsx:28, and 6 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread web/src/pages/Models.tsx
Comment thread app/audit_store.py
Comment thread app/audit_store.py Outdated
Comment thread app/audit_store.py Outdated
Comment thread web/src/pages/Storage.tsx Outdated
Comment thread web/src/api.ts Outdated
Comment thread app/audit_store.py Outdated
Comment thread web/src/pages/Storage.tsx Outdated
Comment thread web/src/storage.test.tsx Outdated
Comment thread web/src/i18n.test.tsx

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 11 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="web/src/api.ts" line_range="153-175" />
<code_context>
+const resourceCache = new Map<string, unknown>();
</code_context>
<issue_to_address>
**Previous session data appears**

When a different administrator signs in in the same page or tab after the cache has been populated, `resourceCache` is keyed only by request path and survives authentication changes, so the new session immediately renders the previous session’s model or credential data while its request is pending.

Scope cached entries to the authenticated session or clear the cache when the session changes.
</issue_to_address>

### Comment 2
<location path="app/audit_store.py" line_range="688" />
<code_context>
+                        exported["event"] += len(rows)
+                        last = (rows[-1][1], rows[-1][0])
+                os.chmod(tmp, 0o600)
+                os.replace(tmp, target)
+            except Exception:
+                try:
</code_context>
<issue_to_address>
**Archived records appear twice**

When the process stops after `os.replace` publishes the archive and before the database transaction commits, `archive` publishes the JSONL file before committing row deletions, so a crash leaves both the file and the original rows; a retry exports those rows again and operators see duplicate archived records.

Make publishing the archive and committing the deletions crash-safe so a retry cannot export the same rows again.
</issue_to_address>

### Comment 3
<location path="web/src/UsageOverview.tsx" line_range="314-337" />
<code_context>
+    domain && granularity !== "hour" && hourlyRows?.length && de - ds <= 48 * HOUR_STEP
</code_context>
<issue_to_address>
**Hourly trend uses daily data**

When a daily trend switches to hourly buckets, `yAt`, `pathOf`, and `choose` continue to read or store `rows` instead of `displayRows`, so the chart pairs daily values with hourly positions and hourly selections cannot activate the tooltip; an hourly index beyond the daily rows can also throw while rendering.

Use `displayRows` consistently for chart values and selection.
</issue_to_address>

### Comment 4
<location path="web/src/pages/Dashboard.tsx" line_range="181-183" />
<code_context>
+            <UsageTrend
+              rows={d.series}
+              hourlyRows={hourly.data?.series}
+              granularity={actualGrain}
+              partial={period.partial === true}
+            />
+          </Panel>
           <div className={s.dashboardGrid}>
</code_context>
<issue_to_address>
**Hourly incompleteness goes unmarked**

When hourly aggregates are incomplete but the selected daily range is complete, `Dashboard` passes the daily range’s `partial` flag to `UsageTrend` after it switches to hourly rows, so the chart presents incomplete hourly data without a warning.

Pass the hourly response’s partial flag when the trend displays hourly rows.

Also at `web/src/UsageOverview.tsx:314-315`.
</issue_to_address>

### Comment 5
<location path="web/src/pages/Storage.tsx" line_range="146" />
<code_context>
+docker compose down        # 本地部署则停止 converter 进程
+
+# 2. 压缩(在部署目录执行)
+sqlite3 auth/logs.sqlite3 "PRAGMA wal_checkpoint(TRUNCATE); VACUUM;"
+
+# 3. 重启网关
</code_context>
<issue_to_address>
**Compaction targets the wrong database**

When the configured runtime root or `CODEBUDDY_AUTH_DIR` places the managed database outside `auth`, the displayed command always opens `auth/logs.sqlite3`, while the running audit database is under the configured managed directory; following the instructions vacuums a different or nonexistent database and leaves the active logs uncompressed.

Build the command using the configured audit database path instead of the fixed `auth/logs.sqlite3` path.

Also at `web/src/pages/Storage.tsx:142`.
</issue_to_address>

### Comment 6
<location path="web/src/api.ts" line_range="170" />
<code_context>
     const controller = new AbortController();
-    setState({ data: null, loading: true, error: null });
+    // A cached snapshot stays on screen during the refresh instead of flashing a spinner.
+    setState((prev) => ({ data: prev.data, loading: prev.data === null, error: null }));
     void api
       .get<unknown>(path, { signal: controller.signal })
</code_context>
<issue_to_address>
**Range changes show stale dashboard data**

When the user changes the dashboard range or granularity, `useResource` retains the previous response when `path` changes, so the dashboard continues rendering metrics for the old range while the newly selected range loads; if that request fails, the catch also preserves the old data without showing an error. The selected range can therefore display results for a different period.

Retain the previous snapshot only for refreshes of the same path; clear or explicitly mark data from a different path while it loads and if that request fails.
</issue_to_address>

### Comment 7
<location path="web/src/pages/Dashboard.tsx" line_range="126" />
<code_context>
   const [granularity, setGranularity] = useState("auto");
   const resource = useResource(`/dashboard?days=${days}&granularity=${granularity}`, normalize);
+  // Hourly buckets power the trend's zoom-in granularity swap.
+  const hourly = useResource(`/dashboard?days=${days}&granularity=hour`, normalize);
   const d = resource.data;
   const period = d?.range ?? {};
</code_context>
<issue_to_address>
**Refresh leaves hourly data stale**

When new audit data arrives after the dashboard loads and the user clicks Refresh before zooming, the refresh button reloads only the primary dashboard resource; the separately fetched hourly resource remains unchanged. After new requests arrive, zooming into the refreshed daily chart still shows the old hourly snapshot.

Reload both dashboard resources when the refresh button is clicked.
</issue_to_address>

### Comment 8
<location path="app/admin_api.py" line_range="526-527" />
<code_context>
+        try:
+            directory = Path(str(audit.path)).parent / "archives"
+            archives = sorted((entry.name for entry in directory.glob("audit-*.jsonl")), reverse=True)[:20]
+        except OSError:
+            pass
+        return JSONResponse({**result, "archives": archives})
+
</code_context>
<issue_to_address>
**Archive listing hides read errors**

When the archive directory cannot be read, an `OSError` while listing the archive directory is discarded, so `/admin/storage` returns an empty archive list and the page reports no archives instead of telling the operator that the listing failed.

Return a storage-listing error or include a visible degraded/error field instead of treating the failure as an empty directory.
</issue_to_address>

### Comment 9
<location path="web/src/pages/Storage.tsx" line_range="34" />
<code_context>
+  const [error, setError] = useState<string | null>(null);
+  const [result, setResult] = useState<string | null>(null);
+  return (
+    <Panel title="归档旧日志" hint="导出为 JSONL 后从库中删除,统计聚合不受影响">
+      <form
+        className={s.archiveForm}
</code_context>
<issue_to_address>
**Storage page ignores selected language**

When the user selects English, the storage page’s labels and instructions are hard-coded in Chinese, so users who select English still see Chinese throughout this page instead of translated storage controls.

Add storage-page strings to both language dictionaries and render them through the translation helper.
</issue_to_address>

### Comment 10
<location path="web/src/storage.test.tsx" line_range="39" />
<code_context>
+    fireEvent.click(screen.getByRole("button", { name: "归档并删除" }));
+    await waitFor(() => expect(post).toHaveBeenCalledWith("/storage/archive", { days: 30 }));
+    expect(await screen.findByText(/已归档 6 条/)).toBeTruthy();
+    expect(get).toHaveBeenCalledWith("/storage", expect.objectContaining({}));
+  });
+});
</code_context>
<issue_to_address>
**Stale storage data still passes**

When the archive succeeds but the page does not reload its storage data, the “shows sizes with reclaimable estimate and archives old logs on demand” test passes if a successful archive stops refreshing storage: `render(<Storage />)` already causes the initial GET, which satisfies the single `toHaveBeenCalledWith` assertion at line 39.

Clear the GET mock after the initial render, or assert that the archive causes an additional `/storage` request.
</issue_to_address>

### Comment 11
<location path="app/audit_store.py" line_range="687-689" />
<code_context>
+                                ensure_ascii=False) + "\n")
+                        exported["event"] += len(rows)
+                        last = (rows[-1][1], rows[-1][0])
+                os.chmod(tmp, 0o600)
+                os.replace(tmp, target)
+            except Exception:
+                try:
+                    tmp.unlink()
</code_context>
<issue_to_address>
**Committed archives are not crash-durable**

When power is lost after the database commit but before the unsynced archive reaches durable storage, `os.replace` publishes the archive before the database deletion commits, but the archive file and directory are never synced to durable storage. A power loss after the SQLite commit can therefore leave deleted audit rows without a durable archive copy.

Flush and `fsync` the archive file and its containing directory before committing the row deletions.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 11 findings to address first, and archiving writes old request and event details to a JSONL file, then deletes those rows from SQLite; reverting the code does not restore them, and recovery would require manually re-importing the archive. A missing or unusable archive can therefore leave audit details permanently unavailable.

Blocking findings: web/src/api.ts:175, app/audit_store.py:688, web/src/UsageOverview.tsx:337, web/src/pages/Dashboard.tsx:183, web/src/pages/Storage.tsx:146, and 6 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread web/src/api.ts
Comment thread app/audit_store.py
Comment thread web/src/UsageOverview.tsx Outdated
Comment thread web/src/pages/Dashboard.tsx
Comment thread web/src/pages/Storage.tsx Outdated
Comment thread web/src/pages/Dashboard.tsx
Comment thread app/admin_api.py Outdated
Comment thread web/src/pages/Storage.tsx Outdated
Comment thread web/src/storage.test.tsx Outdated
Comment thread app/audit_store.py

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New security issues found

Comment thread app/audit_store.py Outdated
@maiphucgiang
maiphucgiang merged commit 2408abc into main Oct 14, 2026
8 checks passed
@maiphucgiang
maiphucgiang deleted the feat/dashboard-overview branch October 14, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant