From cc9ab72e33f6ef0e60f339ef14d743bbd22885f2 Mon Sep 17 00:00:00 2001 From: Alan Greene Date: Sat, 18 Jan 2025 18:06:57 +0000 Subject: [PATCH] Add support for log groups Update log viewer to support `::group::` and `::endgroup::` commands which create collapsible groups in the log content. When logs of a running step are viewed, groups are rendered in their expanded state by default. When logs of a completed step are viewed, groups are rendered in their collapsed state by default. In either case, if a user manually toggles a group, that state is respected until they switch to another view, regardless of the running state of the step. --- docs/logs.md | 27 ++++++++ .../components/src/components/Log/Log.jsx | 68 +++++++++++++++++-- .../Log/samples/timestamps_log_levels.txt | 3 +- .../src/components/LogFormat/LogFormat.jsx | 36 ++++++++-- .../components/LogFormat/LogFormat.stories.js | 31 ++++++++- .../components/LogFormat/LogFormat.test.jsx | 8 +-- .../src/components/LogFormat/_LogFormat.scss | 22 +++++- packages/e2e/cypress.config.js | 2 +- .../e2e/cypress/e2e/common/pipelinerun.cy.js | 18 +++++ src/nls/messages_en.json | 6 +- 10 files changed, 198 insertions(+), 23 deletions(-) diff --git a/docs/logs.md b/docs/logs.md index 3dff712cb..1381453ca 100644 --- a/docs/logs.md +++ b/docs/logs.md @@ -52,6 +52,33 @@ echo '::warning::Something that may require attention but is non-blocking…' The displayed log levels can be changed via the settings menu in the toolbar at the top of the log viewer. +### Log groups + +In addition to log levels, the log viewer also supports collapsible groups within the logs. The format supported is described below. + +``` + ::group:: +… + ::endgroup:: +``` + +A `group` command marks the beginning of the group. The content of `message` is displayed as the title / summary of the group along with an indicator of the group's current state (i.e. expanded or collapsed). Clicking the summary will toggle the state of the group. + +Groups are rendered in the collapsed state by default unless the step is still in progress when the logs are viewed. The user can expand or collapse groups as desired and their state will be maintained until the user navigates to a different view. + +Log groups cannot be mixed with log levels on the same line, the `group`, `endgroup`, and log level commands are mutually exclusive. However, logs contained within a group can use log levels as normal. + +Nesting groups is not supported. A `group` command will implicitly terminate any prior unterminated group. + +For example, the following snippet would output a log group with the summary 'Additional config' containing a number of messages at the `info` level: + +```sh +echo '::group::Additional config' +echo 'This extends the base config' +echo '::info:: More info about the config…' +echo '::endgroup::' +``` + ## Logs persistence By default, Tekton Dashboard loads the logs from the Kubernetes API server, using the pod logs API. However, it also supports loading logs from an external source when the container logs or the pods associated with the `TaskRuns` are no longer available on the cluster. diff --git a/packages/components/src/components/Log/Log.jsx b/packages/components/src/components/Log/Log.jsx index 5bf522fdd..f8d6c8caa 100644 --- a/packages/components/src/components/Log/Log.jsx +++ b/packages/components/src/components/Log/Log.jsx @@ -10,6 +10,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +/* istanbul ignore file */ import { Component, createRef } from 'react'; import { Button, PrefixContext, SkeletonText } from '@carbon/react'; @@ -31,7 +32,7 @@ const itemSize = 16; // This should be kept in sync with the line-height in SCSS const defaultHeight = itemSize * 100 + itemSize / 2; const logFormatRegex = - /^((?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3,9}Z)\s?)?(::(?error|warning|info|notice|debug)::)?(?.*)?$/s; + /^((?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3,9}Z)\s?)?(::(?group|endgroup|error|warning|info|notice|debug)::)?(?.*)?$/s; function LogsFilteredNotification({ displayedLogLines, totalLogLines }) { const intl = useIntl(); @@ -46,7 +47,8 @@ function LogsFilteredNotification({ displayedLogLines, totalLogLines }) { {intl.formatMessage({ id: 'dashboard.logs.hidden.all', - defaultMessage: 'All lines hidden due to selected log levels' + defaultMessage: + 'All lines hidden due to selected log levels or collapsed groups' })} ); @@ -58,7 +60,8 @@ function LogsFilteredNotification({ displayedLogLines, totalLogLines }) { ? intl.formatMessage( { id: 'dashboard.logs.hidden.one', - defaultMessage: '1 line hidden due to selected log levels' + defaultMessage: + '1 line hidden due to selected log levels or collapsed groups' }, { numHiddenLines: totalLogLines - displayedLogLines } ) @@ -66,7 +69,7 @@ function LogsFilteredNotification({ displayedLogLines, totalLogLines }) { { id: 'dashboard.logs.hidden', defaultMessage: - '{numHiddenLines, plural, other {# lines}} hidden due to selected log levels' + '{numHiddenLines, plural, other {# lines}} hidden due to selected log levels or collapsed groups' }, { numHiddenLines: totalLogLines - displayedLogLines } ); @@ -82,7 +85,7 @@ function LogsFilteredNotification({ displayedLogLines, totalLogLines }) { export class LogContainer extends Component { constructor(props) { super(props); - this.state = { loading: true, logs: [] }; + this.state = { groupsExpanded: {}, loading: true, logs: [] }; this.logRef = createRef(); this.textRef = createRef(); } @@ -116,6 +119,15 @@ export class LogContainer extends Component { this.cancelled = true; } + onToggleGroup = ({ expanded, groupIndex }) => { + this.setState(({ groupsExpanded }) => ({ + groupsExpanded: { + ...groupsExpanded, + [groupIndex]: expanded + } + })); + }; + handleLogScroll = () => { if (!this.state.loading) { const isLogBottomUnseen = this.isLogBottomUnseen(); @@ -296,9 +308,14 @@ export class LogContainer extends Component { } const { - groups: { level, message, timestamp } + groups: { command, message, timestamp } } = logFormatRegex.exec(line); + let level; + if (!['group', 'endgroup'].includes(command)) { + level = command; + } return { + command, level, message, timestamp @@ -310,6 +327,7 @@ export class LogContainer extends Component { } = this.props; const { reason } = (stepStatus && stepStatus.terminated) || {}; const { + groupsExpanded, logs = [ intl.formatMessage({ id: 'dashboard.pipelineRun.logEmpty', @@ -319,7 +337,8 @@ export class LogContainer extends Component { } = this.state; let previousTimestamp; - const parsedLogs = logs.reduce((acc, line) => { + let currentGroupIndex = null; + const parsedLogs = logs.reduce((acc, line, index) => { const parsedLogLine = parseLogLine(line); if (!parsedLogLine.timestamp) { // multiline log, use same timestamp as previous line @@ -328,6 +347,39 @@ export class LogContainer extends Component { previousTimestamp = parsedLogLine.timestamp; } + const isGroup = parsedLogLine.command === 'group'; + const isEndGroup = parsedLogLine.command === 'endgroup'; + if (isGroup) { + currentGroupIndex = index; + parsedLogLine.groupIndex = index; + if (groupsExpanded[index] !== false) { + // if not already explicitly collapsed, + // respect state if explicitly expanded, otherwise + // should be expanded by default if viewed when step running + // or collapsed by default if viewing after run complete + parsedLogLine.expanded = + groupsExpanded[index] || this.wasRunningAfterMounting(); + } + } else if (isEndGroup) { + currentGroupIndex = null; + // we don't render anything for the endgroup command + return acc; + } + + if (!isGroup && currentGroupIndex !== null) { + // we're inside a group, determine if the line should be rendered + if ( + groupsExpanded[currentGroupIndex] === false || + (!groupsExpanded[currentGroupIndex] && + !this.wasRunningAfterMounting()) + ) { + // skip if either explicitly collapsed, or viewed after step completed so collapsed by default + return acc; + } + + parsedLogLine.groupIndex = currentGroupIndex; + } + if ( !logLevels || // we treat lines with no log level as if they specified 'info' @@ -351,6 +403,7 @@ export class LogContainer extends Component { ); @@ -378,6 +431,7 @@ export class LogContainer extends Component { )} diff --git a/packages/components/src/components/Log/samples/timestamps_log_levels.txt b/packages/components/src/components/Log/samples/timestamps_log_levels.txt index 581404241..efc29a7db 100644 --- a/packages/components/src/components/Log/samples/timestamps_log_levels.txt +++ b/packages/components/src/components/Log/samples/timestamps_log_levels.txt @@ -84,7 +84,7 @@ 2024-11-14T14:11:42.498953257Z For more information on the stages that can be skipped refer to the doc link below 2024-11-14T14:11:42.498957672Z https://cloud.ibm.com/docs/devsecops?topic=devsecops-cd-devsecops-pr-pipeline 2024-11-14T14:11:42.498961928Z -2024-11-14T14:11:42.498964925Z +2024-11-14T14:11:42.498964925Z ::group::Process repository parameters 2024-11-14T14:11:43.342596128Z ::debug::[set-commit-status:48] | repository: https://github.com/example-org/example-app 2024-11-14T14:11:43.342933970Z ::debug::[set-commit-status:60] | commit-sha: b00b89b53ead4344a55d003b1f5698183e6128ab 2024-11-14T14:11:43.342946722Z ::debug::[set-commit-status:72] | state: pending @@ -112,6 +112,7 @@ 2024-11-14T14:11:43.750947704Z ::debug::[get_repo_params:167] | Unable to retreive Personal Access Token. Attempt to fetch token from Toolchain Broker. 2024-11-14T14:11:43.782406568Z ::debug::[get_repo_params:89] | get_absolute_scm_type called for https://github.com/example-org/example-app. SCM Type identified as github 2024-11-14T14:11:44.375617191Z ::debug::[get_credentials_v2:187] | Fetch Git Token for SCM Type: github, SCM ID: integrated, Repository URL: https://github.com/example-org/example-app. +2024-11-14T14:11:44.375617191Z ::endgroup:: 2024-11-14T14:11:44.415251742Z ::info::Fetch git token for https://github.com/example-org/example-inventory.git 2024-11-14T14:11:44.507177971Z ::debug::[get_credentials_v2:106] | Returning git token for https://github.com/example-org/example-inventory.git as found in cache. 2024-11-14T14:11:44.825013004Z ::debug::[set-commit-status:219] | Calling set-commit-status with params %s: --state=pending --targetURL=https://cloud.ibm.com/devops/pipelines/tekton/a3fe08ad-d5de-4cd2-98b0-f5c5219a33cd/runs/5f3949cf-de85-415c-a6cc-74a336bbe206/code-unit-tests/run-stage?env_id=ibm:yp:us-south --context=tekton/code-unit-tests --description=Running unit tests... --git-provider=github --git-token-path=/workspace/app/secrets/app-token --git-api-url=https://github.com/api/v3 diff --git a/packages/components/src/components/LogFormat/LogFormat.jsx b/packages/components/src/components/LogFormat/LogFormat.jsx index 8b980b33e..d3100b832 100644 --- a/packages/components/src/components/LogFormat/LogFormat.jsx +++ b/packages/components/src/components/LogFormat/LogFormat.jsx @@ -1,5 +1,5 @@ /* -Copyright 2020-2024 The Tekton Authors +Copyright 2020-2025 The Tekton Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -10,6 +10,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +/* istanbul ignore file */ import tlds from 'tlds'; import LinkifyIt from 'linkify-it'; @@ -92,7 +93,11 @@ const linkify = (str, styleObj, classNameString) => { return elements; }; -const LogFormat = ({ fields = { message: true }, logs = [] }) => { +const LogFormat = ({ + fields = { message: true }, + logs = [], + onToggleGroup +}) => { let properties = { classes: {}, foregroundColor: null, @@ -249,7 +254,14 @@ const LogFormat = ({ fields = { message: true }, logs = [] }) => { }; const parse = (log, index) => { - const { level, message = '', timestamp } = log; + const { + command, + expanded, + groupIndex = null, + level, + message = '', + timestamp + } = log; if (!message?.length && !timestamp && !level) { return
; } @@ -283,7 +295,9 @@ const LogFormat = ({ fields = { message: true }, logs = [] }) => { return (
@@ -297,7 +311,19 @@ const LogFormat = ({ fields = { message: true }, logs = [] }) => { )} {fields.level && getDecoratedLevel(level)} - {line} + {command === 'group' && ( +
+ onToggleGroup({ expanded: event.target.open, groupIndex }) + } + open={expanded} + > + {line} +
+ )} + {!['group', 'endgroup'].includes(command) && ( + {line} + )}
); }; diff --git a/packages/components/src/components/LogFormat/LogFormat.stories.js b/packages/components/src/components/LogFormat/LogFormat.stories.js index 48a0f030e..1a4a19c33 100644 --- a/packages/components/src/components/LogFormat/LogFormat.stories.js +++ b/packages/components/src/components/LogFormat/LogFormat.stories.js @@ -1,5 +1,5 @@ /* -Copyright 2020-2024 The Tekton Authors +Copyright 2020-2025 The Tekton Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -148,6 +148,35 @@ export const LogLevelsAndTimestamps = { timestamp: '2024-11-14T14:10:56.869719012Z', level: 'notice', message: 'Sample notice' + }, + { + timestamp: '2024-11-14T14:10:56.869719012Z', + command: 'group', + expanded: false, + message: 'Collapsed group' + }, + { + timestamp: '2024-11-14T14:10:56.869719012Z', + command: 'group', + expanded: true, + message: 'Expanded group' + }, + { + timestamp: '2024-11-14T14:10:56.869719012Z', + level: 'info', + isInGroup: true, + message: 'First line inside group' + }, + { + timestamp: '2024-11-14T14:10:56.869719012Z', + level: 'debug', + isInGroup: true, + message: 'Second line inside group' + }, + { + timestamp: '2024-11-14T14:10:56.869719012Z', + isInGroup: true, + message: 'A line with no log level inside a group' } ] } diff --git a/packages/components/src/components/LogFormat/LogFormat.test.jsx b/packages/components/src/components/LogFormat/LogFormat.test.jsx index f9a379445..b3d843515 100644 --- a/packages/components/src/components/LogFormat/LogFormat.test.jsx +++ b/packages/components/src/components/LogFormat/LogFormat.test.jsx @@ -1,5 +1,5 @@ /* -Copyright 2020-2024 The Tekton Authors +Copyright 2020-2025 The Tekton Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -321,7 +321,7 @@ describe('LogFormat', () => { const logs = [{ message: 'Hello' }, { message: '' }, { message: 'World' }]; const { container } = render(); expect(container.childNodes[0].innerHTML).toBe( - '
Hello

World
' + '
Hello

World
' ); }); @@ -352,7 +352,7 @@ describe('LogFormat', () => { ); expect(container.childNodes[0].innerHTML).toBe( - '
Hello
' + '
Hello
' ); rerender(); expect(container.childNodes[0].innerHTML).toMatch( @@ -361,7 +361,7 @@ describe('LogFormat', () => { // accept anything (`.*`) for test purposes as it may be localised // and we're more concerned with the structure here new RegExp( - `
.*Hello
` + `
.*Hello
` ) ); }); diff --git a/packages/components/src/components/LogFormat/_LogFormat.scss b/packages/components/src/components/LogFormat/_LogFormat.scss index 8a793f6cd..a6942d9b0 100644 --- a/packages/components/src/components/LogFormat/_LogFormat.scss +++ b/packages/components/src/components/LogFormat/_LogFormat.scss @@ -1,5 +1,5 @@ /* -Copyright 2020-2024 The Tekton Authors +Copyright 2020-2025 The Tekton Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -117,6 +117,26 @@ $colors: ( font-weight: 300; } + &.tkn--log-line--group { + details { + display: inline-block; + } + } + + &.tkn--log-line--in-group { + // indent log content inside an expanded group + // account for lines with no explicit log level + .tkn--log-line--level, + .tkn--log-line--content { + margin-inline-start: 0.5rem; + } + + // reset for lines with log level so we only apply indent on log level + .tkn--log-line--level + .tkn--log-line--content { + margin-inline-start: 0; + } + } + .tkn--log-line--level { display: inline-block; padding-inline: 4px; diff --git a/packages/e2e/cypress.config.js b/packages/e2e/cypress.config.js index 49b7bd1f1..51757d30e 100644 --- a/packages/e2e/cypress.config.js +++ b/packages/e2e/cypress.config.js @@ -1,5 +1,5 @@ /* -Copyright 2022-2024 The Tekton Authors +Copyright 2022-2025 The Tekton Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at diff --git a/packages/e2e/cypress/e2e/common/pipelinerun.cy.js b/packages/e2e/cypress/e2e/common/pipelinerun.cy.js index 35c96138a..8d214501d 100644 --- a/packages/e2e/cypress/e2e/common/pipelinerun.cy.js +++ b/packages/e2e/cypress/e2e/common/pipelinerun.cy.js @@ -42,6 +42,9 @@ spec: #!/bin/ash echo "Hello World!" echo "::debug::This line should be hidden by default" + echo "::group::This is the start of a group" + echo "::info::This line is inside a group" + echo "::endgroup::" `; cy.applyResource(pipelineRun); @@ -70,5 +73,20 @@ spec: cy.contains('Debug').click(); cy.get('.tkn--log-settings-menu button').type('{esc}'); cy.contains('.tkn--log', 'hidden by default'); + + // Reload page to test log group behaviour after step completed (collapsed by default). + // The step runs so quickly we may not see expanded by default behaviour during the run + // as the logs may only load after the step is completed. + // `cy.reload()` is causing the renderer to crash consistently when run in CI, so navigate + // to the run again instead. + // cy.reload(); + cy.visit(`/#/pipelineruns`); + cy.contains('h1', 'PipelineRuns'); + cy.get(`[title=${pipelineRunName}]`).click(); + + cy.contains('.tkn--log', 'Hello World!'); + cy.contains('.tkn--log', 'inside a group').should('not.exist'); + cy.contains('summary', 'start of a group').click(); + cy.contains('.tkn--log', 'inside a group'); }); }); diff --git a/src/nls/messages_en.json b/src/nls/messages_en.json index 0cb2a2765..02cd45f7a 100644 --- a/src/nls/messages_en.json +++ b/src/nls/messages_en.json @@ -148,9 +148,9 @@ "dashboard.logo.alt": "Tekton logo", "dashboard.logo.tooltip": "Meow", "dashboard.logs.downloadButtonTooltip": "Download logs", - "dashboard.logs.hidden": "{numHiddenLines, plural, other {# lines}} hidden due to selected log levels", - "dashboard.logs.hidden.all": "All lines hidden due to selected log levels", - "dashboard.logs.hidden.one": "1 line hidden due to selected log levels", + "dashboard.logs.hidden": "{numHiddenLines, plural, other {# lines}} hidden due to selected log levels or collapsed groups", + "dashboard.logs.hidden.all": "All lines hidden due to selected log levels or collapsed groups", + "dashboard.logs.hidden.one": "1 line hidden due to selected log levels or collapsed groups", "dashboard.logs.launchButtonTooltip": "Open logs in a new window", "dashboard.logs.logLevels.debug": "Debug", "dashboard.logs.logLevels.error": "Error",