Skip to content

Commit 100394b

Browse files
committed
feat: history for dashboard
1 parent 5267b4d commit 100394b

File tree

15 files changed

+497
-133
lines changed

15 files changed

+497
-133
lines changed

Diff for: package-lock.json

+9-9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright 2023 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React from "react"
18+
import { Box, Text } from "ink"
19+
20+
import type { GridSpec, Worker } from "./types.js"
21+
22+
type Props = {
23+
gridModels: GridSpec[]
24+
workers: Worker[][]
25+
}
26+
27+
export default class Timeline extends React.PureComponent<Props> {
28+
/** Text to use for one cell's worth of time */
29+
private readonly block = {
30+
historic: "■",
31+
latest: "▏",
32+
}
33+
34+
private get maxLabelLength() {
35+
return this.props.gridModels.filter((_) => !_.isQualitative).reduce((N, { title }) => Math.max(N, title.length), 0)
36+
}
37+
38+
/** @return max number of time cells, across all grids and all workers */
39+
private nTimeCells() {
40+
// outer loop: iterate across grids
41+
return this.props?.workers.reduce((nTimes, _) => {
42+
if (Array.isArray(_) && _.length > 0) {
43+
return Math.max(
44+
nTimes,
45+
_.reduce((nTimesInner, _) => Math.max(nTimesInner, _.metricHistory.length), 0)
46+
// ^^^ inner loop, iterate across workers in that grid
47+
)
48+
} else {
49+
return nTimes
50+
}
51+
}, 0)
52+
}
53+
54+
/** @return the accumulated `total` and count `N` across a set of `workers` for the given `timeIdx` */
55+
private accum(workers: Worker[], timeIdx: number, field: "valueTotal" | "metricIdxTotal") {
56+
return workers.reduce(
57+
(A, worker) => {
58+
const history = worker.metricHistory
59+
if (history[timeIdx]) {
60+
A.total += history[timeIdx][field]
61+
A.N += history[timeIdx].N
62+
}
63+
return A
64+
},
65+
{ total: 0, N: 0 }
66+
)
67+
}
68+
69+
/** @return average metric value across a set of `workers` for the given `timeIdx` */
70+
private avg(workers: Worker[], timeIdx: number, field: "valueTotal" | "metricIdxTotal"): number {
71+
const { total, N } = this.accum(workers, timeIdx, field)
72+
if (N === 0) {
73+
if (timeIdx === 0) return 0
74+
else {
75+
for (let t = timeIdx - 1; t >= 0; t--) {
76+
const { total, N } = this.accum(workers, t, field)
77+
if (N !== 0) {
78+
return Math.round(total / N)
79+
}
80+
}
81+
return 0
82+
}
83+
}
84+
85+
return Math.round(total / N)
86+
}
87+
88+
/** @return long-term average, averaged over time and across a set of `workers` */
89+
private longTermAvg(workers: Worker[], nTimes: number) {
90+
const { total, N } = Array(nTimes)
91+
.fill(0)
92+
.map((_, timeIdx) => this.accum(workers, timeIdx, "valueTotal"))
93+
.reduce(
94+
(A, { total, N }) => {
95+
A.total += total
96+
A.N += N
97+
return A
98+
},
99+
{ total: 0, N: 0 }
100+
)
101+
102+
return Math.round(total / N)
103+
}
104+
105+
/**
106+
* Render one cell to represent the average over the given `workers`
107+
* for the given grid, for the given time.
108+
*/
109+
private cell(workers: Worker[], spec: GridSpec, timeIdx: number, isLatest: boolean) {
110+
const metricIdx = this.avg(workers, timeIdx, "metricIdxTotal")
111+
const style = spec.states[metricIdx] ? spec.states[metricIdx].style : { color: "gray", dimColor: true }
112+
113+
return (
114+
<React.Fragment key={timeIdx}>
115+
<Text {...style}>{this.block.historic}</Text>
116+
<Text dimColor>{isLatest ? this.block.latest : ""}</Text>
117+
</React.Fragment>
118+
)
119+
}
120+
121+
/** Render one horizontal array of cells for the given grid */
122+
private cells(workers: Worker[], spec: GridSpec, nTimes: number, timeStartIdx: number) {
123+
return Array(nTimes - timeStartIdx)
124+
.fill(0)
125+
.map((_, idx, A) => this.cell(workers, spec, idx + timeStartIdx, idx === A.length - 1))
126+
}
127+
128+
/** Render the timeline UI for the given grid */
129+
private timeline(workers: Worker[], spec: GridSpec, nTimes: number, timeStartIdx: number) {
130+
return spec.isQualitative ? (
131+
<React.Fragment />
132+
) : (
133+
<React.Fragment>
134+
<Box justifyContent="flex-end">
135+
<Text>{spec.title.padStart(this.maxLabelLength)}</Text>
136+
</Box>
137+
<Box marginLeft={1}>{this.cells(workers, spec, nTimes, timeStartIdx)}</Box>
138+
<Text>
139+
{Math.round(this.avg(workers, nTimes - 1, "valueTotal"))
140+
.toFixed()
141+
.padStart(3) + "%"}
142+
</Text>
143+
<Text color="yellow"> μ={Math.round(this.longTermAvg(workers, nTimes)) + "%"}</Text>
144+
</React.Fragment>
145+
)
146+
}
147+
148+
public render() {
149+
if (!this.props?.workers) {
150+
// no grid info, yet
151+
return <React.Fragment />
152+
}
153+
154+
const nTimes = this.nTimeCells()
155+
156+
// to help us compute whether we are about to overflow terminal width
157+
const maxLabelLength = this.props.gridModels.reduce((N, spec) => {
158+
return Math.max(N, "100% μ=100%".length + spec.title.length)
159+
}, 0)
160+
161+
// once we overflow, display the suffix of history information, starting at this index
162+
const timeStartIdx = Math.abs(Math.max(0, nTimes + maxLabelLength - process.stdout.columns))
163+
164+
if (nTimes === 0) {
165+
// none of the grids have any temporal information, yet
166+
return <React.Fragment />
167+
} else {
168+
// render one `this.timeline()` row per grid
169+
return (
170+
<Box flexDirection="column">
171+
{this.props.workers.map((workers, gridIdx) => (
172+
<Box key={gridIdx}>{this.timeline(workers, this.props.gridModels[gridIdx], nTimes, timeStartIdx)}</Box>
173+
))}
174+
</Box>
175+
)
176+
}
177+
}
178+
}

