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)
+}