Skip to content

Commit 0bf9ead

Browse files
committed
feat: add log lines to dashboard
1 parent 5ffce16 commit 0bf9ead

File tree

10 files changed

+140
-69
lines changed

10 files changed

+140
-69
lines changed

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

+4-4
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
import React from "react"
1818
import { Box, BoxProps, Spacer, Text, TextProps } from "ink"
1919

20-
import type { Props, State } from "./index.js"
21-
import type { UpdatePayload, Worker } from "./types.js"
20+
import type { Props } from "./index.js"
21+
import type { Worker } from "./types.js"
2222

2323
import { avg } from "./stats.js"
2424

@@ -30,7 +30,7 @@ type GridProps = {
3030
scale: Props["scale"]
3131
title: NonNullable<Props["grids"][number]>["title"]
3232
states: NonNullable<Props["grids"][number]>["states"]
33-
workers: State["workers"][number]
33+
workers: Worker[]
3434
}
3535

3636
export default class Grid extends React.PureComponent<GridProps> {
@@ -74,7 +74,7 @@ export default class Grid extends React.PureComponent<GridProps> {
7474
}
7575

7676
/** @return current `Worker[]` model */
77-
private get workers(): UpdatePayload["workers"] {
77+
private get workers(): Worker[] {
7878
return this.props.workers || []
7979
}
8080

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

+49-36
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ 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"
21+
import type { GridSpec, UpdatePayload, LogLineUpdate, WorkersUpdate, Worker } from "./types.js"
2222

2323
import Grid from "./Grid.js"
2424
import Timeline from "./Timeline.js"
25+
import { isWorkersUpdate } from "./types.js"
2526

2627
export type Props = {
2728
/** CodeFlare Profile for this dashboard */
@@ -33,42 +34,39 @@ export type Props = {
3334
/** Scale up the grid? [default: 1] */
3435
scale?: number
3536

37+
/** Grid models, where null means to insert a line break */
3638
grids: (null | GridSpec)[]
3739
}
3840

39-
export type State = {
40-
/** millis since epoch of the first update */
41-
firstUpdate: number
41+
export type State = Pick<WorkersUpdate, "events"> &
42+
LogLineUpdate & {
43+
/** millis since epoch of the first update */
44+
firstUpdate: number
4245

43-
/** millis since epoch of the last update */
44-
lastUpdate: number
46+
/** millis since epoch of the last update */
47+
lastUpdate: number
4548

46-
/** iteration count to help us keep "last updated ago" UI fresh */
47-
iter: number
49+
/** iteration count to help us keep "last updated ago" UI fresh */
50+
iter: number
4851

49-
/** interval to keep "last updated ago" UI fresh */
50-
agoInterval: ReturnType<typeof setInterval>
52+
/** interval to keep "last updated ago" UI fresh */
53+
agoInterval: ReturnType<typeof setInterval>
5154

52-
/** Lines of raw output to be displayed */
53-
events: UpdatePayload["events"]
55+
/** Controller that allows us to shut down gracefully */
56+
watchers: { quit: () => void }[]
5457

55-
/** Controller that allows us to shut down gracefully */
56-
watchers: { quit: () => void }[]
57-
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[][]
63-
}
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[][]
63+
}
6464

6565
export default class Dashboard extends React.PureComponent<Props, State> {
6666
public componentDidMount() {
6767
this.setState({
6868
workers: [],
69-
watchers: this.gridModels.map((props, gridIdx) =>
70-
props.initWatcher((model: UpdatePayload) => this.onUpdate(gridIdx, model))
71-
),
69+
watchers: this.gridModels.map((props, gridIdx) => props.initWatcher((model) => this.onUpdate(gridIdx, model))),
7270
agoInterval: setInterval(() => this.setState((curState) => ({ iter: (curState?.iter || 0) + 1 })), 5 * 1000),
7371
})
7472
}
@@ -87,8 +85,15 @@ export default class Dashboard extends React.PureComponent<Props, State> {
8785
this.setState((curState) => ({
8886
firstUpdate: (curState && curState.firstUpdate) || Date.now(), // TODO pull from the events
8987
lastUpdate: Date.now(), // TODO pull from the events
90-
events: !model.events || model.events.length === 0 ? curState?.events : model.events,
91-
workers: !curState?.workers
88+
events: !isWorkersUpdate(model)
89+
? curState?.events
90+
: !model.events || model.events.length === 0
91+
? curState?.events
92+
: model.events,
93+
logLine: !isWorkersUpdate(model) ? model.logLine : curState?.logLine,
94+
workers: !isWorkersUpdate(model)
95+
? curState?.workers
96+
: !curState?.workers
9297
? [model.workers]
9398
: [...curState.workers.slice(0, gridIdx), model.workers, ...curState.workers.slice(gridIdx + 1)],
9499
}))
@@ -102,10 +107,15 @@ export default class Dashboard extends React.PureComponent<Props, State> {
102107
}
103108

104109
/** @return current `events` model */
105-
private get events(): UpdatePayload["events"] {
110+
private get events(): State["events"] {
106111
return this.state?.events
107112
}
108113

114+
/** @return current `logLine` model */
115+
private get logLine(): State["logLine"] {
116+
return this.state?.logLine
117+
}
118+
109119
/** @return first update time */
110120
private get firstUpdate() {
111121
return this.state?.firstUpdate || Date.now()
@@ -184,17 +194,20 @@ export default class Dashboard extends React.PureComponent<Props, State> {
184194

185195
/** Render log lines and events */
186196
private footer() {
187-
if (!this.events) {
197+
if (!this.events && !this.logLine) {
188198
return <React.Fragment />
189199
} else {
190-
const rows = this.events.map(({ line, timestamp }) => {
191-
// the controller (controller/dashboard/utilization/Live)
192-
// leaves a {timestamp} breadcrumb in the raw line text, so
193-
// that we,as the view, can inject a "5m ago" text, while
194-
// preserving the ansi formatting that surrounds the timestamp
195-
const txt = line.replace("{timestamp}", () => this.agos(timestamp))
196-
return <Text key={txt}>{txt}</Text>
197-
})
200+
const rows = (this.events || [])
201+
.map(({ line, timestamp }) => {
202+
// the controller (controller/dashboard/utilization/Live)
203+
// leaves a {timestamp} breadcrumb in the raw line text, so
204+
// that we,as the view, can inject a "5m ago" text, while
205+
// preserving the ansi formatting that surrounds the timestamp
206+
const txt = line.replace("{timestamp}", () => this.agos(timestamp))
207+
return <Text key={txt}>{txt}</Text>
208+
})
209+
.concat((this.logLine ? [this.logLine] : []).map((line) => <Text key={line}>{line}</Text>))
210+
198211
return (
199212
<Box marginTop={1} flexDirection="column">
200213
{rows}

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

+13-2
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,26 @@ export type Worker = {
3737
lastUpdate: number
3838
}
3939

40-
/** Model that allows the controllers to pass updated `Worker` info */
41-
export type UpdatePayload = {
40+
export type LogLineUpdate = {
41+
/** Log lines */
42+
logLine: string
43+
}
44+
45+
export type WorkersUpdate = {
4246
/** Per-worker status info */
4347
workers: Worker[]
4448

4549
/** Lines of raw event lines to be displayed */
4650
events?: { line: string; timestamp: number }[]
4751
}
4852

53+
/** Model that allows the controllers to pass updated `Worker` info */
54+
export type UpdatePayload = LogLineUpdate | WorkersUpdate
55+
56+
export function isWorkersUpdate(update: UpdatePayload): update is WorkersUpdate {
57+
return Array.isArray((update as WorkersUpdate).workers)
58+
}
59+
4960
/** Callback from controller when it has updated data */
5061
export type OnData = (payload: UpdatePayload) => void
5162

Diff for: plugins/plugin-codeflare-dashboard/src/controller/dashboard/kinds.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,38 @@
1616

1717
import type { SupportedGrid } from "./grids.js"
1818

19-
type Kind = SupportedGrid | "logs"
19+
type Kind = SupportedGrid | "logs" | "env"
2020
export type KindA = Kind | "all"
2121
export default Kind
2222

23-
export const resourcePaths: Record<Kind, string[]> = {
23+
/** A filepath with a `Kind` discriminant to help understand the content of the `filepath` */
24+
export type KindedSource = { kind: Kind; filepath: string }
25+
26+
/**
27+
* A source to be tailf'd is either a string (the filepath to the
28+
* source) or that plus a `Kind` discriminant.
29+
*/
30+
type Source = string | KindedSource
31+
32+
/** Extract the `filepath` property of `source` */
33+
export function filepathOf(source: Source) {
34+
return typeof source === "string" ? source : source.filepath
35+
}
36+
37+
export const resourcePaths: Record<Kind, Source[]> = {
2438
status: [
2539
"events/kubernetes.txt",
2640
"events/job-status.txt",
2741
"events/pods.txt",
2842
"events/runtime-env-setup.txt",
29-
// "logs/job.txt",
43+
{ kind: "logs", filepath: "logs/job.txt" },
3044
],
3145
"gpu%": ["resources/gpu.txt"],
3246
"gpumem%": ["resources/gpu.txt"],
3347
"cpu%": ["resources/pod-vmstat.txt"],
3448
"mem%": ["resources/pod-memory.txt"],
3549
logs: ["logs/job.txt"],
50+
env: ["env.json"],
3651
}
3752

3853
export function validKinds() {

Diff for: plugins/plugin-codeflare-dashboard/src/controller/dashboard/options.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ type Options = {
2626

2727
/** Number of lines of events to show [default: 8] */
2828
events: number
29+
30+
/** Number of lines of application logs to show [default: 1] */
31+
lines: number
2932
}
3033

3134
export default Options
3235

3336
export const flags = {
3437
boolean: ["demo"],
35-
alias: { events: ["e"], theme: ["t"], demo: ["d"], scale: ["s"] },
38+
alias: { events: ["e"], lines: ["l"], theme: ["t"], demo: ["d"], scale: ["s"] },
3639
}

Diff for: plugins/plugin-codeflare-dashboard/src/controller/dashboard/status/Live.ts

+28-9
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export default class Live {
4040
/** Number of lines of event output to retain. TODO this depends on height of terminal? */
4141
private static readonly MAX_HEAP = 1000
4242

43+
/** Model of logLines. TODO circular buffer and obey options.lines */
44+
// private logLine = ""
45+
4346
/** Model of the lines of output */
4447
private readonly events = new Heap<Event>((a, b) => {
4548
if (a.line === b.line) {
@@ -62,9 +65,13 @@ export default class Live {
6265
private readonly opts: Pick<Options, "events">
6366
) {
6467
tails.map((tailf) => {
65-
tailf.then(({ stream }) => {
68+
tailf.then(({ kind, stream }) => {
6669
stream.on("data", (data) => {
6770
if (data) {
71+
if (kind === "logs") {
72+
this.pushLineAndPublish(data, cb)
73+
}
74+
6875
const line = stripAnsi(data)
6976
const cols = line.split(/\s+/)
7077

@@ -77,7 +84,6 @@ export default class Live {
7784

7885
if (!name || !timestamp) {
7986
// console.error("Bad status record", line)
80-
// this.pushEventAndPublish(data, metric, timestamp, cb)
8187
return
8288
} else if (!metric) {
8389
// ignoring this line
@@ -130,6 +136,14 @@ export default class Live {
130136
})
131137
}
132138

139+
/** @return the most important events, to be shown in the UI */
140+
private importantEvents() {
141+
return this.events
142+
.toArray()
143+
.slice(0, this.opts.events || 8)
144+
.sort((a, b) => a.timestamp - b.timestamp)
145+
}
146+
133147
private readonly lookup: Record<string, Event> = {}
134148
/** Add `line` to our heap `this.events` */
135149
private pushEvent(line: string, metric: WorkerState, timestamp: number) {
@@ -163,16 +177,21 @@ export default class Live {
163177
if (this.opts.events === 0) {
164178
return []
165179
} else {
166-
return this.events
167-
.toArray()
168-
.slice(0, this.opts.events || 8)
169-
.sort((a, b) => a.timestamp - b.timestamp)
180+
return this.importantEvents()
170181
}
171182
}
172183

173-
/** `pushEvent` and then pass the updated model to `cb` */
174-
private pushEventAndPublish(line: string, metric: WorkerState, timestamp: number, cb: OnData) {
175-
cb({ events: this.pushEvent(line, metric, timestamp), workers: Object.values(this.workers) })
184+
/** Helps with debouncing logLine updates */
185+
private logLineTO: null | ReturnType<typeof setTimeout> = null
186+
187+
/** Add the given `line` to our logLines model and pass the updated model to `cb` */
188+
private pushLineAndPublish(logLine: string, cb: OnData) {
189+
if (logLine) {
190+
// here we avoid a flood of React renders by batching them up a
191+
// bit; i thought react 18 was supposed to help with this. hmm.
192+
if (this.logLineTO) clearTimeout(this.logLineTO)
193+
this.logLineTO = setTimeout(() => cb({ logLine }), 1)
194+
}
176195
}
177196

178197
private asMillisSinceEpoch(timestamp: string) {

Diff for: plugins/plugin-codeflare-dashboard/src/controller/dashboard/tailf.ts

+14-7
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import split2 from "split2"
1919
import chokidar from "chokidar"
2020
import TailFile from "@logdna/tail-file"
2121

22-
import Kind, { resourcePaths } from "./kinds.js"
22+
import Kind, { KindedSource, resourcePaths } from "./kinds.js"
2323

2424
export type Tail = {
25+
kind: Kind
2526
stream: import("stream").Readable
2627
quit: TailFile["quit"]
2728
}
@@ -34,7 +35,7 @@ export function waitTillExists(filepath: string) {
3435
})
3536
}
3637

37-
async function initTail(filepath: string, split = true): Promise<Tail> {
38+
async function initTail({ kind, filepath }: KindedSource, split = true): Promise<Tail> {
3839
await waitTillExists(filepath)
3940

4041
return new Promise<Tail>((resolve, reject) => {
@@ -47,17 +48,23 @@ async function initTail(filepath: string, split = true): Promise<Tail> {
4748
tail.start()
4849

4950
resolve({
51+
kind,
5052
stream: split ? tail.pipe(split2()) : tail,
5153
quit: tail.quit.bind(tail),
5254
})
5355
})
5456
}
5557

56-
export async function pathsFor(kind: Kind, profile: string, jobId: string) {
58+
export async function pathsFor(mkind: Kind, profile: string, jobId: string) {
5759
const { Profiles } = await import("madwizard")
58-
return resourcePaths[kind].map((resourcePath) =>
59-
join(Profiles.guidebookJobDataPath({ profile }), jobId, resourcePath)
60-
)
60+
return resourcePaths[mkind].map((src) => {
61+
const kind = typeof src === "string" ? mkind : src.kind
62+
const resourcePath = typeof src === "string" ? src : src.filepath
63+
return {
64+
kind,
65+
filepath: join(Profiles.guidebookJobDataPath({ profile }), jobId, resourcePath),
66+
}
67+
})
6168
}
6269

6370
export default async function tailf(
@@ -66,5 +73,5 @@ export default async function tailf(
6673
jobId: string,
6774
split = true
6875
): Promise<Promise<Tail>[]> {
69-
return pathsFor(kind, profile, jobId).then((_) => _.map((filepath) => initTail(filepath, split)))
76+
return pathsFor(kind, profile, jobId).then((_) => _.map((src) => initTail(src, split)))
7077
}

0 commit comments

Comments
 (0)