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]
