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 2d5d2cc2a7c17e5da2d24c4ad47c8eab676a8081 Author: Logic <[email protected]> AuthorDate: Sat Aug 29 16:12:03 2026 +0800 Align Explore workbench with Greptime patterns --- web-app/src/assets/i18n/explore/en-us.json | 9 +++ web-app/src/assets/i18n/explore/ja-jp.json | 9 +++ web-app/src/assets/i18n/explore/pt-br.json | 9 +++ web-app/src/assets/i18n/explore/zh-cn.json | 9 +++ web-app/src/assets/i18n/explore/zh-tw.json | 9 +++ .../components/explore-active-filters.test.tsx | 77 ++++++++++++++++++ .../explore/components/explore-active-filters.tsx | 18 ++++- .../components/explore-advanced-filters.tsx | 33 +++++++- ...plore-investigation-responsive-contract.test.ts | 60 +++++++++++++- .../explore-investigation-view-primitives.tsx | 15 +++- .../explore-investigation-view.module.css | 19 ++++- .../components/explore-investigation-view.test.tsx | 26 ++++++ .../components/explore-log-investigation-view.tsx | 3 +- .../explore/components/explore-log-statistics.tsx | 5 +- .../components/explore-query-bar.module.css | 44 +++++++++-- .../explore/components/explore-query-bar.tsx | 90 ++++++++++++++++----- .../explore/components/explore-trace-filters.tsx | 2 +- .../explore-trace-investigation-view.tsx | 3 +- .../components/explore-workbench.module.css | 18 +---- .../explore/components/explore-workbench.test.tsx | 92 +++++++++++++++++----- .../explore/components/explore-workbench.tsx | 87 +------------------- .../explore/components/log-result.module.css | 22 +++--- .../explore/components/log-result.test.tsx | 11 ++- .../components/signal-result-frame.module.css | 4 + .../pages/explore-focused-investigation.test.tsx | 14 +++- .../pages/explore-focused-investigation.tsx | 18 +---- .../features/explore/pages/explore-page.test.tsx | 30 ++++++- .../src/features/explore/pages/explore-page.tsx | 11 +-- .../explore/pages/explore-state-i18n.test.ts | 11 +++ web-app/src/layout/basic/basic-layout.test.tsx | 67 +++++++++++++++- web-app/src/layout/shell/hertzbeat-shell.tsx | 31 +++++++- web-app/src/layout/shell/shell-navigation.tsx | 21 ++--- .../runtime/hertzbeat-perses-primitive-frame.tsx | 10 ++- .../runtime/hertzbeat-perses-primitives.module.css | 54 ++++++++++++- .../runtime/hertzbeat-perses-primitives.test.tsx | 22 +++++- 35 files changed, 745 insertions(+), 218 deletions(-) diff --git a/web-app/src/assets/i18n/explore/en-us.json b/web-app/src/assets/i18n/explore/en-us.json index 116dfb0994..ebf4a23d37 100644 --- a/web-app/src/assets/i18n/explore/en-us.json +++ b/web-app/src/assets/i18n/explore/en-us.json @@ -1,5 +1,6 @@ { "exploreTrace": { + "attributeFilter": "Trace attributes, key:value", "closeDetail": "Close detail" }, "exploreInvestigation": { @@ -70,6 +71,14 @@ }, "explore": { "exactWindow": "Fixed time window", + "queryToolbar": "Explore query controls", + "filterContext": "{{label}}: {{value}}", + "addFilters": "Add filters", + "queryLabels": { + "metrics": "Metrics query", + "logs": "Logs query", + "traces": "Traces query" + }, "instanceId": "Service instance ID", "httpRouteTemplate": "HTTP route template, for example /checkout", "instanceContext": "Instance: {{value}}", diff --git a/web-app/src/assets/i18n/explore/ja-jp.json b/web-app/src/assets/i18n/explore/ja-jp.json index e4b5ee13a4..3e35e6dee5 100644 --- a/web-app/src/assets/i18n/explore/ja-jp.json +++ b/web-app/src/assets/i18n/explore/ja-jp.json @@ -1,5 +1,6 @@ { "exploreTrace": { + "attributeFilter": "トレース属性、key:value", "closeDetail": "詳細を閉じる" }, "exploreInvestigation": { @@ -70,6 +71,14 @@ }, "explore": { "exactWindow": "固定時間範囲", + "queryToolbar": "Explore クエリコントロール", + "filterContext": "{{label}}: {{value}}", + "addFilters": "フィルターを追加", + "queryLabels": { + "metrics": "メトリクスクエリ", + "logs": "ログクエリ", + "traces": "トレースクエリ" + }, "instanceId": "サービスインスタンス ID", "httpRouteTemplate": "HTTP ルートテンプレート(例: /checkout)", "instanceContext": "インスタンス: {{value}}", diff --git a/web-app/src/assets/i18n/explore/pt-br.json b/web-app/src/assets/i18n/explore/pt-br.json index 6327909208..4b3c130d06 100644 --- a/web-app/src/assets/i18n/explore/pt-br.json +++ b/web-app/src/assets/i18n/explore/pt-br.json @@ -1,5 +1,6 @@ { "exploreTrace": { + "attributeFilter": "Atributos de trace, chave:valor", "closeDetail": "Fechar detalhes" }, "exploreInvestigation": { @@ -70,6 +71,14 @@ }, "explore": { "exactWindow": "Intervalo de tempo fixo", + "queryToolbar": "Controles de consulta do Explore", + "filterContext": "{{label}}: {{value}}", + "addFilters": "Adicionar filtros", + "queryLabels": { + "metrics": "Consulta de métricas", + "logs": "Consulta de logs", + "traces": "Consulta de traces" + }, "instanceId": "ID da instância do serviço", "httpRouteTemplate": "Modelo de rota HTTP, por exemplo /checkout", "instanceContext": "Instância: {{value}}", diff --git a/web-app/src/assets/i18n/explore/zh-cn.json b/web-app/src/assets/i18n/explore/zh-cn.json index 8a77874089..5ee4014d12 100644 --- a/web-app/src/assets/i18n/explore/zh-cn.json +++ b/web-app/src/assets/i18n/explore/zh-cn.json @@ -1,5 +1,6 @@ { "exploreTrace": { + "attributeFilter": "Trace 属性,key:value", "closeDetail": "关闭详情" }, "exploreInvestigation": { @@ -70,6 +71,14 @@ }, "explore": { "exactWindow": "固定时间范围", + "queryToolbar": "探索查询控件", + "filterContext": "{{label}}:{{value}}", + "addFilters": "添加筛选条件", + "queryLabels": { + "metrics": "指标查询", + "logs": "日志查询", + "traces": "链路查询" + }, "instanceId": "服务实例 ID", "httpRouteTemplate": "HTTP 路由模板,例如 /checkout", "instanceContext": "实例:{{value}}", diff --git a/web-app/src/assets/i18n/explore/zh-tw.json b/web-app/src/assets/i18n/explore/zh-tw.json index 0bc5e0eee2..18372544d9 100644 --- a/web-app/src/assets/i18n/explore/zh-tw.json +++ b/web-app/src/assets/i18n/explore/zh-tw.json @@ -1,5 +1,6 @@ { "exploreTrace": { + "attributeFilter": "Trace 屬性,key:value", "closeDetail": "關閉詳情" }, "exploreInvestigation": { @@ -70,6 +71,14 @@ }, "explore": { "exactWindow": "固定時間範圍", + "queryToolbar": "探索查詢控制項", + "filterContext": "{{label}}:{{value}}", + "addFilters": "新增篩選條件", + "queryLabels": { + "metrics": "指標查詢", + "logs": "日誌查詢", + "traces": "追蹤查詢" + }, "instanceId": "服務執行個體 ID", "httpRouteTemplate": "HTTP 路由範本,例如 /checkout", "instanceContext": "執行個體:{{value}}", diff --git a/web-app/src/features/explore/components/explore-active-filters.test.tsx b/web-app/src/features/explore/components/explore-active-filters.test.tsx index 0b7b4bc04f..0017ec6537 100644 --- a/web-app/src/features/explore/components/explore-active-filters.test.tsx +++ b/web-app/src/features/explore/components/explore-active-filters.test.tsx @@ -22,6 +22,7 @@ import { i18n, initializeI18n, loadLocale } from '@/core/i18n/i18n'; import { QUERY_CONTEXT_FIELDS } from '@/shared/query-context'; import { ExploreActiveFilters } from './explore-active-filters'; +import type { ExploreQuery } from '../model/explore-model'; describe('Explore active filters', () => { beforeAll(async () => { @@ -134,6 +135,82 @@ describe('Explore active filters', () => { expect(removeFilter).toHaveBeenCalledWith('hideNoise'); expect(updateQuery).not.toHaveBeenCalled(); }); + + it.each([ + [ + 'metric label filter', + { signal: 'metrics', timeRange: 'last-30m', metricFilter: 'method=GET' }, + 'Label filter, key=value: method=GET', + 'metricFilter' + ], + [ + 'metric group by', + { signal: 'metrics', timeRange: 'last-30m', groupBy: 'service.name' }, + 'Group by label: service.name', + 'groupBy' + ], + [ + 'metric aggregation', + { signal: 'metrics', timeRange: 'last-30m', aggregation: 'sum' }, + 'Aggregation: sum', + 'aggregation' + ], + [ + 'metric step', + { signal: 'metrics', timeRange: 'last-30m', step: '60' }, + 'Step in seconds, for example 60: 60', + 'step' + ], + [ + 'log resource filter', + { signal: 'logs', timeRange: 'last-30m', resourceFilter: 'service.name=checkout' }, + 'Resource attributes, key=value: service.name=checkout', + 'resourceFilter' + ], + [ + 'log attribute filter', + { signal: 'logs', timeRange: 'last-30m', attributeFilter: 'http.status_code:500' }, + 'Log attributes, key:value: http.status_code:500', + 'attributeFilter' + ], + [ + 'trace resource filter', + { signal: 'traces', timeRange: 'last-30m', resourceFilter: 'service.name=checkout' }, + 'Resource attributes, key=value: service.name=checkout', + 'resourceFilter' + ], + [ + 'trace attribute filter', + { signal: 'traces', timeRange: 'last-30m', attributeFilter: 'http.route:/checkout' }, + 'Trace attributes, key:value: http.route:/checkout', + 'attributeFilter' + ], + [ + 'trace minimum duration', + { signal: 'traces', timeRange: 'last-30m', minDurationMs: 25 }, + 'Minimum duration (ms): 25', + 'minDurationMs' + ], + [ + 'trace maximum duration', + { signal: 'traces', timeRange: 'last-30m', maxDurationMs: 800 }, + 'Maximum duration (ms): 800', + 'maxDurationMs' + ] + ] as const)('shows and removes the applied %s chip', (_name, query, label, key) => { + const removeFilter = vi.fn(() => true); + render( + <ExploreActiveFilters + query={query as ExploreQuery} + t={i18n.t} + updateQuery={vi.fn()} + removeFilter={removeFilter} + /> + ); + + closeFilter(label); + expect(removeFilter).toHaveBeenCalledWith(key); + }); }); function closeFilter(label: string) { diff --git a/web-app/src/features/explore/components/explore-active-filters.tsx b/web-app/src/features/explore/components/explore-active-filters.tsx index 7fe58b4251..cee2c3582a 100644 --- a/web-app/src/features/explore/components/explore-active-filters.tsx +++ b/web-app/src/features/explore/components/explore-active-filters.tsx @@ -73,7 +73,7 @@ export function ExploreActiveFilters({ query, t, updateQuery, removeFilter }: Pr } function activeFilter(value: unknown, key: keyof ExploreQueryPatch, label: string): ActiveFilter[] { - return value ? [{ key, label }] : []; + return value != null && value !== '' && value !== false ? [{ key, label }] : []; } function signalActiveFilters(query: ExploreQuery, t: TFunction): ActiveFilter[] { @@ -84,13 +84,17 @@ function signalActiveFilters(query: ExploreQuery, t: TFunction): ActiveFilter[] 'operationName', t('explore.operationContext', { value: query.operationName }) ), + ...contextFilter(query.metricFilter, 'metricFilter', t('exploreMetric.filter'), t), + ...contextFilter(query.groupBy, 'groupBy', t('exploreMetric.groupBy'), t), + ...contextFilter(query.aggregation, 'aggregation', t('exploreMetric.aggregation'), t), ...activeFilter( query.temporalAggregation, 'temporalAggregation', t('exploreMetric.temporalAggregationContext', { value: t(`exploreMetric.temporalAggregationValues.${query.temporalAggregation}`) }) - ) + ), + ...contextFilter(query.step, 'step', t('exploreMetric.step'), t) ]; } const trace = activeFilter(query.traceId, 'traceId', t('explore.traceIdContext', { value: query.traceId })); @@ -99,12 +103,18 @@ function signalActiveFilters(query: ExploreQuery, t: TFunction): ActiveFilter[] ...activeFilter(query.severityText, 'severityText', `${t('explore.severity')}: ${query.severityText}`), ...trace, ...activeFilter(query.spanId, 'spanId', t('explore.spanIdContext', { value: query.spanId })), + ...contextFilter(query.resourceFilter, 'resourceFilter', t('exploreLog.resourceFilter'), t), + ...contextFilter(query.attributeFilter, 'attributeFilter', t('exploreLog.attributeFilter'), t), ...activeFilter(query.hideInternal, 'hideInternal', t('exploreLog.hideInternal')), ...activeFilter(query.hideNoise, 'hideNoise', t('exploreLog.hideNoise')) ]; } return [ ...trace, + ...contextFilter(query.resourceFilter, 'resourceFilter', t('exploreLog.resourceFilter'), t), + ...contextFilter(query.attributeFilter, 'attributeFilter', t('exploreTrace.attributeFilter'), t), + ...contextFilter(query.minDurationMs, 'minDurationMs', t('exploreTrace.minDuration'), t), + ...contextFilter(query.maxDurationMs, 'maxDurationMs', t('exploreTrace.maxDuration'), t), ...activeFilter(query.errorOnly, 'errorOnly', t('exploreTrace.errorOnly')), ...activeFilter( query.spanScope, @@ -116,3 +126,7 @@ function signalActiveFilters(query: ExploreQuery, t: TFunction): ActiveFilter[] ...activeFilter(query.hideInternal, 'hideInternal', t('exploreTrace.hideInternal')) ]; } + +function contextFilter(value: unknown, key: keyof ExploreQueryPatch, label: string, t: TFunction) { + return activeFilter(value, key, t('explore.filterContext', { label, value })); +} diff --git a/web-app/src/features/explore/components/explore-advanced-filters.tsx b/web-app/src/features/explore/components/explore-advanced-filters.tsx index bd0da64764..5634c433b3 100644 --- a/web-app/src/features/explore/components/explore-advanced-filters.tsx +++ b/web-app/src/features/explore/components/explore-advanced-filters.tsx @@ -15,6 +15,7 @@ * limitations under the License. */ +import { FilterOutlined } from '@ant-design/icons'; import { Input } from 'antd'; import type { TFunction } from 'i18next'; @@ -27,11 +28,37 @@ import { ExploreTraceFilters } from './explore-trace-filters'; import styles from './explore-query-bar.module.css'; type Props = Pick<ExploreSubmissionViewModel, 'draft' | 'errors' | 'updateField'> & { t: TFunction }; +type GuidedProps = Pick<ExploreSubmissionViewModel, 'draft' | 'updateField'> & { t: TFunction }; + +export function ExploreGuidedFilters({ draft, t, updateField }: GuidedProps) { + return ( + <details className={styles.guided} open={hasGuidedFilter(draft) || undefined}> + <summary aria-label={t('explore.addFilters')}> + <FilterOutlined aria-hidden /> + <span>{t('explore.addFilters')}</span> + </summary> + <div className={styles.guidedFields}> + <Input + value={draft.serviceName} + aria-label={t('explore.serviceName')} + onChange={event => updateField({ field: 'serviceName', value: event.target.value })} + placeholder={t('explore.serviceName')} + /> + <Input + value={draft.environment} + aria-label={t('explore.environment')} + onChange={event => updateField({ field: 'environment', value: event.target.value })} + placeholder={t('explore.environment')} + /> + </div> + </details> + ); +} export function ExploreAdvancedFilters({ draft, errors, t, updateField }: Props) { return ( <details className={styles.advanced} open={hasAdvancedFilter(draft) || undefined}> - <summary>{t('explore.advancedFilters')}</summary> + <summary aria-label={t('explore.advancedFilters')}>{t('explore.advancedFilters')}</summary> <div className={styles.advancedFields}> <Input value={draft.instance} @@ -55,6 +82,10 @@ export function ExploreAdvancedFilters({ draft, errors, t, updateField }: Props) ); } +function hasGuidedFilter(draft: ExploreSubmissionViewModel['draft']) { + return [draft.serviceName, draft.environment].some(value => value != null && value !== ''); +} + function hasAdvancedFilter(draft: ExploreSubmissionViewModel['draft']) { return [draft.instance, draft.endpoint, ...signalFilters(draft)].some( value => value != null && value !== false && value !== '' 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 4c039b20ac..4d4fbfacd4 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 @@ -7,7 +7,11 @@ import appStyles from '@/app/styles.css?raw'; import traceStyles from './explore-investigation-trace.module.css?raw'; import viewStyles from './explore-investigation-view.module.css?raw'; import queryStyles from './explore-query-bar.module.css?raw'; +import historyStyles from './explore-history-result.module.css?raw'; +import logStyles from './log-result.module.css?raw'; +import resultFrameStyles from './signal-result-frame.module.css?raw'; import workspaceStyles from './explore-workbench.module.css?raw'; +import persesStyles from '@/platform/perses/runtime/hertzbeat-perses-primitives.module.css?raw'; describe('Explore investigation responsive contract', () => { it('keeps the Gantt readable at ordinary desktop widths and contains narrow runtime overflow', () => { @@ -27,20 +31,20 @@ describe('Explore investigation responsive contract', () => { it('releases the shared desktop body floor only while a narrow focused investigation is mounted', () => { expect(appStyles).toMatch(/body\s*\{[^}]*min-width:\s*1024px/s); expect(viewStyles).toMatch( - /@media \(max-width:\s*700px\)[\s\S]*:global\(body:has\(\[data-explore-investigation='true'\]\)\)\s*\{[^}]*min-width:\s*0/s + /@media \(max-width:\s*768px\)[\s\S]*:global\(body:has\(\[data-explore-investigation='true'\]\)\)\s*\{[^}]*min-width:\s*0/s ); }); it('removes secondary shell summaries only from a narrow focused investigation header', () => { expect(viewStyles).toMatch( - /@media \(max-width:\s*700px\)[\s\S]*body:has\(\[data-explore-investigation='true'\]\)[\s\S]*\[data-testid='shell-time-policy'\][\s\S]*\[data-testid='shell-status-greptime'\][\s\S]*\[data-testid='shell-status-collector'\][\s\S]*\{[^}]*display:\s*none/s + /@media \(max-width:\s*768px\)[\s\S]*body:has\(\[data-explore-investigation='true'\]\)[\s\S]*\[data-testid='shell-time-policy'\][\s\S]*\[data-testid='shell-status-greptime'\][\s\S]*\[data-testid='shell-status-collector'\][\s\S]*\{[^}]*display:\s*none/s ); expect(viewStyles).not.toMatch(/\[data-testid='shell-status-server'\]/); }); - it('contains ordinary Explore at 700px without changing the desktop shell floor', () => { + it('contains ordinary Explore through tablet width without changing the desktop shell floor', () => { expect(workspaceStyles).toMatch( - /@media \(max-width:\s*700px\)[\s\S]*body:has\(\[data-explore-workspace='true'\]\)[\s\S]*min-width:\s*0/s + /@media \(max-width:\s*768px\)[\s\S]*body:has\(\[data-explore-workspace='true'\]\)[\s\S]*min-width:\s*0/s ); expect(workspaceStyles).toMatch( /body:has\(\[data-explore-workspace='true'\]\)[\s\S]*\[data-testid='shell-time-policy'\][\s\S]*\[data-testid='shell-status-greptime'\][\s\S]*\[data-testid='shell-status-collector'\][\s\S]*display:\s*none/s @@ -49,4 +53,52 @@ describe('Explore investigation responsive contract', () => { expect(queryStyles).toMatch(/@media \(max-width:\s*700px\)[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\)/s); expect(appStyles).toMatch(/body\s*\{[^}]*min-width:\s*1024px/s); }); + + 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).not.toMatch(/\.statistics\s*>\s*section\s*\{[^}]*border-radius:/s); + expect(logStyles).not.toMatch(/\.statistics\s*\{[^}]*grid-template-columns:/s); + }); + + it('flattens embedded Perses cards and keeps narrow host actions on one scrollable line', () => { + 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(/\.interactions\s+button\s*\{[^}]*white-space:\s*nowrap/s); + }); + + it('keeps narrow Logs readable with stable content width and runtime-local scrolling', () => { + expect(historyStyles).toMatch(/\.persesFrame\s*\{[^}]*min-width:\s*0[^}]*overflow:\s*hidden/s); + expect(persesStyles).toMatch( + /\.runtime:has\(>\s*:global\(\[data-perses-primitive='logs-table'\]\)\)\s*\{[^}]*overflow-x:\s*auto/s + ); + expect(persesStyles).toMatch( + /\.runtime\s*>\s*:global\(\[data-perses-primitive='logs-table'\]\)\s*\{[^}]*min-width:\s*640px/s + ); + }); + + 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 + ); + }); + + it('keeps focused signal facts flat inside their single outer surface', () => { + expect(viewStyles).toMatch(/\.signalSection\s*\{[^}]*border:\s*1px solid[^}]*border-radius:/s); + expect(viewStyles).toMatch(/\.fact\s*\{[^}]*border-bottom:\s*1px solid/s); + expect(viewStyles).not.toMatch(/\.fact\s*\{[^}]*border:\s*1px solid/s); + expect(viewStyles).not.toMatch(/\.fact\s*\{[^}]*border-radius:/s); + }); + + it('uses tabular figures for ordinary Logs overview and result counts', () => { + expect(logStyles).toMatch(/\.overviewStats\s+dd\s*\{[^}]*font-variant-numeric:\s*tabular-nums/s); + expect(queryStyles).toMatch(/\.commandFields\s*\{[^}]*min-width:\s*0/s); + expect(resultFrameStyles).toMatch(/\.identity\s+span\s*\{[^}]*font-variant-numeric:\s*tabular-nums/s); + }); }); diff --git a/web-app/src/features/explore/components/explore-investigation-view-primitives.tsx b/web-app/src/features/explore/components/explore-investigation-view-primitives.tsx index 228ee9e5fd..bccf580b49 100644 --- a/web-app/src/features/explore/components/explore-investigation-view-primitives.tsx +++ b/web-app/src/features/explore/components/explore-investigation-view-primitives.tsx @@ -14,7 +14,15 @@ export type AvailabilityItem = { state: InvestigationEvidenceState; }; -export function InvestigationContextBand({ window, onBack }: { window: InvestigationTimeWindow; onBack: () => void }) { +export function InvestigationContextBand({ + window, + onBack, + onRefresh +}: { + window: InvestigationTimeWindow; + onBack: () => void; + onRefresh: () => void; +}) { const { t } = useInvestigationTranslation(); const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', @@ -28,7 +36,10 @@ export function InvestigationContextBand({ window, onBack }: { window: Investiga <span>{t('exploreInvestigation.exactWindow')}</span> <time>{`${formatter.format(window.from)} – ${formatter.format(window.to)}`}</time> </div> - <Button onClick={onBack}>{t('exploreInvestigation.actions.backToResults')}</Button> + <div className={styles.contextActions}> + <Button onClick={onBack}>{t('exploreInvestigation.actions.backToResults')}</Button> + <Button onClick={onRefresh}>{t('common.refresh')}</Button> + </div> </header> ); } diff --git a/web-app/src/features/explore/components/explore-investigation-view.module.css b/web-app/src/features/explore/components/explore-investigation-view.module.css index 0f8f5ab5ef..34a49a9819 100644 --- a/web-app/src/features/explore/components/explore-investigation-view.module.css +++ b/web-app/src/features/explore/components/explore-investigation-view.module.css @@ -31,6 +31,12 @@ color: var(--hb-text-secondary); } +.contextActions { + display: flex; + flex: 0 0 auto; + gap: var(--hb-space-2); +} + .contextBand time { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; @@ -103,8 +109,11 @@ min-width: 0; gap: var(--hb-space-1); padding: var(--hb-space-3); - border: 1px solid var(--hb-border-subtle); - border-radius: var(--hb-radius-sm); + border-bottom: 1px solid var(--hb-border-subtle); +} + +.fact:last-child { + border-bottom: 0; } .fact dt { @@ -118,7 +127,7 @@ font-variant-numeric: tabular-nums; } -@media (max-width: 700px) { +@media (max-width: 768px) { :global(body:has([data-explore-investigation='true'])) { min-width: 0; } @@ -136,6 +145,10 @@ flex-direction: column; } + .contextActions { + flex-wrap: wrap; + } + .capabilityGrid, .capabilityGrid[data-count='3'], .capabilityGrid[data-count='5'] { diff --git a/web-app/src/features/explore/components/explore-investigation-view.test.tsx b/web-app/src/features/explore/components/explore-investigation-view.test.tsx index 95f3a1a617..9d332f0920 100644 --- a/web-app/src/features/explore/components/explore-investigation-view.test.tsx +++ b/web-app/src/features/explore/components/explore-investigation-view.test.tsx @@ -147,6 +147,30 @@ describe('focused Explore investigation presentation', () => { expect(screen.getByRole('button', { name: i18n.t('exploreInvestigation.actions.backToResults') })).toBeEnabled(); }); + it('keeps manual refresh beside Back in the exact-window context band', () => { + const refresh = vi.fn(); + renderTrace(traceReady(), { onRefresh: refresh }); + + const refreshButton = screen.getByRole('button', { name: i18n.t('common.refresh') }); + const context = refreshButton.closest('header'); + expect(context).not.toBeNull(); + fireEvent.click(refreshButton); + expect(refresh).toHaveBeenCalledOnce(); + expect( + within(context as HTMLElement).getByRole('button', { + name: i18n.t('exploreInvestigation.actions.backToResults') + }) + ).toBeInTheDocument(); + }); + + it('renders facts as one semantic list inside the outer signal surface', () => { + renderTrace(traceReady()); + + const traces = screen.getByRole('region', { name: i18n.t('exploreInvestigation.sections.traces') }); + expect(within(traces).getAllByRole('term').length).toBeGreaterThan(0); + expect(within(traces).getAllByRole('definition').length).toBeGreaterThan(0); + }); + it('does not let ready RED evidence mask unavailable service metrics', () => { const ready = traceReady(); renderTrace({ @@ -211,6 +235,7 @@ function renderTrace( state={state} evidenceCurrent onBack={vi.fn()} + onRefresh={vi.fn()} onSelectSpan={vi.fn()} onOpenLogs={vi.fn()} onOpenMetrics={vi.fn()} @@ -228,6 +253,7 @@ function renderLog(state: ReadyLog, overrides: Partial<React.ComponentProps<type state={state} evidenceCurrent onBack={vi.fn()} + onRefresh={vi.fn()} onFocusTrace={vi.fn()} onOpenMetrics={vi.fn()} onOpenTopology={vi.fn()} diff --git a/web-app/src/features/explore/components/explore-log-investigation-view.tsx b/web-app/src/features/explore/components/explore-log-investigation-view.tsx index 0b1cedcc7c..118a2532e6 100644 --- a/web-app/src/features/explore/components/explore-log-investigation-view.tsx +++ b/web-app/src/features/explore/components/explore-log-investigation-view.tsx @@ -28,6 +28,7 @@ type Props = { state: ReadyState; evidenceCurrent: boolean; onBack: () => void; + onRefresh: () => void; onFocusTrace: () => void; onOpenMetrics?: (() => void) | undefined; onOpenTopology?: (() => void) | undefined; @@ -47,7 +48,7 @@ export function ExploreLogInvestigationView(props: Props) { data-explore-investigation="true" aria-label={t('exploreInvestigation.title')} > - <InvestigationContextBand window={state.route.window} onBack={props.onBack} /> + <InvestigationContextBand window={state.route.window} onBack={props.onBack} onRefresh={props.onRefresh} /> <LogAvailability logs={snapshot.selectedLog.state} traces={traceEvidenceState(state)} 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 d0e4b25de6..2ecd34b001 100644 --- a/web-app/src/features/explore/components/explore-log-statistics.tsx +++ b/web-app/src/features/explore/components/explore-log-statistics.tsx @@ -32,7 +32,7 @@ export function ExploreLogStatistics({ function Overview({ statistics, t }: { statistics: Pick<LogHistoryEvidence, 'overview'>; t: TFunction }) { return ( - <section aria-label={t('exploreLog.overview')}> + <section className={styles.overview} aria-label={t('exploreLog.overview')}> <h3>{t('exploreLog.overview')}</h3> {statistics.overview.kind === 'error' ? ( <Alert type="warning" showIcon message={t('exploreLog.statisticsUnavailable')} /> @@ -63,7 +63,7 @@ function Trend({ }) { const rows = statistics.trend.kind === 'ready' ? Object.keys(statistics.trend.data.hourlyStats) : []; return ( - <section aria-label={t('exploreLog.trend')}> + <section className={styles.trend} aria-label={t('exploreLog.trend')}> <h3>{t('exploreLog.trend')}</h3> {statistics.trend.kind === 'error' ? ( <Alert type="warning" showIcon message={t('exploreLog.statisticsUnavailable')} /> @@ -97,6 +97,7 @@ function TrendResult({ outcome={result.outcome} runtimeIdentity={result.runtimeIdentity} messages={explorePersesMessages(t)} + variant="compact" /> ); } diff --git a/web-app/src/features/explore/components/explore-query-bar.module.css b/web-app/src/features/explore/components/explore-query-bar.module.css index 731b27523a..5638c63112 100644 --- a/web-app/src/features/explore/components/explore-query-bar.module.css +++ b/web-app/src/features/explore/components/explore-query-bar.module.css @@ -19,10 +19,27 @@ min-width: 0; } -.primaryRow { - display: grid; - grid-template-columns: minmax(280px, 1.6fr) minmax(180px, 0.8fr) minmax(160px, 0.7fr); +.commandFields { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 8px; +} + +.queryInput { + min-width: 240px; + flex: 1 1 360px; +} + +.timeRange { + width: 176px; + flex: 0 0 176px; +} + +.commandActions { + display: flex; gap: 8px; + white-space: nowrap; } .queryInput :global(input) { @@ -33,10 +50,12 @@ font-weight: 600; } +.guided, .advanced { margin-top: 10px; } +.guided summary, .advanced summary { width: max-content; color: var(--hb-text-muted); @@ -45,11 +64,13 @@ user-select: none; } +.guided[open] summary, .advanced[open] summary { margin-bottom: 10px; color: var(--hb-text-secondary); } +.guidedFields, .advancedFields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -88,14 +109,27 @@ } @media (max-width: 880px) { - .primaryRow, + .guidedFields, .advancedFields { grid-template-columns: repeat(2, minmax(180px, 1fr)); } } @media (max-width: 700px) { - .primaryRow, + .commandFields, + .commandActions { + align-items: stretch; + flex-direction: column; + } + + .queryInput, + .timeRange { + width: 100%; + min-width: 0; + flex-basis: auto; + } + + .guidedFields, .advancedFields { grid-template-columns: minmax(0, 1fr); } diff --git a/web-app/src/features/explore/components/explore-query-bar.tsx b/web-app/src/features/explore/components/explore-query-bar.tsx index ae23bb438c..733f66cfeb 100644 --- a/web-app/src/features/explore/components/explore-query-bar.tsx +++ b/web-app/src/features/explore/components/explore-query-bar.tsx @@ -15,29 +15,43 @@ * limitations under the License. */ -import { Button, Input } from 'antd'; +import { Button, Input, Select } from 'antd'; import type { TFunction } from 'i18next'; import { OperationalCommandBar } from '@/shared/operational-page'; +import { globalAutoRefreshValues, type SharedTimeValue } from '@/shared/time'; -import type { ExploreQuery, ExploreQueryPatch } from '../model/explore-model'; +import { + EXPLORE_TIME_RANGES, + exploreUsesExactWindow, + presetTimeRangePatch, + type ExploreQuery, + type ExploreQueryPatch, + type ExploreTimeRange +} from '../model/explore-model'; import type { ExploreSubmissionViewModel } from '../model/explore-submission-model'; import { ExploreActiveFilters } from './explore-active-filters'; -import { ExploreAdvancedFilters } from './explore-advanced-filters'; +import { ExploreAdvancedFilters, ExploreGuidedFilters } from './explore-advanced-filters'; import styles from './explore-query-bar.module.css'; type Props = { query: ExploreQuery; t: TFunction; updateQuery: (changes: ExploreQueryPatch) => void; + updateScope: (changes: ExploreQueryPatch) => void; + refresh: () => Promise<void>; + time: SharedTimeValue | null | undefined; submission: ExploreSubmissionViewModel; }; -export function ExploreQueryBar({ query, t, updateQuery, submission }: Props) { +const EXACT_WINDOW_OPTION = 'exact-window'; + +export function ExploreQueryBar({ query, t, updateQuery, updateScope, refresh, time, submission }: Props) { const { draft, errors, updateField } = submission; return ( <form className={styles.form} + aria-label={t('explore.queryToolbar')} onSubmit={event => { event.preventDefault(); submission.submit(); @@ -45,33 +59,73 @@ export function ExploreQueryBar({ query, t, updateQuery, submission }: Props) { > <OperationalCommandBar primary={ - <div className={styles.primaryRow}> + <div className={styles.commandFields}> + <ExploreTimeRange query={query} t={t} updateScope={updateScope} /> <Input className={styles.queryInput ?? ''} value={draft.query} + aria-label={t(`explore.queryLabels.${query.signal}`)} onChange={event => updateField({ field: 'query', value: event.target.value })} placeholder={t(`explore.queryPlaceholders.${query.signal}`)} /> - <Input - value={draft.serviceName} - onChange={event => updateField({ field: 'serviceName', value: event.target.value })} - placeholder={t('explore.serviceName')} - /> - <Input - value={draft.environment} - onChange={event => updateField({ field: 'environment', value: event.target.value })} - placeholder={t('explore.environment')} - /> + <ExploreAutoRefresh query={query} t={t} time={time} /> </div> } secondary={ - <Button className={styles.run ?? ''} type="primary" htmlType="submit"> - {t('common.query')} - </Button> + <div className={styles.commandActions}> + <Button className={styles.run ?? ''} type="primary" htmlType="submit"> + {t('common.query')} + </Button> + <Button onClick={() => void refresh()}>{t('common.refresh')}</Button> + </div> } /> + <ExploreGuidedFilters draft={draft} t={t} updateField={updateField} /> <ExploreAdvancedFilters draft={draft} errors={errors} t={t} updateField={updateField} /> <ExploreActiveFilters query={query} t={t} updateQuery={updateQuery} removeFilter={submission.removeFilter} /> </form> ); } + +function ExploreTimeRange({ query, t, updateScope }: Pick<Props, 'query' | 't' | 'updateScope'>) { + const exactWindow = exploreUsesExactWindow(query); + const exactOption = exactWindow + ? [{ value: EXACT_WINDOW_OPTION, label: t('explore.exactWindow'), disabled: true }] + : []; + return ( + <Select<string> + className={styles.timeRange ?? ''} + aria-label={t('explore.timeRange')} + value={exactWindow ? EXACT_WINDOW_OPTION : query.timeRange} + options={[ + ...exactOption, + ...EXPLORE_TIME_RANGES.map(value => ({ value, label: t(`explore.timeRanges.${value}`) })) + ]} + onChange={value => updateTimeRange(query, value, updateScope)} + /> + ); +} + +function ExploreAutoRefresh({ query, t, time }: Pick<Props, 'query' | 't' | 'time'>) { + const fixedWindowFields = query.start != null || query.end != null; + if (fixedWindowFields || !time) return null; + return ( + <Select<number> + className={styles.timeRange ?? ''} + aria-label={autoRefreshLabel(time.autoRefreshMs, t)} + value={time.autoRefreshMs} + options={globalAutoRefreshValues.map(interval => ({ value: interval, label: autoRefreshLabel(interval, t) }))} + onChange={interval => time.setAutoRefresh(interval)} + /> + ); +} + +function updateTimeRange(query: ExploreQuery, value: string, updateScope: Props['updateScope']) { + if (!EXPLORE_TIME_RANGES.includes(value as ExploreTimeRange)) return; + updateScope(presetTimeRangePatch(query, value as ExploreTimeRange)); +} + +function autoRefreshLabel(interval: number, t: TFunction) { + if (interval === 0) return t('shell.time.autoRefreshOff'); + return t('shell.time.autoRefreshSeconds', { seconds: interval / 1_000 }); +} diff --git a/web-app/src/features/explore/components/explore-trace-filters.tsx b/web-app/src/features/explore/components/explore-trace-filters.tsx index 8aab575b2b..c222979ee4 100644 --- a/web-app/src/features/explore/components/explore-trace-filters.tsx +++ b/web-app/src/features/explore/components/explore-trace-filters.tsx @@ -65,7 +65,7 @@ export function ExploreTraceFilters({ draft, errors, t, updateField }: Props) { <Input value={draft.attributeFilter} onChange={event => updateField({ field: 'attributeFilter', value: event.target.value })} - placeholder={t('exploreLog.attributeFilter')} + placeholder={t('exploreTrace.attributeFilter')} /> <TraceScopeFilters draft={draft} errors={errors} t={t} updateField={updateField} /> <Checkbox diff --git a/web-app/src/features/explore/components/explore-trace-investigation-view.tsx b/web-app/src/features/explore/components/explore-trace-investigation-view.tsx index 7a85fa4abc..2009fa1ea3 100644 --- a/web-app/src/features/explore/components/explore-trace-investigation-view.tsx +++ b/web-app/src/features/explore/components/explore-trace-investigation-view.tsx @@ -24,6 +24,7 @@ type Props = { state: ReadyState; evidenceCurrent: boolean; onBack: () => void; + onRefresh: () => void; onSelectSpan: (spanId: string) => void; onOpenLogs: () => void; onOpenMetrics?: (() => void) | undefined; @@ -44,7 +45,7 @@ export function ExploreTraceInvestigationView(props: Props) { data-explore-investigation="true" aria-label={t('exploreInvestigation.title')} > - <InvestigationContextBand window={state.route.window} onBack={props.onBack} /> + <InvestigationContextBand window={state.route.window} onBack={props.onBack} onRefresh={props.onRefresh} /> <TraceAvailability gantt={ganttState} logs={logsState} diff --git a/web-app/src/features/explore/components/explore-workbench.module.css b/web-app/src/features/explore/components/explore-workbench.module.css index b0f03d807c..545a354738 100644 --- a/web-app/src/features/explore/components/explore-workbench.module.css +++ b/web-app/src/features/explore/components/explore-workbench.module.css @@ -15,17 +15,6 @@ * limitations under the License. */ -.scope { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding-top: 2px; -} - -.timeRange { - width: 176px; -} - .navigationRow { display: flex; align-items: flex-end; @@ -82,7 +71,7 @@ padding-inline: 12px; } -@media (max-width: 700px) { +@media (max-width: 768px) { :global(body:has([data-explore-workspace='true'])) { min-width: 0; } @@ -93,16 +82,11 @@ display: none; } - .scope, .navigationRow { align-items: stretch; flex-direction: column; } - .timeRange { - width: 100%; - } - .signalNavigation { justify-content: space-between; gap: 12px; diff --git a/web-app/src/features/explore/components/explore-workbench.test.tsx b/web-app/src/features/explore/components/explore-workbench.test.tsx index 2103b38d2e..a8bfea48cc 100644 --- a/web-app/src/features/explore/components/explore-workbench.test.tsx +++ b/web-app/src/features/explore/components/explore-workbench.test.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { I18nextProvider, useTranslation } from 'react-i18next'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -39,7 +39,7 @@ describe('Explore workbench', () => { vi.useRealTimers(); }); - it('keeps signal navigation and shared time scope visible', () => { + it('keeps one accessible query command region with query, time, refresh, and Run controls', () => { const updateQuery = vi.fn(); render( <I18nextProvider i18n={i18n}> @@ -47,10 +47,15 @@ describe('Explore workbench', () => { </I18nextProvider> ); - expect(screen.getByRole('combobox', { name: 'Time range' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: /Auto refresh/u })).toBeInTheDocument(); - expect(screen.getByText('Last 30 minutes')).toBeInTheDocument(); - expect(screen.getByRole('banner')).toHaveAttribute('data-hb-operational-page-header'); + const command = screen.getByRole('form', { name: 'Explore query controls' }); + expect(within(command).getByRole('textbox', { name: 'Metrics query' })).toBeInTheDocument(); + expect(within(command).getByRole('combobox', { name: 'Time range' })).toBeInTheDocument(); + expect(within(command).getByRole('combobox', { name: /Auto refresh/u })).toBeInTheDocument(); + expect(within(command).getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); + expect(within(command).getByRole('button', { name: 'Query' })).toBeInTheDocument(); + expect(screen.getByRole('banner')).not.toContainElement( + document.querySelector('[data-hb-operational-page-actions]') + ); fireEvent.click(screen.getByRole('tab', { name: 'Metrics' })); expect(updateQuery).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('tab', { name: 'Logs' })); @@ -70,7 +75,11 @@ describe('Explore workbench', () => { </I18nextProvider> ); - fireEvent.mouseDown(screen.getByRole('combobox', { name: /Auto refresh/u })); + fireEvent.mouseDown( + within(screen.getByRole('form', { name: 'Explore query controls' })).getByRole('combobox', { + name: /Auto refresh/u + }) + ); fireEvent.click(screen.getByText('Auto refresh 30s')); expect(time.setAutoRefresh).toHaveBeenCalledWith(30_000); }); @@ -82,6 +91,7 @@ describe('Explore workbench', () => { </I18nextProvider> ); expect(screen.getByText('Advanced filters').closest('details')).not.toHaveAttribute('open'); + expect(screen.getByText('Add filters').closest('summary')?.querySelector('.anticon-filter')).not.toBeNull(); }); it('edits optional QueryContext v1 dimensions as an instance and HTTP route template', () => { @@ -97,6 +107,9 @@ describe('Explore workbench', () => { query={query} t={i18n.t} updateQuery={vi.fn()} + updateScope={vi.fn()} + refresh={vi.fn().mockResolvedValue(undefined)} + time={sharedTime()} submission={{ draft: draftFromQuery(query), errors: {}, @@ -132,8 +145,35 @@ describe('Explore workbench', () => { }} t={i18n.t} updateQuery={updateQuery} + /> + <ExploreQueryBar + query={{ + signal: 'metrics', + timeRange: 'last-30m', + serviceName: 'checkout-api', + serviceNamespace: 'commerce', + environment: 'prod', + collectorId: 'collector-east', + start: 1_710_000_000_000, + end: 1_710_000_005_000 + }} + t={i18n.t} + updateQuery={vi.fn()} + updateScope={updateQuery} refresh={refresh} time={sharedTime({ autoRefreshMs: 0 })} + submission={{ + draft: draftFromQuery({ + signal: 'metrics', + timeRange: 'last-30m', + start: 1_710_000_000_000, + end: 1_710_000_005_000 + }), + errors: {}, + updateField: vi.fn(), + submit: vi.fn(), + removeFilter: vi.fn() + }} /> </I18nextProvider> ); @@ -149,8 +189,6 @@ describe('Explore workbench', () => { query={{ signal: 'metrics', timeRange: 'last-30m', collectorId: 'collector-east', start: 2_000, end: 1_000 }} t={i18n.t} updateQuery={vi.fn()} - refresh={vi.fn().mockResolvedValue(undefined)} - time={sharedTime()} /> </I18nextProvider> ); @@ -168,13 +206,19 @@ function WorkbenchSubject({ }) { const { t } = useTranslation(); return ( - <ExploreWorkbench - query={{ signal: 'metrics', timeRange: 'last-30m', query: 'http_requests_total' }} - t={t} - updateQuery={updateQuery} - refresh={vi.fn().mockResolvedValue(undefined)} - time={time} - /> + <> + <ExploreWorkbench + query={{ signal: 'metrics', timeRange: 'last-30m', query: 'http_requests_total' }} + t={t} + updateQuery={updateQuery} + /> + <QuerySubject + query={{ signal: 'metrics', timeRange: 'last-30m', query: 'http_requests_total' }} + time={time} + updateScope={updateQuery} + refresh={vi.fn().mockResolvedValue(undefined)} + /> + </> ); } @@ -196,14 +240,26 @@ function sharedTime(override: Partial<SharedTimeValue> = {}): SharedTimeValue { }; } -function QuerySubject() { +function QuerySubject({ + query = { signal: 'logs', timeRange: 'last-30m' }, + time = sharedTime(), + updateScope = vi.fn(), + refresh = vi.fn().mockResolvedValue(undefined) +}: { + query?: Parameters<typeof draftFromQuery>[0]; + time?: SharedTimeValue; + updateScope?: (changes: ExploreQueryPatch) => void; + refresh?: () => Promise<void>; +} = {}) { const { t } = useTranslation(); - const query = { signal: 'logs', timeRange: 'last-30m' } as const; return ( <ExploreQueryBar query={query} t={t} updateQuery={vi.fn()} + updateScope={updateScope} + refresh={refresh} + time={time} submission={{ draft: draftFromQuery(query), errors: {}, diff --git a/web-app/src/features/explore/components/explore-workbench.tsx b/web-app/src/features/explore/components/explore-workbench.tsx index 87a800866a..c9d811bde4 100644 --- a/web-app/src/features/explore/components/explore-workbench.tsx +++ b/web-app/src/features/explore/components/explore-workbench.tsx @@ -15,122 +15,43 @@ * limitations under the License. */ -import { Button, Select } from 'antd'; +import { Button } from 'antd'; import type { TFunction } from 'i18next'; import { OperationalPageHeader, OperationalStatePanel } from '@/shared/operational-page'; -import { globalAutoRefreshValues, type SharedTimeValue } from '@/shared/time'; import { - EXPLORE_TIME_RANGES, exploreHandoffState, - exploreUsesExactWindow, - presetTimeRangePatch, signalSelectionPatch, type ExploreQuery, type ExploreQueryPatch, - type ExploreSignal, - type ExploreTimeRange + type ExploreSignal } from '../model/explore-model'; import styles from './explore-workbench.module.css'; const signalKeys: ExploreSignal[] = ['metrics', 'logs', 'traces']; -const EXACT_WINDOW_OPTION = 'exact-window'; type Props = { query: ExploreQuery; t: TFunction; updateQuery: (changes: ExploreQueryPatch) => void; - refresh: () => Promise<void>; - time: SharedTimeValue | null | undefined; }; -export function ExploreWorkbench({ query, t, updateQuery, refresh, time }: Props) { +export function ExploreWorkbench({ query, t, updateQuery }: Props) { const handoffState = exploreHandoffState(query); - const exactWindow = exploreUsesExactWindow(query); - const fixedWindowFields = query.start != null || query.end != null; - const updateTimeRange = (value: string) => { - if (!EXPLORE_TIME_RANGES.includes(value as ExploreTimeRange)) return; - const timeRange = value as ExploreTimeRange; - updateQuery(presetTimeRangePatch(query, timeRange)); - }; const selectSignal = (signal: ExploreSignal) => { if (query.signal === signal) return; updateQuery(signalSelectionPatch(signal)); }; return ( <> - <ExploreHeader - exactWindow={exactWindow} - fixedWindowFields={fixedWindowFields} - query={query} - refresh={refresh} - t={t} - time={time} - updateTimeRange={updateTimeRange} - /> + <OperationalPageHeader title={t('explore.title')} description={t('explore.description')} /> {handoffState === 'invalid' && <OperationalStatePanel kind="error" title={t('explore.handoffInvalid')} />} <ExploreSignalNavigation query={query} selectSignal={selectSignal} t={t} updateQuery={updateQuery} /> </> ); } -function ExploreHeader({ - exactWindow, - fixedWindowFields, - query, - refresh, - t, - time, - updateTimeRange -}: Pick<Props, 'query' | 'refresh' | 't' | 'time'> & { - exactWindow: boolean; - fixedWindowFields: boolean; - updateTimeRange: (value: string) => void; -}) { - const exactOption = exactWindow - ? [{ value: EXACT_WINDOW_OPTION, label: t('explore.exactWindow'), disabled: true }] - : []; - return ( - <OperationalPageHeader - title={t('explore.title')} - description={t('explore.description')} - actions={ - <div className={styles.scope} aria-label={t('explore.context')}> - <Select<string> - className={styles.timeRange ?? ''} - aria-label={t('explore.timeRange')} - value={exactWindow ? EXACT_WINDOW_OPTION : query.timeRange} - options={[ - ...exactOption, - ...EXPLORE_TIME_RANGES.map(value => ({ value, label: t(`explore.timeRanges.${value}`) })) - ]} - onChange={updateTimeRange} - /> - {!fixedWindowFields && time && ( - <Select<number> - className={styles.timeRange ?? ''} - aria-label={autoRefreshLabel(time.autoRefreshMs, t)} - value={time.autoRefreshMs} - options={globalAutoRefreshValues.map(interval => ({ - value: interval, - label: autoRefreshLabel(interval, t) - }))} - onChange={interval => time.setAutoRefresh(interval)} - /> - )} - <Button onClick={() => void refresh()}>{t('common.refresh')}</Button> - </div> - } - /> - ); -} - -function autoRefreshLabel(interval: number, t: TFunction) { - if (interval === 0) return t('shell.time.autoRefreshOff'); - return t('shell.time.autoRefreshSeconds', { seconds: interval / 1_000 }); -} - function ExploreSignalNavigation({ query, selectSignal, 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 2e6faa1573..2593b2b6e3 100644 --- a/web-app/src/features/explore/components/log-result.module.css +++ b/web-app/src/features/explore/components/log-result.module.css @@ -48,16 +48,20 @@ } .statistics { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr); - gap: 12px; + display: flex; + min-width: 0; + flex-direction: column; margin-bottom: 12px; } .statistics > section { - padding: 12px; - border: 1px solid var(--ant-color-border-secondary); - border-radius: var(--ant-border-radius); + min-width: 0; + padding: 12px 0; + border-bottom: 1px solid var(--hb-border-subtle); +} + +.statistics > .overview { + padding-top: 8px; } .statistics h3 { @@ -85,19 +89,15 @@ .overviewStats dd { margin: 2px 0 0; font-weight: 600; + font-variant-numeric: tabular-nums; } .trendChart { width: 100%; - height: 220px; min-width: 0; } @media (width <= 900px) { - .statistics { - grid-template-columns: 1fr; - } - .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 5ef84a1642..e5d32d44f3 100644 --- a/web-app/src/features/explore/components/log-result.test.tsx +++ b/web-app/src/features/explore/components/log-result.test.tsx @@ -22,6 +22,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { i18n, initializeI18n, loadLocale } from '@/core/i18n/i18n'; +import { ExploreLogStatistics } from './explore-log-statistics'; import { LogResult } from './log-result'; describe('LogResult', () => { @@ -168,8 +169,7 @@ describe('LogResult', () => { it('renders non-empty hourly evidence as an accessible time-series chart instead of row-by-row history', () => { render( <I18nextProvider i18n={i18n}> - <LogResult - data={{ content: [], totalElements: 0, totalPages: 0, number: 0, size: 20 }} + <ExploreLogStatistics statistics={{ overview: { kind: 'ready', @@ -188,14 +188,17 @@ describe('LogResult', () => { data: { hourlyStats: { '2026-08-06 10:00': 4, '2026-08-06 11:00': 8 } } } }} - query={{ signal: 'logs', timeRange: 'last-30m' }} + timeWindow={{ from: 1_754_467_200_000, to: 1_754_474_400_000 }} + runtimeIdentity="logs-trend:revision-1" t={i18n.t} - navigate={vi.fn()} /> </I18nextProvider> ); const trend = screen.getByRole('region', { name: i18n.t('exploreLog.trend') }); + const runtime = trend.querySelector('[data-visualization-runtime="perses"]'); + expect(runtime).toHaveAttribute('data-variant', 'compact'); + expect(runtime?.firstElementChild).not.toHaveAttribute('style'); expect(within(trend).getByRole('img', { name: i18n.t('exploreLog.trend') })).toBeInTheDocument(); expect(within(trend).queryByRole('list')).not.toBeInTheDocument(); }); diff --git a/web-app/src/features/explore/components/signal-result-frame.module.css b/web-app/src/features/explore/components/signal-result-frame.module.css index d7bbafc3a4..d620c764d2 100644 --- a/web-app/src/features/explore/components/signal-result-frame.module.css +++ b/web-app/src/features/explore/components/signal-result-frame.module.css @@ -47,6 +47,10 @@ font-size: 12px; } +.identity span { + font-variant-numeric: tabular-nums; +} + .meta { display: flex; gap: 22px; diff --git a/web-app/src/features/explore/pages/explore-focused-investigation.test.tsx b/web-app/src/features/explore/pages/explore-focused-investigation.test.tsx index 24e36cff68..cb8b5f90e4 100644 --- a/web-app/src/features/explore/pages/explore-focused-investigation.test.tsx +++ b/web-app/src/features/explore/pages/explore-focused-investigation.test.tsx @@ -20,6 +20,7 @@ vi.mock('../controller/use-log-investigation-controller', () => ({ vi.mock('../components/explore-trace-investigation-view', () => ({ ExploreTraceInvestigationView: (props: { onBack: () => void; + onRefresh: () => void; onSelectSpan: (spanId: string) => void; onOpenLogs: () => void; onOpenMetrics?: (() => void) | undefined; @@ -27,6 +28,7 @@ vi.mock('../components/explore-trace-investigation-view', () => ({ }) => ( <div> <button onClick={props.onBack}>trace-back</button> + <button onClick={props.onRefresh}>trace-refresh</button> <button onClick={() => props.onSelectSpan('fedcba9876543210')}>select-span</button> <button onClick={props.onOpenLogs}>trace-logs</button> {props.onOpenMetrics ? <button onClick={props.onOpenMetrics}>trace-metrics</button> : null} @@ -37,11 +39,13 @@ vi.mock('../components/explore-trace-investigation-view', () => ({ vi.mock('../components/explore-log-investigation-view', () => ({ ExploreLogInvestigationView: (props: { onBack: () => void; + onRefresh: () => void; onFocusTrace: () => void; onOpenTopology?: (() => void) | undefined; }) => ( <div> <button onClick={props.onBack}>log-back</button> + <button onClick={props.onRefresh}>log-refresh</button> <button onClick={props.onFocusTrace}>log-trace</button> {props.onOpenTopology ? <button onClick={props.onOpenTopology}>log-topology</button> : null} </div> @@ -60,7 +64,8 @@ describe('Explore focused investigation page wiring', () => { }); it('uses route builders for Trace actions and returns without focused identities', () => { - controllers.trace.mockReturnValue({ state: traceReady(), refetch: vi.fn().mockResolvedValue(undefined) }); + const refetch = vi.fn().mockResolvedValue(undefined); + controllers.trace.mockReturnValue({ state: traceReady(), refetch }); const openPath = vi.fn(); renderSubject( <ExploreFocusedTracePage @@ -96,10 +101,13 @@ describe('Explore focused investigation page wiring', () => { fireEvent.click(screen.getByText('trace-back')); expect(pathParams(lastPath(openPath))).not.toHaveProperty('traceId'); expect(pathParams(lastPath(openPath))).not.toHaveProperty('spanId'); + fireEvent.click(screen.getByText('trace-refresh')); + expect(refetch).toHaveBeenCalledOnce(); }); it('hands a selected Log to its exact Trace and clears the Log anchor on return', () => { - controllers.log.mockReturnValue({ state: logReady(), refetch: vi.fn().mockResolvedValue(undefined) }); + const refetch = vi.fn().mockResolvedValue(undefined); + controllers.log.mockReturnValue({ state: logReady(), refetch }); const openPath = vi.fn(); renderSubject( <ExploreFocusedLogPage query={logQuery()} t={i18n.t} updateQuery={vi.fn()} time={undefined} openPath={openPath} /> @@ -122,6 +130,8 @@ describe('Explore focused investigation page wiring', () => { }); fireEvent.click(screen.getByText('log-back')); expect(pathParams(lastPath(openPath))).not.toHaveProperty('logRecordUid'); + fireEvent.click(screen.getByText('log-refresh')); + expect(refetch).toHaveBeenCalledOnce(); }); it('does not offer Log Topology navigation from a trace fallback identity', () => { diff --git a/web-app/src/features/explore/pages/explore-focused-investigation.tsx b/web-app/src/features/explore/pages/explore-focused-investigation.tsx index 72864126e9..d8cca71ed1 100644 --- a/web-app/src/features/explore/pages/explore-focused-investigation.tsx +++ b/web-app/src/features/explore/pages/explore-focused-investigation.tsx @@ -39,19 +39,14 @@ export function ExploreFocusedTracePage({ query, ...common }: CommonProps & { qu const topologyPath = state.kind === 'ready' ? buildTraceInvestigationTopologyPath(state.snapshot) : undefined; return ( <> - <ExploreWorkbench - query={query} - t={common.t} - updateQuery={common.updateQuery} - refresh={investigation.refetch} - time={common.time} - /> + <ExploreWorkbench query={query} t={common.t} updateQuery={common.updateQuery} /> <OperationalResultRegion> {state.kind === 'ready' ? ( <ExploreTraceInvestigationView state={state} evidenceCurrent onBack={() => common.openPath(backToResultsPath(query))} + onRefresh={() => void investigation.refetch()} onSelectSpan={spanId => common.openPath(buildExplorePath(mergeExploreQuery(query, { spanId })))} onOpenLogs={() => common.openPath( @@ -79,19 +74,14 @@ export function ExploreFocusedLogPage({ query, ...common }: CommonProps & { quer const topologyPath = state.kind === 'ready' ? buildLogInvestigationTopologyPath(state.snapshot) : undefined; return ( <> - <ExploreWorkbench - query={query} - t={common.t} - updateQuery={common.updateQuery} - refresh={investigation.refetch} - time={common.time} - /> + <ExploreWorkbench query={query} t={common.t} updateQuery={common.updateQuery} /> <OperationalResultRegion> {state.kind === 'ready' ? ( <ExploreLogInvestigationView state={state} evidenceCurrent onBack={() => common.openPath(backToResultsPath(query))} + onRefresh={() => void investigation.refetch()} onFocusTrace={() => common.openPath(logTracePath(query, state.snapshot.selectedLog.log))} {...(metricsPath ? { onOpenMetrics: () => common.openPath(metricsPath) } : {})} {...(topologyPath ? { onOpenTopology: () => common.openPath(topologyPath) } : {})} 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 31809f8a9e..22b45ef09f 100644 --- a/web-app/src/features/explore/pages/explore-page.test.tsx +++ b/web-app/src/features/explore/pages/explore-page.test.tsx @@ -87,6 +87,33 @@ describe('ExplorePage instrumentation context boundary', () => { ); }); + it('keeps the default query surface focused and submits guided filters from a disclosure', async () => { + renderPage('/explore?signal=metrics'); + + const primaryQuery = screen.getByRole('textbox', { name: i18n.t('explore.queryLabels.metrics') }); + expect(screen.getByRole('textbox', { name: en.explore.serviceName })).not.toBeVisible(); + expect(screen.getByRole('textbox', { name: en.explore.environment })).not.toBeVisible(); + expect(screen.getByText(en.explore.advancedFilters)).toBeInTheDocument(); + + fireEvent.click(screen.getByText(i18n.t('explore.addFilters'))); + const serviceName = screen.getByRole('textbox', { name: en.explore.serviceName }); + const environment = screen.getByRole('textbox', { name: en.explore.environment }); + fireEvent.change(primaryQuery, { target: { value: 'http_request_duration_seconds' } }); + fireEvent.change(serviceName, { target: { value: 'checkout' } }); + fireEvent.change(environment, { target: { value: 'prod' } }); + fireEvent.click(querySubmitButton()); + + await waitFor(() => + expect(locationParams()).toEqual( + expect.objectContaining({ + query: 'http_request_duration_seconds', + serviceName: 'checkout', + environment: 'prod' + }) + ) + ); + }); + it('does not widen partial or reversed instrumentation scope into any signal query or SSE stream', async () => { const invalidEntries = [ '/explore?signal=metrics&intakeProfileId=primary-ingress&serviceName=checkout&serviceNamespace=commerce&start=1000&end=2000', @@ -318,7 +345,7 @@ describe('ExplorePage instrumentation context boundary', () => { cleanup(); renderPage('/explore?signal=traces'); fireEvent.click(screen.getByText(en.explore.advancedFilters)); - const attributeFilter = screen.getByPlaceholderText(en.exploreLog.attributeFilter); + const attributeFilter = screen.getByPlaceholderText(i18n.t('exploreTrace.attributeFilter')); fireEvent.change(attributeFilter, { target: { value: 'http.route=/checkout' } }); fireEvent.click(screen.getByRole('checkbox', { name: en.exploreTrace.errorOnly })); expect(locationParams()).not.toHaveProperty('errorOnly'); @@ -345,6 +372,7 @@ describe('ExplorePage instrumentation context boundary', () => { await selectOption(spanScope, en.exploreTrace.spanScopeValues.root); expect(screen.getAllByText(en.exploreTrace.spanScopeValues.root).length).toBeGreaterThan(0); expect(screen.getByRole('checkbox', { name: en.exploreTrace.hideInternal })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(i18n.t('exploreTrace.attributeFilter'))).toBeInTheDocument(); cleanup(); renderPage('/explore?signal=logs'); diff --git a/web-app/src/features/explore/pages/explore-page.tsx b/web-app/src/features/explore/pages/explore-page.tsx index 73387af29b..fdb1cb5a1f 100644 --- a/web-app/src/features/explore/pages/explore-page.tsx +++ b/web-app/src/features/explore/pages/explore-page.tsx @@ -68,13 +68,7 @@ function ExploreHistoricalWorkspace({ return ( <OperationalPage mode="workspace"> <div data-explore-workspace="true"> - <ExploreWorkbench - query={controller.query} - t={t} - updateQuery={controller.updateQuery} - refresh={controller.refresh} - time={controller.time} - /> + <ExploreWorkbench query={controller.query} t={t} updateQuery={controller.updateQuery} /> <section role="tabpanel" id={`explore-panel-${controller.query.signal}`} @@ -84,6 +78,9 @@ function ExploreHistoricalWorkspace({ query={controller.query} t={t} updateQuery={controller.updateManualQuery} + updateScope={controller.updateQuery} + refresh={controller.refresh} + time={controller.time} submission={controller.submission} /> <OperationalResultRegion> 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 c97de6d8e4..9ee9c6f703 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 @@ -32,19 +32,30 @@ const persesKeys = [ 'investigateTrace', 'notRecorded' ] as const; +const queryLabelKeys = ['metrics', 'logs', 'traces'] as const; describe('Explore result state locale contract', () => { it('keeps every honest result state available in all runtime locales', () => { for (const locale of [en, ja, pt, zhCn, zhTw] as LocaleRoot[]) { for (const key of stateKeys) expect(locale.explore.states[key]).toEqual(expect.any(String)); for (const key of persesKeys) expect(locale.explore.perses[key]).toEqual(expect.any(String)); + expect(locale.explore.addFilters).toEqual(expect.any(String)); + expect(locale.explore.queryToolbar).toEqual(expect.any(String)); + expect(locale.explore.filterContext).toEqual(expect.any(String)); + expect(locale.exploreTrace.attributeFilter).toEqual(expect.any(String)); + for (const key of queryLabelKeys) expect(locale.explore.queryLabels[key]).toEqual(expect.any(String)); } }); }); type LocaleRoot = { + exploreTrace: { attributeFilter: string }; explore: { states: Record<(typeof stateKeys)[number], string>; perses: Record<(typeof persesKeys)[number], string>; + addFilters: string; + queryToolbar: string; + filterContext: string; + queryLabels: Record<(typeof queryLabelKeys)[number], string>; }; }; diff --git a/web-app/src/layout/basic/basic-layout.test.tsx b/web-app/src/layout/basic/basic-layout.test.tsx index 81be8217a7..2f409a5679 100644 --- a/web-app/src/layout/basic/basic-layout.test.tsx +++ b/web-app/src/layout/basic/basic-layout.test.tsx @@ -17,9 +17,9 @@ import { Refine } from '@refinedev/core'; import routerProvider from '@refinedev/react-router'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Link, MemoryRouter, Route, Routes } from 'react-router-dom'; import { AppProviders } from '@/app/providers'; import { refineResources, shellAccessControlProvider } from '@/app/refine/refine-resource-registry'; @@ -53,7 +53,10 @@ describe('BasicLayout shell', () => { await loadLocale('en-US'); }); - afterEach(cleanup); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); it('renders the official logo as one constrained accessible brand identity', () => { renderLayout(); @@ -124,8 +127,65 @@ describe('BasicLayout shell', () => { fireEvent.click(screen.getByRole('button', { name: 'Collapse navigation' })); expect(screen.getByTestId('shell-navigation')).toHaveAttribute('data-collapsed', 'true'); }); + + it('uses the real collapsed navigation on a narrow Explore route without overwriting manual state', () => { + const viewport = controlledExploreViewport(true); + vi.stubGlobal('matchMedia', viewport.matchMedia); + renderLayout('/explore', <RouteLinks />); + + expect(screen.getByTestId('shell-navigation')).toHaveAttribute('data-collapsed', 'true'); + expect(screen.queryByRole('button', { name: 'Expand navigation' })).not.toBeInTheDocument(); + act(() => viewport.setNarrow(false)); + expect(screen.getByTestId('shell-navigation')).toHaveAttribute('data-collapsed', 'false'); + expect(screen.getByRole('button', { name: 'Collapse navigation' })).toBeInTheDocument(); + act(() => viewport.setNarrow(true)); + fireEvent.click(screen.getByRole('link', { name: 'Leave Explore test route' })); + expect(screen.getByTestId('shell-navigation')).toHaveAttribute('data-collapsed', 'false'); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse navigation' })); + fireEvent.click(screen.getByRole('link', { name: 'Open Explore test route' })); + act(() => viewport.setNarrow(false)); + fireEvent.click(screen.getByRole('link', { name: 'Leave Explore test route' })); + expect(screen.getByTestId('shell-navigation')).toHaveAttribute('data-collapsed', 'true'); + }); }); +function RouteLinks() { + return ( + <> + <Link to="/explore">Open Explore test route</Link> + <Link to="/alerts">Leave Explore test route</Link> + </> + ); +} + +function controlledExploreViewport(initialNarrow: boolean) { + let narrow = initialNarrow; + const listeners = new Set<(event: MediaQueryListEvent) => void>(); + const exploreMedia = { + get matches() { + return narrow; + }, + media: '(max-width: 768px)', + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener), + dispatchEvent: () => false + } as MediaQueryList; + return { + matchMedia: vi.fn((query: string) => + query === exploreMedia.media ? exploreMedia : { ...exploreMedia, matches: false, media: query } + ), + setNarrow(value: boolean) { + narrow = value; + const event = { matches: value, media: exploreMedia.media } as MediaQueryListEvent; + listeners.forEach(listener => listener(event)); + } + }; +} + function renderLayout(path = '/alerts', routeElement: React.ReactNode = <div>Route content</div>) { return render( <AppProviders> @@ -158,6 +218,7 @@ function renderLayout(path = '/alerts', routeElement: React.ReactNode = <div>Rou <Route path="/monitors/:monitorId" element={routeElement} /> <Route path="/monitors/:monitorId/edit" element={routeElement} /> <Route path="/alerts" element={routeElement} /> + <Route path="/explore" element={routeElement} /> <Route path="/observability/integration" element={routeElement} /> <Route path="/settings/notifications/templates" element={routeElement} /> </Route> diff --git a/web-app/src/layout/shell/hertzbeat-shell.tsx b/web-app/src/layout/shell/hertzbeat-shell.tsx index d969a29c7e..26eafeda11 100644 --- a/web-app/src/layout/shell/hertzbeat-shell.tsx +++ b/web-app/src/layout/shell/hertzbeat-shell.tsx @@ -5,7 +5,7 @@ * The ASF licenses this file to You under the Apache License, Version 2.0. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useResourceParams } from '@refinedev/core'; import { Outlet, useLocation } from 'react-router-dom'; @@ -19,6 +19,8 @@ import { ShellNavigation } from './shell-navigation'; import { readShellResourceMeta, resolveShellTimePolicy } from './shell-navigation-model'; import styles from './hertzbeat-shell.module.css'; +const NARROW_EXPLORE_QUERY = '(max-width: 768px)'; + export function HertzBeatShell() { return ( <QueryContextProvider> @@ -30,8 +32,10 @@ export function HertzBeatShell() { } function RouteOwnedShell() { - const [collapsed, setCollapsed] = useState(false); + const [manualCollapsed, setManualCollapsed] = useState(false); const location = useLocation(); + const routeCollapsed = useNarrowExploreNavigation(location.pathname); + const collapsed = manualCollapsed || routeCollapsed; const { action, resource } = useResourceParams(); const policy: TimeOwnership = resolveShellTimePolicy(readShellResourceMeta(resource?.meta?.shell), action); return ( @@ -44,7 +48,11 @@ function RouteOwnedShell() { <div className={`${styles.shell} ${collapsed ? styles.shellCollapsed : ''}`}> <ShellHeader /> <div className={styles.shellBody}> - <ShellNavigation collapsed={collapsed} onCollapsedChange={setCollapsed} /> + <ShellNavigation + collapsed={collapsed} + collapseLocked={routeCollapsed} + onCollapsedChange={setManualCollapsed} + /> <main className={styles.content}> <Outlet /> </main> @@ -54,3 +62,20 @@ function RouteOwnedShell() { </RouteTimeProvider> ); } + +function useNarrowExploreNavigation(pathname: string) { + const [narrow, setNarrow] = useState(() => matchesNarrowExplore()); + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const media = window.matchMedia(NARROW_EXPLORE_QUERY); + const update = () => setNarrow(media.matches); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + return pathname === applicationRoutePaths.explore && narrow; +} + +function matchesNarrowExplore() { + return typeof window.matchMedia === 'function' && window.matchMedia(NARROW_EXPLORE_QUERY).matches; +} diff --git a/web-app/src/layout/shell/shell-navigation.tsx b/web-app/src/layout/shell/shell-navigation.tsx index 89f44ae37a..f4f58627b0 100644 --- a/web-app/src/layout/shell/shell-navigation.tsx +++ b/web-app/src/layout/shell/shell-navigation.tsx @@ -20,11 +20,12 @@ import { ShellNavigationLink } from './shell-navigation-link'; type ShellNavigationProps = { collapsed: boolean; + collapseLocked?: boolean | undefined; onCollapsedChange: (collapsed: boolean) => void; }; const noSessionRoles: readonly string[] = []; -export function ShellNavigation({ collapsed, onCollapsedChange }: ShellNavigationProps) { +export function ShellNavigation({ collapsed, collapseLocked = false, onCollapsedChange }: ShellNavigationProps) { const { t } = useTranslation(); const { session } = useSession(); const location = useLocation(); @@ -73,14 +74,16 @@ export function ShellNavigation({ collapsed, onCollapsedChange }: ShellNavigatio onToggle={toggle} /> </nav> - <button - className={styles.collapseButton} - type="button" - aria-label={t(collapsed ? 'shell.navigation.expand' : 'shell.navigation.collapse')} - onClick={() => onCollapsedChange(!collapsed)} - > - {collapsed ? <RightOutlined /> : <LeftOutlined />} - </button> + {collapseLocked ? null : ( + <button + className={styles.collapseButton} + type="button" + aria-label={t(collapsed ? 'shell.navigation.expand' : 'shell.navigation.collapse')} + onClick={() => onCollapsedChange(!collapsed)} + > + {collapsed ? <RightOutlined /> : <LeftOutlined />} + </button> + )} </aside> ); } 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 96d98b8ce2..c1daa31c94 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 @@ -31,6 +31,7 @@ export type SharedPrimitiveProps = { className?: string | undefined; runtimeIdentity?: string | undefined; interactions?: HertzBeatPersesTableInteraction[] | undefined; + variant?: 'default' | 'compact' | undefined; }; export type PrimitiveState<T> = @@ -80,7 +81,7 @@ export function HertzBeatPrimitiveFrame<T>({ state, toRuntimeProps, ...props }: } const runtimeRole = runtimeProps.kind === 'metric-time-series' ? 'img' : 'region'; return ( - <div className={className} data-visualization-runtime="perses"> + <div className={className} data-visualization-runtime="perses" data-variant={props.variant ?? 'default'}> <PersesPrimitiveErrorBoundary ariaLabel={props.ariaLabel} fallback={props.messages.runtimeError} @@ -105,7 +106,12 @@ export function HertzBeatPrimitiveFrame<T>({ state, toRuntimeProps, ...props }: function PrimitiveStateFrame(props: SharedPrimitiveProps & { alert?: boolean; children: ReactNode }) { const className = [styles.primitive, props.className].filter(Boolean).join(' '); return ( - <div className={className} role={props.alert ? 'alert' : 'status'} aria-label={props.ariaLabel}> + <div + className={className} + role={props.alert ? 'alert' : 'status'} + aria-label={props.ariaLabel} + data-variant={props.variant ?? 'default'} + > <div className={styles.state}>{props.children}</div> </div> ); 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 7353c730bd..47202c6c34 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 @@ -5,13 +5,47 @@ width: 100%; min-width: 0; min-height: 388px; - grid-template-rows: 360px 28px; + grid-template-rows: 360px auto auto; } .runtime { width: 100%; + min-width: 0; height: 360px; min-height: 360px; + overflow: hidden; + border-top: 1px solid var(--hb-border-subtle); +} + +.runtime:has(> :global([data-perses-primitive='logs-table'])) { + overflow-x: auto; + overflow-y: hidden; +} + +.runtime > :global([data-perses-primitive='logs-table']) { + min-width: 640px; +} + +.runtime :global(.MuiCard-root), +.runtime :global(.MuiPaper-root) { + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +.runtime :global(.MuiTableContainer-root) { + max-width: 100%; + overflow-x: auto; + border-radius: 0 !important; + box-shadow: none !important; +} + +.runtime :global(.MuiCardContent-root > .MuiBox-root) { + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; } .state { @@ -21,6 +55,17 @@ color: var(--hb-text-secondary); } +.primitive[data-variant='compact'] { + min-height: 220px; + grid-template-rows: 220px auto auto; +} + +.primitive[data-variant='compact'] .runtime, +.primitive[data-variant='compact'] .state { + height: 220px; + min-height: 220px; +} + .completeness { display: flex; box-sizing: border-box; @@ -64,6 +109,8 @@ display: flex; flex: none; gap: 6px; + max-width: 100%; + overflow-x: auto; } .interactions button { @@ -73,6 +120,7 @@ background: var(--hb-bg-raised); color: var(--hb-text-primary); cursor: pointer; + white-space: nowrap; } .interactions button:disabled { @@ -85,4 +133,8 @@ align-items: flex-start; flex-direction: column; } + + .interactions li > div { + width: 100%; + } } 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 49f9a8a980..af5b323e13 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 @@ -20,7 +20,19 @@ vi.mock('./perses-signal-runtime', () => ({ PersesSignalRuntime: ({ kind }: { kind: string }) => { if (runtimeControl.fail) throw new Error('private runtime detail'); if (kind === 'logs-table' || kind === 'trace-table') { - return <table aria-label={`official ${kind}`} data-testid={`official-${kind}`} />; + return ( + <section className="MuiCard-root"> + <div className="MuiCardContent-root"> + <div + className="MuiBox-root" + data-testid="official-panel-inner-surface" + style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)' }} + > + <table aria-label={`official ${kind}`} data-testid={`official-${kind}`} /> + </div> + </div> + </section> + ); } if (kind === 'tracing-gantt-chart') { return <button data-testid={`official-${kind}`}>Inspect span</button>; @@ -85,7 +97,7 @@ describe('HertzBeat Perses primitives', () => { const loading = screen.getByRole('status', { name: 'Logs table' }); expect(loading).toHaveTextContent('Loading signal'); - expect(loading).toHaveStyle({ minHeight: '388px', gridTemplateRows: '360px 28px' }); + expect(loading).toHaveStyle({ minHeight: '388px', gridTemplateRows: '360px auto auto' }); view.unmount(); expect(requestSignal?.aborted).toBe(true); }); @@ -201,7 +213,7 @@ describe('HertzBeat Perses primitives', () => { expect(screen.getByRole('img', { name: 'Metric time series' })).toHaveStyle({ height: '360px' }); expect(view.container.querySelector('[data-visualization-runtime="perses"]')).toHaveStyle({ minHeight: '388px', - gridTemplateRows: '360px 28px' + gridTemplateRows: '360px auto auto' }); expect(screen.getByRole('status', { name: 'Metric time series completeness' })).toHaveTextContent( 'Result completeness is unknown' @@ -219,6 +231,7 @@ describe('HertzBeat Perses primitives', () => { expect(screen.getByRole('region', { name: 'Logs table' })).toContainElement( screen.getByRole('table', { name: 'official logs-table' }) ); + expect(screen.getByTestId('official-panel-inner-surface')).toHaveStyle({ borderRadius: '0', boxShadow: 'none' }); const completeness = screen.getByRole('status', { name: 'Logs table completeness' }); expect(completeness).toHaveTextContent('Results are truncated'); expect(completeness).toHaveStyle({ height: '28px', minHeight: '28px' }); @@ -277,6 +290,9 @@ describe('HertzBeat Perses primitives', () => { fireEvent.click(screen.getByRole('button', { name: 'Investigate log checkout ready' })); expect(open).toHaveBeenCalledOnce(); expect(screen.getByRole('button', { name: 'Open trace checkout ready' })).toBeDisabled(); + expect(view.container.querySelector('[data-visualization-runtime="perses"]')).toHaveStyle({ + gridTemplateRows: '360px auto auto' + }); view.rerender( <HertzBeatLogsTableResult --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
