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

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit 8daadb97a0374c2d24d2b15b878062cbf8f93183
Author: Logic <[email protected]>
AuthorDate: Sun Aug 30 01:02:44 2026 +0800

    feat(explore): flatten logs investigation workbench
---
 .../@[email protected]     |  13 ++
 web-app/pnpm-lock.yaml                             |   6 +-
 web-app/src/assets/i18n/en-us.json                 |   2 +
 web-app/src/assets/i18n/explore/en-us.json         |   6 +
 web-app/src/assets/i18n/explore/ja-jp.json         |   6 +
 web-app/src/assets/i18n/explore/pt-br.json         |   6 +
 web-app/src/assets/i18n/explore/zh-cn.json         |   6 +
 web-app/src/assets/i18n/explore/zh-tw.json         |   6 +
 web-app/src/assets/i18n/ja-jp.json                 |   2 +
 web-app/src/assets/i18n/pt-br.json                 |   2 +
 web-app/src/assets/i18n/zh-cn.json                 |   2 +
 web-app/src/assets/i18n/zh-tw.json                 |   2 +
 .../entity/components/entity-signal-view.test.tsx  |   9 ++
 .../entity/components/entity-signal-view.tsx       |   1 +
 .../components/explore-history-result.module.css   |  16 ++
 .../components/explore-investigation-messages.ts   |   1 +
 ...plore-investigation-responsive-contract.test.ts |  23 ++-
 .../explore/components/explore-log-statistics.tsx  |  30 +++-
 .../explore/components/explore-perses-messages.ts  |   1 +
 .../explore/components/log-result.module.css       |  25 ++-
 .../explore/components/log-result.test.tsx         |  31 ++++
 .../features/explore/model/explore-model.test.ts   |  67 ++++++++
 .../src/features/explore/model/explore-model.ts    |  33 +++-
 .../features/explore/pages/explore-page.test.tsx   |  56 ++++++-
 .../src/features/explore/pages/explore-page.tsx    |  52 ++++--
 .../pages/explore-perses-log-panel.test.tsx        | 174 +++++++++++++++++++++
 .../explore/pages/explore-perses-log-panel.tsx     | 115 ++++++++++----
 .../explore/pages/explore-state-i18n.test.ts       |   3 +
 .../runtime/hertzbeat-perses-primitive-frame.tsx   |  71 ++++++---
 .../runtime/hertzbeat-perses-primitives.module.css |  38 +++--
 .../runtime/hertzbeat-perses-primitives.test.tsx   |  62 +++++++-
 .../perses/runtime/hertzbeat-perses-primitives.tsx |   8 +-
 .../runtime/perses-runtime-providers.test.tsx      |  94 +++++++++++
 .../perses/runtime/perses-runtime-providers.tsx    |  33 +++-
 .../perses-signal-runtime-integration.test.tsx     |  12 +-
 .../perses/runtime/perses-signal-runtime.test.tsx  |  50 +++++-
 .../perses/runtime/perses-signal-runtime.tsx       |   6 +-
 37 files changed, 962 insertions(+), 108 deletions(-)

diff --git a/web-app/patches/@[email protected] 
b/web-app/patches/@[email protected]
index 155307ff9c..ac1119d577 100644
--- a/web-app/patches/@[email protected]
+++ b/web-app/patches/@[email protected]
@@ -1,3 +1,16 @@
+diff --git a/lib/components/LogRow/LogRow.js b/lib/components/LogRow/LogRow.js
+index 
51761917b3bee7c5f768542adba6609a8225db48..f49e8d701ca874dbb2a171db36a93ba0a6de09c0
 100644
