mirror of https://github.com/grafana/grafana.git
New Log Details: Create initial component for Log Details (#107466)
Actionlint / Lint GitHub Actions files (push) Waiting to run
Details
Backend Code Checks / Validate Backend Configs (push) Waiting to run
Details
Backend Unit Tests / Detect whether code changed (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (1/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (2/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (3/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (4/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (5/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (6/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (7/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (8/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (1/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (2/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (3/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (4/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (5/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (6/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (7/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (8/8) (push) Blocked by required conditions
Details
Backend Unit Tests / All backend unit tests complete (push) Blocked by required conditions
Details
CodeQL checks / Analyze (actions) (push) Waiting to run
Details
CodeQL checks / Analyze (go) (push) Waiting to run
Details
CodeQL checks / Analyze (javascript) (push) Waiting to run
Details
Lint Frontend / Detect whether code changed (push) Waiting to run
Details
Lint Frontend / Lint (push) Blocked by required conditions
Details
Lint Frontend / Typecheck (push) Blocked by required conditions
Details
Lint Frontend / Betterer (push) Blocked by required conditions
Details
golangci-lint / lint-go (push) Waiting to run
Details
Crowdin Upload Action / upload-sources-to-crowdin (push) Waiting to run
Details
Verify i18n / verify-i18n (push) Waiting to run
Details
End-to-end tests / Detect whether code changed (push) Waiting to run
Details
End-to-end tests / Build & Package Grafana (push) Blocked by required conditions
Details
End-to-end tests / Build E2E test runner (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/dashboards-suite, dashboards-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/panels-suite, panels-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/smoke-tests-suite, smoke-tests-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/various-suite, various-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/dashboards-suite, dashboards-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/panels-suite, panels-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/smoke-tests-suite, smoke-tests-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/various-suite, various-suite) (push) Blocked by required conditions
Details
End-to-end tests / A11y test (push) Blocked by required conditions
Details
End-to-end tests / All E2E tests complete (push) Blocked by required conditions
Details
Frontend tests / Detect whether code changed (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (1) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (2) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (3) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (4) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (5) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (6) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (7) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (8) (push) Blocked by required conditions
Details
Frontend tests / All frontend unit tests complete (push) Blocked by required conditions
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / All backend integration tests complete (push) Blocked by required conditions
Details
publish-kinds-next / main (push) Waiting to run
Details
Reject GitHub secrets / reject-gh-secrets (push) Waiting to run
Details
Build Release Packages / setup (push) Waiting to run
Details
Build Release Packages / Dispatch grafana-enterprise build (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/amd64, darwin-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/arm64, darwin-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/amd64,deb:grafana:linux/amd64,rpm:grafana:linux/amd64,docker:grafana:linux/amd64,docker:grafana:linux/amd64:ubuntu,npm:grafana,storybook, linux-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v6,deb:grafana:linux/arm/v6, linux-armv6) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v7,deb:grafana:linux/arm/v7,docker:grafana:linux/arm/v7,docker:grafana:linux/arm/v7:ubuntu, linux-armv7) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm64,deb:grafana:linux/arm64,rpm:grafana:linux/arm64,docker:grafana:linux/arm64,docker:grafana:linux/arm64:ubuntu, linux-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/s390x,deb:grafana:linux/s390x,rpm:grafana:linux/s390x,docker:grafana:linux/s390x,docker:grafana:linux/s390x:ubuntu, linux-s390x) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/amd64,zip:grafana:windows/amd64,msi:grafana:windows/amd64, windows-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/arm64,zip:grafana:windows/arm64, windows-arm64) (push) Blocked by required conditions
Details
Build Release Packages / Upload artifacts (push) Blocked by required conditions
Details
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Waiting to run
Details
Shellcheck / Shellcheck scripts (push) Waiting to run
Details
Verify Storybook / Verify Storybook (push) Waiting to run
Details
Swagger generated code / Verify committed API specs match (push) Waiting to run
Details
Dispatch sync to mirror / dispatch-job (push) Waiting to run
Details
Trivy Scan / trivy-scan (push) Waiting to run
Details
Actionlint / Lint GitHub Actions files (push) Waiting to run
Details
Backend Code Checks / Validate Backend Configs (push) Waiting to run
Details
Backend Unit Tests / Detect whether code changed (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (1/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (2/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (3/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (4/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (5/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (6/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (7/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (8/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (1/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (2/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (3/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (4/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (5/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (6/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (7/8) (push) Blocked by required conditions
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (8/8) (push) Blocked by required conditions
Details
Backend Unit Tests / All backend unit tests complete (push) Blocked by required conditions
Details
CodeQL checks / Analyze (actions) (push) Waiting to run
Details
CodeQL checks / Analyze (go) (push) Waiting to run
Details
CodeQL checks / Analyze (javascript) (push) Waiting to run
Details
Lint Frontend / Detect whether code changed (push) Waiting to run
Details
Lint Frontend / Lint (push) Blocked by required conditions
Details
Lint Frontend / Typecheck (push) Blocked by required conditions
Details
Lint Frontend / Betterer (push) Blocked by required conditions
Details
golangci-lint / lint-go (push) Waiting to run
Details
Crowdin Upload Action / upload-sources-to-crowdin (push) Waiting to run
Details
Verify i18n / verify-i18n (push) Waiting to run
Details
End-to-end tests / Detect whether code changed (push) Waiting to run
Details
End-to-end tests / Build & Package Grafana (push) Blocked by required conditions
Details
End-to-end tests / Build E2E test runner (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/dashboards-suite, dashboards-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/panels-suite, panels-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/smoke-tests-suite, smoke-tests-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/various-suite, various-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/dashboards-suite, dashboards-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/panels-suite, panels-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/smoke-tests-suite, smoke-tests-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/various-suite, various-suite) (push) Blocked by required conditions
Details
End-to-end tests / A11y test (push) Blocked by required conditions
Details
End-to-end tests / All E2E tests complete (push) Blocked by required conditions
Details
Frontend tests / Detect whether code changed (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (1) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (2) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (3) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (4) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (5) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (6) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (7) (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (8) (push) Blocked by required conditions
Details
Frontend tests / All frontend unit tests complete (push) Blocked by required conditions
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / All backend integration tests complete (push) Blocked by required conditions
Details
publish-kinds-next / main (push) Waiting to run
Details
Reject GitHub secrets / reject-gh-secrets (push) Waiting to run
Details
Build Release Packages / setup (push) Waiting to run
Details
Build Release Packages / Dispatch grafana-enterprise build (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/amd64, darwin-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/arm64, darwin-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/amd64,deb:grafana:linux/amd64,rpm:grafana:linux/amd64,docker:grafana:linux/amd64,docker:grafana:linux/amd64:ubuntu,npm:grafana,storybook, linux-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v6,deb:grafana:linux/arm/v6, linux-armv6) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v7,deb:grafana:linux/arm/v7,docker:grafana:linux/arm/v7,docker:grafana:linux/arm/v7:ubuntu, linux-armv7) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm64,deb:grafana:linux/arm64,rpm:grafana:linux/arm64,docker:grafana:linux/arm64,docker:grafana:linux/arm64:ubuntu, linux-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/s390x,deb:grafana:linux/s390x,rpm:grafana:linux/s390x,docker:grafana:linux/s390x,docker:grafana:linux/s390x:ubuntu, linux-s390x) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/amd64,zip:grafana:windows/amd64,msi:grafana:windows/amd64, windows-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/arm64,zip:grafana:windows/arm64, windows-arm64) (push) Blocked by required conditions
Details
Build Release Packages / Upload artifacts (push) Blocked by required conditions
Details
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Waiting to run
Details
Shellcheck / Shellcheck scripts (push) Waiting to run
Details
Verify Storybook / Verify Storybook (push) Waiting to run
Details
Swagger generated code / Verify committed API specs match (push) Waiting to run
Details
Dispatch sync to mirror / dispatch-job (push) Waiting to run
Details
Trivy Scan / trivy-scan (push) Waiting to run
Details
* Log Details: fork and refactor as functional * LogDetailsBody: refactor styles * LogDetails: decouple from old panel * LogDetails: extract and centralize styles * LogDetailsRow: refactor as functional * Fix unused * Fix wrong label * LogDetails: create new component * LogLineDetails: process links and add sample sections * LogLineDetails: create and use LogLineDetailsFields * LogLineDetails: group labels by type * LogLineDetails: render all fields * Removed unused components * Fix imports * LogLineDetails: fix label * LogLineDetailsFields: fix stats * LogLinedetailsFields: add base styles * LogLineDetails: store open state * getLabelTypeFromRow: internationalize and add plural support * LogLineDetails: get plural field types * LogLineDetails: sticky header * LogLineDetails: introduce log details header * LogLineDetails: extract into submodules * LogDetails: add more header options and store collapsed state * LogDetails: add scroll for log line * LogLineDetailsHeader: add log line toggle button * LogLineDetailsFieldS: improve sizes * LogLineDetails: add search handler * LogLineDetailsFields: implement search * LogLineDetailsFields: switch to fixed key width * LogLineDetailsFields: refactor fields display * Link: remove tooltip * Fix translations * Revert "Link: remove tooltip" This reverts commit cd927302a7889b9430008ae3b81ace0aed343f5f. * LogLineDetailsFields: switch to css grid * Remap new translations * LogLineDetails: implement disable actions * LogLineDetailsFields: migrate links to css grid * LogLineDetailsFields: migrate stats to css grid * LogLabelStats: make functional * LogLineDetailsHeader: refactor listener * LogLineDetailsFields: decrease column minwidth * Reuse current panel unit tests * Translations * Test search * Update public/app/features/logs/components/panel/LogLineDetails.test.tsx * LogLineDetailsHeader: fix zIndex * Create LogLineDetailsDisplayedFields * Revert "Create LogLineDetailsDisplayedFields" This reverts commit 57d460d966483c3126738994e2705b6578aac120. * LogList: recover unwrapped horizontal scroll * LogLineDetails: apply styles suggestion * LogLineDetailsComponent: fix group option name * LogLineDetailsHeader: tweak styles * LogLineDetailsComponent: remove margin of last collapsable
This commit is contained in:
parent
de370fb311
commit
41014f29ed
|
@ -43,7 +43,6 @@ export interface Props extends Themeable2 {
|
||||||
|
|
||||||
onPinLine?: (row: LogRowModel) => void;
|
onPinLine?: (row: LogRowModel) => void;
|
||||||
pinLineButtonTooltipTitle?: PopoverContent;
|
pinLineButtonTooltipTitle?: PopoverContent;
|
||||||
mode?: 'inline' | 'sidebar';
|
|
||||||
links?: Record<string, LinkModel[]>;
|
links?: Record<string, LinkModel[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,7 +50,7 @@ interface LinkModelWithIcon extends LinkModel {
|
||||||
icon?: IconName;
|
icon?: IconName;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useAttributesExtensionLinks = (row: LogRowModel) => {
|
export const useAttributesExtensionLinks = (row: LogRowModel) => {
|
||||||
// Stable context for useMemo inside usePluginLinks
|
// Stable context for useMemo inside usePluginLinks
|
||||||
const context: PluginExtensionResourceAttributesContext = useMemo(() => {
|
const context: PluginExtensionResourceAttributesContext = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
@ -121,7 +120,6 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||||
onPinLine,
|
onPinLine,
|
||||||
styles,
|
styles,
|
||||||
pinLineButtonTooltipTitle,
|
pinLineButtonTooltipTitle,
|
||||||
mode = 'inline',
|
|
||||||
links,
|
links,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const levelStyles = getLogLevelStyles(theme, row.logLevel);
|
const levelStyles = getLogLevelStyles(theme, row.logLevel);
|
||||||
|
@ -152,14 +150,9 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||||
return (
|
return (
|
||||||
<tr className={cx(className, styles.logDetails)}>
|
<tr className={cx(className, styles.logDetails)}>
|
||||||
{showDuplicates && <td />}
|
{showDuplicates && <td />}
|
||||||
{mode === 'inline' && (
|
<td className={levelClassName} aria-label={t('logs.un-themed-log-details.aria-label-log-level', 'Log level')} />
|
||||||
<td
|
|
||||||
className={levelClassName}
|
|
||||||
aria-label={t('logs.un-themed-log-details.aria-label-log-level', 'Log level')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<td colSpan={4}>
|
<td colSpan={4}>
|
||||||
<div className={mode === 'inline' ? styles.logDetailsContainer : styles.logDetailsSidebarContainer}>
|
<div className={styles.logDetailsContainer}>
|
||||||
<table className={styles.logDetailsTable}>
|
<table className={styles.logDetailsTable}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{displayedFields && displayedFields.length > 0 && (
|
{displayedFields && displayedFields.length > 0 && (
|
||||||
|
@ -168,7 +161,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||||
<td
|
<td
|
||||||
colSpan={100}
|
colSpan={100}
|
||||||
className={styles.logDetailsHeading}
|
className={styles.logDetailsHeading}
|
||||||
aria-label={t('logs.un-themed-log-details.aria-label-fields', 'Fields')}
|
aria-label={t('logs.un-themed-log-details.aria-label-line', 'Log line')}
|
||||||
>
|
>
|
||||||
<Trans i18nKey="logs.log-details.log-line">Log line</Trans>
|
<Trans i18nKey="logs.log-details.log-line">Log line</Trans>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { PureComponent } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data';
|
import { LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { stylesFactory, withTheme2, Themeable2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { LogLabelStatsRow } from './LogLabelStatsRow';
|
import { LogLabelStatsRow } from './LogLabelStatsRow';
|
||||||
|
|
||||||
const STATS_ROW_LIMIT = 5;
|
const STATS_ROW_LIMIT = 5;
|
||||||
|
|
||||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
logsStats: css({
|
logsStats: css({
|
||||||
label: 'logs-stats',
|
label: 'logs-stats',
|
||||||
|
@ -42,9 +42,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||||
padding: '5px 0px',
|
padding: '5px 0px',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
interface Props extends Themeable2 {
|
interface Props {
|
||||||
|
className?: string;
|
||||||
stats: LogLabelStatsModel[];
|
stats: LogLabelStatsModel[];
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -52,10 +53,9 @@ interface Props extends Themeable2 {
|
||||||
isLabel?: boolean;
|
isLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnThemedLogLabelStats extends PureComponent<Props> {
|
export const LogLabelStats = ({ className, label, rowCount, stats, value, isLabel }: Props) => {
|
||||||
render() {
|
const style = useStyles2(getStyles);
|
||||||
const { label, rowCount, stats, value, theme, isLabel } = this.props;
|
const rows = useMemo(() => {
|
||||||
const style = getStyles(theme);
|
|
||||||
const topRows = stats.slice(0, STATS_ROW_LIMIT);
|
const topRows = stats.slice(0, STATS_ROW_LIMIT);
|
||||||
let activeRow = topRows.find((row) => row.value === value);
|
let activeRow = topRows.find((row) => row.value === value);
|
||||||
let otherRows = stats.slice(STATS_ROW_LIMIT);
|
let otherRows = stats.slice(STATS_ROW_LIMIT);
|
||||||
|
@ -66,45 +66,45 @@ class UnThemedLogLabelStats extends PureComponent<Props> {
|
||||||
activeRow = otherRows.find((row) => row.value === value);
|
activeRow = otherRows.find((row) => row.value === value);
|
||||||
otherRows = otherRows.filter((row) => row.value !== value);
|
otherRows = otherRows.filter((row) => row.value !== value);
|
||||||
}
|
}
|
||||||
|
return { topRows, otherRows, insertActiveRow, activeRow };
|
||||||
|
}, [stats, value]);
|
||||||
|
|
||||||
const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
|
const otherCount = useMemo(() => rows.otherRows.reduce((sum, row) => sum + row.count, 0), [rows.otherRows]);
|
||||||
const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
|
const topCount = useMemo(() => rows.topRows.reduce((sum, row) => sum + row.count, 0), [rows.topRows]);
|
||||||
const total = topCount + otherCount;
|
const total = topCount + otherCount;
|
||||||
const otherProportion = otherCount / total;
|
const otherProportion = otherCount / total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.logsStats} data-testid="logLabelStats">
|
<div className={className ?? style.logsStats} data-testid="logLabelStats">
|
||||||
<div className={style.logsStatsHeader}>
|
<div className={style.logsStatsHeader}>
|
||||||
<div className={style.logsStatsTitle}>
|
<div className={style.logsStatsTitle}>
|
||||||
{isLabel
|
{isLabel
|
||||||
? t(
|
? t(
|
||||||
'logs.un-themed-log-label-stats.label-log-stats',
|
'logs.un-themed-log-label-stats.label-log-stats',
|
||||||
'{{label}}: {{total}} of {{rowCount}} rows have that label',
|
'{{label}}: {{total}} of {{rowCount}} rows have that label',
|
||||||
{
|
{
|
||||||
label,
|
label,
|
||||||
total,
|
total,
|
||||||
rowCount,
|
rowCount,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
: t(
|
: t(
|
||||||
'logs.un-themed-log-label-stats.field-log-stats',
|
'logs.un-themed-log-label-stats.field-log-stats',
|
||||||
'{{label}}: {{total}} of {{rowCount}} rows have that field'
|
'{{label}}: {{total}} of {{rowCount}} rows have that field'
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={style.logsStatsBody}>
|
|
||||||
{topRows.map((stat) => (
|
|
||||||
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
|
|
||||||
))}
|
|
||||||
{insertActiveRow && activeRow && <LogLabelStatsRow key={activeRow.value} {...activeRow} active />}
|
|
||||||
{otherCount > 0 && (
|
|
||||||
<LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className={style.logsStatsBody}>
|
||||||
}
|
{rows.topRows.map((stat) => (
|
||||||
}
|
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
|
||||||
|
))}
|
||||||
export const LogLabelStats = withTheme2(UnThemedLogLabelStats);
|
{rows.insertActiveRow && rows.activeRow && (
|
||||||
LogLabelStats.displayName = 'LogLabelStats';
|
<LogLabelStatsRow key={rows.activeRow.value} {...rows.activeRow} active />
|
||||||
|
)}
|
||||||
|
{otherCount > 0 && (
|
||||||
|
<LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -187,14 +187,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||||
margin: theme.spacing(2.5, 1, 2.5, 2),
|
margin: theme.spacing(2.5, 1, 2.5, 2),
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}),
|
}),
|
||||||
logDetailsSidebarContainer: css({
|
|
||||||
label: 'logs-row-details-table',
|
|
||||||
border: `1px solid ${theme.colors.border.medium}`,
|
|
||||||
padding: theme.spacing(0, 1, 1),
|
|
||||||
borderRadius: theme.shape.radius.default,
|
|
||||||
margin: theme.spacing(0, 1, 0, 1),
|
|
||||||
cursor: 'default',
|
|
||||||
}),
|
|
||||||
logDetailsTable: css({
|
logDetailsTable: css({
|
||||||
label: 'logs-row-details-table',
|
label: 'logs-row-details-table',
|
||||||
lineHeight: '18px',
|
lineHeight: '18px',
|
||||||
|
|
|
@ -528,9 +528,6 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
|
||||||
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
|
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
paddingBottom: theme.spacing(0.75),
|
paddingBottom: theme.spacing(0.75),
|
||||||
'& .field': {
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
wrappedLogLine: css({
|
wrappedLogLine: css({
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
|
|
|
@ -0,0 +1,460 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
LogLevel,
|
||||||
|
LogRowModel,
|
||||||
|
FieldType,
|
||||||
|
createDataFrame,
|
||||||
|
DataFrameType,
|
||||||
|
PluginExtensionPoints,
|
||||||
|
toDataFrame,
|
||||||
|
LogsSortOrder,
|
||||||
|
DataFrame,
|
||||||
|
ScopedVars,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { setPluginLinksHook } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||||
|
import { createLogLine } from '../mocks/logRow';
|
||||||
|
|
||||||
|
import { LogLineDetails, Props } from './LogLineDetails';
|
||||||
|
import { LogListContext, LogListContextData } from './LogListContext';
|
||||||
|
import { defaultValue } from './__mocks__/LogListContext';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => {
|
||||||
|
return {
|
||||||
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
jest.mock('./LogListContext');
|
||||||
|
|
||||||
|
const setup = (
|
||||||
|
propOverrides?: Partial<Props>,
|
||||||
|
rowOverrides?: Partial<LogRowModel>,
|
||||||
|
contextOverrides?: Partial<LogListContextData>
|
||||||
|
) => {
|
||||||
|
const logs = [createLogLine({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides })];
|
||||||
|
|
||||||
|
const props: Props = {
|
||||||
|
containerElement: document.createElement('div'),
|
||||||
|
logs,
|
||||||
|
onResize: jest.fn(),
|
||||||
|
...(propOverrides || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextData: LogListContextData = {
|
||||||
|
...defaultValue,
|
||||||
|
showDetails: logs,
|
||||||
|
...contextOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<LogListContext.Provider value={contextData}>
|
||||||
|
<LogLineDetails {...props} />
|
||||||
|
</LogListContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LogLineDetails', () => {
|
||||||
|
describe('when fields are present', () => {
|
||||||
|
test('should render the fields and the log line', () => {
|
||||||
|
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
|
||||||
|
expect(screen.getByText('Log line')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
test('fields should be visible by default', () => {
|
||||||
|
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
|
||||||
|
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('key2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
test('should show an option to display the log line when displayed fields are used', async () => {
|
||||||
|
const onClickShowField = jest.fn();
|
||||||
|
|
||||||
|
setup(
|
||||||
|
undefined,
|
||||||
|
{ labels: { key1: 'label1' } },
|
||||||
|
{ displayedFields: ['key1'], onClickShowField, onClickHideField: jest.fn() }
|
||||||
|
);
|
||||||
|
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Show log line')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByLabelText('Show log line'));
|
||||||
|
|
||||||
|
expect(onClickShowField).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
test('should show an active option to display the log line when displayed fields are used', async () => {
|
||||||
|
const onClickHideField = jest.fn();
|
||||||
|
|
||||||
|
setup(
|
||||||
|
undefined,
|
||||||
|
{ labels: { key1: 'label1' } },
|
||||||
|
{ displayedFields: ['key1', LOG_LINE_BODY_FIELD_NAME], onClickHideField, onClickShowField: jest.fn() }
|
||||||
|
);
|
||||||
|
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Hide log line')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByLabelText('Hide log line'));
|
||||||
|
|
||||||
|
expect(onClickHideField).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
test('should not show an option to display the log line when displayed fields are not used', () => {
|
||||||
|
setup(undefined, { labels: { key1: 'label1' } }, { displayedFields: [] });
|
||||||
|
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('Show log line')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
test('should render the filter controls when the callbacks are provided', () => {
|
||||||
|
setup(
|
||||||
|
undefined,
|
||||||
|
{ labels: { key1: 'label1' } },
|
||||||
|
{
|
||||||
|
onClickFilterLabel: () => {},
|
||||||
|
onClickFilterOutLabel: () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('Filter for value in query A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Filter out value in query A')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
describe('Toggleable filters', () => {
|
||||||
|
test('should pass the log row to Explore filter functions', async () => {
|
||||||
|
const onClickFilterLabelMock = jest.fn();
|
||||||
|
const onClickFilterOutLabelMock = jest.fn();
|
||||||
|
const isLabelFilterActiveMock = jest.fn().mockResolvedValue(true);
|
||||||
|
const log = createLogLine({
|
||||||
|
logLevel: LogLevel.error,
|
||||||
|
timeEpochMs: 1546297200000,
|
||||||
|
labels: { key1: 'label1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
setup(
|
||||||
|
{
|
||||||
|
logs: [log],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
onClickFilterLabel: onClickFilterLabelMock,
|
||||||
|
onClickFilterOutLabel: onClickFilterOutLabelMock,
|
||||||
|
isLabelFilterActive: isLabelFilterActiveMock,
|
||||||
|
showDetails: [log],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isLabelFilterActiveMock).toHaveBeenCalledWith('key1', 'label1', log.dataFrame.refId);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByLabelText('Filter for value in query A'));
|
||||||
|
expect(onClickFilterLabelMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onClickFilterLabelMock).toHaveBeenCalledWith(
|
||||||
|
'key1',
|
||||||
|
'label1',
|
||||||
|
expect.objectContaining({
|
||||||
|
fields: [
|
||||||
|
expect.objectContaining({ values: [0] }),
|
||||||
|
expect.objectContaining({ values: ['line1'] }),
|
||||||
|
expect.objectContaining({ values: [{ app: 'app01' }] }),
|
||||||
|
],
|
||||||
|
length: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByLabelText('Filter out value in query A'));
|
||||||
|
expect(onClickFilterOutLabelMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onClickFilterOutLabelMock).toHaveBeenCalledWith(
|
||||||
|
'key1',
|
||||||
|
'label1',
|
||||||
|
expect.objectContaining({
|
||||||
|
fields: [
|
||||||
|
expect.objectContaining({ values: [0] }),
|
||||||
|
expect.objectContaining({ values: ['line1'] }),
|
||||||
|
expect.objectContaining({ values: [{ app: 'app01' }] }),
|
||||||
|
],
|
||||||
|
length: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('should not render filter controls when the callbacks are not provided', () => {
|
||||||
|
setup(
|
||||||
|
undefined,
|
||||||
|
{ labels: { key1: 'label1' } },
|
||||||
|
{
|
||||||
|
onClickFilterLabel: undefined,
|
||||||
|
onClickFilterOutLabel: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText('Filter for value')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('Filter out value')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the log has no fields to display', () => {
|
||||||
|
test('should render no details available message', () => {
|
||||||
|
setup(undefined, { entry: '' });
|
||||||
|
expect(screen.getByText('No fields to display.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
test('should not render headings', () => {
|
||||||
|
setup(undefined, { entry: '' });
|
||||||
|
expect(screen.queryByText('Fields')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Links')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('should render fields from the dataframe with links', () => {
|
||||||
|
const entry = 'traceId=1234 msg="some message"';
|
||||||
|
const dataFrame = toDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] },
|
||||||
|
{ name: 'entry', values: [entry] },
|
||||||
|
// As we have traceId in message already this will shadow it.
|
||||||
|
{
|
||||||
|
name: 'traceId',
|
||||||
|
values: ['1234'],
|
||||||
|
config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] },
|
||||||
|
},
|
||||||
|
{ name: 'userId', values: ['5678'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const log = createLogLine(
|
||||||
|
{ entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 },
|
||||||
|
{
|
||||||
|
escape: false,
|
||||||
|
order: LogsSortOrder.Descending,
|
||||||
|
timeZone: 'browser',
|
||||||
|
virtualization: undefined,
|
||||||
|
wrapLogMessage: true,
|
||||||
|
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => {
|
||||||
|
if (field.config && field.config.links) {
|
||||||
|
return field.config.links.map((link) => {
|
||||||
|
return {
|
||||||
|
href: link.url.replace('${__value.text}', field.values[rowIndex]),
|
||||||
|
title: link.title,
|
||||||
|
target: '_blank',
|
||||||
|
origin: field,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setup({ logs: [log] }, undefined, { showDetails: [log] });
|
||||||
|
|
||||||
|
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Links')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('traceId')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('link title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1234')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show the correct log details fields, links and labels for DataFrameType.LogLines frames', () => {
|
||||||
|
const entry = 'test';
|
||||||
|
const dataFrame = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] },
|
||||||
|
{ name: 'body', type: FieldType.string, values: [entry] },
|
||||||
|
{
|
||||||
|
name: 'labels',
|
||||||
|
type: FieldType.other,
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
label1: 'value1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shouldNotShowFieldName',
|
||||||
|
type: FieldType.string,
|
||||||
|
values: ['shouldNotShowFieldValue'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shouldShowLinkName',
|
||||||
|
type: FieldType.string,
|
||||||
|
values: ['shouldShowLinkValue'],
|
||||||
|
config: { links: [{ title: 'link', url: 'localhost:3210/${__value.text}' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
type: DataFrameType.LogLines,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = createLogLine(
|
||||||
|
{ entry, dataFrame, entryFieldIndex: 0, rowIndex: 0, labels: { label1: 'value1' } },
|
||||||
|
{
|
||||||
|
escape: false,
|
||||||
|
order: LogsSortOrder.Descending,
|
||||||
|
timeZone: 'browser',
|
||||||
|
virtualization: undefined,
|
||||||
|
wrapLogMessage: true,
|
||||||
|
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => {
|
||||||
|
if (field.config && field.config.links) {
|
||||||
|
return field.config.links.map((link) => {
|
||||||
|
return {
|
||||||
|
href: link.url.replace('${__value.text}', field.values[rowIndex]),
|
||||||
|
title: link.title,
|
||||||
|
target: '_blank',
|
||||||
|
origin: field,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setup({ logs: [log] }, undefined, { showDetails: [log] });
|
||||||
|
|
||||||
|
expect(screen.getByText('Log line')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Links')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Don't show additional fields for DataFrameType.LogLines
|
||||||
|
expect(screen.queryByText('shouldNotShowFieldName')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('shouldNotShowFieldValue')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Show labels and links
|
||||||
|
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('shouldShowLinkName')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('shouldShowLinkValue')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load plugin links for logs view resource attributes extension point', () => {
|
||||||
|
const usePluginLinksMock = jest.fn().mockReturnValue({ links: [] });
|
||||||
|
setPluginLinksHook(usePluginLinksMock);
|
||||||
|
jest.requireMock('@grafana/runtime').usePluginLinks = usePluginLinksMock;
|
||||||
|
|
||||||
|
const rowOverrides = {
|
||||||
|
datasourceType: 'loki',
|
||||||
|
datasourceUid: 'grafanacloud-logs',
|
||||||
|
labels: { key1: 'label1', key2: 'label2' },
|
||||||
|
};
|
||||||
|
setup(undefined, rowOverrides);
|
||||||
|
|
||||||
|
expect(usePluginLinksMock).toHaveBeenCalledWith({
|
||||||
|
extensionPointId: PluginExtensionPoints.LogsViewResourceAttributes,
|
||||||
|
limitPerPlugin: 10,
|
||||||
|
context: {
|
||||||
|
datasource: {
|
||||||
|
type: 'loki',
|
||||||
|
uid: 'grafanacloud-logs',
|
||||||
|
},
|
||||||
|
attributes: { key1: ['label1'], key2: ['label2'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Label types', () => {
|
||||||
|
const entry = 'test';
|
||||||
|
const labels = {
|
||||||
|
label1: 'value1',
|
||||||
|
label2: 'value2',
|
||||||
|
label3: 'value3',
|
||||||
|
};
|
||||||
|
const dataFrame = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] },
|
||||||
|
{ name: 'body', type: FieldType.string, values: [entry] },
|
||||||
|
{ name: 'id', type: FieldType.string, values: ['1'] },
|
||||||
|
{
|
||||||
|
name: 'labels',
|
||||||
|
type: FieldType.other,
|
||||||
|
values: [labels],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'labelTypes',
|
||||||
|
type: FieldType.other,
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
label1: 'I',
|
||||||
|
label2: 'S',
|
||||||
|
label3: 'P',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
type: DataFrameType.LogLines,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
test('should show label types if they are available and supported', () => {
|
||||||
|
setup(undefined, {
|
||||||
|
entry,
|
||||||
|
dataFrame,
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
|
labels,
|
||||||
|
datasourceType: 'loki',
|
||||||
|
rowId: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show labels and links
|
||||||
|
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Indexed labels')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Parsed fields')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Structured metadata')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
test('should not show label types if they are unavailable or not supported', () => {
|
||||||
|
setup(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
entry,
|
||||||
|
dataFrame,
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
|
labels,
|
||||||
|
datasourceType: 'other datasource',
|
||||||
|
rowId: '1',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show labels and links
|
||||||
|
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should allow to search within fields', async () => {
|
||||||
|
setup(undefined, {
|
||||||
|
entry,
|
||||||
|
dataFrame,
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
|
labels,
|
||||||
|
datasourceType: 'loki',
|
||||||
|
rowId: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search field names and values');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'something else');
|
||||||
|
|
||||||
|
expect(screen.getAllByText('No results to display.')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,43 +3,22 @@ import { Resizable } from 're-resizable';
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { getDragStyles, useStyles2 } from '@grafana/ui';
|
||||||
import { getDragStyles, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
|
|
||||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
|
||||||
|
|
||||||
import { LogDetails } from '../LogDetails';
|
|
||||||
import { getLogRowStyles } from '../getLogRowStyles';
|
|
||||||
|
|
||||||
|
import { LogLineDetailsComponent } from './LogLineDetailsComponent';
|
||||||
import { useLogListContext } from './LogListContext';
|
import { useLogListContext } from './LogListContext';
|
||||||
import { LogListModel } from './processing';
|
import { LogListModel } from './processing';
|
||||||
import { LOG_LIST_MIN_WIDTH } from './virtualization';
|
import { LOG_LIST_MIN_WIDTH } from './virtualization';
|
||||||
|
|
||||||
interface Props {
|
export interface Props {
|
||||||
containerElement: HTMLDivElement;
|
containerElement: HTMLDivElement;
|
||||||
getFieldLinks?: GetFieldLinksFn;
|
logOptionsStorageKey?: string;
|
||||||
logs: LogListModel[];
|
logs: LogListModel[];
|
||||||
onResize(): void;
|
onResize(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize }: Props) => {
|
export const LogLineDetails = ({ containerElement, logOptionsStorageKey, logs, onResize }: Props) => {
|
||||||
const {
|
const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext();
|
||||||
app,
|
|
||||||
closeDetails,
|
|
||||||
detailsWidth,
|
|
||||||
displayedFields,
|
|
||||||
isLabelFilterActive,
|
|
||||||
onClickFilterLabel,
|
|
||||||
onClickFilterOutLabel,
|
|
||||||
onClickShowField,
|
|
||||||
onClickHideField,
|
|
||||||
onPinLine,
|
|
||||||
pinLineButtonTooltipTitle,
|
|
||||||
setDetailsWidth,
|
|
||||||
showDetails,
|
|
||||||
wrapLogMessage,
|
|
||||||
} = useLogListContext();
|
|
||||||
const getRows = useCallback(() => logs, [logs]);
|
|
||||||
const logRowsStyles = getLogRowStyles(useTheme2());
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const dragStyles = useStyles2(getDragStyles);
|
const dragStyles = useStyles2(getDragStyles);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
@ -53,6 +32,10 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize
|
||||||
|
|
||||||
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
|
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
|
||||||
|
|
||||||
|
if (!showDetails.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Resizable
|
<Resizable
|
||||||
onResize={handleResize}
|
onResize={handleResize}
|
||||||
|
@ -65,35 +48,7 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize
|
||||||
>
|
>
|
||||||
<div className={styles.container} ref={containerRef}>
|
<div className={styles.container} ref={containerRef}>
|
||||||
<div className={styles.scrollContainer}>
|
<div className={styles.scrollContainer}>
|
||||||
<IconButton
|
<LogLineDetailsComponent log={showDetails[0]} logOptionsStorageKey={logOptionsStorageKey} logs={logs} />
|
||||||
name="times"
|
|
||||||
className={styles.closeIcon}
|
|
||||||
aria-label={t('logs.log-details.close', 'Close log details')}
|
|
||||||
onClick={closeDetails}
|
|
||||||
/>
|
|
||||||
<table width="100%">
|
|
||||||
<tbody>
|
|
||||||
<LogDetails
|
|
||||||
getRows={getRows}
|
|
||||||
mode="sidebar"
|
|
||||||
row={showDetails[0]}
|
|
||||||
showDuplicates={false}
|
|
||||||
styles={logRowsStyles}
|
|
||||||
wrapLogMessage={wrapLogMessage}
|
|
||||||
onPinLine={onPinLine}
|
|
||||||
getFieldLinks={getFieldLinks}
|
|
||||||
onClickFilterLabel={onClickFilterLabel}
|
|
||||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
|
||||||
onClickShowField={onClickShowField}
|
|
||||||
onClickHideField={onClickHideField}
|
|
||||||
hasError={showDetails[0].hasError}
|
|
||||||
displayedFields={displayedFields}
|
|
||||||
app={app}
|
|
||||||
isFilterLabelActive={isLabelFilterActive}
|
|
||||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Resizable>
|
</Resizable>
|
||||||
|
@ -104,15 +59,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
container: css({
|
container: css({
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
boxShadow: theme.shadows.z1,
|
||||||
|
border: `1px solid ${theme.colors.border.medium}`,
|
||||||
|
borderRight: 'none',
|
||||||
}),
|
}),
|
||||||
scrollContainer: css({
|
scrollContainer: css({
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
position: 'relative',
|
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}),
|
}),
|
||||||
closeIcon: css({
|
componentWrapper: css({
|
||||||
position: 'absolute',
|
padding: theme.spacing(0, 1, 1, 1),
|
||||||
top: theme.spacing(1),
|
|
||||||
right: theme.spacing(1.5),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { camelCase, groupBy } from 'lodash';
|
||||||
|
import { startTransition, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { DataFrameType, GrafanaTheme2, store } from '@grafana/data';
|
||||||
|
import { t, Trans } from '@grafana/i18n';
|
||||||
|
import { ControlledCollapse, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { getLabelTypeFromRow } from '../../utils';
|
||||||
|
import { useAttributesExtensionLinks } from '../LogDetails';
|
||||||
|
import { createLogLineLinks } from '../logParser';
|
||||||
|
|
||||||
|
import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields';
|
||||||
|
import { LogLineDetailsHeader } from './LogLineDetailsHeader';
|
||||||
|
import { LogListModel } from './processing';
|
||||||
|
|
||||||
|
interface LogLineDetailsComponentProps {
|
||||||
|
log: LogListModel;
|
||||||
|
logOptionsStorageKey?: string;
|
||||||
|
logs: LogListModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const inputRef = useRef('');
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const extensionLinks = useAttributesExtensionLinks(log);
|
||||||
|
const fieldsWithLinks = useMemo(() => {
|
||||||
|
const fieldsWithLinks = log.fields.filter((f) => f.links?.length);
|
||||||
|
const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort();
|
||||||
|
const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort();
|
||||||
|
const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks);
|
||||||
|
return {
|
||||||
|
links: displayedFieldsWithLinks,
|
||||||
|
linksFromVariableMap: fieldsWithLinksFromVariableMap,
|
||||||
|
};
|
||||||
|
}, [log.entryFieldIndex, log.fields]);
|
||||||
|
const fieldsWithoutLinks =
|
||||||
|
log.dataFrame.meta?.type === DataFrameType.LogLines
|
||||||
|
? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links
|
||||||
|
[]
|
||||||
|
: // for other frames, do not show the log message unless there is a link attached
|
||||||
|
log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort();
|
||||||
|
const labelsWithLinks: LabelWithLinks[] = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(log.labels)
|
||||||
|
.sort()
|
||||||
|
.map((label) => ({
|
||||||
|
key: label,
|
||||||
|
value: log.labels[label],
|
||||||
|
link: extensionLinks?.[label],
|
||||||
|
})),
|
||||||
|
[extensionLinks, log.labels]
|
||||||
|
);
|
||||||
|
const groupedLabels = useMemo(
|
||||||
|
() => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''),
|
||||||
|
[labelsWithLinks, log]
|
||||||
|
);
|
||||||
|
const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]);
|
||||||
|
|
||||||
|
const logLineOpen = logOptionsStorageKey
|
||||||
|
? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false)
|
||||||
|
: false;
|
||||||
|
const linksOpen = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) : true;
|
||||||
|
const fieldsOpen = logOptionsStorageKey
|
||||||
|
? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(option: string, isOpen: boolean) => {
|
||||||
|
console.log(option, isOpen);
|
||||||
|
store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen);
|
||||||
|
},
|
||||||
|
[logOptionsStorageKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = useCallback((newSearch: string) => {
|
||||||
|
inputRef.current = newSearch;
|
||||||
|
startTransition(() => {
|
||||||
|
setSearch(inputRef.current);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const noDetails =
|
||||||
|
!fieldsWithLinks.links.length &&
|
||||||
|
!fieldsWithLinks.linksFromVariableMap.length &&
|
||||||
|
!labelGroups.length &&
|
||||||
|
!fieldsWithoutLinks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LogLineDetailsHeader log={log} search={search} onSearch={handleSearch} />
|
||||||
|
<div className={styles.componentWrapper}>
|
||||||
|
<ControlledCollapse
|
||||||
|
className={styles.collapsable}
|
||||||
|
label={t('logs.log-line-details.log-line-section', 'Log line')}
|
||||||
|
collapsible
|
||||||
|
isOpen={logLineOpen}
|
||||||
|
onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)}
|
||||||
|
>
|
||||||
|
<div className={styles.logLineWrapper}>{log.raw}</div>
|
||||||
|
</ControlledCollapse>
|
||||||
|
{fieldsWithLinks.links.length > 0 && (
|
||||||
|
<ControlledCollapse
|
||||||
|
className={styles.collapsable}
|
||||||
|
label={t('logs.log-line-details.links-section', 'Links')}
|
||||||
|
collapsible
|
||||||
|
isOpen={linksOpen}
|
||||||
|
onToggle={(isOpen: boolean) => handleToggle('linksOpen', isOpen)}
|
||||||
|
>
|
||||||
|
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithLinks.links} search={search} />
|
||||||
|
<LogLineDetailsFields
|
||||||
|
disableActions
|
||||||
|
log={log}
|
||||||
|
logs={logs}
|
||||||
|
fields={fieldsWithLinks.linksFromVariableMap}
|
||||||
|
search={search}
|
||||||
|
/>
|
||||||
|
</ControlledCollapse>
|
||||||
|
)}
|
||||||
|
{labelGroups.map((group) =>
|
||||||
|
group === '' ? (
|
||||||
|
<ControlledCollapse
|
||||||
|
className={styles.collapsable}
|
||||||
|
key={'fields'}
|
||||||
|
label={t('logs.log-line-details.fields-section', 'Fields')}
|
||||||
|
collapsible
|
||||||
|
isOpen={fieldsOpen}
|
||||||
|
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)}
|
||||||
|
>
|
||||||
|
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
|
||||||
|
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} />
|
||||||
|
</ControlledCollapse>
|
||||||
|
) : (
|
||||||
|
<ControlledCollapse
|
||||||
|
className={styles.collapsable}
|
||||||
|
key={group}
|
||||||
|
label={group}
|
||||||
|
collapsible
|
||||||
|
isOpen={store.getBool(`${logOptionsStorageKey}.log-details.${groupOptionName(group)}`, true)}
|
||||||
|
onToggle={(isOpen: boolean) => handleToggle(groupOptionName(group), isOpen)}
|
||||||
|
>
|
||||||
|
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
|
||||||
|
</ControlledCollapse>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!labelGroups.length && fieldsWithoutLinks.length > 0 && (
|
||||||
|
<ControlledCollapse
|
||||||
|
className={styles.collapsable}
|
||||||
|
key={'fields'}
|
||||||
|
label={t('logs.log-line-details.fields-section', 'Fields')}
|
||||||
|
collapsible
|
||||||
|
isOpen={fieldsOpen}
|
||||||
|
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)}
|
||||||
|
>
|
||||||
|
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} />
|
||||||
|
</ControlledCollapse>
|
||||||
|
)}
|
||||||
|
{noDetails && <Trans i18nKey="logs.log-line-details.no-details">No fields to display.</Trans>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupOptionName(group: string) {
|
||||||
|
return `${camelCase(group)}Open`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
collapsable: css({
|
||||||
|
'&:last-of-type': {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
componentWrapper: css({
|
||||||
|
padding: theme.spacing(0, 1, 1, 1),
|
||||||
|
}),
|
||||||
|
logLineWrapper: css({
|
||||||
|
maxHeight: '50vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
}),
|
||||||
|
});
|
|
@ -0,0 +1,522 @@
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { CoreApp, Field, fuzzySearch, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
|
import { ClipboardButton, DataLinkButton, IconButton, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { logRowToSingleRowDataFrame } from '../../logsModel';
|
||||||
|
import { calculateLogsLabelStats, calculateStats } from '../../utils';
|
||||||
|
import { LogLabelStats } from '../LogLabelStats';
|
||||||
|
import { FieldDef } from '../logParser';
|
||||||
|
|
||||||
|
import { useLogListContext } from './LogListContext';
|
||||||
|
import { LogListModel } from './processing';
|
||||||
|
|
||||||
|
interface LogLineDetailsFieldsProps {
|
||||||
|
disableActions?: boolean;
|
||||||
|
fields: FieldDef[];
|
||||||
|
log: LogListModel;
|
||||||
|
logs: LogListModel[];
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => {
|
||||||
|
if (!fields.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const styles = useStyles2(getFieldsStyles);
|
||||||
|
const getLogs = useCallback(() => logs, [logs]);
|
||||||
|
const filteredFields = useMemo(() => (search ? filterFields(fields, search) : fields), [fields, search]);
|
||||||
|
|
||||||
|
if (filteredFields.length === 0) {
|
||||||
|
return t('logs.log-line-details.search.no-results', 'No results to display.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={disableActions ? styles.fieldsTableNoActions : styles.fieldsTable}>
|
||||||
|
{filteredFields.map((field, i) => (
|
||||||
|
<LogLineDetailsField
|
||||||
|
key={`${field.keys[0]}=${field.values[0]}-${i}`}
|
||||||
|
disableActions={disableActions}
|
||||||
|
getLogs={getLogs}
|
||||||
|
fieldIndex={field.fieldIndex}
|
||||||
|
keys={field.keys}
|
||||||
|
links={field.links}
|
||||||
|
log={log}
|
||||||
|
values={field.values}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LinkModelWithIcon extends LinkModel<Field> {
|
||||||
|
icon?: IconName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelWithLinks {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
links?: LinkModelWithIcon[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogLineDetailsLabelFieldsProps {
|
||||||
|
fields: LabelWithLinks[];
|
||||||
|
log: LogListModel;
|
||||||
|
logs: LogListModel[];
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogLineDetailsLabelFields = ({ fields, log, logs, search }: LogLineDetailsLabelFieldsProps) => {
|
||||||
|
if (!fields.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const styles = useStyles2(getFieldsStyles);
|
||||||
|
const getLogs = useCallback(() => logs, [logs]);
|
||||||
|
const filteredFields = useMemo(() => (search ? filterLabels(fields, search) : fields), [fields, search]);
|
||||||
|
|
||||||
|
if (filteredFields.length === 0) {
|
||||||
|
return t('logs.log-line-details.search.no-results', 'No results to display.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.fieldsTable}>
|
||||||
|
{filteredFields.map((field, i) => (
|
||||||
|
<LogLineDetailsField
|
||||||
|
key={`${field.key}=${field.value}-${i}`}
|
||||||
|
getLogs={getLogs}
|
||||||
|
isLabel
|
||||||
|
keys={[field.key]}
|
||||||
|
links={field.links}
|
||||||
|
log={log}
|
||||||
|
values={[field.value]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldsStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
fieldsTable: css({
|
||||||
|
display: 'grid',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
gridTemplateColumns: `${theme.spacing(11.5)} minmax(15%, 30%) 1fr`,
|
||||||
|
}),
|
||||||
|
fieldsTableNoActions: css({
|
||||||
|
display: 'grid',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
gridTemplateColumns: `minmax(15%, 30%) 1fr`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LogLineDetailsFieldProps {
|
||||||
|
keys: string[];
|
||||||
|
values: string[];
|
||||||
|
disableActions?: boolean;
|
||||||
|
fieldIndex?: number;
|
||||||
|
getLogs(): LogListModel[];
|
||||||
|
isLabel?: boolean;
|
||||||
|
links?: LinkModelWithIcon[];
|
||||||
|
log: LogListModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogLineDetailsField = ({
|
||||||
|
disableActions = false,
|
||||||
|
fieldIndex,
|
||||||
|
getLogs,
|
||||||
|
isLabel,
|
||||||
|
links,
|
||||||
|
log,
|
||||||
|
keys,
|
||||||
|
values,
|
||||||
|
}: LogLineDetailsFieldProps) => {
|
||||||
|
const [showFieldsStats, setShowFieldStats] = useState(false);
|
||||||
|
const [fieldCount, setFieldCount] = useState(0);
|
||||||
|
const [fieldStats, setFieldStats] = useState<LogLabelStatsModel[] | null>(null);
|
||||||
|
const {
|
||||||
|
app,
|
||||||
|
closeDetails,
|
||||||
|
displayedFields,
|
||||||
|
isLabelFilterActive,
|
||||||
|
onClickFilterLabel,
|
||||||
|
onClickFilterOutLabel,
|
||||||
|
onClickShowField,
|
||||||
|
onClickHideField,
|
||||||
|
onPinLine,
|
||||||
|
pinLineButtonTooltipTitle,
|
||||||
|
} = useLogListContext();
|
||||||
|
|
||||||
|
const styles = useStyles2(getFieldStyles);
|
||||||
|
|
||||||
|
const getStats = useCallback(() => {
|
||||||
|
if (isLabel) {
|
||||||
|
return calculateLogsLabelStats(getLogs(), keys[0]);
|
||||||
|
}
|
||||||
|
if (fieldIndex !== undefined) {
|
||||||
|
return calculateStats(log.dataFrame.fields[fieldIndex].values);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [fieldIndex, getLogs, isLabel, keys, log.dataFrame.fields]);
|
||||||
|
|
||||||
|
const updateStats = useCallback(() => {
|
||||||
|
const newStats = getStats();
|
||||||
|
const newCount = newStats.reduce((sum, stat) => sum + stat.count, 0);
|
||||||
|
if (!isEqual(fieldStats, newStats) || fieldCount !== newCount) {
|
||||||
|
setFieldStats(newStats);
|
||||||
|
setFieldCount(newCount);
|
||||||
|
}
|
||||||
|
}, [fieldCount, fieldStats, getStats]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showFieldsStats) {
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
}, [showFieldsStats, updateStats]);
|
||||||
|
|
||||||
|
const showField = useCallback(() => {
|
||||||
|
if (onClickShowField) {
|
||||||
|
onClickShowField(keys[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
|
||||||
|
datasourceType: log.datasourceType,
|
||||||
|
logRowUid: log.uid,
|
||||||
|
type: 'enable',
|
||||||
|
});
|
||||||
|
}, [onClickShowField, keys, log.datasourceType, log.uid]);
|
||||||
|
|
||||||
|
const hideField = useCallback(() => {
|
||||||
|
if (onClickHideField) {
|
||||||
|
onClickHideField(keys[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
|
||||||
|
datasourceType: log.datasourceType,
|
||||||
|
logRowUid: log.uid,
|
||||||
|
type: 'disable',
|
||||||
|
});
|
||||||
|
}, [onClickHideField, keys, log.datasourceType, log.uid]);
|
||||||
|
|
||||||
|
const filterLabel = useCallback(() => {
|
||||||
|
if (onClickFilterLabel) {
|
||||||
|
onClickFilterLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
|
||||||
|
datasourceType: log.datasourceType,
|
||||||
|
filterType: 'include',
|
||||||
|
logRowUid: log.uid,
|
||||||
|
});
|
||||||
|
}, [onClickFilterLabel, keys, values, log]);
|
||||||
|
|
||||||
|
const filterOutLabel = useCallback(() => {
|
||||||
|
if (onClickFilterOutLabel) {
|
||||||
|
onClickFilterOutLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
|
||||||
|
datasourceType: log.datasourceType,
|
||||||
|
filterType: 'exclude',
|
||||||
|
logRowUid: log.uid,
|
||||||
|
});
|
||||||
|
}, [onClickFilterOutLabel, keys, values, log]);
|
||||||
|
|
||||||
|
const labelFilterActive = useCallback(async () => {
|
||||||
|
if (isLabelFilterActive) {
|
||||||
|
return await isLabelFilterActive(keys[0], values[0], log.dataFrame?.refId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [isLabelFilterActive, keys, values, log.dataFrame?.refId]);
|
||||||
|
|
||||||
|
const showStats = useCallback(() => {
|
||||||
|
setShowFieldStats((showFieldStats: boolean) => !showFieldStats);
|
||||||
|
|
||||||
|
reportInteraction('grafana_explore_logs_log_details_stats_clicked', {
|
||||||
|
dataSourceType: log.datasourceType,
|
||||||
|
fieldType: isLabel ? 'label' : 'detectedField',
|
||||||
|
type: showFieldsStats ? 'close' : 'open',
|
||||||
|
logRowUid: log.uid,
|
||||||
|
app,
|
||||||
|
});
|
||||||
|
}, [app, isLabel, log.datasourceType, log.uid, showFieldsStats]);
|
||||||
|
|
||||||
|
const refIdTooltip = useMemo(
|
||||||
|
() => (app === CoreApp.Explore && log.dataFrame?.refId ? ` in query ${log.dataFrame?.refId}` : ''),
|
||||||
|
[app, log.dataFrame?.refId]
|
||||||
|
);
|
||||||
|
const singleKey = keys.length === 1;
|
||||||
|
const singleValue = values.length === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.row}>
|
||||||
|
{!disableActions && (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{onClickFilterLabel && (
|
||||||
|
<AsyncIconButton
|
||||||
|
name="search-plus"
|
||||||
|
onClick={filterLabel}
|
||||||
|
// We purposely want to pass a new function on every render to allow the active state to be updated when log details remains open between updates.
|
||||||
|
isActive={labelFilterActive}
|
||||||
|
tooltipSuffix={refIdTooltip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onClickFilterOutLabel && (
|
||||||
|
<IconButton
|
||||||
|
name="search-minus"
|
||||||
|
tooltip={
|
||||||
|
app === CoreApp.Explore && log.dataFrame?.refId
|
||||||
|
? t('logs.log-line-details.fields.filter-out-query', 'Filter out value in query {{query}}', {
|
||||||
|
query: log.dataFrame?.refId,
|
||||||
|
})
|
||||||
|
: t('logs.log-line-details.fields.filter-out', 'Filter out value')
|
||||||
|
}
|
||||||
|
onClick={filterOutLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{singleKey && displayedFields.includes(keys[0]) && (
|
||||||
|
<IconButton
|
||||||
|
variant="primary"
|
||||||
|
tooltip={t('logs.log-line-details.fields.toggle-field-button.hide-this-field', 'Hide this field')}
|
||||||
|
name="eye"
|
||||||
|
onClick={hideField}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{singleKey && !displayedFields.includes(keys[0]) && (
|
||||||
|
<IconButton
|
||||||
|
tooltip={t(
|
||||||
|
'logs.log-line-details.fields.toggle-field-button.field-instead-message',
|
||||||
|
'Show this field instead of the message'
|
||||||
|
)}
|
||||||
|
name="eye"
|
||||||
|
onClick={showField}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
variant={showFieldsStats ? 'primary' : 'secondary'}
|
||||||
|
name="signal"
|
||||||
|
tooltip={t('logs.log-line-details.fields.adhoc-statistics', 'Ad-hoc statistics')}
|
||||||
|
className="stats-button"
|
||||||
|
disabled={!singleKey}
|
||||||
|
onClick={showStats}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.label}>{singleKey ? keys[0] : <MultipleValue values={keys} />}</div>
|
||||||
|
<div className={styles.value}>
|
||||||
|
<div className={styles.valueContainer}>
|
||||||
|
{singleValue ? values[0] : <MultipleValue showCopy={true} values={values} />}
|
||||||
|
{singleValue && <ClipboardButtonWrapper value={values[0]} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{links?.map((link, i) => {
|
||||||
|
if (link.onClick && onPinLine) {
|
||||||
|
const originalOnClick = link.onClick;
|
||||||
|
link.onClick = (e, origin) => {
|
||||||
|
// Pin the line
|
||||||
|
onPinLine(log);
|
||||||
|
|
||||||
|
// Execute the link onClick function
|
||||||
|
originalOnClick(e, origin);
|
||||||
|
|
||||||
|
closeDetails();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.row} key={`${link.title}-${i}`}>
|
||||||
|
<div className={disableActions ? styles.linkNoActions : styles.link}>
|
||||||
|
<DataLinkButton
|
||||||
|
buttonProps={{
|
||||||
|
// Show tooltip message if max number of pinned lines has been reached
|
||||||
|
tooltip:
|
||||||
|
typeof pinLineButtonTooltipTitle === 'object' && link.onClick
|
||||||
|
? pinLineButtonTooltipTitle
|
||||||
|
: undefined,
|
||||||
|
variant: 'secondary',
|
||||||
|
fill: 'outline',
|
||||||
|
...(link.icon && { icon: link.icon }),
|
||||||
|
}}
|
||||||
|
link={link}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showFieldsStats && fieldStats && (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div />
|
||||||
|
<div className={disableActions ? undefined : styles.statsColumn}>
|
||||||
|
<LogLabelStats
|
||||||
|
className={styles.stats}
|
||||||
|
stats={fieldStats}
|
||||||
|
label={keys[0]}
|
||||||
|
value={values[0]}
|
||||||
|
rowCount={fieldCount}
|
||||||
|
isLabel={isLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
row: css({
|
||||||
|
display: 'contents',
|
||||||
|
}),
|
||||||
|
actions: css({
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}),
|
||||||
|
label: css({
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}),
|
||||||
|
value: css({
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
button: {
|
||||||
|
visibility: 'hidden',
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
button: {
|
||||||
|
visibility: 'visible',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
link: css({
|
||||||
|
gridColumn: 'span 3',
|
||||||
|
}),
|
||||||
|
linkNoActions: css({
|
||||||
|
gridColumn: 'span 2',
|
||||||
|
}),
|
||||||
|
stats: css({
|
||||||
|
paddingRight: theme.spacing(1),
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '50vh',
|
||||||
|
}),
|
||||||
|
statsColumn: css({
|
||||||
|
gridColumn: 'span 2',
|
||||||
|
}),
|
||||||
|
valueContainer: css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
lineHeight: theme.typography.body.lineHeight,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ClipboardButtonWrapper = ({ value }: { value: string }) => {
|
||||||
|
const styles = useStyles2(getClipboardButtonStyles);
|
||||||
|
return (
|
||||||
|
<div className={styles.button}>
|
||||||
|
<ClipboardButton
|
||||||
|
getText={() => value}
|
||||||
|
title={t('logs.log-line-details.fields.copy-value-to-clipboard', 'Copy value to clipboard')}
|
||||||
|
fill="text"
|
||||||
|
variant="secondary"
|
||||||
|
icon="copy"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClipboardButtonStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
button: css({
|
||||||
|
'& > button': {
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
padding: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: theme.shape.radius.circle,
|
||||||
|
height: theme.spacing(theme.components.height.sm),
|
||||||
|
width: theme.spacing(theme.components.height.sm),
|
||||||
|
svg: {
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
'span > div': {
|
||||||
|
top: '-5px',
|
||||||
|
'& button': {
|
||||||
|
color: theme.colors.success.main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MultipleValue = ({ showCopy, values = [] }: { showCopy?: boolean; values: string[] }) => {
|
||||||
|
if (values.every((val) => val === '')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{values.map((val, i) => {
|
||||||
|
return (
|
||||||
|
<tr key={`${val}-${i}`}>
|
||||||
|
<td>{val}</td>
|
||||||
|
<td>{showCopy && val !== '' && <ClipboardButtonWrapper value={val} />}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AsyncIconButtonProps extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
||||||
|
name: IconName;
|
||||||
|
isActive(): Promise<boolean>;
|
||||||
|
tooltipSuffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AsyncIconButton = ({ isActive, tooltipSuffix, ...rest }: AsyncIconButtonProps) => {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const tooltip = active ? 'Remove filter' : 'Filter for value';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isActive().then(setActive);
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
return <IconButton {...rest} variant={active ? 'primary' : undefined} tooltip={tooltip + tooltipSuffix} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterFields(fields: FieldDef[], search: string) {
|
||||||
|
const keys = fields.map((field) => field.keys.join(' '));
|
||||||
|
const keysIdx = fuzzySearch(keys, search);
|
||||||
|
const values = fields.map((field) => field.values.join(' '));
|
||||||
|
const valuesIdx = fuzzySearch(values, search);
|
||||||
|
|
||||||
|
const results = keysIdx.map((index) => fields[index]);
|
||||||
|
valuesIdx.forEach((index) => {
|
||||||
|
if (!results.includes(fields[index])) {
|
||||||
|
results.push(fields[index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterLabels(labels: LabelWithLinks[], search: string) {
|
||||||
|
const keys = labels.map((field) => field.key);
|
||||||
|
const keysIdx = fuzzySearch(keys, search);
|
||||||
|
const values = labels.map((field) => field.value);
|
||||||
|
const valuesIdx = fuzzySearch(values, search);
|
||||||
|
|
||||||
|
const results = keysIdx.map((index) => labels[index]);
|
||||||
|
valuesIdx.forEach((index) => {
|
||||||
|
if (!results.includes(labels[index])) {
|
||||||
|
results.push(labels[index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useCallback, useMemo, MouseEvent, useRef, ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
import { colorManipulator, GrafanaTheme2, LogRowModel } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { IconButton, Input, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { copyText, handleOpenLogsContextClick } from '../../utils';
|
||||||
|
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||||
|
|
||||||
|
import { useLogIsPinned, useLogListContext } from './LogListContext';
|
||||||
|
import { LogListModel } from './processing';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
log: LogListModel;
|
||||||
|
search: string;
|
||||||
|
onSearch(newSearch: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
|
||||||
|
const {
|
||||||
|
closeDetails,
|
||||||
|
displayedFields,
|
||||||
|
getRowContextQuery,
|
||||||
|
logSupportsContext,
|
||||||
|
onClickHideField,
|
||||||
|
onClickShowField,
|
||||||
|
onOpenContext,
|
||||||
|
onPermalinkClick,
|
||||||
|
onPinLine,
|
||||||
|
onUnpinLine,
|
||||||
|
} = useLogListContext();
|
||||||
|
const pinned = useLogIsPinned(log);
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const copyLogLine = useCallback(() => {
|
||||||
|
copyText(log.entry, containerRef);
|
||||||
|
}, [log.entry]);
|
||||||
|
|
||||||
|
const copyLinkToLogLine = useCallback(() => {
|
||||||
|
onPermalinkClick?.(log);
|
||||||
|
}, [log, onPermalinkClick]);
|
||||||
|
|
||||||
|
const togglePinning = useCallback(() => {
|
||||||
|
if (pinned) {
|
||||||
|
onUnpinLine?.(log);
|
||||||
|
} else {
|
||||||
|
onPinLine?.(log);
|
||||||
|
}
|
||||||
|
}, [log, onPinLine, onUnpinLine, pinned]);
|
||||||
|
|
||||||
|
const shouldlogSupportsContext = useMemo(
|
||||||
|
() => (logSupportsContext ? logSupportsContext(log) : false),
|
||||||
|
[log, logSupportsContext]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showContext = useCallback(
|
||||||
|
async (event: MouseEvent<HTMLElement>) => {
|
||||||
|
handleOpenLogsContextClick(event, log, getRowContextQuery, (log: LogRowModel) => onOpenContext?.(log, () => {}));
|
||||||
|
},
|
||||||
|
[onOpenContext, getRowContextQuery, log]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showLogLineToggle = onClickHideField && onClickShowField && displayedFields.length > 0;
|
||||||
|
const logLineDisplayed = displayedFields.includes(LOG_LINE_BODY_FIELD_NAME);
|
||||||
|
|
||||||
|
const toggleLogLine = useCallback(() => {
|
||||||
|
if (logLineDisplayed) {
|
||||||
|
onClickHideField?.(LOG_LINE_BODY_FIELD_NAME);
|
||||||
|
} else {
|
||||||
|
onClickShowField?.(LOG_LINE_BODY_FIELD_NAME);
|
||||||
|
}
|
||||||
|
}, [logLineDisplayed, onClickHideField, onClickShowField]);
|
||||||
|
|
||||||
|
const clearSearch = useMemo(
|
||||||
|
() => (
|
||||||
|
<IconButton
|
||||||
|
name="times"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onSearch('');
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tooltip={t('logs.log-line-details.clear-search', 'Clear')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[onSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onSearch(e.target.value);
|
||||||
|
},
|
||||||
|
[onSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.header} ref={containerRef}>
|
||||||
|
<Input
|
||||||
|
onChange={handleSearch}
|
||||||
|
placeholder={t('logs.log-line-details.search-placeholder', 'Search field names and values')}
|
||||||
|
ref={inputRef}
|
||||||
|
suffix={search !== '' ? clearSearch : undefined}
|
||||||
|
/>
|
||||||
|
{showLogLineToggle && (
|
||||||
|
<IconButton
|
||||||
|
tooltip={
|
||||||
|
logLineDisplayed
|
||||||
|
? t('logs.log-line-details.hide-log-line', 'Hide log line')
|
||||||
|
: t('logs.log-line-details.show-log-line', 'Show log line')
|
||||||
|
}
|
||||||
|
tooltipPlacement="top"
|
||||||
|
size="md"
|
||||||
|
name="eye"
|
||||||
|
onClick={toggleLogLine}
|
||||||
|
tabIndex={0}
|
||||||
|
variant={logLineDisplayed ? 'primary' : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
tooltip={t('logs.log-line-details.copy-to-clipboard', 'Copy to clipboard')}
|
||||||
|
tooltipPlacement="top"
|
||||||
|
size="md"
|
||||||
|
name="copy"
|
||||||
|
onClick={copyLogLine}
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
{onPermalinkClick && log.rowId !== undefined && log.uid && (
|
||||||
|
<IconButton
|
||||||
|
tooltip={t('logs.log-line-details.copy-shortlink', 'Copy shortlink')}
|
||||||
|
tooltipPlacement="top"
|
||||||
|
size="md"
|
||||||
|
name="share-alt"
|
||||||
|
onClick={copyLinkToLogLine}
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{pinned && onUnpinLine && (
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
name="gf-pin"
|
||||||
|
onClick={togglePinning}
|
||||||
|
tooltip={t('logs.log-line-details.unpin-line', 'Unpin log')}
|
||||||
|
tooltipPlacement="top"
|
||||||
|
tabIndex={0}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!pinned && onPinLine && (
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
name="gf-pin"
|
||||||
|
onClick={togglePinning}
|
||||||
|
tooltip={t('logs.log-line-details.pin-line', 'Pin log')}
|
||||||
|
tooltipPlacement="top"
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{shouldlogSupportsContext && (
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
name="gf-show-context"
|
||||||
|
onClick={showContext}
|
||||||
|
tooltip={t('logs.log-line-details.show-context', 'Show context')}
|
||||||
|
tooltipPlacement="top"
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
name="times"
|
||||||
|
aria-label={t('logs.log-line-details.close', 'Close log details')}
|
||||||
|
onClick={closeDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
container: css({
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100%',
|
||||||
|
}),
|
||||||
|
scrollContainer: css({
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100%',
|
||||||
|
}),
|
||||||
|
header: css({
|
||||||
|
alignItems: 'center',
|
||||||
|
background: theme.colors.background.canvas,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing(0.75),
|
||||||
|
zIndex: theme.zIndex.navbarFixed,
|
||||||
|
height: theme.spacing(5.5),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
padding: theme.spacing(0.5, 1),
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
}),
|
||||||
|
copyLogButton: css({
|
||||||
|
padding: 0,
|
||||||
|
height: theme.spacing(4),
|
||||||
|
width: theme.spacing(2.5),
|
||||||
|
overflow: 'hidden',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: colorManipulator.alpha(theme.colors.text.primary, 0.12),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
componentWrapper: css({
|
||||||
|
padding: theme.spacing(0, 1, 1, 1),
|
||||||
|
}),
|
||||||
|
});
|
|
@ -191,6 +191,7 @@ export const LogList = ({
|
||||||
initialScrollPosition={initialScrollPosition}
|
initialScrollPosition={initialScrollPosition}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
|
logOptionsStorageKey={logOptionsStorageKey}
|
||||||
logs={logs}
|
logs={logs}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
|
@ -209,6 +210,7 @@ const LogListComponent = ({
|
||||||
initialScrollPosition = 'top',
|
initialScrollPosition = 'top',
|
||||||
loading,
|
loading,
|
||||||
loadMore,
|
loadMore,
|
||||||
|
logOptionsStorageKey,
|
||||||
logs,
|
logs,
|
||||||
showControls,
|
showControls,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
@ -459,7 +461,7 @@ const LogListComponent = ({
|
||||||
{showDetails.length > 0 && (
|
{showDetails.length > 0 && (
|
||||||
<LogLineDetails
|
<LogLineDetails
|
||||||
containerElement={containerElement}
|
containerElement={containerElement}
|
||||||
getFieldLinks={getFieldLinks}
|
logOptionsStorageKey={logOptionsStorageKey}
|
||||||
logs={filteredLogs}
|
logs={filteredLogs}
|
||||||
onResize={handleLogDetailsResize}
|
onResize={handleLogDetailsResize}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -73,7 +73,7 @@ export const defaultValue: LogListContextData = {
|
||||||
setWrapLogMessage: jest.fn(),
|
setWrapLogMessage: jest.fn(),
|
||||||
closeDetails: jest.fn(),
|
closeDetails: jest.fn(),
|
||||||
detailsDisplayed: jest.fn(),
|
detailsDisplayed: jest.fn(),
|
||||||
detailsWidth: 0,
|
detailsWidth: 300,
|
||||||
downloadLogs: jest.fn(),
|
downloadLogs: jest.fn(),
|
||||||
enableLogDetails: false,
|
enableLogDetails: false,
|
||||||
filterLevels: [],
|
filterLevels: [],
|
||||||
|
@ -130,11 +130,12 @@ export const LogListContextProvider = ({
|
||||||
onUnpinLine = jest.fn(),
|
onUnpinLine = jest.fn(),
|
||||||
permalinkedLogId,
|
permalinkedLogId,
|
||||||
pinnedLogs = [],
|
pinnedLogs = [],
|
||||||
|
showDetails = [],
|
||||||
showTime = true,
|
showTime = true,
|
||||||
sortOrder = LogsSortOrder.Descending,
|
sortOrder = LogsSortOrder.Descending,
|
||||||
syntaxHighlighting = true,
|
syntaxHighlighting = true,
|
||||||
wrapLogMessage = true,
|
wrapLogMessage = true,
|
||||||
}: Partial<Props>) => {
|
}: Partial<Props> & { showDetails?: LogListModel[] }) => {
|
||||||
const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log));
|
const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log));
|
||||||
const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log));
|
const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log));
|
||||||
|
|
||||||
|
@ -172,6 +173,7 @@ export const LogListContextProvider = ({
|
||||||
setSortOrder: jest.fn(),
|
setSortOrder: jest.fn(),
|
||||||
setSyntaxHighlighting: jest.fn(),
|
setSyntaxHighlighting: jest.fn(),
|
||||||
setWrapLogMessage: jest.fn(),
|
setWrapLogMessage: jest.fn(),
|
||||||
|
showDetails,
|
||||||
showTime,
|
showTime,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
syntaxHighlighting,
|
syntaxHighlighting,
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
Field,
|
Field,
|
||||||
LogsMetaItem,
|
LogsMetaItem,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
|
|
||||||
import { getLogsExtractFields } from '../explore/Logs/LogsTable';
|
import { getLogsExtractFields } from '../explore/Logs/LogsTable';
|
||||||
|
@ -391,35 +392,33 @@ function getLabelTypeFromFrame(labelKey: string, frame: DataFrame, index: number
|
||||||
return typeField[labelKey] ?? null;
|
return typeField[labelKey] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLabelTypeFromRow(label: string, row: LogRowModel) {
|
export function getLabelTypeFromRow(label: string, row: LogRowModel, plural = false) {
|
||||||
if (!row.datasourceType) {
|
if (!row.datasourceType) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const idField = row.dataFrame.fields.find((field) => field.name === 'id');
|
const labelType = getLabelTypeFromFrame(label, row.dataFrame, row.rowIndex);
|
||||||
if (!idField) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const rowIndex = idField.values.findIndex((id) => id === row.rowId);
|
|
||||||
if (rowIndex < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const labelType = getLabelTypeFromFrame(label, row.dataFrame, rowIndex);
|
|
||||||
if (!labelType) {
|
if (!labelType) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return getDataSourceLabelType(labelType, row.datasourceType);
|
return getDataSourceLabelType(labelType, row.datasourceType, plural);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDataSourceLabelType(labelType: string, datasourceType: string) {
|
function getDataSourceLabelType(labelType: string, datasourceType: string, plural: boolean) {
|
||||||
switch (datasourceType) {
|
switch (datasourceType) {
|
||||||
case 'loki':
|
case 'loki':
|
||||||
switch (labelType) {
|
switch (labelType) {
|
||||||
case 'I':
|
case 'I':
|
||||||
return 'Indexed label';
|
return plural
|
||||||
|
? t('logs.fields.type.loki.indexed-label-plural', 'Indexed labels')
|
||||||
|
: t('logs.fields.type.loki.indexed-label', 'Indexed label');
|
||||||
case 'S':
|
case 'S':
|
||||||
return 'Structured metadata';
|
return plural
|
||||||
|
? t('logs.fields.type.loki.structured-metadata-plural', 'Structured metadata')
|
||||||
|
: t('logs.fields.type.loki.structured-metadata', 'Structured metadata');
|
||||||
case 'P':
|
case 'P':
|
||||||
return 'Parsed label';
|
return plural
|
||||||
|
? t('logs.fields.type.loki.parsed-label-plural', 'Parsed fields')
|
||||||
|
: t('logs.fields.type.loki.parsedl-label', 'Parsed field');
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8356,6 +8356,18 @@
|
||||||
"description-enable-infinite-scrolling": "Experimental. Request more results by scrolling to the bottom of the logs list.",
|
"description-enable-infinite-scrolling": "Experimental. Request more results by scrolling to the bottom of the logs list.",
|
||||||
"description-enable-syntax-highlighting": "Use a predefined syntax coloring grammar to highlight relevant parts of the log lines",
|
"description-enable-syntax-highlighting": "Use a predefined syntax coloring grammar to highlight relevant parts of the log lines",
|
||||||
"description-show-controls": "Display controls to jump to the last or first log line, and filters by log level",
|
"description-show-controls": "Display controls to jump to the last or first log line, and filters by log level",
|
||||||
|
"fields": {
|
||||||
|
"type": {
|
||||||
|
"loki": {
|
||||||
|
"indexed-label": "Indexed label",
|
||||||
|
"indexed-label-plural": "Indexed labels",
|
||||||
|
"parsed-label-plural": "Parsed fields",
|
||||||
|
"parsedl-label": "Parsed field",
|
||||||
|
"structured-metadata": "Structured metadata",
|
||||||
|
"structured-metadata-plural": "Structured metadata"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"font-size-options": {
|
"font-size-options": {
|
||||||
"label-default": "Default",
|
"label-default": "Default",
|
||||||
"label-small": "Small"
|
"label-small": "Small"
|
||||||
|
@ -8379,7 +8391,6 @@
|
||||||
"label-wrap-lines": "Wrap lines"
|
"label-wrap-lines": "Wrap lines"
|
||||||
},
|
},
|
||||||
"log-details": {
|
"log-details": {
|
||||||
"close": "Close log details",
|
|
||||||
"fields": "Fields",
|
"fields": "Fields",
|
||||||
"links": "Links",
|
"links": "Links",
|
||||||
"log-line": "Log line",
|
"log-line": "Log line",
|
||||||
|
@ -8405,6 +8416,35 @@
|
||||||
"show-more": "show more",
|
"show-more": "show more",
|
||||||
"tooltip-error": "Error: {{errorMessage}}"
|
"tooltip-error": "Error: {{errorMessage}}"
|
||||||
},
|
},
|
||||||
|
"log-line-details": {
|
||||||
|
"clear-search": "Clear",
|
||||||
|
"close": "Close log details",
|
||||||
|
"copy-shortlink": "Copy shortlink",
|
||||||
|
"copy-to-clipboard": "Copy to clipboard",
|
||||||
|
"fields": {
|
||||||
|
"adhoc-statistics": "Ad-hoc statistics",
|
||||||
|
"copy-value-to-clipboard": "Copy value to clipboard",
|
||||||
|
"filter-out": "Filter out value",
|
||||||
|
"filter-out-query": "Filter out value in query {{query}}",
|
||||||
|
"toggle-field-button": {
|
||||||
|
"field-instead-message": "Show this field instead of the message",
|
||||||
|
"hide-this-field": "Hide this field"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields-section": "Fields",
|
||||||
|
"hide-log-line": "Hide log line",
|
||||||
|
"links-section": "Links",
|
||||||
|
"log-line-section": "Log line",
|
||||||
|
"no-details": "No fields to display.",
|
||||||
|
"pin-line": "Pin log",
|
||||||
|
"search": {
|
||||||
|
"no-results": "No results to display."
|
||||||
|
},
|
||||||
|
"search-placeholder": "Search field names and values",
|
||||||
|
"show-context": "Show context",
|
||||||
|
"show-log-line": "Show log line",
|
||||||
|
"unpin-line": "Unpin log"
|
||||||
|
},
|
||||||
"log-line-menu": {
|
"log-line-menu": {
|
||||||
"copy-link": "Copy link to log line",
|
"copy-link": "Copy link to log line",
|
||||||
"copy-log": "Copy log line",
|
"copy-log": "Copy log line",
|
||||||
|
@ -8531,6 +8571,7 @@
|
||||||
"un-themed-log-details": {
|
"un-themed-log-details": {
|
||||||
"aria-label-data-links": "Data links",
|
"aria-label-data-links": "Data links",
|
||||||
"aria-label-fields": "Fields",
|
"aria-label-fields": "Fields",
|
||||||
|
"aria-label-line": "Log line",
|
||||||
"aria-label-log-level": "Log level",
|
"aria-label-log-level": "Log level",
|
||||||
"aria-label-no-details": "No details"
|
"aria-label-no-details": "No details"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue