diff --git a/web/src/CopyLogs.test.tsx b/web/src/CopyLogs.test.tsx index 7da999e3ee..330cc7dd9f 100644 --- a/web/src/CopyLogs.test.tsx +++ b/web/src/CopyLogs.test.tsx @@ -2,11 +2,29 @@ import { act, render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import React from "react" import CopyLogs, { copyLogs } from "./CopyLogs" -import { logLinesToString, stripAnsiCodes } from "./logs" +import { + filterLogLinesForDisplay, + logLinesToString, + stripAnsiCodes, +} from "./logs" +import { + createFilterTermState, + EMPTY_FILTER_TERM, + FilterLevel, + FilterSet, + FilterSource, +} from "./logfilters" import LogStore, { LogStoreProvider } from "./LogStore" +import { StarredResourceMemoryProvider } from "./StarredResourcesContext" import { appendLinesForManifestAndSpan } from "./testlogs" import { ResourceName } from "./types" +const DEFAULT_FILTER_SET: FilterSet = { + source: FilterSource.all, + level: FilterLevel.all, + term: EMPTY_FILTER_TERM, +} + describe("CopyLogs", () => { let writeTextMock: jest.Mock @@ -53,7 +71,10 @@ describe("CopyLogs", () => { const logStore = createPopulatedLogStore() render( - + ) @@ -69,7 +90,7 @@ describe("CopyLogs", () => { const logStore = createPopulatedLogStore() render( - + ) @@ -87,7 +108,10 @@ describe("CopyLogs", () => { render( - + ) @@ -103,7 +127,10 @@ describe("CopyLogs", () => { const logStore = createPopulatedLogStore() render( - + ) @@ -119,7 +146,10 @@ describe("CopyLogs", () => { const logStore = createPopulatedLogStore() render( - + ) @@ -140,9 +170,58 @@ describe("CopyLogs", () => { it("returns the number of lines copied", () => { const logStore = createPopulatedLogStore() - const count = copyLogs(logStore, ResourceName.all) + const count = copyLogs(logStore, ResourceName.all, DEFAULT_FILTER_SET) expect(count).toBe(logStore.allLog().length) }) + + it("copies only the currently filtered log lines", async () => { + const logStore = createPopulatedLogStore() + const filterSet: FilterSet = { + ...DEFAULT_FILTER_SET, + term: createFilterTermState("runtime"), + } + + render( + + + + ) + + await act(async () => { + userEvent.click(screen.getByText("Copy")) + }) + + const expectedText = logLinesToString( + filterLogLinesForDisplay(logStore.allLog(), filterSet), + false + ) + expect(writeTextMock).toHaveBeenCalledWith(expectedText) + }) + + it("copies only starred resource logs in starred view", async () => { + const logStore = createPopulatedLogStore() + + render( + + + + + + ) + + await act(async () => { + userEvent.click(screen.getByText("Copy")) + }) + + const expectedText = logLinesToString( + logStore.starredLogPatchSet(["vigoda"], 0).lines, + true + ) + expect(writeTextMock).toHaveBeenCalledWith(expectedText) + }) }) describe("stripAnsiCodes", () => { diff --git a/web/src/CopyLogs.tsx b/web/src/CopyLogs.tsx index ee275af0a9..5b3c344aed 100644 --- a/web/src/CopyLogs.tsx +++ b/web/src/CopyLogs.tsx @@ -1,8 +1,10 @@ import React, { useState } from "react" import styled from "styled-components" import { InstrumentedButton } from "./instrumentedComponents" +import { FilterSet } from "./logfilters" import LogStore, { useLogStore } from "./LogStore" -import { logLinesToString } from "./logs" +import { filterLogLinesForDisplay, logLinesToString } from "./logs" +import { useStarredResources } from "./StarredResourcesContext" import { AnimDuration, Color, @@ -25,25 +27,52 @@ const CopyLogsButton = styled(InstrumentedButton)` export interface CopyLogsProps { resourceName: string + filterSet: FilterSet } -export const copyLogs = (logStore: LogStore, resourceName: string): number => { +function logLinesForResource( + logStore: LogStore, + resourceName: string, + starredResources: string[] +) { const all = resourceName === ResourceName.all - const lines = all ? logStore.allLog() : logStore.manifestLog(resourceName) - const text = logLinesToString(lines, !all) + if (all) { + return logStore.allLog() + } + if (resourceName === ResourceName.starred) { + return logStore.starredLogPatchSet(starredResources, 0).lines + } + return logStore.manifestLog(resourceName) +} + +export const copyLogs = ( + logStore: LogStore, + resourceName: string, + filterSet: FilterSet, + starredResources: string[] = [] +): number => { + const all = resourceName === ResourceName.all + const lines = logLinesForResource(logStore, resourceName, starredResources) + const visibleLines = filterLogLinesForDisplay(lines, filterSet) + const text = logLinesToString(visibleLines, !all) navigator.clipboard.writeText(text) - return lines.length + return visibleLines.length } -const CopyLogs: React.FC = ({ resourceName }) => { +const CopyLogs: React.FC = ({ resourceName, filterSet }) => { const logStore = useLogStore() - const all = resourceName == ResourceName.all + const { starredResources } = useStarredResources() const label = "Copy" const [tooltipOpen, setTooltipOpen] = useState(false) const [tooltipText, setTooltipText] = useState("") const handleClick = () => { - const lineCount = copyLogs(logStore, resourceName) + const lineCount = copyLogs( + logStore, + resourceName, + filterSet, + starredResources + ) setTooltipText( lineCount === 1 ? "Copied 1 line" : `Copied ${lineCount} lines` ) diff --git a/web/src/LogActions.tsx b/web/src/LogActions.tsx index 79d6461f72..15227488f8 100644 --- a/web/src/LogActions.tsx +++ b/web/src/LogActions.tsx @@ -4,6 +4,7 @@ import styled from "styled-components" import ClearLogs from "./ClearLogs" import CopyLogs from "./CopyLogs" import { InstrumentedButton } from "./instrumentedComponents" +import { FilterSet } from "./logfilters" import { AnimDuration, Color, @@ -116,11 +117,13 @@ export const LogsFontSize: React.FC = () => { export interface LogActionsProps { resourceName: string isSnapshot: boolean + filterSet: FilterSet } const LogActions: React.FC = ({ resourceName, isSnapshot, + filterSet, }) => { return ( @@ -128,7 +131,7 @@ const LogActions: React.FC = ({ {isSnapshot || ( <> | - + | diff --git a/web/src/OverviewActionBar.tsx b/web/src/OverviewActionBar.tsx index f3be4696ee..5803d0fede 100644 --- a/web/src/OverviewActionBar.tsx +++ b/web/src/OverviewActionBar.tsx @@ -811,6 +811,7 @@ export default function OverviewActionBar(props: OverviewActionBarProps) { key="logActions" resourceName={resourceName} isSnapshot={isSnapshot} + filterSet={filterSet} /> ) } diff --git a/web/src/OverviewLogPane.tsx b/web/src/OverviewLogPane.tsx index 5fcd6e9248..acb112e3c4 100644 --- a/web/src/OverviewLogPane.tsx +++ b/web/src/OverviewLogPane.tsx @@ -1,13 +1,7 @@ import React, { Component } from "react" import { useNavigate, useLocation } from "react-router-dom" import styled, { keyframes } from "styled-components" -import { - FilterLevel, - FilterSet, - filterSetsEqual, - FilterSource, - TermState, -} from "./logfilters" +import { FilterSet, filterSetsEqual } from "./logfilters" import "./LogLine.scss" import "./LogPane.scss" import LogStore, { @@ -15,16 +9,16 @@ import LogStore, { LogUpdateEvent, useLogStore, } from "./LogStore" -import { isBuildSpanId } from "./logs" +import { DISPLAY_LOG_PROLOGUE_LENGTH, LogDisplay } from "./logs" import PathBuilder, { usePathBuilder } from "./PathBuilder" import { RafContext, useRaf } from "./raf" import { useStarredResources } from "./StarredResourcesContext" import { Color, FontSize, SizeUnit } from "./style-helpers" import Anser from "./third-party/anser/index.js" -import { LogLevel, LogLine, ResourceName } from "./types" +import { LogLine, ResourceName } from "./types" // The number of lines to display before an error. -export const PROLOGUE_LENGTH = 5 +export const PROLOGUE_LENGTH = DISPLAY_LOG_PROLOGUE_LENGTH type OverviewLogComponentProps = { manifestName: string @@ -215,13 +209,12 @@ export class OverviewLogComponent extends Component { private lineHashList: LineHashList = new LineHashList() - // When we're displaying warnings or errors, we want to display the last - // N lines before the error. So we keep track of the last N lines for each span. - private prologuesBySpanId: { [key: string]: LogLine[] } = {} + private logDisplay: LogDisplay constructor(props: OverviewLogComponentProps) { super(props) + this.logDisplay = new LogDisplay(props.filterSet) this.onScroll = this.onScroll.bind(this) this.onLogUpdate = this.onLogUpdate.bind(this) this.renderBuffer = this.renderBuffer.bind(this) @@ -402,7 +395,7 @@ export class OverviewLogComponent extends Component { } this.lineHashList = new LineHashList() - this.prologuesBySpanId = {} + this.logDisplay = new LogDisplay(this.props.filterSet) this.logCheckpoint = 0 this.scrollTop = -1 @@ -417,67 +410,6 @@ export class OverviewLogComponent extends Component { } } - matchesTermFilter(line: LogLine): boolean { - const { term } = this.props.filterSet - - // Don't consider a filter term if the term hasn't been parsed for matching - if (!term || term.state !== TermState.Parsed) { - return true - } - - return term.regexp.test(line.text) - } - - // If we have a level filter on, check if this line matches the level filter. - matchesLevelFilter(line: LogLine): boolean { - let level = this.props.filterSet.level - if (level === FilterLevel.warn && line.level !== LogLevel.WARN) { - return false - } - if (level === FilterLevel.error && line.level !== LogLevel.ERROR) { - return false - } - return true - } - - // Check if this line matches the current filter. - matchesFilter(line: LogLine): boolean { - if (line.buildEvent) { - // Always leave in build event logs. - // This makes it easier to see which logs belong to which builds. - return true - } - - let source = this.props.filterSet.source - if (source === FilterSource.runtime && isBuildSpanId(line.spanId)) { - return false - } - if (source === FilterSource.build && !isBuildSpanId(line.spanId)) { - return false - } - - return this.matchesLevelFilter(line) && this.matchesTermFilter(line) - } - - // Index this line so that we can display prologues to errors. - trackPrologueLine(line: LogLine) { - if (!this.prologuesBySpanId[line.spanId]) { - this.prologuesBySpanId[line.spanId] = [] - } - this.prologuesBySpanId[line.spanId].push(line) - } - - // Gets the prologue for the given span, and clear the lines used for prologuing. - getAndClearPrologue(spanId: string): LogLine[] { - let lines = this.prologuesBySpanId[spanId] - if (!lines) { - return [] - } - - delete this.prologuesBySpanId[spanId] - return lines.slice(-PROLOGUE_LENGTH) // last N lines - } - // Render new logs that have come in since the current checkpoint. readLogsFromLogStore() { let mn = this.props.manifestName @@ -493,21 +425,7 @@ export class OverviewLogComponent extends Component { : logStore.manifestLogPatchSet(mn, startCheckpoint) : logStore.allLogPatchSet(startCheckpoint) - let lines: LogLine[] = [] - let shouldDisplayPrologues = this.props.filterSet.level !== FilterLevel.all - - patch.lines.forEach((line) => { - let matches = this.matchesFilter(line) - if (matches) { - if (shouldDisplayPrologues) { - lines.push(...this.getAndClearPrologue(line.spanId)) - } - lines.push(line) - return - } else if (shouldDisplayPrologues) { - this.trackPrologueLine(line) - } - }) + let lines = this.logDisplay.filterLines(patch.lines) this.logCheckpoint = patch.checkpoint lines.forEach((line) => this.lineHashList.append(line)) @@ -658,7 +576,7 @@ export class OverviewLogComponent extends Component { return } - let shouldDisplayPrologues = this.props.filterSet.level !== FilterLevel.all + let shouldDisplayPrologues = this.logDisplay.shouldDisplayPrologues() let mn = this.props.manifestName let showManifestName = !mn || mn === ResourceName.starred let prevManifestName = entry.prev?.line.manifestName || "" @@ -671,7 +589,7 @@ export class OverviewLogComponent extends Component { let isEndOfAlert = shouldDisplayPrologues && - this.matchesLevelFilter(line) && + this.logDisplay.matchesLevelFilter(line) && (!entry.next || entry.next?.line.level !== line.level) if (isEndOfAlert) { extraClasses.push("is-endOfAlert") @@ -680,9 +598,9 @@ export class OverviewLogComponent extends Component { let isStartOfAlert = shouldDisplayPrologues && !line.buildEvent && - !this.matchesLevelFilter(line) && + !this.logDisplay.matchesLevelFilter(line) && (!entry.prev || - this.matchesLevelFilter(entry.prev.line) || + this.logDisplay.matchesLevelFilter(entry.prev.line) || entry.prev.line.buildEvent) if (isStartOfAlert) { extraClasses.push("is-startOfAlert") diff --git a/web/src/logs.ts b/web/src/logs.ts index 1dc2f42fdb..4a17ef87a4 100644 --- a/web/src/logs.ts +++ b/web/src/logs.ts @@ -1,7 +1,10 @@ // Helper functions for dealing with logs +import { FilterLevel, FilterSet, FilterSource, TermState } from "./logfilters" import { LogLine, ResourceName } from "./types" +export const DISPLAY_LOG_PROLOGUE_LENGTH = 5 + export function logLinesFromString( log: string, manifestName?: string @@ -58,3 +61,96 @@ export function sourcePrefix(n: string) { export function isBuildSpanId(spanId: string): boolean { return spanId.indexOf("build:") === 0 || spanId.indexOf("cmdimage:") === 0 } + +export class LogDisplay { + private prologuesBySpanId: { [key: string]: LogLine[] } = {} + filterSet: FilterSet + + constructor(filterSet: FilterSet) { + this.filterSet = filterSet + } + + shouldDisplayPrologues(): boolean { + return this.filterSet.level !== FilterLevel.all + } + + matchesTermFilter(line: LogLine): boolean { + const { term } = this.filterSet + + if (!term || term.state !== TermState.Parsed) { + return true + } + + return term.regexp.test(line.text) + } + + matchesLevelFilter(line: LogLine): boolean { + let level = this.filterSet.level + if (level === FilterLevel.warn && line.level !== "WARN") { + return false + } + if (level === FilterLevel.error && line.level !== "ERROR") { + return false + } + return true + } + + matchesFilter(line: LogLine): boolean { + if (line.buildEvent) { + return true + } + + let source = this.filterSet.source + if (source === FilterSource.runtime && isBuildSpanId(line.spanId)) { + return false + } + if (source === FilterSource.build && !isBuildSpanId(line.spanId)) { + return false + } + + return this.matchesLevelFilter(line) && this.matchesTermFilter(line) + } + + trackPrologueLine(line: LogLine) { + if (!this.prologuesBySpanId[line.spanId]) { + this.prologuesBySpanId[line.spanId] = [] + } + this.prologuesBySpanId[line.spanId].push(line) + } + + getAndClearPrologue(spanId: string): LogLine[] { + let spanLines = this.prologuesBySpanId[spanId] + if (!spanLines) { + return [] + } + + delete this.prologuesBySpanId[spanId] + return spanLines.slice(-DISPLAY_LOG_PROLOGUE_LENGTH) + } + + filterLines(lines: LogLine[]): LogLine[] { + let result: LogLine[] = [] + let shouldDisplayPrologues = this.shouldDisplayPrologues() + + lines.forEach((line) => { + let matches = this.matchesFilter(line) + if (matches) { + if (shouldDisplayPrologues) { + result.push(...this.getAndClearPrologue(line.spanId)) + } + result.push(line) + } else if (shouldDisplayPrologues) { + this.trackPrologueLine(line) + } + }) + + return result + } +} + +export function filterLogLinesForDisplay( + lines: LogLine[], + filterSet: FilterSet +): LogLine[] { + return new LogDisplay(filterSet).filterLines(lines) +}