Skip to content

Commit a67b424

Browse files
committed
fix: Top should allow pageup/pagedown to cycle through clusters
1 parent acd51c3 commit a67b424

File tree

6 files changed

+189
-81
lines changed

6 files changed

+189
-81
lines changed

Diff for: plugins/plugin-codeflare-dashboard/src/components/Top/Header.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default class Header extends React.PureComponent<Props> {
3030
<Text color="blue" bold>
3131
{"Cluster " /* Cheapo alignment with "Namespace" */}
3232
</Text>
33-
{this.props.cluster}
33+
{this.props.cluster.replace(/:\d+$/, "")}
3434
</Text>
3535

3636
<Spacer />

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

+70-55
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,19 @@ import { Box, Text, render } from "ink"
2222
import type Group from "./Group.js"
2323
import type {
2424
Context,
25-
ChangeContextRequest,
2625
ChangeContextRequestHandler,
2726
WatcherInitializer,
27+
UpdateError,
2828
UpdatePayload,
29+
UpdatePayloadOrError,
2930
ResourceSpec,
3031
} from "./types.js"
3132

33+
import Header from "./Header.js"
3234
import JobBox from "./JobBox.js"
35+
import { isError } from "./types.js"
3336
import defaultValueFor from "./defaults.js"
3437

35-
import Header from "./Header.js"
36-
3738
type UI = {
3839
/** Force a refresh */
3940
refreshCycle?: number
@@ -55,6 +56,9 @@ type State = UI & {
5556
/** Model from controller */
5657
rawModel: UpdatePayload
5758

59+
/** Error in updating model? */
60+
updateError: null | UpdateError
61+
5862
/** Our grouping of `rawModel` */
5963
groups: Group[]
6064

@@ -83,19 +87,19 @@ class Top extends React.PureComponent<Props, State> {
8387
return this.state?.selectedGroupIdx >= 0 && this.state?.selectedGroupIdx < this.state.groups.length
8488
}
8589

90+
private clearCurrentJobSelection() {
91+
this.setState({ selectedGroupIdx: -1 })
92+
}
93+
8694
/** Current cluster context */
8795
private get currentContext() {
8896
return {
97+
context: this.state?.rawModel?.context || this.props.context,
8998
cluster: this.state?.rawModel?.cluster || this.props.cluster,
9099
namespace: this.state?.rawModel?.namespace || this.props.namespace,
91100
}
92101
}
93102

94-
/** Updated cluster context */
95-
private updatedContext({ which }: Pick<ChangeContextRequest, "which">, next: string) {
96-
return Object.assign(this.currentContext, which === "namespace" ? { namespace: next } : { cluster: next })
97-
}
98-
99103
public async componentDidMount() {
100104
this.setState({ watcher: await this.props.initWatcher(this.currentContext, this.onData) })
101105

@@ -131,6 +135,28 @@ class Top extends React.PureComponent<Props, State> {
131135
}
132136
}
133137

138+
private async cycleThroughContexts(which: "namespace" | "cluster", dir: "up" | "down") {
139+
if (this.currentContext) {
140+
const updatedContext = await this.props.changeContext({ which, context: this.currentContext, dir })
141+
142+
if (updatedContext) {
143+
this.reinit(updatedContext)
144+
}
145+
}
146+
}
147+
148+
private cycleThroughJobs(dir: "left" | "right") {
149+
if (this.state.groups) {
150+
const incr = dir === "left" ? -1 : 1
151+
this.setState((curState) => ({
152+
selectedGroupIdx:
153+
curState?.selectedGroupIdx === undefined
154+
? 0
155+
: this.mod(curState.selectedGroupIdx + incr, curState.groups.length + 1),
156+
}))
157+
}
158+
}
159+
134160
/** Handle keyboard events from the user */
135161
private initKeyboardEvents() {
136162
if (!process.stdin.isTTY) {
@@ -149,46 +175,23 @@ class Top extends React.PureComponent<Props, State> {
149175
} else {
150176
switch (key.name) {
151177
case "escape":
152-
this.setState({ selectedGroupIdx: -1 })
178+
this.clearCurrentJobSelection()
153179
break
180+
154181
case "up":
155182
case "down":
156-
/** Change context selection */
157-
if (this.state?.rawModel.namespace) {
158-
this.props
159-
.changeContext({ which: "namespace", from: this.state.rawModel.namespace, dir: key.name })
160-
.then((next) => {
161-
if (next) {
162-
this.reinit(this.updatedContext({ which: "namespace" }, next))
163-
}
164-
})
165-
}
183+
this.cycleThroughContexts("namespace", key.name)
184+
break
185+
186+
case "pageup":
187+
case "pagedown":
188+
this.cycleThroughContexts("cluster", key.name === "pageup" ? "up" : "down")
166189
break
167190

168191
case "left":
169192
case "right":
170-
/** Change job selection */
171-
if (this.state.groups) {
172-
const incr = key.name === "left" ? -1 : 1
173-
this.setState((curState) => ({
174-
selectedGroupIdx:
175-
curState?.selectedGroupIdx === undefined
176-
? 0
177-
: this.mod(curState.selectedGroupIdx + incr, curState.groups.length + 1),
178-
}))
179-
}
193+
this.cycleThroughJobs(key.name)
180194
break
181-
/*case "i":
182-
this.setState((curState) => ({ blockCells: !this.useBlocks(curState) }))
183-
break*/
184-
/*case "g":
185-
this.setState((curState) => ({
186-
groupHosts: !this.groupHosts(curState),
187-
groups: !curState?.rawModel
188-
? curState?.groups
189-
: this.groupBy(curState.rawModel, !this.groupHosts(curState)),
190-
}))
191-
break */
192195
}
193196
}
194197
})
@@ -198,28 +201,38 @@ class Top extends React.PureComponent<Props, State> {
198201
return { min: { cpu: 0, mem: 0, gpu: 0 }, tot: {} }
199202
}
200203

201-
private reinit(context: Context) {
204+
private async reinit(context: Context) {
202205
if (this.state?.watcher) {
203206
this.state?.watcher.kill()
204207
}
205-
this.setState({ groups: [], rawModel: Object.assign({ hosts: [], stats: this.emptyStats }, context) })
206-
this.props.initWatcher(context, this.onData)
208+
this.setState({
209+
groups: [],
210+
updateError: null,
211+
watcher: await this.props.initWatcher(context, this.onData),
212+
rawModel: Object.assign({ hosts: [], stats: this.emptyStats }, context),
213+
})
207214
}
208215

209216
/** We have received data from the controller */
210-
private readonly onData = (rawModel: UpdatePayload) => {
217+
private readonly onData = (rawModel: UpdatePayloadOrError) => {
211218
if (rawModel.cluster !== this.currentContext.cluster || rawModel.namespace !== this.currentContext.namespace) {
212219
// this is straggler data from the prior context
213220
return
214-
}
215-
216-
this.setState((curState) => {
217-
if (JSON.stringify(curState?.rawModel) === JSON.stringify(rawModel)) {
218-
return null
219-
} else {
220-
return { rawModel, groups: this.groupBy(rawModel) }
221+
} else if (isError(rawModel)) {
222+
// update error
223+
if (!this.state?.updateError || JSON.stringify(rawModel) !== JSON.stringify(this.state.updateError)) {
224+
this.setState({ updateError: rawModel })
221225
}
222-
})
226+
} else {
227+
// good update from current context
228+
this.setState((curState) => {
229+
if (JSON.stringify(curState?.rawModel) === JSON.stringify(rawModel)) {
230+
return null
231+
} else {
232+
return { rawModel, groups: this.groupBy(rawModel) }
233+
}
234+
})
235+
}
223236
}
224237

225238
private groupBy(model: UpdatePayload): State["groups"] {
@@ -272,7 +285,9 @@ class Top extends React.PureComponent<Props, State> {
272285
}
273286

274287
private body() {
275-
if (this.state.groups.length === 0) {
288+
if (this.state?.updateError) {
289+
return <Text color="red">{this.state.updateError.message}</Text>
290+
} else if (this.state.groups.length === 0) {
276291
return <Text>No active jobs</Text>
277292
} else {
278293
return (
@@ -291,13 +306,13 @@ class Top extends React.PureComponent<Props, State> {
291306
}
292307

293308
public render() {
294-
if (!this.state?.groups) {
309+
if (!this.state?.updateError && !this.state?.groups) {
295310
// TODO spinner? this means we haven't received the first data set, yet
296311
return <React.Fragment />
297312
} else {
298313
return (
299314
<Box flexDirection="column">
300-
<Header cluster={this.state.rawModel.cluster} namespace={this.state.rawModel.namespace} />
315+
<Header {...this.currentContext} />
301316
<Box marginTop={1}>{this.body()}</Box>
302317
</Box>
303318
)

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

+16-3
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,33 @@ type JobsByHost = {
5454

5555
/** The cluster focus of the model */
5656
export type Context = {
57+
/** Kubernetes context name */
58+
context: string
59+
5760
/** Kubernetes cluster name */
5861
cluster: string
5962

6063
/** Kubernetes namespace */
6164
namespace: string
6265
}
6366

67+
/** Oops, something bad happened while fetching a model update */
68+
export type UpdateError = Context & { message: string }
69+
6470
/** Updated model */
6571
export type UpdatePayload = Context & JobsByHost
6672

67-
export type OnData = (payload: UpdatePayload) => void
73+
/** Updated model or error in doing so */
74+
export type UpdatePayloadOrError = (Context & JobsByHost) | UpdateError
75+
76+
export function isError(payload: UpdatePayloadOrError): payload is UpdateError {
77+
return typeof payload === "object" && typeof (payload as UpdateError).message === "string"
78+
}
79+
80+
export type OnData = (payload: UpdatePayloadOrError) => void
6881

6982
export type WatcherInitializer = (context: Context, cb: OnData) => Promise<{ kill(): void }>
7083

71-
export type ChangeContextRequest = { which: "context" | "namespace"; from: string; dir: "down" | "up" }
84+
export type ChangeContextRequest = { which: "cluster" | "namespace"; context: Context; dir: "down" | "up" }
7285

73-
export type ChangeContextRequestHandler = (req: ChangeContextRequest) => Promise<string | undefined>
86+
export type ChangeContextRequestHandler = (req: ChangeContextRequest) => Promise<Context | undefined>

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

+8-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type { Arguments } from "@kui-shell/core"
2020
import type TopOptions from "./options.js"
2121

2222
import { enterAltBufferMode } from "../term.js"
23-
import { getCurrentCluster, getCurrentNamespace, changeContext } from "../../kubernetes.js"
23+
import { getCurrentContext, getCurrentCluster, getCurrentNamespace, changeContext } from "../../kubernetes.js"
2424

2525
import initWatcher from "./watcher.js"
2626

@@ -36,7 +36,12 @@ export default async function jobsController(args: Arguments<TopOptions>) {
3636
}
3737

3838
// these will be the initial values of cluster and namespace focus
39-
const [cluster, ns] = await Promise.all([getCurrentCluster(), getNamespaceFromArgsOrCurrent(args)])
39+
const [context, cluster, ns] = await Promise.all([
40+
getCurrentContext(),
41+
getCurrentCluster(),
42+
getNamespaceFromArgsOrCurrent(args),
43+
])
44+
debug("context", context)
4045
debug("cluster", cluster)
4146
debug("namespace", ns || "using namespace from user current context")
4247

@@ -45,6 +50,7 @@ export default async function jobsController(args: Arguments<TopOptions>) {
4550

4651
debug("rendering")
4752
await render({
53+
context,
4854
cluster,
4955
namespace: ns,
5056
initWatcher: initWatcher.bind(args.parsedOptions),

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

+29-8
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ import defaultValueFor from "../../../components/Top/defaults.js"
2727
type Model = Record<string, Record<string, Record<string, PodRec>>>
2828
// host job name
2929

30-
export default async function initWatcher(this: TopOptions, { cluster, namespace: ns }: Context, cb: OnData) {
30+
export default async function initWatcher(this: TopOptions, { context, cluster, namespace: ns }: Context, cb: OnData) {
3131
const debug = Debug("plugin-codeflare-dashboard/controller/top")
32-
debug("init watcher callbacks", cluster, ns)
32+
debug("init watcher callbacks", context, cluster, ns)
3333

3434
// To help us parse out one "record's" worth of output from kubectl
3535
const recordSeparator = "-----------"
@@ -76,15 +76,36 @@ export default async function initWatcher(this: TopOptions, { cluster, namespace
7676
"bash",
7777
[
7878
"-c",
79-
`"while true; do kubectl get pod -n ${ns} --no-headers -o=custom-columns=NAME:.metadata.name,JOB:'.metadata.labels.app\\.kubernetes\\.io/instance',HOST:.status.hostIP,CPU:'.spec.containers[0].resources.requests.cpu',CPUL:'.spec.containers[0].resources.limits.cpu',MEM:'.spec.containers[0].resources.requests.memory',MEML:'.spec.containers[0].resources.limits.memory',GPU:.spec.containers[0].resources.requests.'nvidia\\.com/gpu',GPUL:.spec.containers[0].resources.limits.'nvidia\\.com/gpu',JOB2:'.metadata.labels.appwrapper\\.mcad\\.ibm\\.com',CTIME:.metadata.creationTimestamp,USER:'.metadata.labels.app\\.kubernetes\\.io/owner'; echo '${recordSeparator}'; sleep 2; done"`,
79+
`"while true; do kubectl get pod --context ${context} -n ${ns} --no-headers -o=custom-columns=NAME:.metadata.name,JOB:'.metadata.labels.app\\.kubernetes\\.io/instance',HOST:.status.hostIP,CPU:'.spec.containers[0].resources.requests.cpu',CPUL:'.spec.containers[0].resources.limits.cpu',MEM:'.spec.containers[0].resources.requests.memory',MEML:'.spec.containers[0].resources.limits.memory',GPU:.spec.containers[0].resources.requests.'nvidia\\.com/gpu',GPUL:.spec.containers[0].resources.limits.'nvidia\\.com/gpu',JOB2:'.metadata.labels.appwrapper\\.mcad\\.ibm\\.com',CTIME:.metadata.creationTimestamp,USER:'.metadata.labels.app\\.kubernetes\\.io/owner'; echo '${recordSeparator}'; sleep 2; done"`,
8080
],
81-
{ shell: "/bin/bash", stdio: ["ignore", "pipe", "inherit"] }
81+
{ shell: "/bin/bash", stdio: ["ignore", "pipe", "pipe"] }
8282
)
8383
debug("spawned watcher")
84-
process.on("exit", () => child.kill())
8584

86-
child.on("error", (err) => console.error(err))
87-
child.on("exit", (code) => debug("watcher subprocess exiting", code))
85+
const killit = () => child.kill()
86+
process.once("exit", killit)
87+
88+
let message = ""
89+
child.stderr.on("data", (data) => {
90+
const msg = data.toString()
91+
if (message !== msg) {
92+
message += msg
93+
}
94+
})
95+
96+
child.once("error", (err) => {
97+
console.error(err)
98+
process.off("exit", killit)
99+
})
100+
101+
child.once("exit", (code) => {
102+
debug("watcher subprocess exiting", code)
103+
process.off("exit", killit)
104+
105+
if (code !== 0 && message.length > 0) {
106+
cb({ context, cluster, namespace: ns, message })
107+
}
108+
})
88109

89110
let leftover = ""
90111
child.stdout.on("data", (data) => {
@@ -163,7 +184,7 @@ export default async function initWatcher(this: TopOptions, { cluster, namespace
163184
})),
164185
}))
165186

166-
cb(Object.assign({ cluster, namespace: ns }, stats(hosts)))
187+
cb(Object.assign({ context, cluster, namespace: ns }, stats(hosts)))
167188
}
168189
})
169190

0 commit comments

Comments
 (0)