Merge pull request #8369 from backstage/rugvip/logs
core-components: add new LogViewer component + use in plugins
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cloudbuild': patch
|
||||
---
|
||||
|
||||
Remove unnecessary dependency.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Switch to using `LogViewer` component from `@backstage/core-components` to display build logs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Introduce new `LogViewer` component that can be used to display logs. It supports copying, searching, filtering, and displaying text with ANSI color escape codes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-github-actions': patch
|
||||
---
|
||||
|
||||
Switch to using `LogViewer` component from `@backstage/core-components` to display build logs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-circleci': patch
|
||||
---
|
||||
|
||||
Switch to using `LogViewer` component from `@backstage/core-components` to display action output.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Switch to using `LogViewer` component from `@backstage/core-components` to display scaffolder logs.
|
||||
@@ -609,6 +609,48 @@ export type LinkProps = LinkProps_2 &
|
||||
// @public (undocumented)
|
||||
export type LoginRequestListItemClassKey = 'root';
|
||||
|
||||
// @public
|
||||
export function LogViewer(props: LogViewerProps): JSX.Element;
|
||||
|
||||
// @public
|
||||
export type LogViewerClassKey =
|
||||
| 'root'
|
||||
| 'header'
|
||||
| 'log'
|
||||
| 'line'
|
||||
| 'lineSelected'
|
||||
| 'lineCopyButton'
|
||||
| 'lineNumber'
|
||||
| 'textHighlight'
|
||||
| 'textSelectedHighlight'
|
||||
| 'modifierBold'
|
||||
| 'modifierItalic'
|
||||
| 'modifierUnderline'
|
||||
| 'modifierForegroundBlack'
|
||||
| 'modifierForegroundRed'
|
||||
| 'modifierForegroundGreen'
|
||||
| 'modifierForegroundYellow'
|
||||
| 'modifierForegroundBlue'
|
||||
| 'modifierForegroundMagenta'
|
||||
| 'modifierForegroundCyan'
|
||||
| 'modifierForegroundWhite'
|
||||
| 'modifierForegroundGrey'
|
||||
| 'modifierBackgroundBlack'
|
||||
| 'modifierBackgroundRed'
|
||||
| 'modifierBackgroundGreen'
|
||||
| 'modifierBackgroundYellow'
|
||||
| 'modifierBackgroundBlue'
|
||||
| 'modifierBackgroundMagenta'
|
||||
| 'modifierBackgroundCyan'
|
||||
| 'modifierBackgroundWhite'
|
||||
| 'modifierBackgroundGrey';
|
||||
|
||||
// @public
|
||||
export interface LogViewerProps {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "MarkdownContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@types/react-sparklines": "^1.7.0",
|
||||
"@types/react-text-truncate": "^0.14.0",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"classnames": "^2.2.6",
|
||||
"clsx": "^1.1.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
@@ -61,6 +62,8 @@
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
"react-text-truncate": "^0.16.0",
|
||||
"react-use": "^17.2.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.6",
|
||||
"remark-gfm": "^2.0.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
@@ -81,12 +84,15 @@
|
||||
"@types/d3-selection": "^3.0.1",
|
||||
"@types/d3-shape": "^3.0.1",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"@types/ansi-regex": "^5.0.0",
|
||||
"@types/dagre": "^0.7.44",
|
||||
"@types/google-protobuf": "^3.7.2",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/react-helmet": "^6.1.0",
|
||||
"@types/react-syntax-highlighter": "^13.5.2",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/zen-observable": "^0.8.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { AnsiProcessor } from './AnsiProcessor';
|
||||
|
||||
describe('AnsiProcessor', () => {
|
||||
it('should process a single line', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(processor.process('foo\x1b[31mbar\x1b[39mbaz')).toEqual([
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'foo',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'bar',
|
||||
modifiers: { foreground: 'red' },
|
||||
},
|
||||
{
|
||||
text: 'baz',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'foobarbaz',
|
||||
lineNumber: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(processor.process(`foo [32mbar[39m: baz`)).toEqual([
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'foo ',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'bar',
|
||||
modifiers: { foreground: 'green' },
|
||||
},
|
||||
{
|
||||
text: ': baz',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'foo bar: baz',
|
||||
lineNumber: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process multiple lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(
|
||||
processor.process(`
|
||||
a\x1b[34mb\x1b[39mc
|
||||
x\x1b[44my\x1b[49mz
|
||||
`),
|
||||
).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'blue' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { background: 'blue' },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'xyz',
|
||||
lineNumber: 3,
|
||||
},
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry state across lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(
|
||||
processor.process(`
|
||||
a\x1b[45mb\x1b[35mc
|
||||
x\x1b[39my\x1b[49mz`),
|
||||
).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'magenta', background: 'magenta' },
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: { foreground: 'magenta', background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'xyz',
|
||||
lineNumber: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry forward state when appending lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
const out1 = processor.process(`
|
||||
a\x1b[36mb\x1b[3mc`);
|
||||
expect(out1).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'cyan' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const out2 = processor.process(`
|
||||
a\x1b[36mb\x1b[3mc
|
||||
x\x1b[39my\x1b[23mz`);
|
||||
expect(out2).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'cyan' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { italic: true },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'xyz',
|
||||
lineNumber: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
// Verifies that we appended rather than reprocessed
|
||||
expect(out1[0]).toBe(out2[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import ansiRegexMaker from 'ansi-regex';
|
||||
|
||||
const ansiRegex = ansiRegexMaker();
|
||||
const newlineRegex = /\n\r?/g;
|
||||
|
||||
// A mapping of how each escape code changes the modifiers
|
||||
const codeModifiers = Object.fromEntries(
|
||||
Object.entries({
|
||||
1: m => ({ ...m, bold: true }),
|
||||
3: m => ({ ...m, italic: true }),
|
||||
4: m => ({ ...m, underline: true }),
|
||||
22: ({ bold: _, ...m }) => m,
|
||||
23: ({ italic: _, ...m }) => m,
|
||||
24: ({ underline: _, ...m }) => m,
|
||||
30: m => ({ ...m, foreground: 'black' }),
|
||||
31: m => ({ ...m, foreground: 'red' }),
|
||||
32: m => ({ ...m, foreground: 'green' }),
|
||||
33: m => ({ ...m, foreground: 'yellow' }),
|
||||
34: m => ({ ...m, foreground: 'blue' }),
|
||||
35: m => ({ ...m, foreground: 'magenta' }),
|
||||
36: m => ({ ...m, foreground: 'cyan' }),
|
||||
37: m => ({ ...m, foreground: 'white' }),
|
||||
39: ({ foreground: _, ...m }) => m,
|
||||
90: m => ({ ...m, foreground: 'grey' }),
|
||||
40: m => ({ ...m, background: 'black' }),
|
||||
41: m => ({ ...m, background: 'red' }),
|
||||
42: m => ({ ...m, background: 'green' }),
|
||||
43: m => ({ ...m, background: 'yellow' }),
|
||||
44: m => ({ ...m, background: 'blue' }),
|
||||
45: m => ({ ...m, background: 'magenta' }),
|
||||
46: m => ({ ...m, background: 'cyan' }),
|
||||
47: m => ({ ...m, background: 'white' }),
|
||||
49: ({ background: _, ...m }) => m,
|
||||
} as Record<string, (m: ChunkModifiers) => ChunkModifiers>).map(
|
||||
([code, modifier]) => [`\x1b[${code}m`, modifier],
|
||||
),
|
||||
);
|
||||
|
||||
export type AnsiColor =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'magenta'
|
||||
| 'cyan'
|
||||
| 'white'
|
||||
| 'grey';
|
||||
|
||||
export interface ChunkModifiers {
|
||||
foreground?: AnsiColor;
|
||||
background?: AnsiColor;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
export interface AnsiChunk {
|
||||
text: string;
|
||||
modifiers: ChunkModifiers;
|
||||
}
|
||||
|
||||
export class AnsiLine {
|
||||
text: string;
|
||||
|
||||
constructor(
|
||||
readonly lineNumber: number = 1,
|
||||
readonly chunks: AnsiChunk[] = [],
|
||||
) {
|
||||
this.text = chunks
|
||||
.map(c => c.text)
|
||||
.join('')
|
||||
.toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
lastChunk(): AnsiChunk | undefined {
|
||||
return this.chunks[this.chunks.length - 1];
|
||||
}
|
||||
|
||||
replaceLastChunk(newChunks?: AnsiChunk[]) {
|
||||
if (newChunks) {
|
||||
this.chunks.splice(this.chunks.length - 1, 1, ...newChunks);
|
||||
this.text = this.chunks
|
||||
.map(c => c.text)
|
||||
.join('')
|
||||
.toLocaleLowerCase('en-US');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AnsiProcessor {
|
||||
private text: string = '';
|
||||
private lines: AnsiLine[] = [];
|
||||
|
||||
/**
|
||||
* Processes a chunk of text while keeping internal state that optimizes
|
||||
* subsequent processing that appends to the text.
|
||||
*/
|
||||
process(text: string): AnsiLine[] {
|
||||
if (this.text === text) {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
if (text.startsWith(this.text)) {
|
||||
const lastLineIndex = this.lines.length > 0 ? this.lines.length - 1 : 0;
|
||||
const lastLine = this.lines[lastLineIndex] ?? new AnsiLine();
|
||||
const lastChunk = lastLine.lastChunk();
|
||||
|
||||
const newLines = this.processLines(
|
||||
(lastChunk?.text ?? '') + text.slice(this.text.length),
|
||||
lastChunk?.modifiers,
|
||||
lastLine?.lineNumber,
|
||||
);
|
||||
lastLine.replaceLastChunk(newLines[0]?.chunks);
|
||||
|
||||
this.lines[lastLineIndex] = lastLine;
|
||||
this.lines.push(...newLines.slice(1));
|
||||
} else {
|
||||
this.lines = this.processLines(text);
|
||||
}
|
||||
this.text = text;
|
||||
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
// Split a chunk of text up into lines and process each line individually
|
||||
private processLines = (
|
||||
text: string,
|
||||
modifiers: ChunkModifiers = {},
|
||||
startingLineNumber: number = 1,
|
||||
): AnsiLine[] => {
|
||||
const lines: AnsiLine[] = [];
|
||||
|
||||
let currentModifiers = modifiers;
|
||||
let currentLineNumber = startingLineNumber;
|
||||
|
||||
let prevIndex = 0;
|
||||
newlineRegex.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = newlineRegex.exec(text);
|
||||
if (!match) {
|
||||
const chunks = this.processText(
|
||||
text.slice(prevIndex),
|
||||
currentModifiers,
|
||||
);
|
||||
lines.push(new AnsiLine(currentLineNumber, chunks));
|
||||
return lines;
|
||||
}
|
||||
|
||||
const line = text.slice(prevIndex, match.index);
|
||||
prevIndex = match.index + match[0].length;
|
||||
|
||||
const chunks = this.processText(line, currentModifiers);
|
||||
lines.push(new AnsiLine(currentLineNumber, chunks));
|
||||
|
||||
// Modifiers that are active in the last chunk are carried over to the next line
|
||||
currentModifiers =
|
||||
chunks[chunks.length - 1].modifiers ?? currentModifiers;
|
||||
currentLineNumber += 1;
|
||||
}
|
||||
};
|
||||
|
||||
// Processing of a one individual text chunk
|
||||
private processText = (
|
||||
fullText: string,
|
||||
modifiers: ChunkModifiers,
|
||||
): AnsiChunk[] => {
|
||||
const chunks: AnsiChunk[] = [];
|
||||
|
||||
let currentModifiers = modifiers;
|
||||
|
||||
let prevIndex = 0;
|
||||
ansiRegex.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = ansiRegex.exec(fullText);
|
||||
if (!match) {
|
||||
chunks.push({
|
||||
text: fullText.slice(prevIndex),
|
||||
modifiers: currentModifiers,
|
||||
});
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const text = fullText.slice(prevIndex, match.index);
|
||||
chunks.push({ text, modifiers: currentModifiers });
|
||||
|
||||
// For every escape code that we encounter we keep track of where the
|
||||
// next chunk of text starts, and what modifiers it has
|
||||
prevIndex = match.index + match[0].length;
|
||||
currentModifiers = this.processCode(match[0], currentModifiers);
|
||||
}
|
||||
};
|
||||
|
||||
private processCode = (
|
||||
code: string,
|
||||
modifiers: ChunkModifiers,
|
||||
): ChunkModifiers => {
|
||||
return codeModifiers[code]?.(modifiers) ?? modifiers;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { AnsiLine, ChunkModifiers } from './AnsiProcessor';
|
||||
import {
|
||||
calculateHighlightedChunks,
|
||||
findSearchResults,
|
||||
getModifierClasses,
|
||||
} from './LogLine';
|
||||
|
||||
describe('getModifierClasses', () => {
|
||||
const classes = {
|
||||
modifierBold: 'bold',
|
||||
modifierItalic: 'italic',
|
||||
modifierUnderline: 'underline',
|
||||
modifierForegroundBlack: 'black',
|
||||
modifierForegroundRed: 'red',
|
||||
modifierForegroundGreen: 'green',
|
||||
modifierForegroundYellow: 'yellow',
|
||||
modifierForegroundBlue: 'blue',
|
||||
modifierForegroundMagenta: 'magenta',
|
||||
modifierForegroundCyan: 'cyan',
|
||||
modifierForegroundWhite: 'white',
|
||||
modifierForegroundGrey: 'grey',
|
||||
modifierBackgroundBlack: 'bg-black',
|
||||
modifierBackgroundRed: 'bg-red',
|
||||
modifierBackgroundGreen: 'bg-green',
|
||||
modifierBackgroundYellow: 'bg-yellow',
|
||||
modifierBackgroundBlue: 'bg-blue',
|
||||
modifierBackgroundMagenta: 'bg-magenta',
|
||||
modifierBackgroundCyan: 'bg-cyan',
|
||||
modifierBackgroundWhite: 'bg-white',
|
||||
modifierBackgroundGrey: 'bg-grey',
|
||||
};
|
||||
const curried = (modifiers: ChunkModifiers) =>
|
||||
getModifierClasses(
|
||||
classes as Parameters<typeof getModifierClasses>[0],
|
||||
modifiers,
|
||||
);
|
||||
|
||||
it('should transform modifiers to classes', () => {
|
||||
expect(curried({})).toEqual(undefined);
|
||||
expect(curried({ bold: true })).toEqual('bold');
|
||||
expect(curried({ italic: true })).toEqual('italic');
|
||||
expect(curried({ underline: true })).toEqual('underline');
|
||||
expect(curried({ foreground: 'black' })).toEqual('black');
|
||||
expect(curried({ background: 'black' })).toEqual('bg-black');
|
||||
expect(
|
||||
curried({
|
||||
bold: true,
|
||||
italic: true,
|
||||
underline: true,
|
||||
foreground: 'red',
|
||||
background: 'red',
|
||||
}),
|
||||
).toEqual('bold italic underline red bg-red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSearchResults', () => {
|
||||
it('should not return results if there is no match', () => {
|
||||
expect(findSearchResults('Foo', 'Bar')).toEqual(undefined);
|
||||
expect(findSearchResults('Foo Bar', 'oof')).toEqual(undefined);
|
||||
expect(findSearchResults('Foo Bar', '')).toEqual(undefined);
|
||||
expect(findSearchResults('', '')).toEqual(undefined);
|
||||
expect(findSearchResults('', 'Foo')).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should find result indices', () => {
|
||||
expect(findSearchResults('Foo', 'Foo')).toEqual([{ start: 0, end: 3 }]);
|
||||
expect(findSearchResults('Foo', 'o')).toEqual([
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 2, end: 3 },
|
||||
]);
|
||||
expect(findSearchResults('FooBarBaz', 'Bar')).toEqual([
|
||||
{ start: 3, end: 6 },
|
||||
]);
|
||||
expect(findSearchResults('Foo Bar Baz', ' ')).toEqual([
|
||||
{ start: 3, end: 4 },
|
||||
{ start: 7, end: 8 },
|
||||
]);
|
||||
expect(findSearchResults('FooBarBazBarFoo', 'Bar')).toEqual([
|
||||
{ start: 3, end: 6 },
|
||||
{ start: 9, end: 12 },
|
||||
]);
|
||||
expect(findSearchResults('FooBarBazBarFoo', 'Foo')).toEqual([
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 12, end: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not overlap search results', () => {
|
||||
expect(findSearchResults('aaa', 'aa')).toEqual([{ start: 0, end: 2 }]);
|
||||
expect(findSearchResults('aaaa', 'aa')).toEqual([
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 2, end: 4 },
|
||||
]);
|
||||
expect(findSearchResults('aaaaa', 'aa')).toEqual([
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 2, end: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateHighlightedChunks', () => {
|
||||
it('should pass through chunks if there are no results', () => {
|
||||
const chunks = [{ text: 'Foo', modifiers: {} }];
|
||||
const line = new AnsiLine(0, chunks);
|
||||
expect(calculateHighlightedChunks(line, 'bar')).toBe(chunks);
|
||||
});
|
||||
|
||||
it('should highlight one result from plain text', () => {
|
||||
const line = new AnsiLine(0, [{ text: 'FooBarBaz', modifiers: {} }]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBaz',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'bar')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Bar',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'baz')).toEqual([
|
||||
{
|
||||
text: 'FooBar',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight multiple results from plain text', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'FooBarBazBazBarFoo', modifiers: {} },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBazBazBar',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'bar')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Bar',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BazBaz',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Bar',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'baz')).toEqual([
|
||||
{
|
||||
text: 'FooBar',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
{
|
||||
text: 'BarFoo',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should forward modifiers to result', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'FooBarBazBazBarFoo', modifiers: { bold: true } },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBazBazBar',
|
||||
modifiers: { bold: true },
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight full chunks', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'Foo', modifiers: { bold: true } },
|
||||
{ text: 'BarBaz', modifiers: { bold: true } },
|
||||
{ text: 'BazBar', modifiers: { italic: true } },
|
||||
{ text: 'Foo', modifiers: { italic: true } },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBaz',
|
||||
modifiers: { bold: true },
|
||||
},
|
||||
{
|
||||
text: 'BazBar',
|
||||
modifiers: { italic: true },
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { italic: true },
|
||||
highlight: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight partial chunks', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'Fo', modifiers: { bold: true } },
|
||||
{ text: 'oFooFo', modifiers: {} },
|
||||
{ text: 'oBarBaz', modifiers: { italic: true } },
|
||||
{ text: 'Foo', modifiers: { foreground: 'blue' } },
|
||||
{ text: 'FooFoo', modifiers: { italic: true } },
|
||||
{ text: 'F', modifiers: { bold: true } },
|
||||
{ text: 'o', modifiers: {} },
|
||||
{ text: 'o', modifiers: { bold: true } },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Fo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
{
|
||||
text: 'Fo',
|
||||
modifiers: {},
|
||||
highlight: 2,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: { italic: true },
|
||||
highlight: 2,
|
||||
},
|
||||
{
|
||||
text: 'BarBaz',
|
||||
modifiers: { italic: true },
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { foreground: 'blue' },
|
||||
highlight: 3,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { italic: true },
|
||||
highlight: 4,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { italic: true },
|
||||
highlight: 5,
|
||||
},
|
||||
{
|
||||
text: 'F',
|
||||
modifiers: { bold: true },
|
||||
highlight: 6,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: {},
|
||||
highlight: 6,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: { bold: true },
|
||||
highlight: 6,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor';
|
||||
import startCase from 'lodash/startCase';
|
||||
import clsx from 'clsx';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export function getModifierClasses(
|
||||
classes: ReturnType<typeof useStyles>,
|
||||
modifiers: ChunkModifiers,
|
||||
) {
|
||||
const classNames = new Array<string>();
|
||||
if (modifiers.bold) {
|
||||
classNames.push(classes.modifierBold);
|
||||
}
|
||||
if (modifiers.italic) {
|
||||
classNames.push(classes.modifierItalic);
|
||||
}
|
||||
if (modifiers.underline) {
|
||||
classNames.push(classes.modifierUnderline);
|
||||
}
|
||||
if (modifiers.foreground) {
|
||||
const key = `modifierForeground${startCase(
|
||||
modifiers.foreground,
|
||||
)}` as keyof typeof classes;
|
||||
classNames.push(classes[key]);
|
||||
}
|
||||
if (modifiers.background) {
|
||||
const key = `modifierBackground${startCase(
|
||||
modifiers.background,
|
||||
)}` as keyof typeof classes;
|
||||
classNames.push(classes[key]);
|
||||
}
|
||||
return classNames.length > 0 ? classNames.join(' ') : undefined;
|
||||
}
|
||||
|
||||
export function findSearchResults(text: string, searchText: string) {
|
||||
if (!searchText || !text.includes(searchText)) {
|
||||
return undefined;
|
||||
}
|
||||
const searchResults = new Array<{ start: number; end: number }>();
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const start = text.indexOf(searchText, offset);
|
||||
if (start === -1) {
|
||||
break;
|
||||
}
|
||||
const end = start + searchText.length;
|
||||
searchResults.push({ start, end });
|
||||
offset = end;
|
||||
}
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
export interface HighlightAnsiChunk extends AnsiChunk {
|
||||
highlight?: number;
|
||||
}
|
||||
|
||||
export function calculateHighlightedChunks(
|
||||
line: AnsiLine,
|
||||
searchText: string,
|
||||
): HighlightAnsiChunk[] {
|
||||
const results = findSearchResults(line.text, searchText);
|
||||
if (!results) {
|
||||
return line.chunks;
|
||||
}
|
||||
|
||||
const chunks = new Array<HighlightAnsiChunk>();
|
||||
|
||||
let lineOffset = 0;
|
||||
let resultIndex = 0;
|
||||
let result = results[resultIndex];
|
||||
for (const chunk of line.chunks) {
|
||||
const { text, modifiers } = chunk;
|
||||
if (!result || lineOffset + text.length < result.start) {
|
||||
chunks.push(chunk);
|
||||
lineOffset += text.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let localOffset = 0;
|
||||
while (result) {
|
||||
const localStart = Math.max(result.start - lineOffset, 0);
|
||||
if (localStart > text.length) {
|
||||
break; // The next result is not in this chunk
|
||||
}
|
||||
|
||||
const localEnd = Math.min(result.end - lineOffset, text.length);
|
||||
|
||||
const hasTextBeforeResult = localStart > localOffset;
|
||||
if (hasTextBeforeResult) {
|
||||
chunks.push({ text: text.slice(localOffset, localStart), modifiers });
|
||||
}
|
||||
const hasResultText = localEnd > localStart;
|
||||
if (hasResultText) {
|
||||
chunks.push({
|
||||
modifiers,
|
||||
highlight: resultIndex,
|
||||
text: text.slice(localStart, localEnd),
|
||||
});
|
||||
}
|
||||
|
||||
localOffset = localEnd;
|
||||
|
||||
const foundCompleteResult = result.end - lineOffset === localEnd;
|
||||
if (foundCompleteResult) {
|
||||
resultIndex += 1;
|
||||
result = results[resultIndex];
|
||||
} else {
|
||||
break; // The rest of the result is in the following chunks
|
||||
}
|
||||
}
|
||||
|
||||
const hasTextAfterResult = localOffset < text.length;
|
||||
if (hasTextAfterResult) {
|
||||
chunks.push({ text: text.slice(localOffset), modifiers });
|
||||
}
|
||||
|
||||
lineOffset += text.length;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export interface LogLineProps {
|
||||
line: AnsiLine;
|
||||
classes: ReturnType<typeof useStyles>;
|
||||
searchText: string;
|
||||
highlightResultIndex?: number;
|
||||
}
|
||||
|
||||
export function LogLine({
|
||||
line,
|
||||
classes,
|
||||
searchText,
|
||||
highlightResultIndex,
|
||||
}: LogLineProps) {
|
||||
const chunks = useMemo(
|
||||
() => calculateHighlightedChunks(line, searchText),
|
||||
[line, searchText],
|
||||
);
|
||||
|
||||
const elements = useMemo(
|
||||
() =>
|
||||
chunks.map(({ text, modifiers, highlight }, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={clsx(
|
||||
getModifierClasses(classes, modifiers),
|
||||
highlight !== undefined &&
|
||||
(highlight === highlightResultIndex
|
||||
? classes.textSelectedHighlight
|
||||
: classes.textHighlight),
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)),
|
||||
[chunks, highlightResultIndex, classes],
|
||||
);
|
||||
|
||||
return <>{elements}</>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { LogViewer } from './LogViewer';
|
||||
|
||||
export default {
|
||||
title: 'Data Display/LogViewer',
|
||||
component: LogViewer,
|
||||
decorators: [(Story: ComponentType<{}>) => wrapInTestApp(<Story />)],
|
||||
};
|
||||
|
||||
const exampleLog = `Starting up task with 3 steps
|
||||
Beginning step Fetch Skeleton + Template
|
||||
[32minfo[39m: Fetching template content from remote URL {"timestamp":"2021-12-03T15:47:11.625Z"}
|
||||
[32minfo[39m: Listing files and directories in template {"timestamp":"2021-12-03T15:47:12.797Z"}
|
||||
[32minfo[39m: Processing 33 template files/directories with input values {"component_id":"srnthsrthntrhsn","description":"rnthsrtnhssrthnrsthn","destination":{"host":"github.com","owner":"rtshnsrtmhrstmh","repo":"srtmhsrtmhrsthms"},"owner":"rstnhrstnhsrthn","timestamp":"2021-12-03T15:47:12.801Z"}
|
||||
[32minfo[39m: Writing file .editorconfig to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.816Z"}
|
||||
[32minfo[39m: Writing file .eslintignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.818Z"}
|
||||
[32minfo[39m: Writing file .eslintrc.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.820Z"}
|
||||
[32minfo[39m: Writing directory .github/ to template output path. {"timestamp":"2021-12-03T15:47:12.823Z"}
|
||||
[32minfo[39m: Writing file .gitignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.824Z"}
|
||||
[32minfo[39m: Writing file README.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.827Z"}
|
||||
[32minfo[39m: Writing file babel.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.829Z"}
|
||||
[32minfo[39m: Writing file catalog-info.yaml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.831Z"}
|
||||
[32minfo[39m: Writing directory docs/ to template output path. {"timestamp":"2021-12-03T15:47:12.834Z"}
|
||||
[32minfo[39m: Writing file jest.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.836Z"}
|
||||
[32minfo[39m: Writing file mkdocs.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.838Z"}
|
||||
[32minfo[39m: Writing file next-env.d.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.841Z"}
|
||||
[32minfo[39m: Writing file next.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.844Z"}
|
||||
[32minfo[39m: Writing file package.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.845Z"}
|
||||
[32minfo[39m: Writing file prettier.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.848Z"}
|
||||
[32minfo[39m: Writing directory public/ to template output path. {"timestamp":"2021-12-03T15:47:12.849Z"}
|
||||
[32minfo[39m: Writing directory src/ to template output path. {"timestamp":"2021-12-03T15:47:12.850Z"}
|
||||
[32minfo[39m: Writing file tsconfig.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.851Z"}
|
||||
[32minfo[39m: Writing directory .github/workflows/ to template output path. {"timestamp":"2021-12-03T15:47:12.853Z"}
|
||||
[32minfo[39m: Writing file docs/index.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.854Z"}
|
||||
[32minfo[39m: Writing directory public/static/ to template output path. {"timestamp":"2021-12-03T15:47:12.857Z"}
|
||||
[32minfo[39m: Writing directory src/__tests__/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"}
|
||||
[32minfo[39m: Writing directory src/components/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"}
|
||||
[32minfo[39m: Writing directory src/pages/ to template output path. {"timestamp":"2021-12-03T15:47:12.859Z"}
|
||||
[32minfo[39m: Copying file/directory .github/workflows/build.yml without processing. {"timestamp":"2021-12-03T15:47:12.859Z"}
|
||||
[32minfo[39m: Writing file .github/workflows/build.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.860Z"}
|
||||
[32minfo[39m: Writing file public/static/fonts.css to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.861Z"}
|
||||
[32minfo[39m: Writing file src/components/Header.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.863Z"}
|
||||
[32minfo[39m: Writing file src/__tests__/index.test.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.865Z"}
|
||||
[32minfo[39m: Writing file src/pages/_app.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.868Z"}
|
||||
[32minfo[39m: Writing file src/pages/_document.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.871Z"}
|
||||
[32minfo[39m: Writing directory src/pages/api/ to template output path. {"timestamp":"2021-12-03T15:47:12.873Z"}
|
||||
[32minfo[39m: Writing file src/pages/index.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.874Z"}
|
||||
[32minfo[39m: Writing file src/pages/api/ping.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.877Z"}
|
||||
[32minfo[39m: Template result written to /var/folders/k6/9s7hd6w17115xlgwnsp0wsbr0000gn/T/5c9f8584-fded-4741-b6ef-46d94ff2cbdb {"timestamp":"2021-12-03T15:47:12.878Z"}
|
||||
Finished step Fetch Skeleton + Template
|
||||
Beginning step Publish
|
||||
HttpError: Not Found
|
||||
at /Users/patriko/dev/backstage/node_modules/@octokit/request/dist-node/index.js:86:21
|
||||
at runMicrotasks (<anonymous>)
|
||||
at processTicksAndRejections (internal/process/task_queues.js:95:5)
|
||||
at async Object.handler (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts:156:20)
|
||||
at async HandlebarsWorkflowRunner.execute (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts:254:11)
|
||||
at async TaskWorker.runOneTask (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:110:13)
|
||||
at async eval (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:100:9)
|
||||
Run completed with status: failed`;
|
||||
|
||||
export const ExampleLogViewer = () => (
|
||||
<div style={{ height: 240 }}>
|
||||
<LogViewer text={exampleLog} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { useApp } from '@backstage/core-plugin-api';
|
||||
|
||||
const RealLogViewer = lazy(() =>
|
||||
import('./RealLogViewer').then(m => ({ default: m.RealLogViewer })),
|
||||
);
|
||||
|
||||
/**
|
||||
* The properties for the LogViewer component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface LogViewerProps {
|
||||
/**
|
||||
* The text of the logs to display.
|
||||
*
|
||||
* The LogViewer component is optimized for appending content at the end of the text.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* The className to apply to the root LogViewer element inside the auto sizer.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays logs in a scrollable text area.
|
||||
*
|
||||
* The LogViewer has support for search and filtering, as well as displaying
|
||||
* text content with ANSI color escape codes.
|
||||
*
|
||||
* Since the LogViewer uses windowing to avoid rendering all contents at once, the
|
||||
* log is sized automatically to fill the available vertical space. This means
|
||||
* it may often be needed to wrap the LogViewer in a container that provides it
|
||||
* with a fixed amount of space.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function LogViewer(props: LogViewerProps) {
|
||||
const { Progress } = useApp().getComponents();
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<RealLogViewer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
|
||||
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
import { LogViewerSearch } from './useLogViewerSearch';
|
||||
|
||||
export interface LogViewerControlsProps extends LogViewerSearch {}
|
||||
|
||||
export function LogViewerControls(props: LogViewerControlsProps) {
|
||||
const { resultCount, resultIndexStep, toggleShouldFilter } = props;
|
||||
const resultIndex = props.resultIndex ?? 0;
|
||||
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
toggleShouldFilter();
|
||||
} else {
|
||||
resultIndexStep(event.shiftKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{resultCount !== undefined && (
|
||||
<>
|
||||
<IconButton size="small" onClick={() => resultIndexStep(true)}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<Typography>
|
||||
{Math.min(resultIndex + 1, resultCount)}/{resultCount}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => resultIndexStep()}>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<TextField
|
||||
size="small"
|
||||
variant="standard"
|
||||
placeholder="Search"
|
||||
value={props.searchInput}
|
||||
onKeyPress={handleKeyPress}
|
||||
onChange={e => props.setSearchInput(e.target.value)}
|
||||
/>
|
||||
<IconButton size="small" onClick={toggleShouldFilter}>
|
||||
{props.shouldFilter ? (
|
||||
<FilterListIcon color="primary" />
|
||||
) : (
|
||||
<FilterListIcon color="disabled" />
|
||||
)}
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import UserEvent from '@testing-library/user-event';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { RealLogViewer } from './RealLogViewer';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import copyToClipboard from 'copy-to-clipboard';
|
||||
|
||||
// Used by useCopyToClipboard
|
||||
jest.mock('copy-to-clipboard', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
|
||||
jest.mock('react-virtualized-auto-sizer', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) => <>{props.children({ width: 400, height: 200 })}</>,
|
||||
}));
|
||||
|
||||
const testText = `Some Log Line
|
||||
Derp
|
||||
Foo
|
||||
|
||||
Foo Foo
|
||||
Wat`;
|
||||
|
||||
describe('RealLogViewer', () => {
|
||||
it('should render text with search and filtering and copying', async () => {
|
||||
const rendered = await renderInTestApp(<RealLogViewer text={testText} />);
|
||||
expect(rendered.getByText('Derp')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Foo Foo')).toBeInTheDocument();
|
||||
|
||||
UserEvent.tab();
|
||||
UserEvent.keyboard('Foo');
|
||||
|
||||
expect(rendered.getByText('1/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{enter}');
|
||||
expect(rendered.getByText('2/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{enter}');
|
||||
expect(rendered.getByText('3/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{enter}');
|
||||
expect(rendered.getByText('1/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{shift}{enter}{/shift}');
|
||||
expect(rendered.getByText('3/3')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.queryByText('Some Log Line')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{meta}{enter}{/meta}');
|
||||
expect(rendered.queryByText('Some Log Line')).not.toBeInTheDocument();
|
||||
UserEvent.keyboard('{meta}{enter}{/meta}');
|
||||
expect(rendered.queryByText('Some Log Line')).toBeInTheDocument();
|
||||
|
||||
// Tab down to line #2 and click
|
||||
UserEvent.tab();
|
||||
UserEvent.tab();
|
||||
UserEvent.tab();
|
||||
UserEvent.click(document.activeElement!);
|
||||
UserEvent.click(rendered.getByTestId('copy-button'));
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('Derp');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import CopyIcon from '@material-ui/icons/FileCopy';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { AnsiProcessor } from './AnsiProcessor';
|
||||
import { HEADER_SIZE, useStyles } from './styles';
|
||||
import clsx from 'clsx';
|
||||
import { LogLine } from './LogLine';
|
||||
import { LogViewerControls } from './LogViewerControls';
|
||||
import { useLogViewerSearch } from './useLogViewerSearch';
|
||||
import { useLogViewerSelection } from './useLogViewerSelection';
|
||||
|
||||
export interface RealLogViewerProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RealLogViewer(props: RealLogViewerProps) {
|
||||
const classes = useStyles();
|
||||
const listRef = useRef<FixedSizeList | null>(null);
|
||||
|
||||
// The processor keeps state that optimizes appending to the text
|
||||
const processor = useMemo(() => new AnsiProcessor(), []);
|
||||
const lines = processor.process(props.text);
|
||||
|
||||
const search = useLogViewerSearch(lines);
|
||||
const selection = useLogViewerSelection(lines);
|
||||
|
||||
useEffect(() => {
|
||||
if (search.resultLine !== undefined && listRef.current) {
|
||||
listRef.current.scrollToItem(search.resultLine - 1, 'center');
|
||||
}
|
||||
}, [search.resultLine]);
|
||||
|
||||
const handleSelectLine = (
|
||||
line: number,
|
||||
event: { shiftKey: boolean; preventDefault: () => void },
|
||||
) => {
|
||||
event.preventDefault();
|
||||
selection.setSelection(line, event.shiftKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<div
|
||||
style={{ width, height }}
|
||||
className={clsx(classes.root, props.className)}
|
||||
>
|
||||
<div className={classes.header}>
|
||||
<LogViewerControls {...search} />
|
||||
</div>
|
||||
<FixedSizeList
|
||||
ref={listRef}
|
||||
className={classes.log}
|
||||
height={height - HEADER_SIZE}
|
||||
width={width}
|
||||
itemData={search.lines}
|
||||
itemSize={20}
|
||||
itemCount={search.lines.length}
|
||||
>
|
||||
{({ index, style, data }) => {
|
||||
const line = data[index];
|
||||
const { lineNumber } = line;
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className={clsx(classes.line, {
|
||||
[classes.lineSelected]: selection.isSelected(lineNumber),
|
||||
})}
|
||||
>
|
||||
{selection.shouldShowButton(lineNumber) && (
|
||||
<IconButton
|
||||
data-testid="copy-button"
|
||||
size="small"
|
||||
className={classes.lineCopyButton}
|
||||
onClick={() => selection.copySelection()}
|
||||
>
|
||||
<CopyIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
)}
|
||||
<a
|
||||
role="row"
|
||||
target="_self"
|
||||
href={`#line-${lineNumber}`}
|
||||
className={classes.lineNumber}
|
||||
onClick={event => handleSelectLine(lineNumber, event)}
|
||||
onKeyPress={event => handleSelectLine(lineNumber, event)}
|
||||
>
|
||||
{lineNumber}
|
||||
</a>
|
||||
<LogLine
|
||||
line={line}
|
||||
classes={classes}
|
||||
searchText={search.searchText}
|
||||
highlightResultIndex={
|
||||
search.resultLine === lineNumber
|
||||
? search.resultLineIndex
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { LogViewer } from './LogViewer';
|
||||
export type { LogViewerProps } from './LogViewer';
|
||||
export type { LogViewerClassKey } from './styles';
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { alpha, makeStyles } from '@material-ui/core/styles';
|
||||
import * as colors from '@material-ui/core/colors';
|
||||
|
||||
export const HEADER_SIZE = 40;
|
||||
|
||||
/** @public Class keys for overriding LogViewer styles */
|
||||
export type LogViewerClassKey =
|
||||
| 'root'
|
||||
| 'header'
|
||||
| 'log'
|
||||
| 'line'
|
||||
| 'lineSelected'
|
||||
| 'lineCopyButton'
|
||||
| 'lineNumber'
|
||||
| 'textHighlight'
|
||||
| 'textSelectedHighlight'
|
||||
| 'modifierBold'
|
||||
| 'modifierItalic'
|
||||
| 'modifierUnderline'
|
||||
| 'modifierForegroundBlack'
|
||||
| 'modifierForegroundRed'
|
||||
| 'modifierForegroundGreen'
|
||||
| 'modifierForegroundYellow'
|
||||
| 'modifierForegroundBlue'
|
||||
| 'modifierForegroundMagenta'
|
||||
| 'modifierForegroundCyan'
|
||||
| 'modifierForegroundWhite'
|
||||
| 'modifierForegroundGrey'
|
||||
| 'modifierBackgroundBlack'
|
||||
| 'modifierBackgroundRed'
|
||||
| 'modifierBackgroundGreen'
|
||||
| 'modifierBackgroundYellow'
|
||||
| 'modifierBackgroundBlue'
|
||||
| 'modifierBackgroundMagenta'
|
||||
| 'modifierBackgroundCyan'
|
||||
| 'modifierBackgroundWhite'
|
||||
| 'modifierBackgroundGrey';
|
||||
|
||||
export const useStyles = makeStyles(
|
||||
theme => ({
|
||||
root: {
|
||||
background: theme.palette.background.paper,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_SIZE,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
log: {
|
||||
fontFamily: '"Monaco", monospace',
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
},
|
||||
line: {
|
||||
position: 'relative',
|
||||
whiteSpace: 'pre',
|
||||
|
||||
'&:hover': {
|
||||
background: theme.palette.action.hover,
|
||||
},
|
||||
},
|
||||
lineSelected: {
|
||||
background: theme.palette.action.selected,
|
||||
|
||||
'&:hover': {
|
||||
background: theme.palette.action.selected,
|
||||
},
|
||||
},
|
||||
lineCopyButton: {
|
||||
position: 'absolute',
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
lineNumber: {
|
||||
display: 'inline-block',
|
||||
textAlign: 'end',
|
||||
width: 60,
|
||||
marginRight: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
},
|
||||
textHighlight: {
|
||||
background: alpha(theme.palette.info.main, 0.15),
|
||||
},
|
||||
textSelectedHighlight: {
|
||||
background: alpha(theme.palette.info.main, 0.4),
|
||||
},
|
||||
modifierBold: {
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
},
|
||||
modifierItalic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
modifierUnderline: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
modifierForegroundBlack: {
|
||||
color: colors.common.black,
|
||||
},
|
||||
modifierForegroundRed: {
|
||||
color: colors.red[500],
|
||||
},
|
||||
modifierForegroundGreen: {
|
||||
color: colors.green[500],
|
||||
},
|
||||
modifierForegroundYellow: {
|
||||
color: colors.yellow[500],
|
||||
},
|
||||
modifierForegroundBlue: {
|
||||
color: colors.blue[500],
|
||||
},
|
||||
modifierForegroundMagenta: {
|
||||
color: colors.purple[500],
|
||||
},
|
||||
modifierForegroundCyan: {
|
||||
color: colors.cyan[500],
|
||||
},
|
||||
modifierForegroundWhite: {
|
||||
color: colors.common.white,
|
||||
},
|
||||
modifierForegroundGrey: {
|
||||
color: colors.grey[500],
|
||||
},
|
||||
modifierBackgroundBlack: {
|
||||
background: colors.common.black,
|
||||
},
|
||||
modifierBackgroundRed: {
|
||||
background: colors.red[500],
|
||||
},
|
||||
modifierBackgroundGreen: {
|
||||
background: colors.green[500],
|
||||
},
|
||||
modifierBackgroundYellow: {
|
||||
background: colors.yellow[500],
|
||||
},
|
||||
modifierBackgroundBlue: {
|
||||
background: colors.blue[500],
|
||||
},
|
||||
modifierBackgroundMagenta: {
|
||||
background: colors.purple[500],
|
||||
},
|
||||
modifierBackgroundCyan: {
|
||||
background: colors.cyan[500],
|
||||
},
|
||||
modifierBackgroundWhite: {
|
||||
background: colors.common.white,
|
||||
},
|
||||
modifierBackgroundGrey: {
|
||||
background: colors.grey[500],
|
||||
},
|
||||
}),
|
||||
{ name: 'BackstageLogViewer' },
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { applySearchFilter, useLogViewerSearch } from './useLogViewerSearch';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
|
||||
const lines = [
|
||||
new AnsiLine(1, [{ text: 'FooBar', modifiers: {} }]),
|
||||
new AnsiLine(2, [{ text: 'Baz', modifiers: {} }]),
|
||||
new AnsiLine(3, [{ text: 'FooBarFoo', modifiers: {} }]),
|
||||
new AnsiLine(4, [{ text: 'Baz', modifiers: {} }]),
|
||||
new AnsiLine(5, [{ text: 'BazFoo', modifiers: {} }]),
|
||||
new AnsiLine(6, [{ text: 'FooFooFoo', modifiers: {} }]),
|
||||
new AnsiLine(7, [{ text: '', modifiers: {} }]),
|
||||
new AnsiLine(8, [{ text: 'Bar', modifiers: {} }]),
|
||||
];
|
||||
|
||||
describe('applySearchFilter', () => {
|
||||
it('should find search results', () => {
|
||||
expect(applySearchFilter(lines, '')).toEqual({
|
||||
lines: lines,
|
||||
results: undefined,
|
||||
});
|
||||
expect(applySearchFilter(lines, 'foo')).toEqual({
|
||||
lines: [lines[0], lines[2], lines[4], lines[5]],
|
||||
results: [
|
||||
{ lineNumber: 1, lineIndex: 0 },
|
||||
{ lineNumber: 3, lineIndex: 0 },
|
||||
{ lineNumber: 3, lineIndex: 1 },
|
||||
{ lineNumber: 5, lineIndex: 0 },
|
||||
{ lineNumber: 6, lineIndex: 0 },
|
||||
{ lineNumber: 6, lineIndex: 1 },
|
||||
{ lineNumber: 6, lineIndex: 2 },
|
||||
],
|
||||
});
|
||||
expect(applySearchFilter(lines, 'bar')).toEqual({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
results: [
|
||||
{ lineNumber: 1, lineIndex: 0 },
|
||||
{ lineNumber: 3, lineIndex: 0 },
|
||||
{ lineNumber: 8, lineIndex: 0 },
|
||||
],
|
||||
});
|
||||
expect(applySearchFilter(lines, 'baz')).toEqual({
|
||||
lines: [lines[1], lines[3], lines[4]],
|
||||
results: [
|
||||
{ lineNumber: 2, lineIndex: 0 },
|
||||
{ lineNumber: 4, lineIndex: 0 },
|
||||
{ lineNumber: 5, lineIndex: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogViewerSearch', () => {
|
||||
it('should provide search state', () => {
|
||||
const rendered = renderHook(() => useLogViewerSearch(lines));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchText: '',
|
||||
shouldFilter: false,
|
||||
resultCount: undefined,
|
||||
resultIndex: 0,
|
||||
resultLine: undefined,
|
||||
resultLineIndex: undefined,
|
||||
});
|
||||
|
||||
rendered.result.current.resultIndexStep();
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchText: '',
|
||||
shouldFilter: false,
|
||||
resultCount: undefined,
|
||||
resultIndex: 0,
|
||||
resultLine: undefined,
|
||||
resultLineIndex: undefined,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.toggleShouldFilter());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchText: '',
|
||||
shouldFilter: true,
|
||||
resultCount: undefined,
|
||||
resultIndex: 0,
|
||||
resultLine: undefined,
|
||||
resultLineIndex: undefined,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.setSearchInput('BAR'));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
searchInput: 'BAR',
|
||||
searchText: 'bar',
|
||||
shouldFilter: true,
|
||||
resultCount: 3,
|
||||
resultIndex: 0,
|
||||
resultLine: 1,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 1,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 2,
|
||||
resultLine: 8,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 0,
|
||||
resultLine: 1,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep(true));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 2,
|
||||
resultLine: 8,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.setSearchInput('FOO'));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[4], lines[5]],
|
||||
searchInput: 'FOO',
|
||||
searchText: 'foo',
|
||||
shouldFilter: true,
|
||||
resultCount: 7,
|
||||
resultIndex: 2,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 1,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.toggleShouldFilter());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
shouldFilter: false,
|
||||
resultCount: 7,
|
||||
resultIndex: 2,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 1,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchInput: 'FOO',
|
||||
searchText: 'foo',
|
||||
shouldFilter: false,
|
||||
resultCount: 7,
|
||||
resultIndex: 3,
|
||||
resultLine: 5,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
resultIndex: 4,
|
||||
resultLine: 6,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
resultIndex: 5,
|
||||
resultLine: 6,
|
||||
resultLineIndex: 1,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
resultIndex: 6,
|
||||
resultLine: 6,
|
||||
resultLineIndex: 2,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.setSearchInput('BAR'));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
searchText: 'bar',
|
||||
resultCount: 3,
|
||||
resultIndex: 6,
|
||||
resultLine: 8,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep(true));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
searchText: 'bar',
|
||||
resultCount: 3,
|
||||
resultIndex: 1,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
|
||||
export function applySearchFilter(lines: AnsiLine[], searchText: string) {
|
||||
if (!searchText) {
|
||||
return { lines };
|
||||
}
|
||||
|
||||
const matchingLines = [];
|
||||
const searchResults = [];
|
||||
for (const line of lines) {
|
||||
if (line.text.includes(searchText)) {
|
||||
matchingLines.push(line);
|
||||
|
||||
let offset = 0;
|
||||
let lineResultIndex = 0;
|
||||
for (;;) {
|
||||
const start = line.text.indexOf(searchText, offset);
|
||||
if (start === -1) {
|
||||
break;
|
||||
}
|
||||
searchResults.push({
|
||||
lineNumber: line.lineNumber,
|
||||
lineIndex: lineResultIndex++,
|
||||
});
|
||||
offset = start + searchText.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lines: matchingLines,
|
||||
results: searchResults,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LogViewerSearch {
|
||||
lines: AnsiLine[];
|
||||
|
||||
searchText: string;
|
||||
searchInput: string;
|
||||
setSearchInput: (searchInput: string) => void;
|
||||
|
||||
shouldFilter: boolean;
|
||||
toggleShouldFilter: () => void;
|
||||
|
||||
resultCount: number | undefined;
|
||||
resultIndex: number | undefined;
|
||||
resultIndexStep: (decrement?: boolean) => void;
|
||||
|
||||
resultLine: number | undefined;
|
||||
resultLineIndex: number | undefined;
|
||||
}
|
||||
|
||||
export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const searchText = searchInput.toLocaleLowerCase('en-US');
|
||||
|
||||
const [resultIndex, setResultIndex] = useState<number>(0);
|
||||
|
||||
const [shouldFilter, toggleShouldFilter] = useToggle(false);
|
||||
|
||||
const filter = useMemo(
|
||||
() => applySearchFilter(lines, searchText),
|
||||
[lines, searchText],
|
||||
);
|
||||
|
||||
const searchResult = filter.results
|
||||
? filter.results[Math.min(resultIndex, filter.results.length - 1)]
|
||||
: undefined;
|
||||
const resultCount = filter.results?.length;
|
||||
|
||||
const resultIndexStep = (decrement?: boolean) => {
|
||||
if (decrement) {
|
||||
if (resultCount !== undefined) {
|
||||
const next = Math.min(resultIndex - 1, resultCount - 2);
|
||||
setResultIndex(next < 0 ? resultCount - 1 : next);
|
||||
}
|
||||
} else {
|
||||
if (resultCount !== undefined) {
|
||||
const next = resultIndex + 1;
|
||||
setResultIndex(next >= resultCount ? 0 : next);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lines: shouldFilter ? filter.lines : lines,
|
||||
searchText,
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
shouldFilter,
|
||||
toggleShouldFilter,
|
||||
resultCount,
|
||||
resultIndex,
|
||||
resultIndexStep,
|
||||
resultLine: searchResult?.lineNumber,
|
||||
resultLineIndex: searchResult?.lineIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { TestApiProvider, MockErrorApi } from '@backstage/test-utils';
|
||||
import { errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
import { useLogViewerSelection } from './useLogViewerSelection';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import copyToClipboard from 'copy-to-clipboard';
|
||||
|
||||
// Used by useCopyToClipboard
|
||||
jest.mock('copy-to-clipboard', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const lines = [
|
||||
new AnsiLine(1, [{ text: '1', modifiers: {} }]),
|
||||
new AnsiLine(2, [{ text: '2', modifiers: {} }]),
|
||||
new AnsiLine(3, [{ text: '3', modifiers: {} }]),
|
||||
new AnsiLine(4, [{ text: '4', modifiers: {} }]),
|
||||
new AnsiLine(5, [{ text: '5', modifiers: {} }]),
|
||||
];
|
||||
|
||||
describe('useLogViewerSelection', () => {
|
||||
it('should manage a selection', () => {
|
||||
const rendered = renderHook(() => useLogViewerSelection(lines), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(2, false));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(3, false));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(4)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(1, true));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(4)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(4, true));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(4)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(5)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(4)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(5)).toBe(false);
|
||||
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
act(() => rendered.result.current.copySelection());
|
||||
expect(copyToClipboard).toHaveBeenLastCalledWith('3\n4');
|
||||
|
||||
act(() => rendered.result.current.setSelection(2, true));
|
||||
act(() => rendered.result.current.setSelection(4, true));
|
||||
|
||||
act(() => rendered.result.current.copySelection());
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4');
|
||||
|
||||
act(() => rendered.result.current.setSelection(2, false));
|
||||
act(() => rendered.result.current.setSelection(4, false));
|
||||
act(() => rendered.result.current.setSelection(4, false));
|
||||
act(() => rendered.result.current.setSelection(5, true));
|
||||
act(() => rendered.result.current.copySelection());
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
|
||||
export function useLogViewerSelection(lines: AnsiLine[]) {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const [sel, setSelection] = useState<{ start: number; end: number }>();
|
||||
const start = sel ? Math.min(sel.start, sel.end) : undefined;
|
||||
const end = sel ? Math.max(sel.start, sel.end) : undefined;
|
||||
|
||||
const [{ error }, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
}, [error, errorApi]);
|
||||
|
||||
return {
|
||||
shouldShowButton(line: number) {
|
||||
return start === line || end === line;
|
||||
},
|
||||
isSelected(line: number) {
|
||||
if (!sel) {
|
||||
return false;
|
||||
}
|
||||
return start! <= line && line <= end!;
|
||||
},
|
||||
setSelection(line: number, add: boolean) {
|
||||
if (add) {
|
||||
setSelection(s =>
|
||||
s ? { start: s.start, end: line } : { start: line, end: line },
|
||||
);
|
||||
} else {
|
||||
setSelection(s =>
|
||||
s?.start === line && s?.end === line
|
||||
? undefined
|
||||
: { start: line, end: line },
|
||||
);
|
||||
}
|
||||
},
|
||||
copySelection() {
|
||||
if (sel) {
|
||||
const copyText = lines
|
||||
.slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end))
|
||||
.map(l => l.chunks.map(c => c.text).join(''))
|
||||
.join('\n');
|
||||
copyToClipboard(copyText);
|
||||
setSelection(undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export * from './HeaderIconLinkRow';
|
||||
export * from './HorizontalScrollGrid';
|
||||
export * from './Lifecycle';
|
||||
export * from './Link';
|
||||
export * from './LogViewer';
|
||||
export * from './MarkdownContent';
|
||||
export * from './OAuthRequestDialog';
|
||||
export * from './OverflowTooltip';
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
LifecycleClassKey,
|
||||
MarkdownContentClassKey,
|
||||
LoginRequestListItemClassKey,
|
||||
LogViewerClassKey,
|
||||
OAuthRequestDialogClassKey,
|
||||
OverflowTooltipClassKey,
|
||||
GaugeClassKey,
|
||||
@@ -110,6 +111,7 @@ type BackstageComponentsNameToClassKey = {
|
||||
BackstageLifecycle: LifecycleClassKey;
|
||||
BackstageMarkdownContent: MarkdownContentClassKey;
|
||||
BackstageLoginRequestListItem: LoginRequestListItemClassKey;
|
||||
BackstageLogViewer: LogViewerClassKey;
|
||||
OAuthRequestDialog: OAuthRequestDialogClassKey;
|
||||
BackstageOverflowTooltip: OverflowTooltipClassKey;
|
||||
BackstageGauge: GaugeClassKey;
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"humanize-duration": "^3.27.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.0.2",
|
||||
"react-lazylog": "^4.5.2",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4"
|
||||
@@ -63,7 +62,6 @@
|
||||
"@types/humanize-duration": "^3.25.1",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/react-lazylog": "^4.5.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.35.0"
|
||||
},
|
||||
|
||||
+5
-8
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { LogViewer } from '@backstage/core-components';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
@@ -24,10 +24,9 @@ import {
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { BuildStepAction } from 'circleci-api';
|
||||
import React, { Suspense, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { durationHumanized } from '../../../../util';
|
||||
|
||||
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
|
||||
const useStyles = makeStyles({
|
||||
accordionDetails: {
|
||||
padding: 0,
|
||||
@@ -85,11 +84,9 @@ export const ActionOutput = ({
|
||||
{messages.length === 0 ? (
|
||||
'Nothing here...'
|
||||
) : (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<div style={{ height: '20vh', width: '100%' }}>
|
||||
<LazyLog text={messages.join('\n')} extraLines={1} enableSearch />
|
||||
</div>
|
||||
</Suspense>
|
||||
<div style={{ height: '20vh', width: '100%' }}>
|
||||
<LogViewer text={messages.join('\n')} />
|
||||
</div>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"luxon": "^2.0.2",
|
||||
"qs": "^6.9.4",
|
||||
"react-lazylog": "^4.5.3",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4"
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@octokit/rest": "^18.5.3",
|
||||
"luxon": "^2.0.2",
|
||||
"react-lazylog": "^4.5.3",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { LogViewer } from '@backstage/core-components';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { readGitHubIntegrationConfigs } from '@backstage/integration';
|
||||
import {
|
||||
@@ -32,14 +32,11 @@ import {
|
||||
} from '@material-ui/core';
|
||||
import DescriptionIcon from '@material-ui/icons/Description';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import React, { Suspense } from 'react';
|
||||
import React from 'react';
|
||||
import { useProjectName } from '../useProjectName';
|
||||
import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs';
|
||||
|
||||
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
|
||||
const LinePart = React.lazy(() => import('react-lazylog/build/LinePart'));
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
button: {
|
||||
order: -1,
|
||||
marginRight: 0,
|
||||
@@ -53,49 +50,19 @@ const useStyles = makeStyles<Theme>(() => ({
|
||||
justifyContent: 'center',
|
||||
margin: 'auto',
|
||||
},
|
||||
normalLog: {
|
||||
normalLogContainer: {
|
||||
height: '75vh',
|
||||
width: '100%',
|
||||
},
|
||||
modalLog: {
|
||||
modalLogContainer: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
log: {
|
||||
background: theme.palette.background.default,
|
||||
},
|
||||
}));
|
||||
|
||||
const DisplayLog = ({
|
||||
jobLogs,
|
||||
className,
|
||||
}: {
|
||||
jobLogs: any;
|
||||
className: string;
|
||||
}) => {
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<div className={className}>
|
||||
<LazyLog
|
||||
text={jobLogs ?? 'No Values Found'}
|
||||
extraLines={1}
|
||||
caseInsensitive
|
||||
enableSearch
|
||||
formatPart={line => {
|
||||
if (
|
||||
line.toLocaleLowerCase().includes('error') ||
|
||||
line.toLocaleLowerCase().includes('failed') ||
|
||||
line.toLocaleLowerCase().includes('failure')
|
||||
) {
|
||||
return (
|
||||
<LinePart style={{ color: 'red' }} part={{ text: line }} />
|
||||
);
|
||||
}
|
||||
return line;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A component for Run Logs visualization.
|
||||
*/
|
||||
@@ -123,6 +90,7 @@ export const WorkflowRunLogs = ({
|
||||
repo,
|
||||
id: runId,
|
||||
});
|
||||
const logText = jobLogs.value ? String(jobLogs.value) : undefined;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleOpen = () => {
|
||||
@@ -162,18 +130,19 @@ export const WorkflowRunLogs = ({
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Fade in={open}>
|
||||
<DisplayLog
|
||||
jobLogs={jobLogs.value || undefined}
|
||||
className={classes.modalLog}
|
||||
/>
|
||||
<div className={classes.modalLogContainer}>
|
||||
<LogViewer
|
||||
text={logText ?? 'No Values Found'}
|
||||
className={classes.log}
|
||||
/>
|
||||
</div>
|
||||
</Fade>
|
||||
</Modal>
|
||||
</AccordionSummary>
|
||||
{jobLogs.value && (
|
||||
<DisplayLog
|
||||
jobLogs={jobLogs.value || undefined}
|
||||
className={classes.normalLog}
|
||||
/>
|
||||
{logText && (
|
||||
<div className={classes.normalLogContainer}>
|
||||
<LogViewer text={logText} className={classes.log} />
|
||||
</div>
|
||||
)}
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
catalogApiRef,
|
||||
starredEntitiesApiRef,
|
||||
DefaultStarredEntitiesApi,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { scaffolderApiRef, ScaffolderClient } from '../src';
|
||||
import { ScaffolderPage } from '../src/plugin';
|
||||
@@ -25,14 +29,25 @@ import {
|
||||
configApiRef,
|
||||
discoveryApiRef,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { CatalogEntityPage } from '@backstage/plugin-catalog';
|
||||
|
||||
createDevApp()
|
||||
.addPage({
|
||||
path: '/catalog/:kind/:namespace/:name',
|
||||
element: <CatalogEntityPage />,
|
||||
})
|
||||
.registerApi({
|
||||
api: catalogApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef },
|
||||
factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }),
|
||||
})
|
||||
.registerApi({
|
||||
api: starredEntitiesApiRef,
|
||||
deps: { storageApi: storageApiRef },
|
||||
factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }),
|
||||
})
|
||||
.registerApi({
|
||||
api: scaffolderApiRef,
|
||||
deps: {
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.0.2",
|
||||
"qs": "^6.9.4",
|
||||
"react-lazylog": "^4.5.2",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4",
|
||||
@@ -67,6 +66,7 @@
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/plugin-catalog": "^0.7.3",
|
||||
"@backstage/cli": "^0.10.0",
|
||||
"@backstage/core-app-api": "^0.1.24",
|
||||
"@backstage/dev-utils": "^0.2.13",
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Header,
|
||||
Lifecycle,
|
||||
Page,
|
||||
Progress,
|
||||
LogViewer,
|
||||
} from '@backstage/core-components';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
@@ -40,15 +40,13 @@ import Check from '@material-ui/icons/Check';
|
||||
import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord';
|
||||
import classNames from 'classnames';
|
||||
import { DateTime, Interval } from 'luxon';
|
||||
import React, { memo, Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useInterval } from 'react-use';
|
||||
import { Status, TaskOutput } from '../../types';
|
||||
import { useTaskEventStream } from '../hooks/useEventStream';
|
||||
import { TaskPageLinks } from './TaskPageLinks';
|
||||
|
||||
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
|
||||
|
||||
// typings are wrong for this library, so fallback to not parsing types.
|
||||
const humanizeDuration = require('humanize-duration');
|
||||
|
||||
@@ -213,22 +211,6 @@ export const TaskStatusStepper = memo(
|
||||
},
|
||||
);
|
||||
|
||||
const TaskLogger = memo(({ log }: { log: string }) => {
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<div style={{ height: '80vh' }}>
|
||||
<LazyLog
|
||||
text={log}
|
||||
extraLines={1}
|
||||
follow
|
||||
selectableLines
|
||||
enableSearch
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
const hasLinks = ({ entityRef, remoteUrl, links = [] }: TaskOutput): boolean =>
|
||||
!!(entityRef || remoteUrl || links.length > 0);
|
||||
|
||||
@@ -318,7 +300,9 @@ export const TaskPage = () => {
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<TaskLogger log={logAsString} />
|
||||
<div style={{ height: '80vh' }}>
|
||||
<LogViewer text={logAsString} />
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"event-source-polyfill": "^1.0.25",
|
||||
"git-url-parse": "^11.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react-lazylog": "^4.5.2",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-text-truncate": "^0.16.0",
|
||||
|
||||
@@ -14,32 +14,30 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
TechDocsBuildLogs,
|
||||
TechDocsBuildLogsDrawerContent,
|
||||
} from './TechDocsBuildLogs';
|
||||
|
||||
// react-lazylog is based on a react-virtualized component which doesn't
|
||||
// write the content to the dom, so we mock it.
|
||||
jest.mock('react-lazylog/build/LazyLog', () => {
|
||||
return {
|
||||
default: ({ text }: { text: string }) => {
|
||||
return <p>{text}</p>;
|
||||
},
|
||||
};
|
||||
});
|
||||
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
|
||||
jest.mock('react-virtualized-auto-sizer', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) => <>{props.children({ width: 400, height: 200 })}</>,
|
||||
}));
|
||||
|
||||
describe('<TechDocsBuildLogs />', () => {
|
||||
it('should render with button', () => {
|
||||
const rendered = render(<TechDocsBuildLogs buildLog={[]} />);
|
||||
it('should render with button', async () => {
|
||||
const rendered = await renderInTestApp(<TechDocsBuildLogs buildLog={[]} />);
|
||||
expect(rendered.getByText(/Show Build Logs/i)).toBeInTheDocument();
|
||||
expect(rendered.queryByText(/Build Details/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open drawer', () => {
|
||||
const rendered = render(<TechDocsBuildLogs buildLog={[]} />);
|
||||
it('should open drawer', async () => {
|
||||
const rendered = await renderInTestApp(<TechDocsBuildLogs buildLog={[]} />);
|
||||
rendered.getByText(/Show Build Logs/i).click();
|
||||
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -48,7 +46,7 @@ describe('<TechDocsBuildLogs />', () => {
|
||||
describe('<TechDocsBuildLogsDrawerContent />', () => {
|
||||
it('should render with empty log', async () => {
|
||||
const onClose = jest.fn();
|
||||
const rendered = render(
|
||||
const rendered = await renderInTestApp(
|
||||
<TechDocsBuildLogsDrawerContent buildLog={[]} onClose={onClose} />,
|
||||
);
|
||||
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
|
||||
@@ -61,7 +59,7 @@ describe('<TechDocsBuildLogsDrawerContent />', () => {
|
||||
|
||||
it('should render logs', async () => {
|
||||
const onClose = jest.fn();
|
||||
const rendered = render(
|
||||
const rendered = await renderInTestApp(
|
||||
<TechDocsBuildLogsDrawerContent
|
||||
buildLog={['Line 1', 'Line 2']}
|
||||
onClose={onClose}
|
||||
@@ -74,9 +72,9 @@ describe('<TechDocsBuildLogsDrawerContent />', () => {
|
||||
expect(onClose).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should call onClose', () => {
|
||||
it('should call onClose', async () => {
|
||||
const onClose = jest.fn();
|
||||
const rendered = render(
|
||||
const rendered = await renderInTestApp(
|
||||
<TechDocsBuildLogsDrawerContent buildLog={[]} onClose={onClose} />,
|
||||
);
|
||||
rendered.getByTitle('Close the drawer').click();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { LogViewer } from '@backstage/core-components';
|
||||
import {
|
||||
Button,
|
||||
createStyles,
|
||||
@@ -26,9 +26,7 @@ import {
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import Close from '@material-ui/icons/Close';
|
||||
import React, { Suspense, useState } from 'react';
|
||||
|
||||
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const useDrawerStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -46,6 +44,9 @@ const useDrawerStyles = makeStyles((theme: Theme) =>
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
logs: {
|
||||
background: theme.palette.background.default,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -57,6 +58,8 @@ export const TechDocsBuildLogsDrawerContent = ({
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const classes = useDrawerStyles();
|
||||
const logText =
|
||||
buildLog.length === 0 ? 'Waiting for logs...' : buildLog.join('\n');
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
@@ -83,18 +86,7 @@ export const TechDocsBuildLogsDrawerContent = ({
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
|
||||
<Suspense fallback={<Progress />}>
|
||||
<LazyLog
|
||||
text={
|
||||
buildLog.length === 0 ? 'Waiting for logs...' : buildLog.join('\n')
|
||||
}
|
||||
extraLines={1}
|
||||
follow
|
||||
selectableLines
|
||||
enableSearch
|
||||
/>
|
||||
</Suspense>
|
||||
<LogViewer text={logText} className={classes.logs} />
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6974,6 +6974,13 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/ansi-regex@^5.0.0":
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/@types/ansi-regex/-/ansi-regex-5.0.0.tgz#569a5189a92cc46d63fb2ad91e6b130f33d999c1"
|
||||
integrity sha512-SQafVL3pXFh/5qq/nN6p5858g//zSVzcb8JzCLtoVxm8YNPggMQfEIm7aaTNysxpw1S+lFTaW8kv+aR0/CEhCA==
|
||||
dependencies:
|
||||
ansi-regex "*"
|
||||
|
||||
"@types/archiver@^5.1.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz#2b34ba56d4d7102d256b922c7e91e09eab79db6f"
|
||||
@@ -8036,14 +8043,6 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-lazylog@^4.5.0":
|
||||
version "4.5.1"
|
||||
resolved "https://registry.npmjs.org/@types/react-lazylog/-/react-lazylog-4.5.1.tgz#babb5d814f7035b5434518769975e12f299356a8"
|
||||
integrity sha512-g4yeosa1zYhu2BUJmuu2H2o0dsdRj0o8Omw3pBiVHdLHJaeYIyArvyMRR3bI/MxZxG4EaiRl8AOQ6zeM8P46jA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
immutable ">=3.8.2"
|
||||
|
||||
"@types/react-redux@^7.1.16":
|
||||
version "7.1.19"
|
||||
resolved "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.19.tgz#477bd0a9b01bae6d6bf809418cdfa7d3c16d4c62"
|
||||
@@ -8096,6 +8095,20 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-virtualized-auto-sizer@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4"
|
||||
integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-window@^1.8.5":
|
||||
version "1.8.5"
|
||||
resolved "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
|
||||
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0":
|
||||
version "16.14.18"
|
||||
resolved "https://registry.npmjs.org/@types/react/-/react-16.14.18.tgz#b2bcea05ee244fde92d409f91bd888ca8e54b20f"
|
||||
@@ -9235,6 +9248,11 @@ ansi-html@0.0.7, ansi-html@^0.0.7:
|
||||
resolved "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e"
|
||||
integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4=
|
||||
|
||||
ansi-regex@*, ansi-regex@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
|
||||
integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
|
||||
|
||||
ansi-regex@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
|
||||
@@ -9255,11 +9273,6 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1:
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
|
||||
|
||||
ansi-regex@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
|
||||
integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
|
||||
|
||||
ansi-styles@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
|
||||
@@ -17047,7 +17060,7 @@ immer@^9.0.1, immer@^9.0.6:
|
||||
resolved "https://registry.npmjs.org/immer/-/immer-9.0.7.tgz#b6156bd7db55db7abc73fd2fdadf4e579a701075"
|
||||
integrity sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA==
|
||||
|
||||
immutable@>=3.8.2, immutable@^3.8.2, immutable@^3.x.x:
|
||||
immutable@^3.8.2, immutable@^3.x.x:
|
||||
version "3.8.2"
|
||||
resolved "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
|
||||
integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=
|
||||
@@ -20524,6 +20537,11 @@ memjs@^1.3.0:
|
||||
resolved "https://registry.npmjs.org/memjs/-/memjs-1.3.0.tgz#b7959b4ff3770e4c785463fd147f1e4fafd47a24"
|
||||
integrity sha512-y/V9a0auepA9Lgyr4QieK6K2FczjHucEdTpSS+hHVNmVEkYxruXhkHu8n6DSRQ4HXHEE3cc6Sf9f88WCJXGXsQ==
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
memoize-one@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
|
||||
@@ -24561,7 +24579,7 @@ react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-i
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||
|
||||
react-lazylog@^4.5.2, react-lazylog@^4.5.3:
|
||||
react-lazylog@^4.5.2:
|
||||
version "4.5.3"
|
||||
resolved "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e"
|
||||
integrity sha512-lyov32A/4BqihgXgtNXTHCajXSXkYHPlIEmV8RbYjHIMxCFSnmtdg4kDCI3vATz7dURtiFTvrw5yonHnrS+NNg==
|
||||
@@ -24807,6 +24825,11 @@ react-use@^17.2.4:
|
||||
ts-easing "^0.2.0"
|
||||
tslib "^2.1.0"
|
||||
|
||||
react-virtualized-auto-sizer@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
|
||||
integrity sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ==
|
||||
|
||||
react-virtualized@^9.21.0:
|
||||
version "9.21.2"
|
||||
resolved "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.21.2.tgz#02e6df65c1e020c8dbf574ec4ce971652afca84e"
|
||||
@@ -24819,6 +24842,14 @@ react-virtualized@^9.21.0:
|
||||
prop-types "^15.6.0"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react-window@^1.8.6:
|
||||
version "1.8.6"
|
||||
resolved "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112"
|
||||
integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^16.12.0, react@^16.13.1:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
|
||||
|
||||
Reference in New Issue
Block a user