Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Timeline view for kubernetes Jobs #5371

Merged
merged 1 commit into from
Aug 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/plugin-client-common/i18n/resources_en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"Duration": "Duration: {0}",
"Cold Start": "Cold start delay: {0}. {1}",
"Close this tab": "Close this tab",
"concurrencyInDurationSplit": "{0} concurrency with execution time {1}",
"concurrencyColdStartInDurationSplit": "{0} concurrency in cold starts with execution time {1}",
"Split the Terminal": "Split the Terminal",
"Output has been pinned to a watch pane": "Output has been **pinned** to a watch pane",
"No more splits allowed": "No more splits allowed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
* limitations under the License.
*/

import { Tab, REPL, Table as KuiTable, Row as KuiRow } from '@kui-shell/core'

import * as React from 'react'
import * as prettyPrintDuration from 'pretty-ms'
import { Tab, REPL, Table as KuiTable, Row as KuiRow } from '@kui-shell/core'

import ErrorCell from './ErrorCell'
import { onClickForCell } from './TableCell'
Expand All @@ -43,19 +43,41 @@ export const findGridableColumn = (response: KuiTable) => {

const thresholds = [2000, 4000, 6000, 8000]

export function durationCss(duration: number, isError: boolean) {
if (isError) {
return 'red-background'
} else if (duration < thresholds[0]) {
return 'color-latency0'
export function nDurationBuckets() {
return 5
}

export function durationRangeOfSplit(idx: number) {
return idx === 0
? `<{prettyPrintDuration(thresholds[0])}`
: idx === thresholds.length
? `>${prettyPrintDuration(thresholds[idx - 1])}`
: `${prettyPrintDuration(thresholds[idx - 1])}\u2014${prettyPrintDuration(thresholds[idx])}`
}

export function durationBucket(duration: number) {
if (duration < thresholds[0]) {
return 0
} else if (duration < thresholds[1]) {
return 'color-latency1'
return 1
} else if (duration < thresholds[2]) {
return 'color-latency3'
return 3
} else if (duration < thresholds[3]) {
return 'color-latency4'
return 4
} else {
return 5
}
}

export function durationCssForBucket(idx: number) {
return `color-latency${idx}`
}

export function durationCss(duration: number, isError: boolean) {
if (isError) {
return 'red-background'
} else {
return 'color-latency5'
return durationCssForBucket(durationBucket(duration))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { DataTable, DataTableHeader, TableContainer, Table } from 'carbon-compon

import sortRow from './sort'
import Card from '../../spi/Card'
import Timeline from './Timeline'
import renderBody from './TableBody'
import renderHeader from './TableHeader'
import SequenceDiagram from './SequenceDiagram'
Expand Down Expand Up @@ -139,8 +140,9 @@ export default class PaginatedTable<P extends Props, S extends State> extends Re
headers,
rows,
footer,
asSequence: false,
asGrid: this.props.asGrid && findGridableColumn(this.props.response) >= 0,
asSequence: false,
asTimeline: false,
page: 1,
pageSize: this.defaultPageSize
} as S
Expand Down Expand Up @@ -205,6 +207,9 @@ export default class PaginatedTable<P extends Props, S extends State> extends Re
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private bottomToolbar(lightweightTables = false) {
const gridableColumn = findGridableColumn(this.props.response)
const hasSequenceButton = isTableWithTimestamp(this.props.response)
const hasTimelineButton = hasSequenceButton // same

return (
<React.Fragment>
{this.hasFooterLines() && <Toolbar stream={this.footerLines()} repl={this.props.repl} />}
Expand All @@ -221,9 +226,12 @@ export default class PaginatedTable<P extends Props, S extends State> extends Re
page={this.state.page}
totalItems={this.state.rows.length}
pageSize={this.state.pageSize}
hasSequenceButton={isTableWithTimestamp(this.props.response)}
hasSequenceButton={hasSequenceButton}
asSequence={this.state.asSequence}
setAsSequence={(asSequence: boolean) => this.setState({ asSequence })}
hasTimelineButton={hasTimelineButton}
asTimeline={this.state.asTimeline}
setAsTimeline={(asTimeline: boolean) => this.setState({ asTimeline })}
/>
)}
</React.Fragment>
Expand Down Expand Up @@ -304,11 +312,21 @@ export default class PaginatedTable<P extends Props, S extends State> extends Re
return <SequenceDiagram {...this.props} />
}

private timeline() {
return <Timeline {...this.props} />
}

private content(includeToolbars = false, lightweightTables = false) {
return (
<React.Fragment>
{includeToolbars && this.topToolbar(lightweightTables)}
{this.state.asGrid ? this.grid(this.state.rows) : this.state.asSequence ? this.sequence() : this.table()}
{this.state.asGrid
? this.grid(this.state.rows)
: this.state.asSequence
? this.sequence()
: this.state.asTimeline
? this.timeline()
: this.table()}
{includeToolbars && this.bottomToolbar(lightweightTables)}
</React.Fragment>
)
Expand All @@ -323,6 +341,7 @@ export default class PaginatedTable<P extends Props, S extends State> extends Re
'kui--data-table-wrapper' +
(this.state.asGrid ? ' kui--data-table-as-grid' : '') +
(this.state.asSequence ? ' kui--data-table-as-sequence' : '') +
(this.state.asTimeline ? ' kui--data-table-as-timeline' : '') +
(lightweightTables ? ' kui--data-table-wrapper-lightweight' : '')

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export default class SequenceDiagram extends React.PureComponent<Props, State> {
const className = durationCss(duration, false)

const gap =
intervalIdx === 0
intervalIdx === 0 && rowIdx === 0
? 0
: rowIdx === 0
? startMillis - this.state.intervals[intervalIdx - 1].endMillis
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*
* Copyright 2020 IBM Corporation
*
* 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 * as React from 'react'
import { REPL, Tab, Table, i18n } from '@kui-shell/core'

import { durationBucket, nDurationBuckets, durationCssForBucket, durationRangeOfSplit } from './Grid'

import '../../../../web/scss/components/Table/Timeline.scss'

const strings = i18n('plugin-client-common')

interface Bucket {
startMillis: number
endMillis: number
coldStart: number
execution: number
durationSplit: { coldStart: number; execution: number }[]
}

interface Props {
response: Table
tab: Tab
repl: REPL
nBuckets?: number
}

interface State {
bucketTimeRange: number
maxBucketOccupancy: number
buckets: Bucket[]
}

export default class Timeline extends React.PureComponent<Props, State> {
/**
* Default number of buckets in the timeline
*
*/
private static readonly defaultNBuckets = 25

public constructor(props: Props) {
super(props)
this.state = Timeline.computeBuckets(props.response, props.nBuckets || Timeline.defaultNBuckets)
}

public static getDerivedStateFromProps(props: Props) {
return Timeline.computeBuckets(props.response, props.nBuckets || Timeline.defaultNBuckets)
}

private static computeBuckets(response: Table, nBuckets: number) {
const idx1 = response.startColumnIdx
const idx2 = response.completeColumnIdx

const { minStart, maxEnd } = response.body.reduce(
(range, row) => {
const startCell = row.attributes[idx1]
const startTime = startCell && startCell.value ? new Date(startCell.value).getTime() : 0

const endCell = row.attributes[idx2]
const endTime = endCell && endCell.value ? new Date(endCell.value).getTime() : 0

if (range.minStart === 0 || startTime < range.minStart) {
range.minStart = startTime
}

if (endTime > range.maxEnd) {
range.maxEnd = endTime
}

return range
},
{ minStart: 0, maxEnd: 0 }
)

const bucketTimeRange = Math.max(1, maxEnd - minStart)
const timeRangePerBucket = Math.floor(bucketTimeRange / nBuckets)

const bucketOf = (millis: number) => Math.min(nBuckets - 1, Math.floor((millis - minStart) / timeRangePerBucket))

const initialBuckets: Bucket[] = Array(nBuckets)
.fill(0)
.map((_, idx) => ({
startMillis: minStart + timeRangePerBucket * idx,
endMillis: idx === nBuckets - 1 ? maxEnd : minStart + timeRangePerBucket * (idx + 1),
coldStart: 0,
execution: 0,
durationSplit: Array(nDurationBuckets())
.fill(0)
.map(() => ({ execution: 0, coldStart: 0 }))
}))

// need to slice so as not to permute the original table model
const buckets = response.body.reduce((buckets, row) => {
const startMillis = new Date(row.attributes[idx1].value).getTime()
const endMillis =
!row.attributes[idx2].value || row.attributes[idx2].value === '<none>'
? startMillis
: new Date(row.attributes[idx2].value).getTime()

const coldStart =
response.coldStartColumnIdx >= 0 ? parseInt(row.attributes[response.coldStartColumnIdx].value, 10) : 0

const startBucketIdx = bucketOf(startMillis)
const coldStartEndBucketIdx = bucketOf(startMillis + coldStart)
const endBucketIdx = bucketOf(endMillis)

// const nColdBucketsSpanned = coldStartEndBucketIdx - startBucketIdx + 1
// const nExecutionBucketsSpanned = endBucketIdx - coldStartEndBucketIdx + 1

const splitIdx = durationBucket(endMillis - startMillis)
for (let bucketIdx = startBucketIdx; bucketIdx < coldStartEndBucketIdx + 1; bucketIdx++) {
buckets[bucketIdx].coldStart++
buckets[bucketIdx].durationSplit[splitIdx].coldStart++
}
for (let bucketIdx = coldStartEndBucketIdx; bucketIdx < endBucketIdx + 1; bucketIdx++) {
buckets[bucketIdx].execution++
buckets[bucketIdx].durationSplit[splitIdx].execution++
}

return buckets
}, initialBuckets)

return {
bucketTimeRange,
maxBucketOccupancy: buckets.reduce((max, bucket) => Math.max(max, bucket.coldStart + bucket.execution), 0),
buckets
}
}

private getFraction(numerator: number) {
const denominator = this.state.maxBucketOccupancy
return `${((numerator / denominator) * 100).toFixed(10).toString()}%`
}

private bar(occupancy: number, css: string, title?: string, needsOverlay = false) {
return occupancy > 0 ? (
<div
key={`${css}-${needsOverlay}-${occupancy}`}
className={css}
style={{ height: this.getFraction(occupancy) }}
title={title}
>
{needsOverlay ? <div className="kui--bar-overlay">&nbsp;</div> : <React.Fragment>&nbsp;</React.Fragment>}
</div>
) : (
undefined
)
}

private buckets() {
return (
<div className="kui--timeline-buckets">
{this.state.buckets.map((bucket, idx) => {
const { coldStart = 0, execution = 0, durationSplit } = bucket
const leftover = this.state.maxBucketOccupancy - coldStart - execution

return (
<div className="kui--timeline-bucket" key={`${idx}-${bucket.coldStart}-${bucket.execution}`}>
{this.bar(leftover, 'leftover')}
{durationSplit.reverse().map((split, idx) => {
const splitIdx = durationSplit.length - idx - 1
const range = durationRangeOfSplit(splitIdx)
return (
<React.Fragment key={splitIdx}>
{this.bar(
split.execution,
durationCssForBucket(splitIdx),
strings(`concurrencyInDurationSplit`, split.execution, range)
)}
{this.bar(
split.coldStart,
durationCssForBucket(splitIdx),
strings(`concurrencyColdStartInDurationSplit`, split.execution, range),
true
)}
</React.Fragment>
)
})}
{/* this.bar(execution, "execution") */}
{/** durationSplit.map((split, idx) => this.bar(split.coldStart, durationCssForBucket(idx)))**/}
{/* this.bar(coldStart, true) */}
</div>
)
})}
</div>
)
}

private yAxis() {
const nTicks = 3

return (
<div className="kui--timeline-y-axis">
<div className="kui--timeline-ticks">
<div className="kui--timeline-occupancy-line kui--timeline-tick">
<div className="kui--timeline-occupancy-line-label">{this.state.maxBucketOccupancy} max concurrency</div>
</div>
{Array(nTicks)
.fill(0)
.map((_, idx) => (
<div key={idx} className="kui--timeline-tick" style={{ top: ~~((100 * (idx + 1)) / (nTicks + 1)) + '%' }}>
&nbsp;
</div>
))}
</div>
</div>
)
}

private xAxis() {
return <React.Fragment />
}

public render() {
if (!this.state) {
return <React.Fragment />
}

return (
<div className="kui--data-table-container bx--data-table-container">
<div className="kui--timeline">
{this.yAxis()}
{this.xAxis()}
{this.buckets()}
</div>
</div>
)
}
}