Skip to content

Commit de7d7ab

Browse files
committed
Improve log display
Add support for toggling display of timestamps in the logs, so the user no longer has to navigate away to the settings page to do this. This is available on both PipelineRun and TaskRun pages. We always request timestamps and just show / hide them depending on the user's preference. This is persisted to browser localStorage as with the toggle on the settings page. Add support for detecting GitHub Actions workflow command-style log levels in log output. This provides an improved user experience as it allows for filtering logs to hide unwanted noise, e.g. debug logs, by default. We may consider allowing the format to be customised in a future release depending on user feedback. Refactor the styles so `LogFormat` now correctly owns most of the styling of the log content, with `Log` only responsible for additional styling of the container. Refactor use of the `LogsToolbar` component to allow for customisable use by third-party consumers of the Dashboard components. This means they can much more easily take advantage of the new features, such as toggling timestamps and log levels, without having to reimplement the menu and related code themselves. Eliminate redundant use of `split` and `join` calls when processing the logs, improving performance. `LogFormat` now receives an array of log line objects, pre-parsed into the new structure with the `timestamp`, `level` (optional), and `message` fields. Where a multiline log is encountered, the timestamp of the first line is reused for subsequent lines in that log. Fix issue where in some cases a blank line did not reserve vertical space, leading to cramped display of logs. Now each line is guaranteed to occupy a minimum height, ensuring blank lines output in the logs to aid in readability are preserved in the UI. Update `FormattedDate` to add support for displaying seconds, as this is quite important in the log context. Default to `false` for this setting so existing date / timestamps in other parts of the UI are unaffected. The full raw timestamp as received in the logs in displayed in a tooltip on hover. Update unit tests to reflect the new and changed components and behaviours. Update common PipelineRun E2E to exercise the new log toolbar and validate the log content is rendered as expected. Add new stories to cover the new functionality. Update existing stories to demonstrate use of the new functionality in context. Update Carbon: - resolve issue with Plex Mono font Some glyphs weren't included in the Plex version packaged with previous Carbon releases, resulting in broken formatting for some log content, e.g. using box characters to print tables. In `@carbon/react` 1.71.0 the Plex version has been updated, as well as changing how it's consumed. Instead of a single package with all of the font variants, they're now published as separate packages per font family. Add the `$use-per-family-plex` flag to our config to use these new packages. The custom `$font-path` is still required for compatibility with Vite. - resolve issue with duplicate onChange events from MenuItemSelectable - resolve issue with duplicate onChange events when clearing a ComboBox - document the log viewer feature, the new log format, and the existing external logs support Notes: - colours of the log level badges are based on the colours of the Carbon `Tag` component, with their opacity reduced so they're not as intense due to the potentially large number of them that could be displayed in the logs. These all meet minimum colour contrast ratio required for WCAG 2.0 level AA (i.e. > 4.5:1). - the default log level is 'info' if no log level is explicitly provided in the logs, however we only display the badge when the log level is explicitly set. This avoids unnecessary and unwanted noise / clutter in the logs when not using the new log format. - highlight and hover state included to highlight log lines, aiding in consuming the content, especially with longer log lines where the log level badge may not be adjacent to the content being read. A future update to the log viewer will add support for line wrapping but this is out of scope for this particular change.
1 parent 33ae69c commit de7d7ab

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1416
-339
lines changed