Diff for: plugins/plugin-codeflare-dashboard/src/components/Dashboard/index.tsx

+23-6
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import React from "react"
1818
import prettyMillis from "pretty-ms"
1919
import { Box, Spacer, Text } from "ink"
2020

21+
import type { GridSpec, UpdatePayload, Worker } from "./types.js"
22+
2123
import Grid from "./Grid.js"
22-
import type { GridSpec, UpdatePayload } from "./types.js"
24+
import Timeline from "./Timeline.js"
2325

2426
export type Props = {
2527
/** CodeFlare Profile for this dashboard */
@@ -53,15 +55,18 @@ export type State = {
5355
/** Controller that allows us to shut down gracefully */
5456
watchers: { quit: () => void }[]
5557

56-
/** Model of current workers */
57-
workers: UpdatePayload["workers"][]
58+
/**
59+
* Model of current workers; outer idx is grid index; inner idx is
60+
* worker idx, i.e. for each grid, we have an array of Workers.
61+
*/
62+
workers: Worker[][]
5863
}
5964

6065
export default class Dashboard extends React.PureComponent<Props, State> {
6166
public componentDidMount() {
6267
this.setState({
6368
workers: [],
64-
watchers: this.grids.map((props, gridIdx) =>
69+
watchers: this.gridModels.map((props, gridIdx) =>
6570
props.initWatcher((model: UpdatePayload) => this.onUpdate(gridIdx, model))
6671
),
6772
agoInterval: setInterval(() => this.setState((curState) => ({ iter: (curState?.iter || 0) + 1 })), 5 * 1000),
@@ -90,7 +95,7 @@ export default class Dashboard extends React.PureComponent<Props, State> {
9095
}
9196

9297
/** @return the grid models, excluding the `null` linebreak indicators */
93-
private get grids(): GridSpec[] {
98+
private get gridModels(): GridSpec[] {
9499
// [email protected] does not seem to be smart enough here, hence the
95100
// type conversion :(
96101
return this.props.grids.filter((_) => _ !== null) as GridSpec[]
@@ -220,7 +225,7 @@ export default class Dashboard extends React.PureComponent<Props, State> {
220225
}
221226

222227
/** Render the grids */
223-
private body() {
228+
private grids() {
224229
return this.gridRows().map((row, ridx) => (
225230
<Box key={ridx} justifyContent="space-around">
226231
{row.map(({ grid, widx }) => (
@@ -239,6 +244,18 @@ export default class Dashboard extends React.PureComponent<Props, State> {
239244
))
240245
}
241246

247+
/** Render the grids and timelines */
248+
private body() {
249+
return (
250+
<Box flexDirection="column">
251+
{this.grids()}
252+
<Box marginTop={1}>
253+
<Timeline gridModels={this.gridModels} workers={this.state?.workers} />
254+
</Box>
255+
</Box>
256+
)
257+
}
258+
242259
public render() {
243260
return (
244261
<Box flexDirection="column">

Diff for: plugins/plugin-codeflare-dashboard/src/components/Dashboard/types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export type Worker = {
2424
/** Current metric value */
2525
metric: string
2626

27+
/** History of metric values */
28+
metricHistory: { valueTotal: number; metricIdxTotal: number; N: number }[]
29+
2730
/** Color for grid cell and legend */
2831
style: TextProps
2932

@@ -54,6 +57,13 @@ export type GridSpec = {
5457
/** title of grid */
5558
title: string
5659

60+
/**
61+
* Is this metric not quantitative? If not, it will not be shown in
62+
* average/temporal views, as it is not meaningful to compute the
63+
* average of a qualitative metric.
64+
*/
65+
isQualitative: boolean
66+
5767
/** Names for distinct states */
5868
states: { state: string; style: TextProps }[]
5969

0 commit comments

Comments
 (0)