+--- a/lib/components/LogRow/LogRow.js
++++ b/lib/components/LogRow/LogRow.js
+@@ -134,6 +134,8 @@ const DefaultLogRow = ({ log, isExpanded, index, onToggle, 
isExpandable = true,
+                         children: /*#__PURE__*/ _jsx(ExpandButton, {
+                             size: "small",
+                             isExpanded: isExpanded,
++                            "aria-label": isExpanded ? "Collapse log details" 
: "Expand log details",
++                            "aria-expanded": isExpanded,
+                             children: /*#__PURE__*/ _jsx(ChevronRight, {
+                                 sx: {
+                                     fontSize: '12px'
 diff --git a/lib/components/LogRow/ansiColors.css 
b/lib/components/LogRow/ansiColors.css
 new file mode 100644
 index 
0000000000000000000000000000000000000000..435b8c96a06a901510714a1506b7271e60eba81f
diff --git a/web-app/pnpm-lock.yaml b/web-app/pnpm-lock.yaml
index 3784010e43..5bcbac55bc 100644
--- a/web-app/pnpm-lock.yaml
+++ b/web-app/pnpm-lock.yaml
@@ -6,7 +6,7 @@ settings:
 
 patchedDependencies:
   '@perses-dev/[email protected]':
-    hash: d82d0721e88723e2d5476ca9049a489ee36d459d5154ea126db902c57f5f7bc2
+    hash: 81d1f6a904f0e7791dffe84f4e98f80c0482212297fae06f3fbd597f3535962b
     path: patches/@[email protected]
 
 importers:
@@ -75,7 +75,7 @@ importers:
         version: 0.54.0(5da87865a4acb83dfcec0b3c898dc4f4)
       '@perses-dev/logs-table-plugin':
         specifier: 0.3.0
-        version: 
0.3.0(patch_hash=d82d0721e88723e2d5476ca9049a489ee36d459d5154ea126db902c57f5f7bc2)(@emotion/[email protected](@types/[email protected])([email protected]))(@emotion/[email protected](@emotion/[email protected](@types/[email protected])([email protected]))(@types/[email protected])([email protected]))(@hookform/[email protected]([email protected]([email protected])))(@perses-dev/[email protected](c35d1af9abf44fbd7e846f18f415c68a))(@perses-dev/[email protected](5da87865a4acb83dfcec0b3c898dc4f4))(@perses-dev/plu
 [...]
+        version: 
0.3.0(patch_hash=81d1f6a904f0e7791dffe84f4e98f80c0482212297fae06f3fbd597f3535962b)(@emotion/[email protected](@types/[email protected])([email protected]))(@emotion/[email protected](@emotion/[email protected](@types/[email protected])([email protected]))(@types/[email protected])([email protected]))(@hookform/[email protected]([email protected]([email protected])))(@perses-dev/[email protected](c35d1af9abf44fbd7e846f18f415c68a))(@perses-dev/[email protected](5da87865a4acb83dfcec0b3c898dc4f4))(@perses-dev/plu
 [...]
       '@perses-dev/plugin-system':
         specifier: 0.54.0
         version: 0.54.0(5da87865a4acb83dfcec0b3c898dc4f4)
@@ -5750,7 +5750,7 @@ snapshots:
       - vue-tsc
       - webpack
 
-  
'@perses-dev/[email protected](patch_hash=d82d0721e88723e2d5476ca9049a489ee36d459d5154ea126db902c57f5f7bc2)(@emotion/[email protected](@types/[email protected])([email protected]))(@emotion/[email protected](@emotion/[email protected](@types/[email protected])([email protected]))(@types/[email protected])([email protected]))(@hookform/[email protected]([email protected]([email protected])))(@perses-dev/[email protected](c35d1af9abf44fbd7e846f18f415c68a))(@perses-dev/[email protected](5da87865a4acb83dfcec0b3c898dc4f4))
 [...]
+  
'@perses-dev/[email protected](patch_hash=81d1f6a904f0e7791dffe84f4e98f80c0482212297fae06f3fbd597f3535962b)(@emotion/[email protected](@types/[email protected])([email protected]))(@emotion/[email protected](@emotion/[email protected](@types/[email protected])([email protected]))(@types/[email protected])([email protected]))(@hookform/[email protected]([email protected]([email protected])))(@perses-dev/[email protected](c35d1af9abf44fbd7e846f18f415c68a))(@perses-dev/[email protected](5da87865a4acb83dfcec0b3c898dc4f4))
 [...]
     dependencies:
       '@emotion/react': 11.14.0(@types/[email protected])([email protected])
       '@emotion/styled': 
11.14.1(@emotion/[email protected](@types/[email protected])([email protected]))(@types/[email protected])([email protected])
diff --git a/web-app/src/assets/i18n/en-us.json 
b/web-app/src/assets/i18n/en-us.json
index 9ed75daff0..55969c5421 100644
--- a/web-app/src/assets/i18n/en-us.json
+++ b/web-app/src/assets/i18n/en-us.json
@@ -437,6 +437,7 @@
         "truncated": "Showing a bounded subset of matching evidence.",
         "truncationUnknown": "Result completeness is unknown.",
         "runtimeError": "The visualization runtime could not render this 
evidence.",
+        "investigationActions": "Investigation actions ({{count}})",
         "invalid": "The signal request is invalid.",
         "permission": "You do not have permission to inspect this signal.",
         "overloaded": "Signal storage is busy. Try again.",
@@ -650,6 +651,7 @@
     "overview": "Log overview",
     "trend": "Log trend",
     "trendEmpty": "No trend buckets were returned for this query.",
+    "trendInsufficient": "Only one trend bucket is available ({{count}} logs), 
so no time trend can be drawn.",
     "statisticsUnavailable": "This statistics region could not be loaded.",
     "statistics": {
       "total": "Total",
diff --git a/web-app/src/assets/i18n/explore/en-us.json 
b/web-app/src/assets/i18n/explore/en-us.json
index ebf4a23d37..8327e4a35b 100644
--- a/web-app/src/assets/i18n/explore/en-us.json
+++ b/web-app/src/assets/i18n/explore/en-us.json
@@ -94,11 +94,17 @@
       "overloaded": "Signal storage is busy. Try again.",
       "unavailable": "Signal storage is unavailable.",
       "contract": "The evidence could not be rendered because it was 
incomplete.",
+      "investigationActions": "Investigation actions ({{count}})",
       "logsTable": "Historical logs",
       "tracesTable": "Historical traces",
       "pagination": "Historical result pages",
+      "rowsReturned": "Rows returned / total",
+      "requestedPage": "Requested page / available pages",
+      "evidenceWindow": "Exact evidence window",
       "investigateLog": "Investigate log {{value}}",
+      "investigateLogAction": "Investigate",
       "openTrace": "Open trace {{value}}",
+      "openTraceAction": "Open trace",
       "investigateTrace": "Investigate trace {{value}}",
       "notRecorded": "Not recorded"
     },
diff --git a/web-app/src/assets/i18n/explore/ja-jp.json 
b/web-app/src/assets/i18n/explore/ja-jp.json
index 3e35e6dee5..ac177d9b3c 100644
--- a/web-app/src/assets/i18n/explore/ja-jp.json
+++ b/web-app/src/assets/i18n/explore/ja-jp.json
@@ -94,11 +94,17 @@
       "overloaded": "シグナルストレージが混雑しています。再試行してください。",
       "unavailable": "シグナルストレージを利用できません。",
       "contract": "証拠が不完全なため表示できません。",
+      "investigationActions": "調査アクション({{count}} 件)",
       "logsTable": "履歴ログ",
       "tracesTable": "履歴トレース",
       "pagination": "履歴結果ページ",
+      "rowsReturned": "返された行数 / 合計",
+      "requestedPage": "要求ページ / 利用可能ページ数",
+      "evidenceWindow": "正確な証拠の時間範囲",
       "investigateLog": "ログ {{value}} を調査",
+      "investigateLogAction": "調査",
       "openTrace": "トレース {{value}} を開く",
+      "openTraceAction": "トレースを開く",
       "investigateTrace": "トレース {{value}} を調査",
       "notRecorded": "記録なし"
     },
diff --git a/web-app/src/assets/i18n/explore/pt-br.json 
b/web-app/src/assets/i18n/explore/pt-br.json
index 4b3c130d06..394ddce99b 100644
--- a/web-app/src/assets/i18n/explore/pt-br.json
+++ b/web-app/src/assets/i18n/explore/pt-br.json
@@ -94,11 +94,17 @@
       "overloaded": "O armazenamento de sinais está ocupado. Tente novamente.",
       "unavailable": "O armazenamento de sinais está indisponível.",
       "contract": "As evidências estão incompletas e não puderam ser 
renderizadas.",
+      "investigationActions": "Ações de investigação ({{count}})",
       "logsTable": "Logs históricos",
       "tracesTable": "Traces históricos",
       "pagination": "Páginas de resultados históricos",
+      "rowsReturned": "Linhas retornadas / total",
+      "requestedPage": "Página solicitada / páginas disponíveis",
+      "evidenceWindow": "Janela exata de evidências",
       "investigateLog": "Investigar log {{value}}",
+      "investigateLogAction": "Investigar",
       "openTrace": "Abrir trace {{value}}",
+      "openTraceAction": "Abrir trace",
       "investigateTrace": "Investigar trace {{value}}",
       "notRecorded": "Não registrado"
     },
diff --git a/web-app/src/assets/i18n/explore/zh-cn.json 
b/web-app/src/assets/i18n/explore/zh-cn.json
index 5ee4014d12..372a9cadf3 100644
--- a/web-app/src/assets/i18n/explore/zh-cn.json
+++ b/web-app/src/assets/i18n/explore/zh-cn.json
@@ -94,11 +94,17 @@
       "overloaded": "信号存储繁忙,请重试。",
       "unavailable": "信号存储不可用。",
       "contract": "证据不完整,无法渲染。",
+      "investigationActions": "调查操作({{count}} 条)",
       "logsTable": "历史日志",
       "tracesTable": "历史链路",
       "pagination": "历史结果分页",
+      "rowsReturned": "本页返回行数 / 总数",
+      "requestedPage": "请求页 / 可用页",
+      "evidenceWindow": "精确证据窗口",
       "investigateLog": "调查日志 {{value}}",
+      "investigateLogAction": "调查",
       "openTrace": "打开链路 {{value}}",
+      "openTraceAction": "打开链路",
       "investigateTrace": "调查链路 {{value}}",
       "notRecorded": "未记录"
     },
diff --git a/web-app/src/assets/i18n/explore/zh-tw.json 
b/web-app/src/assets/i18n/explore/zh-tw.json
index 18372544d9..3d17bb22e0 100644
--- a/web-app/src/assets/i18n/explore/zh-tw.json
+++ b/web-app/src/assets/i18n/explore/zh-tw.json
@@ -94,11 +94,17 @@
       "overloaded": "訊號儲存忙碌,請重試。",
       "unavailable": "訊號儲存不可用。",
       "contract": "證據不完整,無法呈現。",
+      "investigationActions": "調查操作({{count}} 筆)",
       "logsTable": "歷史日誌",
       "tracesTable": "歷史追蹤",
       "pagination": "歷史結果分頁",
+      "rowsReturned": "本頁傳回列數 / 總數",
+      "requestedPage": "請求頁 / 可用頁",
+      "evidenceWindow": "精確證據視窗",
       "investigateLog": "調查日誌 {{value}}",
+      "investigateLogAction": "調查",
       "openTrace": "開啟追蹤 {{value}}",
+      "openTraceAction": "開啟追蹤",
       "investigateTrace": "調查追蹤 {{value}}",
       "notRecorded": "未記錄"
     },
diff --git a/web-app/src/assets/i18n/ja-jp.json 
b/web-app/src/assets/i18n/ja-jp.json
index 2b43a5ea2e..c19800517b 100644
--- a/web-app/src/assets/i18n/ja-jp.json
+++ b/web-app/src/assets/i18n/ja-jp.json
@@ -429,6 +429,7 @@
         "truncated": "一致する証拠の制限された一部を表示しています。",
         "truncationUnknown": "結果の完全性は不明です。",
         "runtimeError": "可視化ランタイムはこの証拠を描画できませんでした。",
+        "investigationActions": "調査アクション({{count}} 件)",
         "invalid": "シグナル要求が無効です。",
         "permission": "このシグナルを確認する権限がありません。",
         "overloaded": "シグナルストレージが混雑しています。再試行してください。",
@@ -645,6 +646,7 @@
     "overview": "ログ概要",
     "trend": "ログ傾向",
     "trendEmpty": "このクエリでは傾向の時間区分が返されませんでした。",
+    "trendInsufficient": "傾向の時間区分が 1 件({{count}} ログ)のため、時間傾向を描画できません。",
     "statisticsUnavailable": "この統計領域を読み込めませんでした。",
     "statistics": {
       "total": "合計",
diff --git a/web-app/src/assets/i18n/pt-br.json 
b/web-app/src/assets/i18n/pt-br.json
index 6b58f26608..850af15b80 100644
--- a/web-app/src/assets/i18n/pt-br.json
+++ b/web-app/src/assets/i18n/pt-br.json
@@ -445,6 +445,7 @@
         "truncated": "Exibindo um subconjunto limitado da evidência 
correspondente.",
         "truncationUnknown": "A completude do resultado é desconhecida.",
         "runtimeError": "O runtime de visualização não conseguiu renderizar 
esta evidência.",
+        "investigationActions": "Ações de investigação ({{count}})",
         "invalid": "A solicitação do sinal é inválida.",
         "permission": "Você não tem permissão para inspecionar este sinal.",
         "overloaded": "O armazenamento de sinais está ocupado. Tente 
novamente.",
@@ -661,6 +662,7 @@
     "overview": "Visão geral dos logs",
     "trend": "Tendência dos logs",
     "trendEmpty": "Nenhum intervalo de tendência foi retornado para esta 
consulta.",
+    "trendInsufficient": "Há apenas um intervalo de tendência ({{count}} 
logs), portanto não é possível traçar uma tendência temporal.",
     "statisticsUnavailable": "Não foi possível carregar esta região de 
estatísticas.",
     "statistics": {
       "total": "Total",
diff --git a/web-app/src/assets/i18n/zh-cn.json 
b/web-app/src/assets/i18n/zh-cn.json
index a730244096..3f99bd2fb1 100644
--- a/web-app/src/assets/i18n/zh-cn.json
+++ b/web-app/src/assets/i18n/zh-cn.json
@@ -409,6 +409,7 @@
         "truncated": "当前展示匹配证据的有界子集。",
         "truncationUnknown": "结果完整性未知。",
         "runtimeError": "可视化运行时无法渲染该证据。",
+        "investigationActions": "调查操作({{count}} 条)",
         "invalid": "信号请求无效。",
         "permission": "没有权限查看该信号。",
         "overloaded": "信号存储繁忙,请重试。",
@@ -622,6 +623,7 @@
     "overview": "日志概览",
     "trend": "日志趋势",
     "trendEmpty": "此查询未返回趋势时间桶。",
+    "trendInsufficient": "只有一个趋势时间桶({{count}} 条日志),无法绘制时间趋势。",
     "statisticsUnavailable": "此统计区域无法加载。",
     "statistics": {
       "total": "总计",
diff --git a/web-app/src/assets/i18n/zh-tw.json 
b/web-app/src/assets/i18n/zh-tw.json
index 9f35064e28..d0c5806256 100644
--- a/web-app/src/assets/i18n/zh-tw.json
+++ b/web-app/src/assets/i18n/zh-tw.json
@@ -409,6 +409,7 @@
         "truncated": "目前顯示匹配證據的有界子集。",
         "truncationUnknown": "結果完整性未知。",
         "runtimeError": "視覺化執行環境無法呈現該證據。",
+        "investigationActions": "調查操作({{count}} 筆)",
         "invalid": "信號請求無效。",
         "permission": "沒有權限查看該信號。",
         "overloaded": "信號儲存忙碌,請重試。",
@@ -622,6 +623,7 @@
     "overview": "日誌概覽",
     "trend": "日誌趨勢",
     "trendEmpty": "此查詢未傳回趨勢時間區段。",
+    "trendInsufficient": "只有一個趨勢時間區段({{count}} 筆日誌),無法繪製時間趨勢。",
     "statisticsUnavailable": "此統計區域無法載入。",
     "statistics": {
       "total": "總計",
diff --git a/web-app/src/features/entity/components/entity-signal-view.test.tsx 
b/web-app/src/features/entity/components/entity-signal-view.test.tsx
index f8559c62f5..55387e753d 100644
--- a/web-app/src/features/entity/components/entity-signal-view.test.tsx
+++ b/web-app/src/features/entity/components/entity-signal-view.test.tsx
@@ -5,6 +5,11 @@ import { I18nextProvider } from 'react-i18next';
 import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
 
 import { i18n, initializeI18n, loadLocale } from '@/core/i18n/i18n';
+import enUS from '@/assets/i18n/en-us.json';
+import jaJP from '@/assets/i18n/ja-jp.json';
+import ptBR from '@/assets/i18n/pt-br.json';
+import zhCN from '@/assets/i18n/zh-cn.json';
+import zhTW from '@/assets/i18n/zh-tw.json';
 
 import type { EntitySignalViewState } from '../model/entity-signal-view-model';
 import { EntitySignalView } from './entity-signal-view';
@@ -24,6 +29,10 @@ describe('EntitySignalView', () => {
   });
   afterEach(cleanup);
 
+  it.each([enUS, jaJP, ptBR, zhCN, zhTW])('owns localized host-interaction 
summary copy', locale => {
+    
expect(locale.entity.signals.query.investigationActions).toContain('{{count}}');
+  });
+
   it('keeps each available signal in its own section and explains correlations 
in the evidence rail', () => {
     renderView(readyState());
 
diff --git a/web-app/src/features/entity/components/entity-signal-view.tsx 
b/web-app/src/features/entity/components/entity-signal-view.tsx
index 0b44a4e615..135a17e638 100644
--- a/web-app/src/features/entity/components/entity-signal-view.tsx
+++ b/web-app/src/features/entity/components/entity-signal-view.tsx
@@ -131,6 +131,7 @@ function primitiveMessages(t: ReturnType<typeof 
useTranslation>['t']): HertzBeat
     truncated: t('entity.signals.query.truncated'),
     truncationUnknown: t('entity.signals.query.truncationUnknown'),
     runtimeError: t('entity.signals.query.runtimeError'),
+    investigationActions: count => 
t('entity.signals.query.investigationActions', { count }),
     failures: {
       'perses.query.invalid': t('entity.signals.query.invalid'),
       'perses.query.permission': t('entity.signals.query.permission'),
diff --git 
a/web-app/src/features/explore/components/explore-history-result.module.css 
b/web-app/src/features/explore/components/explore-history-result.module.css
index 8259198d0c..6502a1cda3 100644
--- a/web-app/src/features/explore/components/explore-history-result.module.css
+++ b/web-app/src/features/explore/components/explore-history-result.module.css
@@ -12,6 +12,22 @@
   overflow: hidden;
 }
 
+.logRegion {
+  min-width: 0;
+  padding-block: 12px;
+  border-radius: 0;
+  background: transparent;
+  box-shadow: none;
+}
+
+.logRegion + .logRegion {
+  border-top: 1px solid var(--hb-border-subtle);
+}
+
+.logRegion[data-explore-log-region='query'] {
+  padding-block: 0;
+}
+
 @media (width <= 700px) {
   .pagination {
     justify-content: flex-start;
diff --git 
a/web-app/src/features/explore/components/explore-investigation-messages.ts 
b/web-app/src/features/explore/components/explore-investigation-messages.ts
index 2bb98d0a4e..5288eac167 100644
--- a/web-app/src/features/explore/components/explore-investigation-messages.ts
+++ b/web-app/src/features/explore/components/explore-investigation-messages.ts
@@ -27,6 +27,7 @@ export function investigationPrimitiveMessages(
     truncated: t(key('query.truncated')),
     truncationUnknown: t(key('query.truncationUnknown')),
     runtimeError: t(key('query.runtimeError')),
+    investigationActions: count => t('explore.perses.investigationActions', { 
count }),
     failures: {
       'perses.query.invalid': t(key('query.invalid')),
       'perses.query.permission': t(key('query.permission')),
diff --git 
a/web-app/src/features/explore/components/explore-investigation-responsive-contract.test.ts
 
b/web-app/src/features/explore/components/explore-investigation-responsive-contract.test.ts
index 4d4fbfacd4..6ea192f212 100644
--- 
a/web-app/src/features/explore/components/explore-investigation-responsive-contract.test.ts
+++ 
b/web-app/src/features/explore/components/explore-investigation-responsive-contract.test.ts
@@ -56,20 +56,32 @@ describe('Explore investigation responsive contract', () => 
{
 
   it('keeps Logs overview, trend, and result on flat full-width workbench 
surfaces', () => {
     
expect(logStyles).toMatch(/\.statistics\s*\{[^}]*flex-direction:\s*column/s);
-    
expect(logStyles).toMatch(/\.statistics\s*>\s*section\s*\{[^}]*border-bottom:\s*1px
 solid/s);
+    expect(logStyles).toMatch(/\.statistics\s*\{[^}]*margin-bottom:\s*0/s);
+    expect(logStyles).toMatch(
+      
/\.statistics\s*>\s*\.overview\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*auto\s+minmax\(0,\s*1fr\)/s
+    );
+    
expect(logStyles).not.toMatch(/\.statistics\s*>\s*section\s*\{[^}]*border-bottom:/s);
     
expect(logStyles).not.toMatch(/\.statistics\s*>\s*section\s*\{[^}]*border-radius:/s);
     
expect(logStyles).not.toMatch(/\.statistics\s*\{[^}]*grid-template-columns:/s);
+    
expect(historyStyles).toMatch(/\.logRegion\s*\{[^}]*padding-block:\s*12px/s);
+    
expect(historyStyles).toMatch(/\.logRegion\s*\+\s*\.logRegion\s*\{[^}]*border-top:\s*1px
 solid/s);
   });
 
-  it('flattens embedded Perses cards and keeps narrow host actions on one 
scrollable line', () => {
+  it('flattens embedded Perses cards and keeps collapsed host actions on a 
flat scrollable disclosure', () => {
     
expect(persesStyles).toMatch(/\.runtime\s+:global\(\.MuiCard-root\)[\s\S]*border-radius:\s*0/s);
     
expect(persesStyles).toMatch(/\.runtime\s+:global\(\.MuiPaper-root\)[\s\S]*box-shadow:\s*none/s);
     
expect(persesStyles).toMatch(/\.runtime\s+:global\(\.MuiTableContainer-root\)[^}]*overflow-x:\s*auto/s);
     expect(persesStyles).toMatch(
       
/\.runtime\s+:global\(\.MuiCardContent-root\s*>\s*\.MuiBox-root\)[^}]*box-shadow:\s*none/s
     );
-    
expect(persesStyles).toMatch(/\.interactions\s+li\s*>\s*div\s*\{[^}]*overflow-x:\s*auto/s);
+    
expect(persesStyles).toMatch(/\.interactionList\s+li\s*>\s*div\s*\{[^}]*overflow-x:\s*auto/s);
     
expect(persesStyles).toMatch(/\.interactions\s+button\s*\{[^}]*white-space:\s*nowrap/s);
+    expect(persesStyles).toMatch(
+      /@media \(width <= 
700px\)[\s\S]*\.interactionList\s+li\s*>\s*div\s*\{[^}]*flex-basis:\s*auto/s
+    );
+    expect(persesStyles).toMatch(
+      
/\.interactions\s*\{[^}]*border:\s*0[^}]*border-radius:\s*0[^}]*background:\s*transparent[^}]*box-shadow:\s*none/s
+    );
   });
 
   it('keeps narrow Logs readable with stable content width and runtime-local 
scrolling', () => {
@@ -85,7 +97,10 @@ describe('Explore investigation responsive contract', () => {
   it('gives runtime, completeness, and interactions independent grid rows', () 
=> {
     
expect(persesStyles).toMatch(/\.primitive\s*\{[^}]*grid-template-rows:\s*360px\s+auto\s+auto/s);
     expect(persesStyles).toMatch(
-      
/\.primitive\[data-variant='compact'\]\s*\{[^}]*min-height:\s*220px[^}]*grid-template-rows:\s*220px\s+auto\s+auto/s
+      
/\.primitive\[data-variant='compact'\]\s*\{[^}]*min-height:\s*180px[^}]*grid-template-rows:\s*180px\s+auto\s+auto/s
+    );
+    expect(persesStyles).toMatch(
+      
/\.primitive\[data-variant='compact'\]\s+\.runtime,[\s\S]*height:\s*180px[^}]*min-height:\s*180px/s
     );
   });
 
diff --git a/web-app/src/features/explore/components/explore-log-statistics.tsx 
b/web-app/src/features/explore/components/explore-log-statistics.tsx
index 2ecd34b001..bb6f2d81e7 100644
--- a/web-app/src/features/explore/components/explore-log-statistics.tsx
+++ b/web-app/src/features/explore/components/explore-log-statistics.tsx
@@ -15,17 +15,25 @@ export function ExploreLogStatistics({
   statistics,
   timeWindow,
   runtimeIdentity,
+  onTimeWindowChange,
   t
 }: {
   statistics: Pick<LogHistoryEvidence, 'overview' | 'trend'>;
   timeWindow: ExactTimeWindow;
   runtimeIdentity: string;
+  onTimeWindowChange?: ((window: ExactTimeWindow) => void) | undefined;
   t: TFunction;
 }) {
   return (
     <div className={styles.statistics}>
       <Overview statistics={statistics} t={t} />
-      <Trend statistics={statistics} timeWindow={timeWindow} 
runtimeIdentity={runtimeIdentity} t={t} />
+      <Trend
+        statistics={statistics}
+        timeWindow={timeWindow}
+        runtimeIdentity={runtimeIdentity}
+        onTimeWindowChange={onTimeWindowChange}
+        t={t}
+      />
     </div>
   );
 }
@@ -54,14 +62,20 @@ function Trend({
   statistics,
   timeWindow,
   runtimeIdentity,
+  onTimeWindowChange,
   t
 }: {
   statistics: Pick<LogHistoryEvidence, 'trend'>;
   timeWindow: ExactTimeWindow;
   runtimeIdentity: string;
+  onTimeWindowChange?: ((window: ExactTimeWindow) => void) | undefined;
   t: TFunction;
 }) {
   const rows = statistics.trend.kind === 'ready' ? 
Object.keys(statistics.trend.data.hourlyStats) : [];
+  const singleBucketCount =
+    statistics.trend.kind === 'ready' && rows.length === 1
+      ? Object.values(statistics.trend.data.hourlyStats)[0]
+      : undefined;
   return (
     <section className={styles.trend} aria-label={t('exploreLog.trend')}>
       <h3>{t('exploreLog.trend')}</h3>
@@ -69,8 +83,16 @@ function Trend({
         <Alert type="warning" showIcon 
message={t('exploreLog.statisticsUnavailable')} />
       ) : rows.length === 0 ? (
         <p>{t('exploreLog.trendEmpty')}</p>
+      ) : rows.length === 1 ? (
+        <p>{t('exploreLog.trendInsufficient', { count: singleBucketCount 
})}</p>
       ) : (
-        <TrendResult trend={statistics.trend.data} timeWindow={timeWindow} 
runtimeIdentity={runtimeIdentity} t={t} />
+        <TrendResult
+          trend={statistics.trend.data}
+          timeWindow={timeWindow}
+          runtimeIdentity={runtimeIdentity}
+          onTimeWindowChange={onTimeWindowChange}
+          t={t}
+        />
       )}
     </section>
   );
@@ -80,11 +102,13 @@ function TrendResult({
   trend,
   timeWindow,
   runtimeIdentity,
+  onTimeWindowChange,
   t
 }: {
   trend: Extract<LogHistoryEvidence['trend'], { kind: 'ready' }>['data'];
   timeWindow: ExactTimeWindow;
   runtimeIdentity: string;
+  onTimeWindowChange?: ((window: ExactTimeWindow) => void) | undefined;
   t: TFunction;
 }) {
   const result = createLogTrendPersesResult(trend, timeWindow, 
runtimeIdentity);
@@ -97,6 +121,8 @@ function TrendResult({
       outcome={result.outcome}
       runtimeIdentity={result.runtimeIdentity}
       messages={explorePersesMessages(t)}
+      onTimeWindowChange={onTimeWindowChange}
+      timeWindowChangeEnabled={onTimeWindowChange != null}
       variant="compact"
     />
   );
diff --git a/web-app/src/features/explore/components/explore-perses-messages.ts 
b/web-app/src/features/explore/components/explore-perses-messages.ts
index 28332be166..432601727c 100644
--- a/web-app/src/features/explore/components/explore-perses-messages.ts
+++ b/web-app/src/features/explore/components/explore-perses-messages.ts
@@ -11,6 +11,7 @@ export function explorePersesMessages(t: TFunction): 
HertzBeatPersesPrimitiveMes
     truncated: t('explore.perses.truncated'),
     truncationUnknown: t('explore.perses.truncationUnknown'),
     runtimeError: t('explore.perses.runtimeError'),
+    investigationActions: count => t('explore.perses.investigationActions', { 
count }),
     failures: {
       'perses.query.invalid': t('explore.perses.invalid'),
       'perses.query.permission': t('explore.perses.permission'),
diff --git a/web-app/src/features/explore/components/log-result.module.css 
b/web-app/src/features/explore/components/log-result.module.css
index 2593b2b6e3..d450ce7d75 100644
--- a/web-app/src/features/explore/components/log-result.module.css
+++ b/web-app/src/features/explore/components/log-result.module.css
@@ -51,24 +51,31 @@
   display: flex;
   min-width: 0;
   flex-direction: column;
-  margin-bottom: 12px;
+  margin-bottom: 0;
 }
 
 .statistics > section {
   min-width: 0;
-  padding: 12px 0;
-  border-bottom: 1px solid var(--hb-border-subtle);
+  padding: 8px 0;
 }
 
 .statistics > .overview {
-  padding-top: 8px;
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: center;
+  gap: 16px;
+  padding-top: 0;
 }
 
 .statistics h3 {
-  margin: 0 0 10px;
+  margin: 0;
   font-size: 14px;
 }
 
+.statistics > .trend h3 {
+  margin-bottom: 8px;
+}
+
 .overviewStats {
   display: grid;
   grid-template-columns: repeat(7, minmax(0, 1fr));
@@ -98,6 +105,14 @@
 }
 
 @media (width <= 900px) {
+  .statistics > .overview {
+    display: block;
+  }
+
+  .statistics > .overview h3 {
+    margin-bottom: 8px;
+  }
+
   .overviewStats {
     grid-template-columns: repeat(4, minmax(0, 1fr));
   }
diff --git a/web-app/src/features/explore/components/log-result.test.tsx 
b/web-app/src/features/explore/components/log-result.test.tsx
index e5d32d44f3..48f677de6c 100644
--- a/web-app/src/features/explore/components/log-result.test.tsx
+++ b/web-app/src/features/explore/components/log-result.test.tsx
@@ -203,6 +203,37 @@ describe('LogResult', () => {
     expect(within(trend).queryByRole('list')).not.toBeInTheDocument();
   });
 
+  it('reports a single observed trend bucket instead of presenting an empty 
chart grid as a trend', () => {
+    render(
+      <I18nextProvider i18n={i18n}>
+        <ExploreLogStatistics
+          statistics={{
+            overview: {
+              kind: 'ready',
+              data: {
+                totalCount: 6,
+                traceCount: 0,
+                debugCount: 0,
+                infoCount: 6,
+                warnCount: 0,
+                errorCount: 0,
+                fatalCount: 0
+              }
+            },
+            trend: { kind: 'ready', data: { hourlyStats: { '2026-08-06 10:00': 
6 } } }
+          }}
+          timeWindow={{ from: 1_754_467_200_000, to: 1_754_470_800_000 }}
+          runtimeIdentity="logs-trend:revision-single"
+          t={i18n.t}
+        />
+      </I18nextProvider>
+    );
+
+    const trend = screen.getByRole('region', { name: 
i18n.t('exploreLog.trend') });
+    expect(within(trend).getByText(i18n.t('exploreLog.trendInsufficient', { 
count: 6 }))).toBeInTheDocument();
+    expect(within(trend).queryByRole('img')).not.toBeInTheDocument();
+  });
+
   it.each([
     ['unavailable', 'common.unavailable'],
     ['error', 'exploreLog.streamFailed'],
diff --git a/web-app/src/features/explore/model/explore-model.test.ts 
b/web-app/src/features/explore/model/explore-model.test.ts
index ce4268697e..9c1103655f 100644
--- a/web-app/src/features/explore/model/explore-model.test.ts
+++ b/web-app/src/features/explore/model/explore-model.test.ts
@@ -23,6 +23,7 @@ import {
   exploreHandoffState,
   exploreQueryContext,
   exploreUsesExactWindow,
+  logTrendZoomPatch,
   mergeExploreContextChanges,
   mergeExploreQuery,
   parseExploreQuery,
@@ -697,4 +698,70 @@ describe('explore query state', () => {
   it('uses bounded time presets', () => {
     expect(timeRangeMilliseconds('last-24h')).toBe(86_400_000);
   });
+
+  it('turns a bounded Log trend zoom into an exact query without dropping 
active filters', () => {
+    const query = parseExploreQuery(
+      new URLSearchParams(
+        
'signal=logs&timeRange=last-30m&windowMode=preset&page=3&logRecordUid=record-1' 
+
+          
'&serviceName=checkout&serviceNamespace=commerce&environment=prod&query=timeout&severityText=warn'
 +
+          
'&resourceFilter=cloud.region%3Dus-east&attributeFilter=http.status_code%3D500' 
+
+          '&traceId=0123456789abcdef0123456789abcdef&spanId=0123456789abcdef'
+      )
+    );
+    expect(query.signal).toBe('logs');
+    if (query.signal !== 'logs') throw new Error('Expected a Log Explore 
query');
+    const patch = logTrendZoomPatch(
+      query,
+      { from: 1_750_000_000_000, to: 1_750_003_600_000 },
+      { from: 1_750_000_600_000, to: 1_750_001_200_000 }
+    );
+
+    expect(patch).toEqual({
+      start: 1_750_000_600_000,
+      end: 1_750_001_200_000,
+      windowMode: undefined,
+      pageIndex: undefined,
+      logRecordUid: undefined,
+      traceId: '0123456789abcdef0123456789abcdef',
+      spanId: '0123456789abcdef'
+    });
+    expect(mergeExploreQuery(query, patch!)).toMatchObject({
+      signal: 'logs',
+      serviceName: 'checkout',
+      serviceNamespace: 'commerce',
+      environment: 'prod',
+      query: 'timeout',
+      severityText: 'warn',
+      resourceFilter: 'cloud.region=us-east',
+      attributeFilter: 'http.status_code=500',
+      traceId: '0123456789abcdef0123456789abcdef',
+      spanId: '0123456789abcdef',
+      start: 1_750_000_600_000,
+      end: 1_750_001_200_000,
+      windowMode: undefined,
+      pageIndex: undefined,
+      logRecordUid: undefined
+    });
+  });
+
+  it('rejects an unchanged Log trend window', () => {
+    const window = { from: 1_750_000_000_000, to: 1_750_003_600_000 };
+    expect(logTrendZoomPatch({ signal: 'logs', timeRange: 'last-30m' }, 
window, window)).toBeUndefined();
+  });
+
+  it.each([
+    ['unsafe start', { from: Number.MAX_SAFE_INTEGER + 1, to: 
Number.MAX_SAFE_INTEGER + 2 }],
+    ['non-positive start', { from: 0, to: 1_750_000_600_000 }],
+    ['reversed window', { from: 1_750_000_600_000, to: 1_750_000_000_000 }],
+    ['outside evidence', { from: 1_749_999_999_999, to: 1_750_000_600_000 }],
+    ['over 24 hours', { from: 1_750_000_000_000, to: 1_750_086_400_001 }]
+  ] as const)('rejects a %s Log trend zoom', (_name, requested) => {
+    expect(
+      logTrendZoomPatch(
+        { signal: 'logs', timeRange: 'last-30m' },
+        { from: 1_750_000_000_000, to: 1_750_100_000_000 },
+        requested
+      )
+    ).toBeUndefined();
+  });
 });
diff --git a/web-app/src/features/explore/model/explore-model.ts 
b/web-app/src/features/explore/model/explore-model.ts
index 76aed998ad..6a48d9ef6b 100644
--- a/web-app/src/features/explore/model/explore-model.ts
+++ b/web-app/src/features/explore/model/explore-model.ts
@@ -21,7 +21,8 @@ import {
   type ExploreQuery,
   type ExploreQueryPatch,
   type ExploreSignal,
-  type ExploreTimeRange
+  type ExploreTimeRange,
+  type LogExploreQuery
 } from './explore-query';
 
 import type { ExactTimeWindow, QueryContext } from '@/shared/query-context';
@@ -147,6 +148,36 @@ export function presetTimeRangePatch(query: ExploreQuery, 
timeRange: ExploreTime
   };
 }
 
+export function logTrendZoomPatch(
+  query: LogExploreQuery,
+  evidenceWindow: ExactTimeWindow,
+  requestedWindow: ExactTimeWindow
+): ExploreQueryPatch | undefined {
+  if (!validTrendZoomWindow(evidenceWindow, requestedWindow)) return undefined;
+  return {
+    start: requestedWindow.from,
+    end: requestedWindow.to,
+    windowMode: undefined,
+    pageIndex: undefined,
+    logRecordUid: undefined,
+    // In historical Logs these identities are active filters, so an exact 
zoom must preserve them explicitly.
+    traceId: query.traceId,
+    spanId: query.spanId
+  };
+}
+
+function validTrendZoomWindow(evidence: ExactTimeWindow, requested: 
ExactTimeWindow) {
+  if (![evidence.from, evidence.to, requested.from, 
requested.to].every(isPositiveSafeInteger)) return false;
+  if (evidence.from >= evidence.to || requested.from >= requested.to) return 
false;
+  if (requested.from < evidence.from || requested.to > evidence.to) return 
false;
+  if (requested.to - requested.from > 24 * 60 * 60_000) return false;
+  return requested.from !== evidence.from || requested.to !== evidence.to;
+}
+
+function isPositiveSafeInteger(value: number) {
+  return Number.isSafeInteger(value) && value > 0;
+}
+
 function dependentFilterCleanup(query: ExploreQuery, changes: 
ExploreQueryPatch) {
   const currentTraceId = 'traceId' in query ? query.traceId : undefined;
   const currentLogRecordUid = query.signal === 'logs' ? query.logRecordUid : 
undefined;
diff --git a/web-app/src/features/explore/pages/explore-page.test.tsx 
b/web-app/src/features/explore/pages/explore-page.test.tsx
index 22b45ef09f..b296569a1f 100644
--- a/web-app/src/features/explore/pages/explore-page.test.tsx
+++ b/web-app/src/features/explore/pages/explore-page.test.tsx
@@ -437,23 +437,48 @@ describe('ExplorePage instrumentation context boundary', 
() => {
     expect(screen.queryByText(en.explore.empty.logs)).not.toBeInTheDocument();
   });
 
+  it('exposes historical Logs as ordered flat Query, Trend, and Result sibling 
regions', async () => {
+    api.loadLogSignal.mockResolvedValueOnce(logEvidence(logPage('flat log 
evidence', 'not-a-trace-id')));
+    renderPage('/explore?signal=logs');
+    await screen.findByText(i18n.t('explore.perses.investigationActions', { 
count: 1 }));
+    expandLogInvestigationActions();
+    expect((await screen.findAllByText(/flat log 
evidence/u)).length).toBeGreaterThan(0);
+
+    const panel = screen.getByRole('tabpanel', { name: en.explore.signals.logs 
});
+    const regions = Array.from(panel.querySelectorAll(':scope > 
[data-explore-log-region]'));
+    expect(regions.map(region => 
region.getAttribute('data-explore-log-region'))).toEqual(['query', 'trend', 
'result']);
+    regions.forEach(region => {
+      expect(region.querySelector('.ant-card, [data-surface="card"], 
[data-hb-card]')).toBeNull();
+    });
+  });
+
+  it.each(['metrics', 'traces'] as const)('keeps the Logs flat-stack contract 
scoped away from %s', signal => {
+    renderPage(`/explore?signal=${signal}`);
+    const panel = screen.getByRole('tabpanel', { name: 
en.explore.signals[signal] });
+    expect(panel.querySelector('[data-explore-log-region]')).toBeNull();
+  });
+
   it('labels retained log evidence during refresh and disables stale 
drilldowns until replacement succeeds', async () => {
     const refresh = deferred(logEvidence(logPage('fresh evidence', 
'fedcba9876543210fedcba9876543210')));
     api.loadLogSignal
       .mockResolvedValueOnce(logEvidence(logPage('cached evidence', 
'0123456789abcdef0123456789abcdef')))
       .mockReturnValueOnce(refresh.promise);
     renderPage('/explore?signal=logs');
+    await screen.findByText(i18n.t('explore.perses.investigationActions', { 
count: 1 }));
+    expandLogInvestigationActions();
     expect((await screen.findAllByText(/cached 
evidence/u)).length).toBeGreaterThan(0);
 
     fireEvent.click(screen.getByRole('button', { name: en.common.refresh }));
 
     expect(await 
screen.findByText(i18n.t('explore.states.refreshing'))).toBeInTheDocument();
+    expandLogInvestigationActions();
     expect(screen.getByRole('button', { name: /Open trace/u })).toBeDisabled();
     expect(screen.getAllByText(/cached evidence/u).length).toBeGreaterThan(0);
 
     refresh.resolve();
-    expect((await screen.findAllByText(/fresh 
evidence/u)).length).toBeGreaterThan(0);
-    
expect(screen.queryByText(i18n.t('explore.states.refreshing'))).not.toBeInTheDocument();
+    await waitFor(() => 
expect(screen.queryByText(i18n.t('explore.states.refreshing'))).not.toBeInTheDocument());
+    expandLogInvestigationActions();
+    expect(screen.getAllByText(/fresh evidence/u).length).toBeGreaterThan(0);
     expect(screen.getByRole('button', { name: /Open trace/u })).toBeEnabled();
   });
 
@@ -462,11 +487,14 @@ describe('ExplorePage instrumentation context boundary', 
() => {
       .mockResolvedValueOnce(logEvidence(logPage('cached evidence', 
'0123456789abcdef0123456789abcdef')))
       .mockRejectedValueOnce(new ApiMessageError('offline', { status: 503 }));
     renderPage('/explore?signal=logs');
+    await screen.findByText(i18n.t('explore.perses.investigationActions', { 
count: 1 }));
+    expandLogInvestigationActions();
     expect((await screen.findAllByText(/cached 
evidence/u)).length).toBeGreaterThan(0);
 
     fireEvent.click(screen.getByRole('button', { name: en.common.refresh }));
 
     expect(await screen.findByText(/Refresh 
failed/u)).toHaveTextContent(i18n.t('explore.states.transportError'));
+    expandLogInvestigationActions();
     expect(screen.getByRole('button', { name: /Open trace/u })).toBeDisabled();
     
expect(screen.getByTestId('investigation-target')).toHaveTextContent('none');
   });
@@ -479,6 +507,9 @@ describe('ExplorePage instrumentation context boundary', () 
=> {
         '&severityText=warn&hideNoise=true'
     );
 
+    await screen.findByText(i18n.t('explore.perses.investigationActions', { 
count: 1 }));
+    expect(screen.queryByRole('button', { name: /Investigate log/u 
})).not.toBeInTheDocument();
+    expandLogInvestigationActions();
     expect((await screen.findAllByText(/ready 
evidence/u)).length).toBeGreaterThan(0);
     expect(JSON.parse(screen.getByTestId('investigation-target').textContent 
?? '')).toEqual({
       log: {
@@ -514,10 +545,13 @@ describe('ExplorePage instrumentation context boundary', 
() => {
 
     renderPage('/explore?signal=logs');
 
-    const action = await screen.findByRole('button', { name: /Investigate 
log/u });
+    const summary = await 
screen.findByText(i18n.t('explore.perses.investigationActions', { count: 1 }));
+    expect(screen.queryByRole('button', { name: /Investigate log/u 
})).not.toBeInTheDocument();
+    fireEvent.click(summary);
+    const action = screen.getByRole('button', { name: /Investigate log/u });
     expect(action.textContent).not.toContain(longBody);
-    expect(action.textContent?.length).toBeLessThan(160);
-    expect(document.querySelectorAll('[data-perses-host-interactions] > 
li')).toHaveLength(1);
+    
expect(action).toHaveTextContent(i18n.t('explore.perses.investigateLogAction'));
+    expect(document.querySelectorAll('[data-perses-host-interactions] > ul > 
li')).toHaveLength(1);
   });
 
   it('opens a trusted historical trace row as an exact focused investigation', 
async () => {
@@ -534,7 +568,9 @@ describe('ExplorePage instrumentation context boundary', () 
=> {
         '&start=1000&end=2000'
     );
 
-    fireEvent.click(await screen.findByRole('button', { name: /Investigate 
trace/u }));
+    const interactions = await 
screen.findByText(i18n.t('explore.perses.investigationActions', { count: 1 }));
+    fireEvent.click(interactions);
+    fireEvent.click(screen.getByRole('button', { name: /Investigate trace/u 
}));
 
     await waitFor(() =>
       expect(locationParams()).toMatchObject({
@@ -681,6 +717,14 @@ function locationParams() {
   return Object.fromEntries(new 
URLSearchParams(screen.getByTestId('location').textContent ?? ''));
 }
 
+function expandLogInvestigationActions() {
+  const interactions = 
document.querySelector('[data-perses-host-interactions]');
+  if (!(interactions instanceof HTMLDetailsElement)) throw new Error('Log 
investigation disclosure is missing');
+  if (!interactions.open) {
+    
fireEvent.click(within(interactions).getByText(i18n.t('explore.perses.investigationActions',
 { count: 1 })));
+  }
+}
+
 function querySubmitButton() {
   const button = screen
     .getAllByRole('button', { name: en.common.query })
diff --git a/web-app/src/features/explore/pages/explore-page.tsx 
b/web-app/src/features/explore/pages/explore-page.tsx
index fdb1cb5a1f..c3518abef6 100644
--- a/web-app/src/features/explore/pages/explore-page.tsx
+++ b/web-app/src/features/explore/pages/explore-page.tsx
@@ -22,6 +22,7 @@ import { OperationalPage, OperationalResultRegion } from 
'@/shared/operational-p
 
 import { ExploreQueryBar } from '../components/explore-query-bar';
 import { ExploreWorkbench } from '../components/explore-workbench';
+import historyStyles from '../components/explore-history-result.module.css';
 import { useExplorePageController } from 
'../controller/use-explore-page-controller';
 import { ExploreFocusedLogPage, ExploreFocusedTracePage } from 
'./explore-focused-investigation';
 import { ExploreResultPanel } from './explore-result-panel';
@@ -65,6 +66,27 @@ function ExploreHistoricalWorkspace({
   controller: ReturnType<typeof useExplorePageController>;
   t: TFunction;
 }) {
+  const queryBar = (
+    <ExploreQueryBar
+      query={controller.query}
+      t={t}
+      updateQuery={controller.updateManualQuery}
+      updateScope={controller.updateQuery}
+      refresh={controller.refresh}
+      time={controller.time}
+      submission={controller.submission}
+    />
+  );
+  const resultPanel = (
+    <ExploreResultPanel
+      query={controller.query}
+      result={controller.result}
+      retry={controller.refresh}
+      openPath={controller.openPath}
+    />
+  );
+  const flatLogs =
+    controller.query.signal === 'logs' && controller.result.kind === 'ready' 
&& controller.result.signal === 'logs';
   return (
     <OperationalPage mode="workspace">
       <div data-explore-workspace="true">
@@ -74,23 +96,19 @@ function ExploreHistoricalWorkspace({
           id={`explore-panel-${controller.query.signal}`}
           aria-labelledby={`explore-tab-${controller.query.signal}`}
         >
-          <ExploreQueryBar
-            query={controller.query}
-            t={t}
-            updateQuery={controller.updateManualQuery}
-            updateScope={controller.updateQuery}
-            refresh={controller.refresh}
-            time={controller.time}
-            submission={controller.submission}
-          />
-          <OperationalResultRegion>
-            <ExploreResultPanel
-              query={controller.query}
-              result={controller.result}
-              retry={controller.refresh}
-              openPath={controller.openPath}
-            />
-          </OperationalResultRegion>
+          {flatLogs ? (
+            <>
+              <section className={historyStyles.logRegion} 
data-explore-log-region="query">
+                {queryBar}
+              </section>
+              {resultPanel}
+            </>
+          ) : (
+            <>
+              {queryBar}
+              <OperationalResultRegion>{resultPanel}</OperationalResultRegion>
+            </>
+          )}
         </section>
       </div>
     </OperationalPage>
diff --git 
a/web-app/src/features/explore/pages/explore-perses-log-panel.test.tsx 
b/web-app/src/features/explore/pages/explore-perses-log-panel.test.tsx
new file mode 100644
index 0000000000..8b0a7eba17
--- /dev/null
+++ b/web-app/src/features/explore/pages/explore-perses-log-panel.test.tsx
@@ -0,0 +1,174 @@
+/* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { I18nextProvider } from 'react-i18next';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { i18n, initializeI18n, loadLocale } from '@/core/i18n/i18n';
+
+vi.mock('@/platform/perses', () => ({
+  HertzBeatLogsTableResult: () => <div>Perses log rows</div>,
+  HertzBeatMetricTimeSeriesResult: ({
+    onTimeWindowChange,
+    timeWindowChangeEnabled
+  }: {
+    onTimeWindowChange?: ((window: { from: number; to: number }) => void) | 
undefined;
+    timeWindowChangeEnabled?: boolean | undefined;
+  }) => (
+    <button
+      type="button"
+      disabled={!timeWindowChangeEnabled}
+      onClick={() => onTimeWindowChange?.({ from: evidenceWindow.from + 
60_000, to: evidenceWindow.to - 60_000 })}
+    >
+      Zoom trend
+    </button>
+  )
+}));
+
+import type { LogExploreQuery } from '../model/explore-query';
+import type { LogHistoryEvidence } from '../model/explore-signal-contract';
+import { ExplorePersesLogPanel } from './explore-perses-log-panel';
+
+const evidenceWindow = { from: 1_750_000_000_000, to: 1_750_003_600_000 } as 
const;
+
+describe('ExplorePersesLogPanel trend ownership', () => {
+  beforeAll(async () => {
+    await initializeI18n();
+    await loadLocale('en-US');
+  });
+  afterEach(cleanup);
+
+  it('publishes a current trend zoom as a canonical exact query and shows 
offset-page provenance', () => {
+    const openPath = vi.fn();
+    renderPanel(query, true, openPath);
+
+    expect(screen.getByText('1 / 57')).toBeInTheDocument();
+    expect(screen.getByText('3 / 3')).toBeInTheDocument();
+    expect(
+      screen.getByText(`${new Date(evidenceWindow.from).toISOString()} – ${new 
Date(evidenceWindow.to).toISOString()}`)
+    ).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Zoom trend' }));
+
+    const params = new 
URLSearchParams(String(openPath.mock.calls[0]?.[0]).split('?')[1]);
+    expect(Object.fromEntries(params)).toMatchObject({
+      signal: 'logs',
+      serviceName: 'checkout',
+      serviceNamespace: 'commerce',
+      environment: 'prod',
+      query: 'timeout',
+      severityText: 'WARN',
+      resourceFilter: 'cloud.region=us-east',
+      attributeFilter: 'http.status_code=500',
+      traceId: '0123456789abcdef0123456789abcdef',
+      spanId: '0123456789abcdef',
+      start: String(evidenceWindow.from + 60_000),
+      end: String(evidenceWindow.to - 60_000)
+    });
+    expect(params.has('windowMode')).toBe(false);
+    expect(params.has('page')).toBe(false);
+    expect(params.has('logRecordUid')).toBe(false);
+  });
+
+  it('does not expose trend zoom while retained evidence is stale', () => {
+    const openPath = vi.fn();
+    renderPanel(query, false, openPath);
+
+    expect(screen.getByRole('button', { name: 'Zoom trend' })).toBeDisabled();
+    fireEvent.click(screen.getByRole('button', { name: 'Zoom trend' }));
+    expect(openPath).not.toHaveBeenCalled();
+  });
+
+  it('keeps the canonical requested page visible when an empty response 
reports a different page', () => {
+    renderPanel({ ...query, pageIndex: 4 }, true, vi.fn(), {
+      ...page,
+      content: [],
+      totalElements: 0,
+      totalPages: 0,
+      number: 0
+    });
+
+    expect(screen.getByText('5 / 0')).toBeInTheDocument();
+  });
+});
+
+const query: LogExploreQuery = {
+  signal: 'logs',
+  timeRange: 'last-30m',
+  windowMode: 'preset',
+  pageIndex: 2,
+  logRecordUid: 'record-1',
+  serviceName: 'checkout',
+  serviceNamespace: 'commerce',
+  environment: 'prod',
+  query: 'timeout',
+  severityText: 'WARN',
+  resourceFilter: 'cloud.region=us-east',
+  attributeFilter: 'http.status_code=500',
+  traceId: '0123456789abcdef0123456789abcdef',
+  spanId: '0123456789abcdef'
+};
+
+const page: LogHistoryEvidence['page'] = {
+  content: [
+    {
+      logRecordUid: 'record-1',
+      timeUnixNano: '1750000000000000000',
+      observedTimeUnixNano: null,
+      severityNumber: 9,
+      severityText: 'INFO',
+      body: 'timeout',
+      attributes: {},
+      droppedAttributesCount: 0,
+      traceId: null,
+      spanId: null,
+      traceFlags: null,
+      resource: {},
+      resourceSchemaUrl: null,
+      instrumentationScope: null,
+      scopeSchemaUrl: null
+    }
+  ],
+  totalElements: 57,
+  totalPages: 3,
+  number: 2,
+  size: 20
+};
+
+function renderPanel(
+  value: LogExploreQuery,
+  evidenceCurrent: boolean,
+  openPath: (path: string) => void,
+  data: LogHistoryEvidence['page'] = page
+) {
+  return render(
+    <I18nextProvider i18n={i18n}>
+      <ExplorePersesLogPanel
+        data={data}
+        statistics={{
+          overview: {
+            kind: 'ready',
+            data: {
+              totalCount: 57,
+              traceCount: 1,
+              debugCount: 0,
+              infoCount: 56,
+              warnCount: 1,
+              errorCount: 0,
+              fatalCount: 0
+            }
+          },
+          trend: {
+            kind: 'ready',
+            data: { hourlyStats: { '2025-06-15 15:00': 28, '2025-06-15 16:00': 
29 } }
+          }
+        }}
+        query={value}
+        openPath={openPath}
+        timeWindow={evidenceWindow}
+        revision={1}
+        evidenceCurrent={evidenceCurrent}
+      />
+    </I18nextProvider>
+  );
+}
diff --git a/web-app/src/features/explore/pages/explore-perses-log-panel.tsx 
b/web-app/src/features/explore/pages/explore-perses-log-panel.tsx
index f7829cf383..9eb860ae54 100644
--- a/web-app/src/features/explore/pages/explore-perses-log-panel.tsx
+++ b/web-app/src/features/explore/pages/explore-perses-log-panel.tsx
@@ -8,6 +8,7 @@ import { usePublishShellInvestigation } from 
'@/shared/investigation';
 import type { ExactTimeWindow } from '@/shared/query-context';
 
 import { ExploreHistoryPagination } from 
'../components/explore-history-pagination';
+import historyStyles from '../components/explore-history-result.module.css';
 import { ExploreLogStatistics } from '../components/explore-log-statistics';
 import { explorePersesMessages } from '../components/explore-perses-messages';
 import { ExploreMessageResult, ExploreResultFrame } from 
'../components/explore-state-panel';
@@ -15,7 +16,7 @@ import { SignalEmptyState, SignalResultFrame } from 
'../components/signal-result
 import { materializeLogInvestigation } from '../model/explore-agent-handoff';
 import { buildLogInvestigationPath, buildTraceInvestigationPath } from 
'../model/explore-investigation-model';
 import { createExploreLogPersesResult } from 
'../model/explore-perses-result-model';
-import type { LogExploreQuery } from '../model/explore-model';
+import { buildExplorePath, logTrendZoomPatch, mergeExploreQuery, type 
LogExploreQuery } from '../model/explore-model';
 import type { LogHistoryEvidence, LogRow } from 
'../model/explore-signal-contract';
 import { logBody, logTimestampMs } from '../model/explore-signal-model';
 
@@ -38,34 +39,92 @@ export function ExplorePersesLogPanel(props: Props) {
   );
   if (!timeWindow) return <ExploreMessageResult kind="error" 
message={t('explore.states.contractError')} />;
   const result = createExploreLogPersesResult(query, data, timeWindow, 
revision);
+  const onTrendTimeWindowChange = evidenceCurrent
+    ? (nextWindow: ExactTimeWindow) => openLogTrendZoom(query, timeWindow, 
nextWindow, openPath)
+    : undefined;
+  const statisticsView = (
+    <ExploreLogStatistics
+      statistics={statistics}
+      timeWindow={timeWindow}
+      runtimeIdentity={result.runtimeIdentity}
+      onTimeWindowChange={onTrendTimeWindowChange}
+      t={t}
+    />
+  );
+  const resultView = (
+    <SignalResultFrame
+      title={t('explore.signals.logs')}
+      count={data.totalElements}
+      meta={logPageProvenance(data, query, timeWindow, t)}
+    >
+      {data.totalElements === 0 ? (
+        <SignalEmptyState title={t('explore.empty.logs')} 
hint={t('explore.description')} />
+      ) : (
+        <HertzBeatLogsTableResult
+          title={t('explore.signals.logs')}
+          ariaLabel={t('explore.perses.logsTable')}
+          query={result.query}
+          outcome={result.outcome}
+          runtimeIdentity={result.runtimeIdentity}
+          interactions={logInteractions(data.content, query, timeWindow, 
evidenceCurrent, openPath, t)}
+          messages={explorePersesMessages(t)}
+        />
+      )}
+      <ExploreHistoryPagination page={data} query={query} 
enabled={evidenceCurrent} openPath={openPath} t={t} />
+    </SignalResultFrame>
+  );
+  if (!evidenceCurrent || data.totalElements === 0) {
+    return (
+      <ExploreResultFrame>
+        {statisticsView}
+        {resultView}
+      </ExploreResultFrame>
+    );
+  }
   return (
-    <ExploreResultFrame>
-      <ExploreLogStatistics
-        statistics={statistics}
-        timeWindow={timeWindow}
-        runtimeIdentity={result.runtimeIdentity}
-        t={t}
-      />
-      <SignalResultFrame title={t('explore.signals.logs')} 
count={data.totalElements}>
-        {data.totalElements === 0 ? (
-          <SignalEmptyState title={t('explore.empty.logs')} 
hint={t('explore.description')} />
-        ) : (
-          <HertzBeatLogsTableResult
-            title={t('explore.signals.logs')}
-            ariaLabel={t('explore.perses.logsTable')}
-            query={result.query}
-            outcome={result.outcome}
-            runtimeIdentity={result.runtimeIdentity}
-            interactions={logInteractions(data.content, query, timeWindow, 
evidenceCurrent, openPath, t)}
-            messages={explorePersesMessages(t)}
-          />
-        )}
-        <ExploreHistoryPagination page={data} query={query} 
enabled={evidenceCurrent} openPath={openPath} t={t} />
-      </SignalResultFrame>
-    </ExploreResultFrame>
+    <>
+      <section className={historyStyles.logRegion} 
data-explore-log-region="trend">
+        {statisticsView}
+      </section>
+      <section className={historyStyles.logRegion} 
data-explore-log-region="result">
+        {resultView}
+      </section>
+    </>
   );
 }
 
+function openLogTrendZoom(
+  query: LogExploreQuery,
+  evidenceWindow: ExactTimeWindow,
+  requestedWindow: ExactTimeWindow,
+  openPath: (path: string) => void
+) {
+  const patch = logTrendZoomPatch(query, evidenceWindow, requestedWindow);
+  if (patch) openPath(buildExplorePath(mergeExploreQuery(query, patch)));
+}
+
+function logPageProvenance(
+  page: LogHistoryEvidence['page'],
+  query: LogExploreQuery,
+  window: ExactTimeWindow,
+  t: ReturnType<typeof useTranslation>['t']
+) {
+  return [
+    {
+      label: t('explore.perses.rowsReturned'),
+      value: `${page.content.length.toLocaleString()} / 
${page.totalElements.toLocaleString()}`
+    },
+    {
+      label: t('explore.perses.requestedPage'),
+      value: `${((query.pageIndex ?? 0) + 1).toLocaleString()} / 
${page.totalPages.toLocaleString()}`
+    },
+    {
+      label: t('explore.perses.evidenceWindow'),
+      value: `${new Date(window.from).toISOString()} – ${new 
Date(window.to).toISOString()}`
+    }
+  ];
+}
+
 function currentLogEvidence(data: LogHistoryEvidence['page'], current: 
boolean) {
   return current
     ? { totalElements: data.totalElements, number: data.number, size: 
data.size, contentCount: data.content.length }
@@ -100,14 +159,16 @@ function rowActions(
   const actions: HertzBeatPersesTableInteraction['actions'] = [];
   if (row.logRecordUid) {
     actions.push({
-      label: t('explore.perses.investigateLog', { value: 
logInteractionLabel(row, t) }),
+      label: t('explore.perses.investigateLogAction'),
+      ariaLabel: t('explore.perses.investigateLog', { value: 
logInteractionLabel(row, t) }),
       disabled: !enabled,
       onAction: () => openPath(buildLogInvestigationPath(query, 
selectedLog(row), window, browserTimeZone()))
     });
   }
   if (validTraceId(row.traceId)) {
     actions.push({
-      label: t('explore.perses.openTrace', { value: shortId(row.traceId) }),
+      label: t('explore.perses.openTraceAction'),
+      ariaLabel: t('explore.perses.openTrace', { value: shortId(row.traceId) 
}),
       disabled: !enabled,
       onAction: () => openPath(buildTraceInvestigationPath(query, 
selectedTrace(row), window, browserTimeZone()))
     });
diff --git a/web-app/src/features/explore/pages/explore-state-i18n.test.ts 
b/web-app/src/features/explore/pages/explore-state-i18n.test.ts
index 9ee9c6f703..b36ff20e63 100644
--- a/web-app/src/features/explore/pages/explore-state-i18n.test.ts
+++ b/web-app/src/features/explore/pages/explore-state-i18n.test.ts
@@ -27,6 +27,9 @@ const persesKeys = [
   'logsTable',
   'tracesTable',
   'pagination',
+  'rowsReturned',
+  'requestedPage',
+  'evidenceWindow',
   'investigateLog',
   'openTrace',
   'investigateTrace',
diff --git 
a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitive-frame.tsx 
b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitive-frame.tsx
index c1daa31c94..3d591e8bd1 100644
--- a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitive-frame.tsx
+++ b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitive-frame.tsx
@@ -1,6 +1,8 @@
 /* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
 
-import { Component, lazy, Suspense, type ReactNode } from 'react';
+import { Component, lazy, Suspense, useState, type ReactNode } from 'react';
+
+import type { ExactTimeWindow } from '@/shared/query-context';
 
 import type { HertzBeatQueryFailure, HertzBeatQueryOutcome } from 
'../datasource/hertzbeat-query-contract';
 import { PersesSignalDataError } from './perses-signal-data';
@@ -15,13 +17,19 @@ export type HertzBeatPersesPrimitiveMessages = {
   truncated: ReactNode;
   truncationUnknown: ReactNode;
   runtimeError: ReactNode;
+  investigationActions: (count: number) => ReactNode;
   failures: Record<FailureMessageKey, ReactNode>;
 };
 
 export type HertzBeatPersesTableInteraction = {
   key: string;
   label: ReactNode;
-  actions: Array<{ label: string; disabled?: boolean | undefined; onAction: () 
=> void }>;
+  actions: Array<{
+    label: string;
+    ariaLabel?: string | undefined;
+    disabled?: boolean | undefined;
+    onAction: () => void;
+  }>;
 };
 
 export type SharedPrimitiveProps = {
@@ -31,6 +39,8 @@ export type SharedPrimitiveProps = {
   className?: string | undefined;
   runtimeIdentity?: string | undefined;
   interactions?: HertzBeatPersesTableInteraction[] | undefined;
+  onTimeWindowChange?: ((window: ExactTimeWindow) => void) | undefined;
+  timeWindowChangeEnabled?: boolean | undefined;
   variant?: 'default' | 'compact' | undefined;
 };
 
@@ -98,7 +108,7 @@ export function HertzBeatPrimitiveFrame<T>({ state, 
toRuntimeProps, ...props }:
         </div>
       </PersesPrimitiveErrorBoundary>
       <Completeness ariaLabel={props.ariaLabel} truncated={outcome.truncated} 
messages={props.messages} />
-      <PersesHostInteractions interactions={props.interactions} />
+      <PersesHostInteractions interactions={props.interactions} 
messages={props.messages} />
     </div>
   );
 }
@@ -117,23 +127,48 @@ function PrimitiveStateFrame(props: SharedPrimitiveProps 
& { alert?: boolean; ch
   );
 }
 
-function PersesHostInteractions({ interactions }: { interactions?: 
HertzBeatPersesTableInteraction[] | undefined }) {
+function PersesHostInteractions({
+  interactions,
+  messages
+}: {
+  interactions?: HertzBeatPersesTableInteraction[] | undefined;
+  messages: HertzBeatPersesPrimitiveMessages;
+}) {
+  const [open, setOpen] = useState(false);
   if (!interactions?.length) return null;
   return (
-    <ul className={styles.interactions} data-perses-host-interactions>
-      {interactions.map(interaction => (
-        <li key={interaction.key}>
-          <span>{interaction.label}</span>
-          <div>
-            {interaction.actions.map(action => (
-              <button key={action.label} type="button" 
disabled={action.disabled} onClick={action.onAction}>
-                {action.label}
-              </button>
-            ))}
-          </div>
-        </li>
-      ))}
-    </ul>
+    <details className={styles.interactions} data-perses-host-interactions 
open={open}>
+      <summary
+        onClick={event => {
+          event.preventDefault();
+          setOpen(current => !current);
+        }}
+      >
+        {messages.investigationActions(interactions.length)}
+      </summary>
+      {open && (
+        <ul className={styles.interactionList}>
+          {interactions.map(interaction => (
+            <li key={interaction.key}>
+              <span>{interaction.label}</span>
+              <div>
+                {interaction.actions.map(action => (
+                  <button
+                    key={action.label}
+                    type="button"
+                    aria-label={action.ariaLabel}
+                    disabled={action.disabled}
+                    onClick={action.onAction}
+                  >
+                    {action.label}
+                  </button>
+                ))}
+              </div>
+            </li>
+          ))}
+        </ul>
+      )}
+    </details>
   );
 }
 
diff --git 
a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.module.css 
b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.module.css
index 47202c6c34..331c294c2e 100644
--- a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.module.css
+++ b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.module.css
@@ -56,14 +56,14 @@
 }
 
 .primitive[data-variant='compact'] {
-  min-height: 220px;
-  grid-template-rows: 220px auto auto;
+  min-height: 180px;
+  grid-template-rows: 180px auto auto;
 }
 
 .primitive[data-variant='compact'] .runtime,
 .primitive[data-variant='compact'] .state {
-  height: 220px;
-  min-height: 220px;
+  height: 180px;
+  min-height: 180px;
 }
 
 .completeness {
@@ -78,16 +78,31 @@
 }
 
 .interactions {
+  margin: 8px 0 0;
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  box-shadow: none;
+}
+
+.interactions > summary {
+  padding: 6px 8px;
+  color: var(--hb-text-secondary);
+  cursor: pointer;
+  font-size: 12px;
+}
+
+.interactionList {
   display: grid;
   gap: 6px;
   max-height: 224px;
-  margin: 8px 0 0;
+  margin: 0;
   padding: 0;
   overflow: auto;
   list-style: none;
 }
 
-.interactions li {
+.interactionList li {
   display: flex;
   min-width: 0;
   align-items: center;
@@ -97,7 +112,7 @@
   border-bottom: 1px solid var(--hb-border-subtle);
 }
 
-.interactions li > span {
+.interactionList li > span {
   min-width: 0;
   overflow: hidden;
   color: var(--hb-text-secondary);
@@ -105,9 +120,11 @@
   white-space: nowrap;
 }
 
-.interactions li > div {
+.interactionList li > div {
   display: flex;
   flex: none;
+  flex-basis: 176px;
+  justify-content: flex-end;
   gap: 6px;
   max-width: 100%;
   overflow-x: auto;
@@ -129,12 +146,13 @@
 }
 
 @media (width <= 700px) {
-  .interactions li {
+  .interactionList li {
     align-items: flex-start;
     flex-direction: column;
   }
 
-  .interactions li > div {
+  .interactionList li > div {
     width: 100%;
+    flex-basis: auto;
   }
 }
diff --git 
a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.test.tsx 
b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.test.tsx
index af5b323e13..3e2be5db65 100644
--- a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.test.tsx
+++ b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.test.tsx
@@ -5,7 +5,7 @@
  * The ASF licenses this file to You under the Apache License, Version 2.0.
  */
 
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { cleanup, fireEvent, render, screen, within } from 
'@testing-library/react';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
 import { queryHertzBeatData } from '../datasource/hertzbeat-query-client';
@@ -17,7 +17,15 @@ vi.mock('../datasource/hertzbeat-query-client', async 
importOriginal => {
   return { ...actual, queryHertzBeatData: vi.fn() };
 });
 vi.mock('./perses-signal-runtime', () => ({
-  PersesSignalRuntime: ({ kind }: { kind: string }) => {
+  PersesSignalRuntime: ({
+    kind,
+    onTimeWindowChange,
+    timeWindowChangeEnabled
+  }: {
+    kind: string;
+    onTimeWindowChange?: ((window: { from: number; to: number }) => void) | 
undefined;
+    timeWindowChangeEnabled?: boolean | undefined;
+  }) => {
     if (runtimeControl.fail) throw new Error('private runtime detail');
     if (kind === 'logs-table' || kind === 'trace-table') {
       return (
@@ -37,7 +45,16 @@ vi.mock('./perses-signal-runtime', () => ({
     if (kind === 'tracing-gantt-chart') {
       return <button data-testid={`official-${kind}`}>Inspect span</button>;
     }
-    return <div data-testid={`official-${kind}`} />;
+    return (
+      <button
+        type="button"
+        data-testid={`official-${kind}`}
+        disabled={timeWindowChangeEnabled === false}
+        onClick={() => onTimeWindowChange?.({ from: timeWindow.from + 1_000, 
to: timeWindow.to - 1_000 })}
+      >
+        Zoom time series
+      </button>
+    );
   }
 }));
 
@@ -45,6 +62,7 @@ import {
   HertzBeatLogsTable,
   HertzBeatLogsTableResult,
   HertzBeatMetricTimeSeries,
+  HertzBeatMetricTimeSeriesResult,
   HertzBeatTraceTable,
   HertzBeatTracingGanttChart,
   type HertzBeatPersesPrimitiveMessages
@@ -58,6 +76,7 @@ const messages: HertzBeatPersesPrimitiveMessages = {
   truncated: 'Results are truncated',
   truncationUnknown: 'Result completeness is unknown',
   runtimeError: 'Visualization unavailable',
+  investigationActions: count => `Investigation actions (${count})`,
   failures: {
     'perses.query.invalid': 'Invalid query',
     'perses.query.permission': 'Permission denied',
@@ -287,6 +306,11 @@ describe('HertzBeat Perses primitives', () => {
       />
     );
 
+    const interactions = 
view.container.querySelector('[data-perses-host-interactions]');
+    expect(interactions).toBeInstanceOf(HTMLDetailsElement);
+    expect(interactions).not.toHaveAttribute('open');
+    expect(screen.queryByRole('button', { name: 'Investigate log checkout 
ready' })).not.toBeInTheDocument();
+    fireEvent.click(within(interactions as 
HTMLElement).getByText('Investigation actions (1)'));
     fireEvent.click(screen.getByRole('button', { name: 'Investigate log 
checkout ready' }));
     expect(open).toHaveBeenCalledOnce();
     expect(screen.getByRole('button', { name: 'Open trace checkout ready' 
})).toBeDisabled();
@@ -306,6 +330,38 @@ describe('HertzBeat Perses primitives', () => {
     );
     expect(screen.queryByRole('button', { name: 'Investigate log checkout 
ready' })).not.toBeInTheDocument();
   });
+
+  it('forwards a time-series zoom only when the caller owns a callback', () => 
{
+    const onTimeWindowChange = vi.fn();
+    render(
+      <HertzBeatMetricTimeSeriesResult
+        title="Log trend"
+        ariaLabel="Log trend"
+        query={{ signal: 'metrics', queryKind: 'time-series', timeWindow, 
metric: { name: 'hertzbeat_log_count' } }}
+        outcome={metricOutcome() as never}
+        onTimeWindowChange={onTimeWindowChange}
+        messages={messages}
+      />
+    );
+
+    fireEvent.click(screen.getByRole('button', { name: 'Zoom time series' }));
+    expect(onTimeWindowChange).toHaveBeenCalledWith({ from: timeWindow.from + 
1_000, to: timeWindow.to - 1_000 });
+  });
+
+  it('forwards a disabled time-series range interaction to the Perses 
runtime', () => {
+    render(
+      <HertzBeatMetricTimeSeriesResult
+        title="Retained Log trend"
+        ariaLabel="Retained Log trend"
+        query={{ signal: 'metrics', queryKind: 'time-series', timeWindow, 
metric: { name: 'hertzbeat_log_count' } }}
+        outcome={metricOutcome() as never}
+        timeWindowChangeEnabled={false}
+        messages={messages}
+      />
+    );
+
+    expect(screen.getByRole('button', { name: 'Zoom time series' 
})).toBeDisabled();
+  });
 });
 
 function metricOutcome(): HertzBeatQueryOutcome<unknown> {
diff --git 
a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.tsx 
b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.tsx
index 6426bcdd15..de1056986f 100644
--- a/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.tsx
+++ b/web-app/src/platform/perses/runtime/hertzbeat-perses-primitives.tsx
@@ -48,7 +48,9 @@ export function HertzBeatMetricTimeSeries(props: 
SharedPrimitiveProps & { query:
     kind: 'metric-time-series' as const,
     title: props.title,
     timeWindow: props.query.timeWindow,
-    data: toPersesTimeSeriesData(outcome.data.series, props.query.timeWindow)
+    data: toPersesTimeSeriesData(outcome.data.series, props.query.timeWindow),
+    onTimeWindowChange: props.onTimeWindowChange,
+    timeWindowChangeEnabled: props.timeWindowChangeEnabled
   }));
 }
 
@@ -90,7 +92,9 @@ export function HertzBeatMetricTimeSeriesResult(
     kind: 'metric-time-series' as const,
     title: props.title,
     timeWindow: props.query.timeWindow,
-    data: toPersesTimeSeriesData(outcome.data.series, props.query.timeWindow)
+    data: toPersesTimeSeriesData(outcome.data.series, props.query.timeWindow),
+    onTimeWindowChange: props.onTimeWindowChange,
+    timeWindowChangeEnabled: props.timeWindowChangeEnabled
   }));
 }
 
diff --git 
a/web-app/src/platform/perses/runtime/perses-runtime-providers.test.tsx 
b/web-app/src/platform/perses/runtime/perses-runtime-providers.test.tsx
new file mode 100644
index 0000000000..856e17439a
--- /dev/null
+++ b/web-app/src/platform/perses/runtime/perses-runtime-providers.test.tsx
@@ -0,0 +1,94 @@
+/* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import type { TimeRangeValue } from '@perses-dev/spec';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const window = { from: 1_750_000_000_000, to: 1_750_000_060_000 } as const;
+
+vi.mock('@mui/material', () => ({ ThemeProvider: passthrough }));
+vi.mock('@perses-dev/components', () => ({
+  ChartsProvider: passthrough,
+  SnackbarProvider: passthrough,
+  generateChartsTheme: vi.fn(() => ({})),
+  getTheme: vi.fn(() => ({}))
+}));
+vi.mock('@perses-dev/dashboards', () => ({
+  DatasourceStoreProvider: passthrough,
+  VariableProvider: passthrough
+}));
+vi.mock('@tanstack/react-query', () => ({
+  QueryClient: class QueryClient {},
+  QueryClientProvider: passthrough
+}));
+vi.mock('@perses-dev/plugin-system', () => ({
+  PluginRegistry: passthrough,
+  RouterProvider: passthrough,
+  TimeRangeProvider: ({
+    children,
+    timeRange,
+    setTimeRange
+  }: {
+    children: React.ReactNode;
+    timeRange: TimeRangeValue;
+    setTimeRange: (value: TimeRangeValue) => void;
+  }) => (
+    <>
+      {children}
+      <output aria-label="active range start">{'start' in timeRange ? 
timeRange.start.getTime() : 'relative'}</output>
+      <button type="button" onClick={() => setTimeRange({ start: new 
Date(window.from), end: new Date(window.to) })}>
+        Same range
+      </button>
+      <button
+        type="button"
+        onClick={() => setTimeRange({ start: new Date(window.from + 1_000), 
end: new Date(window.to - 1_000) })}
+      >
+        Zoom range
+      </button>
+      <button type="button" onClick={() => setTimeRange({ start: new 
Date(Number.NaN), end: new Date(window.to) })}>
+        Invalid range
+      </button>
+    </>
+  )
+}));
+vi.mock('@/core/runtime-theme-context', () => ({ useRuntimeTheme: () => ({ 
theme: 'light' }) }));
+vi.mock('../plugins/perses-plugin-loader', () => ({ 
hertzBeatPersesPluginLoader: { kind: 'test-loader' } }));
+
+import { PersesRuntimeProviders } from './perses-runtime-providers';
+
+describe('PersesRuntimeProviders time ownership', () => {
+  afterEach(cleanup);
+
+  it('publishes only a changed safe absolute range and never publishes on 
mount', () => {
+    const onTimeWindowChange = vi.fn();
+    render(
+      <PersesRuntimeProviders timeWindow={window} 
onTimeWindowChange={onTimeWindowChange}>
+        <div>Evidence</div>
+      </PersesRuntimeProviders>
+    );
+
+    expect(onTimeWindowChange).not.toHaveBeenCalled();
+    fireEvent.click(screen.getByRole('button', { name: 'Same range' }));
+    fireEvent.click(screen.getByRole('button', { name: 'Invalid range' }));
+    expect(onTimeWindowChange).not.toHaveBeenCalled();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Zoom range' }));
+    expect(onTimeWindowChange).toHaveBeenCalledOnce();
+    expect(onTimeWindowChange).toHaveBeenCalledWith({ from: window.from + 
1_000, to: window.to - 1_000 });
+  });
+
+  it('keeps a disabled evidence range immutable', () => {
+    render(
+      <PersesRuntimeProviders timeWindow={window} 
timeWindowChangeEnabled={false}>
+        <div>Stale evidence</div>
+      </PersesRuntimeProviders>
+    );
+
+    fireEvent.click(screen.getByRole('button', { name: 'Zoom range' }));
+    expect(screen.getByRole('status', { name: 'active range start' 
})).toHaveTextContent(String(window.from));
+  });
+});
+
+function passthrough({ children }: { children: React.ReactNode }) {
+  return children;
+}
diff --git a/web-app/src/platform/perses/runtime/perses-runtime-providers.tsx 
b/web-app/src/platform/perses/runtime/perses-runtime-providers.tsx
index f3d00c1373..496e38bbc0 100644
--- a/web-app/src/platform/perses/runtime/perses-runtime-providers.tsx
+++ b/web-app/src/platform/perses/runtime/perses-runtime-providers.tsx
@@ -17,7 +17,7 @@ import { DatasourceStoreProvider, VariableProvider } from 
'@perses-dev/dashboard
 import { PluginRegistry, RouterProvider, TimeRangeProvider, type PluginLoader 
} from '@perses-dev/plugin-system';
 import type { DurationString, TimeRangeValue } from '@perses-dev/spec';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { useMemo, useState, type ReactNode } from 'react';
+import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react';
 
 import { useRuntimeTheme } from '@/core/runtime-theme-context';
 import type { ExactTimeWindow } from '@/shared/query-context';
@@ -45,17 +45,33 @@ const datasourceApi: DatasourceApi = {
 export function PersesRuntimeProviders({
   children,
   timeWindow,
-  pluginLoader = hertzBeatPersesPluginLoader
+  pluginLoader = hertzBeatPersesPluginLoader,
+  onTimeWindowChange,
+  timeWindowChangeEnabled = true
 }: {
   children: ReactNode;
   timeWindow: ExactTimeWindow;
   pluginLoader?: PluginLoader | undefined;
+  onTimeWindowChange?: ((window: ExactTimeWindow) => void) | undefined;
+  timeWindowChangeEnabled?: boolean | undefined;
 }) {
   const { theme } = useRuntimeTheme();
   const [persesTimeRange, setPersesTimeRange] = useState<TimeRangeValue>(() => 
toTimeRange(timeWindow));
+  const lastAbsoluteWindow = useRef<ExactTimeWindow>(timeWindow);
   const [refreshInterval, setRefreshInterval] = useState<DurationString>('0s');
   const muiTheme = useMemo(() => getTheme(theme === 'dark' ? 'dark' : 
'light'), [theme]);
   const chartsTheme = useMemo(() => generateChartsTheme(muiTheme, {}), 
[muiTheme]);
+  const updateTimeRange = useCallback(
+    (value: TimeRangeValue) => {
+      if (!timeWindowChangeEnabled) return;
+      setPersesTimeRange(value);
+      const nextWindow = exactWindow(value);
+      if (!nextWindow || sameWindow(lastAbsoluteWindow.current, nextWindow)) 
return;
+      lastAbsoluteWindow.current = nextWindow;
+      onTimeWindowChange?.(nextWindow);
+    },
+    [onTimeWindowChange, timeWindowChangeEnabled]
+  );
 
   return (
     <ThemeProvider theme={muiTheme}>
@@ -70,7 +86,7 @@ export function PersesRuntimeProviders({
                 <TimeRangeProvider
                   timeRange={persesTimeRange}
                   refreshInterval={refreshInterval}
-                  setTimeRange={setPersesTimeRange}
+                  setTimeRange={updateTimeRange}
                   setRefreshInterval={setRefreshInterval}
                 >
                   <VariableProvider>
@@ -91,3 +107,14 @@ export function PersesRuntimeProviders({
 function toTimeRange(window: ExactTimeWindow): TimeRangeValue {
   return { start: new Date(window.from), end: new Date(window.to) };
 }
+
+function exactWindow(value: TimeRangeValue): ExactTimeWindow | undefined {
+  if (!('start' in value)) return undefined;
+  const from = value.start.getTime();
+  const to = value.end.getTime();
+  return Number.isSafeInteger(from) && Number.isSafeInteger(to) && from > 0 && 
from < to ? { from, to } : undefined;
+}
+
+function sameWindow(current: ExactTimeWindow, next: ExactTimeWindow) {
+  return current.from === next.from && current.to === next.to;
+}
diff --git 
a/web-app/src/platform/perses/runtime/perses-signal-runtime-integration.test.tsx
 
b/web-app/src/platform/perses/runtime/perses-signal-runtime-integration.test.tsx
index 1195a6e4f7..746163df20 100644
--- 
a/web-app/src/platform/perses/runtime/perses-signal-runtime-integration.test.tsx
+++ 
b/web-app/src/platform/perses/runtime/perses-signal-runtime-integration.test.tsx
@@ -5,7 +5,7 @@
  * The ASF licenses this file to You under the Apache License, Version 2.0.
  */
 
-import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { cleanup, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
 vi.mock('@/core/runtime-theme-context', () => ({ useRuntimeTheme: () => ({ 
theme: 'light' }) }));
@@ -16,7 +16,6 @@ vi.mock('@tanstack/react-query', async () => {
   const { createRequire } = await import('node:module');
   return createRequire(import.meta.url)('@tanstack/react-query') as 
Record<string, unknown>;
 });
-
 import { PersesSignalRuntime } from './perses-signal-runtime';
 
 const timeWindow = { from: 1_750_000_000_000, to: 1_750_000_060_000 } as const;
@@ -89,9 +88,14 @@ describe('Perses official signal panel integration', () => {
     );
     expectRuntimeFrame(logs.container, 'logs-table');
     expect((timeWindow.from / 1_000) * 1_000).toBe(timeWindow.from);
-    // The real official virtualized table mounts its scroller; jsdom does not
-    // perform the layout measurements needed to materialize a virtual row.
     expect(await screen.findByTestId('virtuoso-scroller')).toBeInTheDocument();
+    const expandLog = await screen.findByRole('button', { name: 'Expand log 
details' });
+    expect(expandLog).toHaveAttribute('aria-expanded', 'false');
+    fireEvent.click(expandLog);
+    expect(await screen.findByRole('button', { name: 'Collapse log details' 
})).toHaveAttribute(
+      'aria-expanded',
+      'true'
+    );
     cleanup();
 
     const traces = render(
diff --git a/web-app/src/platform/perses/runtime/perses-signal-runtime.test.tsx 
b/web-app/src/platform/perses/runtime/perses-signal-runtime.test.tsx
index cb01abacff..0f0a2562d0 100644
--- a/web-app/src/platform/perses/runtime/perses-signal-runtime.test.tsx
+++ b/web-app/src/platform/perses/runtime/perses-signal-runtime.test.tsx
@@ -12,7 +12,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
 const runtimeContract = vi.hoisted(() => ({
   panels: [] as PanelDefinition[],
   queries: [] as QueryDefinition[][],
-  pluginLoaders: [] as unknown[]
+  pluginLoaders: [] as unknown[],
+  timeWindowCallbacks: [] as Array<unknown>,
+  timeWindowChangeFlags: [] as Array<boolean | undefined>
 }));
 
 vi.mock('@perses-dev/dashboards', () => ({
@@ -36,12 +38,18 @@ vi.mock('@perses-dev/plugin-system', () => ({
 vi.mock('./perses-runtime-providers', () => ({
   PersesRuntimeProviders: ({
     children,
-    pluginLoader
+    pluginLoader,
+    onTimeWindowChange,
+    timeWindowChangeEnabled
   }: {
     children: import('react').ReactNode;
     pluginLoader: unknown;
+    onTimeWindowChange?: unknown;
+    timeWindowChangeEnabled?: boolean | undefined;
   }) => {
     runtimeContract.pluginLoaders.push(pluginLoader);
+    runtimeContract.timeWindowCallbacks.push(onTimeWindowChange);
+    runtimeContract.timeWindowChangeFlags.push(timeWindowChangeEnabled);
     return children;
   }
 }));
@@ -60,6 +68,8 @@ describe('PersesSignalRuntime', () => {
     runtimeContract.panels = [];
     runtimeContract.queries = [];
     runtimeContract.pluginLoaders = [];
+    runtimeContract.timeWindowCallbacks = [];
+    runtimeContract.timeWindowChangeFlags = [];
   });
 
   it('routes each typed snapshot to the matching official Perses panel and 
query kind', () => {
@@ -124,4 +134,40 @@ describe('PersesSignalRuntime', () => {
       view.unmount();
     }
   });
+
+  it('forwards a host time-window callback only for a metric time-series 
runtime', () => {
+    const onTimeWindowChange = vi.fn();
+    const metric = render(
+      <PersesSignalRuntime
+        kind="metric-time-series"
+        title="Metric"
+        timeWindow={timeWindow}
+        data={{ timeRange: { start: new Date(timeWindow.from), end: new 
Date(timeWindow.to) }, series: [] }}
+        onTimeWindowChange={onTimeWindowChange}
+      />
+    );
+    
expect(runtimeContract.timeWindowCallbacks.at(-1)).toBe(onTimeWindowChange);
+    expect(runtimeContract.timeWindowChangeFlags.at(-1)).toBe(true);
+    metric.unmount();
+
+    const logs = render(
+      <PersesSignalRuntime kind="logs-table" title="Logs" 
timeWindow={timeWindow} data={{ entries: [] }} />
+    );
+    expect(runtimeContract.timeWindowCallbacks.at(-1)).toBeUndefined();
+    logs.unmount();
+  });
+
+  it('disables metric time-window interaction when the host marks retained 
evidence stale', () => {
+    render(
+      <PersesSignalRuntime
+        kind="metric-time-series"
+        title="Retained metric"
+        timeWindow={timeWindow}
+        data={{ timeRange: { start: new Date(timeWindow.from), end: new 
Date(timeWindow.to) }, series: [] }}
+        timeWindowChangeEnabled={false}
+      />
+    );
+
+    expect(runtimeContract.timeWindowChangeFlags.at(-1)).toBe(false);
+  });
 });
diff --git a/web-app/src/platform/perses/runtime/perses-signal-runtime.tsx 
b/web-app/src/platform/perses/runtime/perses-signal-runtime.tsx
index 909e19e2e3..a19ab79eee 100644
--- a/web-app/src/platform/perses/runtime/perses-signal-runtime.tsx
+++ b/web-app/src/platform/perses/runtime/perses-signal-runtime.tsx
@@ -27,6 +27,8 @@ export type PersesSignalRuntimeProps =
       title: string;
       timeWindow: ExactTimeWindow;
       data: TimeSeriesData;
+      onTimeWindowChange?: ((window: ExactTimeWindow) => void) | undefined;
+      timeWindowChangeEnabled?: boolean | undefined;
     }
   | { kind: 'logs-table'; title: string; timeWindow: ExactTimeWindow; data: 
LogData }
   | { kind: 'trace-table'; title: string; timeWindow: ExactTimeWindow; data: 
TraceData }
@@ -47,6 +49,8 @@ export function PersesSignalRuntime(props: 
PersesSignalRuntimeProps) {
         key={`${props.timeWindow.from}:${props.timeWindow.to}`}
         timeWindow={props.timeWindow}
         pluginLoader={hertzBeatPersesMultiSignalPluginLoader}
+        onTimeWindowChange={props.kind === 'metric-time-series' ? 
props.onTimeWindowChange : undefined}
+        timeWindowChangeEnabled={props.kind !== 'metric-time-series' || 
props.timeWindowChangeEnabled !== false}
       >
         <DataQueriesProvider definitions={queries}>
           <Panel panelOptions={{ hideHeader: true }} definition={definition} />
@@ -103,7 +107,7 @@ function panelDefinition(props: PersesSignalRuntimeProps): 
PanelDefinition {
       kind: 'Panel',
       spec: {
         display,
-        plugin: { kind: 'LogsTable', spec: { allowWrap: true, enableDetails: 
true, showTime: true } }
+        plugin: { kind: 'LogsTable', spec: { allowWrap: true, enableDetails: 
true, showAll: true, showTime: true } }
       }
     };
   }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to