.eslintrc.cjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ module.exports = {
6565
'import/no-extraneous-dependencies': 'off',
6666
'import/no-named-as-default': 'off',
6767
'import/no-named-as-default-member': 'off',
68-
'import/no-unresolved': ['error', { ignore: ['\\.svg\\?react$'] }],
68+
'import/no-unresolved': [
69+
'error',
70+
{ ignore: ['\\.svg\\?react$', '\\.txt\\?raw$'] }
71+
],
6972
'import/prefer-default-export': 'off',
7073
'jsx-a11y/anchor-is-valid': 'off',
7174
'no-case-declarations': 'off',

docs/logs.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<!--
2+
---
3+
linkTitle: "Logs"
4+
weight: 4
5+
---
6+
-->
7+
8+
# Tekton Dashboard log viewer
9+
10+
This guide describes the features and functionality of the log viewer provided by the Tekton Dashboard on the `TaskRun` and `PipelineRun` details pages.
11+
12+
## Basic functionality
13+
14+
The Tekton Dashboard log viewer supports ANSI colour codes and text styles, and will automatically detect URLs in log content and render them as clickable links opening in a new window.
15+
16+
## Toolbar
17+
18+
The toolbar diplayed in the log viewer includes a number of additional features, including:
19+
20+
- maximize: increase the area available to the log viewer by hiding the task list and run header. This allows the user to eliminate distractions from other parts of the app and focus on the log content.
21+
- open in new window: open the raw logs in a separate browser window. This provides an unmodified and unprocessed view of the logs, without any of the added features provided by the log viewer.
22+
- download: download the raw logs as a text file.
23+
- user preferences: these are persisted to browser local storage and applied to all logs in the app. See the following sections for more details.
24+
25+
### Timestamps
26+
27+
The Dashboard will always request logs with timestamps from the Kubernetes pod logs API, and show / hide the timestamps in the log viewer based on the user's preference. This can be toggled from the settings menu in the toolbar at the top of the log viewer.
28+
29+
The timestamps are localised based on users' browser settings, with the raw timestamp value received from the API provided as a tooltip on hover.
30+
31+
In releases prior to Tekton Dashboard v0.54, the timestamp preference was found on the Settings page, and governed whether or not timestamps were requested from the Kubernetes API server.
32+
33+
### Log levels
34+
35+
The log viewer parses log lines to detect the associated log level and decorate them accordingly to help with log consumability. The format supported is described below.
36+
37+
```
38+
<timestamp> ::<level>::<message>
39+
```
40+
41+
- `timestamp` is provided by the Kubernetes API server
42+
- `level` is one of `error`, `warning`, `notice`, `info`, `debug`
43+
- `debug` logs are hidden by default
44+
- any log line without an explicit `level` is considered as `info`, but will not display the log level badge to avoid redundancy in the UI where users are not using the supported log format
45+
- `message` is any other content on the line, and may contain ANSI codes for formatting, etc.
46+
47+
For example, the following snippet would output a log line at the `warning` level:
48+
49+
```sh
50+
echo '::warning::Something that may require attention but is non-blocking…'
51+
```
52+
53+
The displayed log levels can be changed via the settings menu in the toolbar at the top of the log viewer.
54+
55+
## Logs persistence
56+
57+
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.
58+
59+
This functionality is described in detail, along with a full walk-through of an example configuration, in [Tekton Dashboard walk-through - Logs persistence](./walkthrough/walkthrough-logs.md).
60+
61+
It can be enabled by providing the `--external-logs` flag to the installer script, or configured directly in the Dashboard deployment's args.
62+
63+
When configured, the Dashboard will first attempt to load pod logs normally, and if they're unavailable will fallback to the provided external logs service by making a `GET` request to the provided endpoint with the following format:
64+
65+
```
66+
GET <external-logs>/<namespace>/<podName>/<container>?startTime=<stepStartTime>&completionTime=<stepCompletionTime>
67+
```
68+
69+
- `namespace`: the namespace containing the run
70+
- `podName`: the name of the `Pod` resource associated with the selected `TaskRun`
71+
- `container`: the name of the container associated with the selected `step`
72+
- `stepStartTime`: the start time of the step container
73+
- `stepCompletionTime`: the completion time of the step container
74+
75+
If the start / completion times are unavailable their respective query parameters will be omitted from the request.
76+
77+
---
78+
79+
Except as otherwise noted, the content of this page is licensed under the [Creative Commons Attribution 4.0 License](https://creativecommons.org/licenses/by/4.0/). Code samples are licensed under the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0).

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/components/src/components/ActionableNotification/ActionableNotification.jsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ import {
1818

1919
export default function ActionableNotification(props) {
2020
return (
21-
<FeatureFlags
22-
flags={{ 'enable-experimental-focus-wrap-without-sentinels': true }}
23-
>
21+
<FeatureFlags enableExperimentalFocusWrapWithoutSentinels>
2422
<CarbonActionableNotification {...props} />
2523
</FeatureFlags>
2624
);

packages/components/src/components/FormattedDate/FormattedDate.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { FormattedDate, FormattedRelativeTime, useIntl } from 'react-intl';
1616
const FormattedDateWrapper = ({
1717
date,
1818
formatTooltip = formattedDate => formattedDate,
19+
includeSeconds = false,
1920
relative
2021
}) => {
2122
const intl = useIntl();
@@ -47,6 +48,7 @@ const FormattedDateWrapper = ({
4748
year={yearFormat}
4849
hour="numeric"
4950
minute="numeric"
51+
{...(includeSeconds ? { second: 'numeric' } : null)}
5052
/>
5153
);
5254
}
@@ -56,7 +58,8 @@ const FormattedDateWrapper = ({
5658
month: 'long',
5759
year: 'numeric',
5860
hour: 'numeric',
59-
minute: 'numeric'
61+
minute: 'numeric',
62+
...(includeSeconds ? { second: 'numeric' } : null)
6063
});
6164
formattedDate = formatTooltip(formattedDate);
6265
return <span title={formattedDate}>{content}</span>;

packages/components/src/components/FormattedDate/FormattedDate.stories.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,9 @@ export const Relative = {
3333
};
3434

3535
export const Absolute = {};
36+
37+
export const Seconds = {
38+
args: {
39+
includeSeconds: true
40+
}
41+
};

packages/components/src/components/FormattedDate/FormattedDate.test.jsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,18 @@ describe('FormattedDate', () => {
2121
});
2222

2323
it('handles absolute date formatting', () => {
24-
const { queryByText } = render(<FormattedDate date="2019/12/01" />);
25-
expect(queryByText(/Dec 1, 2019/i)).toBeTruthy();
24+
const { queryByText } = render(
25+
<FormattedDate date="2019/12/01 12:13:14" />
26+
);
27+
expect(queryByText(/Dec 1, 2019, 12:13/i)).toBeTruthy();
28+
expect(queryByText(/:14/i)).toBeFalsy();
29+
});
30+
31+
it('handles absolute date formatting with seconds', () => {
32+
const { queryByText } = render(
33+
<FormattedDate date="2019/12/01 12:13:14" includeSeconds />
34+
);
35+
expect(queryByText(/Dec 1, 2019, 12:13:14/i)).toBeTruthy();
2636
});
2737

2838
it('handles absolute date formatting for current year', () => {

packages/components/src/components/Log/Log.jsx

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,16 @@ import {
2727
import DotSpinner from '../DotSpinner';
2828
import LogFormat from '../LogFormat';
2929

30-
const LogLine = ({ data, index, style }) => (
31-
<div style={style}>
32-
<LogFormat>{`${data[index]}\n`}</LogFormat>
33-
</div>
34-
);
35-
36-
const itemSize = 15; // This should be kept in sync with the line-height in SCSS
30+
const itemSize = 16; // This should be kept in sync with the line-height in SCSS
3731
const defaultHeight = itemSize * 100 + itemSize / 2;
3832

33+
const logFormatRegex =
34+
/^((?<timestamp>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3,9}Z)\s?)?(::(?<level>error|warning|info|notice|debug)::)?(?<message>.*)?$/s;
35+
3936
export class LogContainer extends Component {
4037
constructor(props) {
4138
super(props);
42-
this.state = { loading: true };
39+
this.state = { loading: true, logs: [] };
4340
this.logRef = createRef();
4441
this.textRef = createRef();
4542
}
@@ -244,7 +241,27 @@ export class LogContainer extends Component {
244241
};
245242

246243
getLogList = () => {
247-
const { stepStatus, intl } = this.props;
244+
const {
245+
intl,
246+
logLevels,
247+
parseLogLine = line => {
248+
if (!line?.length) {
249+
return { message: line };
250+
}
251+
252+
const {
253+
groups: { level, message, timestamp }
254+
} = logFormatRegex.exec(line);
255+
return {
256+
level,
257+
message,
258+
timestamp
259+
};
260+
},
261+
showLevels,
262+
showTimestamps,
263+
stepStatus
264+
} = this.props;
248265
const { reason } = (stepStatus && stepStatus.terminated) || {};
249266
const {
250267
logs = [
@@ -255,8 +272,35 @@ export class LogContainer extends Component {
255272
]
256273
} = this.state;
257274

258-
if (logs.length < 20000) {
259-
return <LogFormat>{logs.join('\n')}</LogFormat>;
275+
let previousTimestamp;
276+
const parsedLogs = logs.reduce((acc, line) => {
277+
const parsedLogLine = parseLogLine(line);
278+
if (!parsedLogLine.timestamp) {
279+
// multiline log, use same timestamp as previous line
280+
parsedLogLine.timestamp = previousTimestamp;
281+
} else {
282+
previousTimestamp = parsedLogLine.timestamp;
283+
}
284+
285+
if (
286+
!logLevels ||
287+
// we treat lines with no log level as if they specified 'info'
288+
// but we don't display a default level for these lines to avoid
289+
// unnecessary noise for users not using the expected log format
290+
(!parsedLogLine.level && logLevels.info) ||
291+
logLevels[parsedLogLine.level]
292+
) {
293+
acc.push(parsedLogLine);
294+
}
295+
return acc;
296+
}, []);
297+
if (parsedLogs.length < 20_000) {
298+
return (
299+
<LogFormat
300+
fields={{ level: showLevels, timestamp: showTimestamps }}
301+
logs={parsedLogs}
302+
/>
303+
);
260304
}
261305

262306
const height = reason
@@ -266,12 +310,19 @@ export class LogContainer extends Component {
266310
return (
267311
<List
268312
height={height}
269-
itemCount={logs.length}
270-
itemData={logs}
313+
itemCount={parsedLogs.length}
314+
itemData={parsedLogs}
271315
itemSize={itemSize}
272316
width="100%"
273317
>
274-
{LogLine}
318+
{({ data, index, style }) => (
319+
<div style={style}>
320+
<LogFormat
321+
fields={{ level: showLevels, timestamp: showTimestamps }}
322+
logs={[data[index]]}
323+
/>
324+
</div>
325+
)}
275326
</List>
276327
);
277328
};
@@ -333,7 +384,7 @@ export class LogContainer extends Component {
333384
logs += decoder.decode(value, { stream: !done });
334385
this.setState({
335386
loading: false,
336-
logs: logs.split('\n')
387+
logs: logs.split(/\r?\n/)
337388
});
338389
} else {
339390
this.setState({
@@ -376,7 +427,7 @@ export class LogContainer extends Component {
376427
} else {
377428
this.setState({
378429
loading: false,
379-
logs: logs ? logs.split('\n') : undefined
430+
logs: logs ? logs.split(/\r?\n/) : undefined
380431
});
381432
if (continuePolling) {
382433
clearTimeout(this.timer);

packages/components/src/components/Log/Log.stories.jsx

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@ See the License for the specific language governing permissions and
1111
limitations under the License.
1212
*/
1313

14+
import { useArgs } from '@storybook/preview-api';
15+
1416
import Log from './Log';
1517
import LogsToolbar from '../LogsToolbar';
1618

1719
const ansiLog =
1820
'\n=== demo-pipeline-run-1-build-skaffold-app-2mrdg-pod-59e217: build-step-git-source-skaffold-git-ml8j4 ===\n{"level":"info","ts":1553865693.943092,"logger":"fallback-logger","caller":"git-init/main.go:100","msg":"Successfully cloned https://github.com/GoogleContainerTools/skaffold @ \\"master\\" in path \\"/workspace\\""}\n\n=== demo-pipeline-run-1-build-skaffold-app-2mrdg-pod-59e217: build-step-build-and-push ===\n\u001b[36mINFO\u001b[0m[0000] Downloading base image golang:1.10.1-alpine3.7\n2019/03/29 13:21:34 No matching credentials were found, falling back on anonymous\n\u001b[36mINFO\u001b[0m[0001] Executing 0 build triggers\n\u001b[36mINFO\u001b[0m[0001] Unpacking rootfs as cmd RUN go build -o /app . requires it.\n\u001b[36mINFO\u001b[0m[0010] Taking snapshot of full filesystem...\n\u001b[36mINFO\u001b[0m[0015] Using files from context: [/workspace/examples/microservices/leeroy-app/app.go]\n\u001b[36mINFO\u001b[0m[0015] COPY app.go .\n\u001b[36mINFO\u001b[0m[0015] Taking snapshot of files...\n\u001b[36mINFO\u001b[0m[0015] RUN go build -o /app .\n\u001b[36mINFO\u001b[0m[0015] cmd: /bin/sh\n\u001b[36mINFO\u001b[0m[0015] args: [-c go build -o /app .]\n\u001b[36mINFO\u001b[0m[0016] Taking snapshot of full filesystem...\n\u001b[36mINFO\u001b[0m[0036] CMD ["./app"]\n\u001b[36mINFO\u001b[0m[0036] COPY --from=builder /app .\n\u001b[36mINFO\u001b[0m[0036] Taking snapshot of files...\nerror pushing image: failed to push to destination gcr.io/christiewilson-catfactory/leeroy-app:latest: Get https://gcr.io/v2/token?scope=repository%3Achristiewilson-catfactory%2Fleeroy-app%3Apush%2Cpull\u0026scope=repository%3Alibrary%2Falpine%3Apull\u0026service=gcr.io exit status 1\n\n=== demo-pipeline-run-1-build-skaffold-app-2mrdg-pod-59e217: nop ===\nBuild successful\n\r\r\n';
1921

20-
const long = Array.from({ length: 60000 }, (v, i) => `Line ${i + 1}\n`).join(
21-
''
22+
const long = Array.from({ length: 60000 }, (v, i) => `Line ${i + 1}`).join(
23+
'\n'
2224
);
2325

2426
const performanceTest = Array.from(
@@ -30,7 +32,7 @@ export default {
3032
component: Log,
3133
decorators: [
3234
Story => (
33-
<div style={{ width: '500px' }}>
35+
<div style={{ width: 'auto' }}>
3436
<Story />
3537
</div>
3638
)
@@ -85,13 +87,17 @@ export const ANSICodes = {
8587
export const Windowed = {
8688
args: {
8789
fetchLogs: () => long,
90+
showLevels: true,
91+
showTimestamps: true,
8892
stepStatus: { terminated: { reason: 'Completed', exitCode: 0 } }
8993
}
9094
};
9195

9296
export const Performance = {
9397
args: {
9498
fetchLogs: () => performanceTest,
99+
showLevels: true,
100+
showTimestamps: true,
95101
stepStatus: { terminated: { reason: 'Completed', exitCode: 0 } }
96102
},
97103
name: 'performance test (<20,000 lines with ANSI)'
@@ -109,8 +115,39 @@ export const Skipped = {
109115

110116
export const Toolbar = {
111117
args: {
112-
fetchLogs: () => 'A log message',
113-
stepStatus: { terminated: { reason: 'Completed', exitCode: 0 } },
114-
toolbar: <LogsToolbar name="step_log_filename.txt" url="/step/log/url" />
118+
fetchLogs: async () =>
119+
(await import('./samples/timestamps_log_levels.txt?raw')).default,
120+
logLevels: {
121+
error: true,
122+
warning: true,
123+
info: true,
124+
notice: true,
125+
debug: false
126+
},
127+
showLevels: true,
128+
showTimestamps: false,
129+
stepStatus: { terminated: { reason: 'Completed', exitCode: 0 } }
130+
},
131+
render: args => {
132+
const [, updateArgs] = useArgs();
133+
return (
134+
<Log
135+
{...args}
136+
toolbar={
137+
<LogsToolbar
138+
logLevels={args.showLevels ? args.logLevels : null}
139+
name="step_log_filename.txt"
140+
onToggleLogLevel={logLevel =>
141+
updateArgs({ logLevels: { ...args.logLevels, ...logLevel } })
142+
}
143+
onToggleShowTimestamps={showTimestamps =>
144+
updateArgs({ showTimestamps })
145+
}
146+
showTimestamps={args.showTimestamps}
147+
url="/step/log/url"
148+
/>
149+
}
150+
/>
151+
);
115152
}
116153
};

0 commit comments

Comments
 (0)