Skip to content

Commit f86f0c5

Browse files
committed
feat: add streaming output support for dashboard logs
1 parent 6b55c30 commit f86f0c5

File tree

10 files changed

+301
-19
lines changed

10 files changed

+301
-19
lines changed

package-lock.json

Lines changed: 30 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"@types/node": "14.11.8",
9393
"@types/react": "17.0.39",
9494
"@types/react-dom": "17.0.11",
95+
"@types/tail": "^2.2.1",
9596
"@types/uuid": "^8.3.4",
9697
"@typescript-eslint/eslint-plugin": "^5.28.0",
9798
"@typescript-eslint/parser": "^5.28.0",

plugins/plugin-client-default/notebooks/dashboard.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,15 @@ layout:
2323

2424
=== "Application Logs"
2525

26-
```ansi
27-
--8<-- "$LOGDIR/logs/job.txt"
26+
```shell
27+
---
28+
execute: now
29+
outputOnly: true
30+
maximize: true
31+
---
32+
codeflare tailf "$LOGDIR/logs/job.txt"
2833
```
29-
34+
3035
--8<-- "./dashboard-source.md"
3136
--8<-- "./dashboard-envvars.md"
3237
--8<-- "./dashboard-dependencies.md"

plugins/plugin-codeflare/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
"access": "public"
2424
},
2525
"dependencies": {
26-
"@patternfly/react-core": "^4.221.3",
2726
"@patternfly/react-charts": "^6.74.3",
28-
"strip-ansi": "6.0.0"
27+
"@patternfly/react-core": "^4.221.3",
28+
"strip-ansi": "6.0.0",
29+
"tail": "^2.2.4"
2930
}
3031
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
* Copyright 2022 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 { ITheme, Terminal } from "xterm"
19+
import { FitAddon } from "xterm-addon-fit"
20+
import { Events } from "@kui-shell/core"
21+
22+
interface Props {
23+
initialContent?: string
24+
on?(eventType: "line", cb: (data: any) => void): void
25+
unwatch?(): void
26+
}
27+
28+
export default class XTerm extends React.PureComponent<Props> {
29+
private terminal: Terminal = new Terminal({
30+
scrollback: 5000,
31+
})
32+
33+
private readonly cleaners: (() => void)[] = []
34+
private readonly container = React.createRef<HTMLDivElement>()
35+
36+
public componentDidMount() {
37+
this.mountTerminal()
38+
39+
if (this.props.initialContent) {
40+
// @starpit i don't know why we have to split the newlines...
41+
this.props.initialContent.split(/\n/).forEach(this.writeln)
42+
//this.terminal.write(this.props.initialContent)
43+
}
44+
if (this.props.on) {
45+
this.props.on("line", this.writeln)
46+
}
47+
}
48+
49+
public componentWillUnmount() {
50+
this.unmountTerminal()
51+
this.cleaners.forEach((cleaner) => cleaner())
52+
53+
if (this.props.unwatch) {
54+
this.props.unwatch()
55+
}
56+
}
57+
58+
private writeln = (data: any) => {
59+
if (typeof data === "string") {
60+
this.terminal.writeln(data)
61+
}
62+
}
63+
64+
private unmountTerminal() {
65+
if (this.terminal) {
66+
this.terminal.dispose()
67+
}
68+
}
69+
70+
private mountTerminal() {
71+
const xtermContainer = this.container.current
72+
if (!xtermContainer) {
73+
return
74+
}
75+
76+
const fitAddon = new FitAddon()
77+
this.terminal.loadAddon(fitAddon)
78+
79+
const inject = () => this.injectTheme(this.terminal, xtermContainer)
80+
inject()
81+
Events.eventChannelUnsafe.on("/theme/change", inject)
82+
this.cleaners.push(() => Events.eventChannelUnsafe.on("/theme/change", inject))
83+
84+
this.terminal.open(xtermContainer)
85+
86+
const doResize = () => {
87+
try {
88+
fitAddon.fit()
89+
} catch (err) {
90+
// this is due to not being in focus, so it isn't critical
91+
console.error(err)
92+
}
93+
}
94+
95+
const observer = new ResizeObserver(function observer(observed) {
96+
// re: the if guard, see https://github.com/IBM/kui/issues/6585
97+
if (observed.every((_) => _.contentRect.width > 0 && _.contentRect.height > 0)) {
98+
setTimeout(doResize)
99+
}
100+
})
101+
observer.observe(xtermContainer)
102+
}
103+
104+
/**
105+
* Take a hex color string and return the corresponding RGBA with the given alpha
106+
*
107+
*/
108+
private alpha(hex: string, alpha: number): string {
109+
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
110+
const red = parseInt(hex.slice(1, 3), 16)
111+
const green = parseInt(hex.slice(3, 5), 16)
112+
const blue = parseInt(hex.slice(5, 7), 16)
113+
114+
return `rgba(${red},${green},${blue},${alpha})`
115+
} else {
116+
return hex
117+
}
118+
}
119+
120+
/**
121+
* Convert the current theme to an xterm.js ITheme
122+
*
123+
*/
124+
private injectTheme(xterm: Terminal, dom: HTMLElement): void {
125+
const theme = getComputedStyle(dom)
126+
// debug('kui theme for xterm', theme)
127+
128+
/** helper to extract a kui theme color */
129+
const val = (key: string, kind = "color"): string => theme.getPropertyValue(`--${kind}-${key}`).trim()
130+
131+
const itheme: ITheme = {
132+
foreground: val("text-01"),
133+
background: val("sidecar-background-01"),
134+
cursor: val("support-01"),
135+
selection: this.alpha(val("selection-background"), 0.3),
136+
137+
black: val("black"),
138+
red: val("red"),
139+
green: val("green"),
140+
yellow: val("yellow"),
141+
blue: val("blue"),
142+
magenta: val("magenta"),
143+
cyan: val("cyan"),
144+
white: val("white"),
145+
146+
brightBlack: val("black"),
147+
brightRed: val("red"),
148+
brightGreen: val("green"),
149+
brightYellow: val("yellow"),
150+
brightBlue: val("blue"),
151+
brightMagenta: val("magenta"),
152+
brightCyan: val("cyan"),
153+
brightWhite: val("white"),
154+
}
155+
156+
// debug('itheme for xterm', itheme)
157+
xterm.setOption("theme", itheme)
158+
xterm.setOption("fontFamily", val("monospace", "font"))
159+
160+
try {
161+
const standIn = document.querySelector("body .repl .repl-input input")
162+
if (standIn) {
163+
const fontTheme = getComputedStyle(standIn)
164+
xterm.setOption("fontSize", parseInt(fontTheme.fontSize.replace(/px$/, ""), 10))
165+
// terminal.setOption('lineHeight', )//parseInt(fontTheme.lineHeight.replace(/px$/, ''), 10))
166+
167+
// FIXME. not tied to theme
168+
xterm.setOption("fontWeight", 400)
169+
xterm.setOption("fontWeightBold", 600)
170+
}
171+
} catch (err) {
172+
console.error("Error setting terminal font size", err)
173+
}
174+
}
175+
176+
private onKeyUp(evt: React.KeyboardEvent) {
177+
if (evt.key === "Escape") {
178+
// swallow escape key presses against the xterm container,
179+
// e.g. we don't want hitting escape in vi to propagate to other
180+
// kui elements
181+
evt.stopPropagation()
182+
}
183+
}
184+
185+
public render() {
186+
return <div ref={this.container} className="xterm-container" onKeyUp={this.onKeyUp} />
187+
}
188+
}

plugins/plugin-codeflare/src/controller/charts/gpu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import React from "react"
1818
import stripAnsi from "strip-ansi"
19-
import { Arguments } from "@kui-shell/core"
19+
import { Arguments, encodeComponent } from "@kui-shell/core"
2020

2121
import { expand } from "../../lib/util"
2222
import { timeRange } from "./timestamps"
@@ -62,7 +62,7 @@ function formatLogObject(logLine: string[]) {
6262
}
6363

6464
export async function parse(filepath: string, REPL: Arguments["REPL"]) {
65-
const logs = stripAnsi(await REPL.qexec<string>(`vfs fslice ${expand(filepath)} 0`))
65+
const logs = stripAnsi(await REPL.qexec<string>(`vfs fslice ${encodeComponent(expand(filepath))} 0`))
6666
const formattedLogs = formatLogs(logs)
6767
return formattedLogs.map((logLine) => formatLogObject(logLine))
6868
}

plugins/plugin-codeflare/src/controller/charts/vmstat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import React from "react"
18-
import { Arguments } from "@kui-shell/core"
18+
import { Arguments, encodeComponent } from "@kui-shell/core"
1919

2020
import { expand } from "../../lib/util"
2121
import { timeRange } from "./timestamps"
@@ -54,7 +54,7 @@ function parseLine(cells: string[]): Log {
5454
}
5555

5656
export async function parse(filepath: string, REPL: Arguments["REPL"]) {
57-
return (await REPL.qexec<string>(`vfs fslice ${expand(filepath)} 0`))
57+
return (await REPL.qexec<string>(`vfs fslice ${encodeComponent(expand(filepath))} 0`))
5858
.split(/\n/)
5959
.filter((logLine) => logLine && !/----|swpd/.test(logLine))
6060
.map((_) => _.split(/\s+/))

plugins/plugin-codeflare/src/controller/dashboard.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@ import { Arguments, CommandOptions, Registrar } from "@kui-shell/core"
1818

1919
import "../../web/scss/components/Dashboard/_index.scss"
2020

21-
interface DashboardOptions {
21+
export interface FollowOptions {
2222
f: boolean
2323
follow: boolean
2424
}
2525

26+
export const followFlags: CommandOptions["flags"] = {
27+
boolean: ["f", "follow"],
28+
alias: { follow: ["f"] },
29+
}
30+
2631
function dashboardcli(args: Arguments) {
2732
const filepath = args.argvNoOptions[1]
2833
if (!filepath) {
@@ -33,22 +38,19 @@ function dashboardcli(args: Arguments) {
3338
return args.REPL.qexec(`codeflare dashboardui ${args.command.slice(restIdx)}`)
3439
}
3540

36-
async function dashboardui(args: Arguments<DashboardOptions>) {
41+
async function dashboardui(args: Arguments<FollowOptions>) {
3742
const { setTabReadonly } = await import("@kui-shell/plugin-madwizard")
3843
setTabReadonly(args)
3944

4045
const filepath = args.argvNoOptions[2]
4146
process.env.LOGDIR = filepath
47+
process.env.FOLLOW = args.parsedOptions.follow ? "-f" : ""
4248

43-
const db = args.parsedOptions.follow ? "dashboard-live.md" : "dashboard.md"
44-
return args.REPL.qexec(`commentary -f /kui/client/${db}`)
49+
return args.REPL.qexec(`commentary -f /kui/client/dashboard.md`)
4550
}
4651

4752
export default function registerDashboardCommands(registrar: Registrar) {
48-
const flags: CommandOptions["flags"] = {
49-
boolean: ["f", "follow"],
50-
alias: { follow: ["f"] },
51-
}
53+
const flags = followFlags
5254

5355
registrar.listen("/dashboard", dashboardcli, { flags, outputOnly: true })
5456
registrar.listen("/codeflare/dashboardui", dashboardui, {

0 commit comments

Comments
 (0)