nodeview-slurm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodeview/__init__.py +3 -0
- nodeview/__main__.py +32 -0
- nodeview/analysis.py +298 -0
- nodeview/app.py +423 -0
- nodeview/app.tcss +35 -0
- nodeview/config.py +61 -0
- nodeview/i18n.py +455 -0
- nodeview/manual.py +176 -0
- nodeview/nodes.py +318 -0
- nodeview/palette.py +325 -0
- nodeview/profile.py +284 -0
- nodeview/render.py +688 -0
- nodeview/snapshot.py +72 -0
- nodeview/source.py +306 -0
- nodeview_slurm-0.1.0.dist-info/METADATA +41 -0
- nodeview_slurm-0.1.0.dist-info/RECORD +18 -0
- nodeview_slurm-0.1.0.dist-info/WHEEL +4 -0
- nodeview_slurm-0.1.0.dist-info/entry_points.txt +2 -0
nodeview/render.py
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
"""Content rendering.
|
|
2
|
+
|
|
3
|
+
These are rich renderables, not Textual widgets: the same functions serve the
|
|
4
|
+
interactive TUI and the static --once snapshot without duplicating anything.
|
|
5
|
+
Each function returns the *content* of a panel; the caller adds the frame.
|
|
6
|
+
|
|
7
|
+
On the use of color see palette.py: here we just ask it for the right hue for
|
|
8
|
+
the right job, and never let a color travel alone.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.console import Group
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from . import analysis as an
|
|
18
|
+
from . import manual as manual_data
|
|
19
|
+
from .i18n import t
|
|
20
|
+
from . import palette
|
|
21
|
+
from .source import Snapshot
|
|
22
|
+
|
|
23
|
+
BUSY, FREE = "●", "○" # single GPUs: the shape tells them apart even without color
|
|
24
|
+
BLOCK, EMPTY = "█", "▁" # continuous meters
|
|
25
|
+
|
|
26
|
+
# internal key -> (icon, color role). The displayed text comes from the
|
|
27
|
+
# catalog: the key must not change when the language changes.
|
|
28
|
+
STATUS = {
|
|
29
|
+
'free': ("✓", 'good'),
|
|
30
|
+
'cpu_only': ("✓", 'good'),
|
|
31
|
+
'partial': ("◐", 'ink_soft'),
|
|
32
|
+
'full': ("●", 'serious'),
|
|
33
|
+
'reserved': ("⏸", 'warning'),
|
|
34
|
+
'drain': ("▲", 'warning'),
|
|
35
|
+
'down': ("✖", 'critical'),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
LEVEL_ICON = {'info': "●", 'warn': "▲", 'bad': "✖"}
|
|
39
|
+
LEVEL_ROLE = {'info': 'ink_muted', 'warn': 'warning', 'bad': 'critical'}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _p():
|
|
43
|
+
return palette.active()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def muted() -> str:
|
|
47
|
+
return _p().ink_muted
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def soft() -> str:
|
|
51
|
+
return _p().ink_soft
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def meter(used: float, total: float, width: int = 14) -> Text:
|
|
55
|
+
"""Quantity: a single hue, from light to dark. No traffic lights."""
|
|
56
|
+
pal = _p()
|
|
57
|
+
if total <= 0:
|
|
58
|
+
return Text(t('na'), style=pal.ink_muted)
|
|
59
|
+
ratio = max(0.0, min(1.0, used / total))
|
|
60
|
+
filled = min(width, max(0, round(width * ratio)))
|
|
61
|
+
if used > 0 and filled == 0:
|
|
62
|
+
filled = 1
|
|
63
|
+
out = Text()
|
|
64
|
+
out.append(BLOCK * filled, style=pal.fill(ratio))
|
|
65
|
+
out.append(EMPTY * (width - filled), style=pal.rule)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def gpu_slots(node, occupants) -> Text:
|
|
70
|
+
"""One dot per GPU, colored like whoever is using it.
|
|
71
|
+
|
|
72
|
+
The indices come from Slurm (`GresUsed` on the node) and the owner from
|
|
73
|
+
the job details (`scontrol -d show job`, which lists for each job the
|
|
74
|
+
indices assigned on each node): position and identity are both real,
|
|
75
|
+
not reconstructed. The colors match the chips and the legend, so you can
|
|
76
|
+
tell at a glance who holds what.
|
|
77
|
+
"""
|
|
78
|
+
pal = _p()
|
|
79
|
+
out = Text()
|
|
80
|
+
if node.gpus_total <= 0:
|
|
81
|
+
return Text("-", style=pal.ink_muted)
|
|
82
|
+
|
|
83
|
+
busy_idx = set(node.gpu_indices_used)
|
|
84
|
+
owners = node.gpu_owners or {}
|
|
85
|
+
# GPUs the node reports as busy but with no known owner: happens when a
|
|
86
|
+
# job ends between one read and the next
|
|
87
|
+
orphans = len(busy_idx - set(owners))
|
|
88
|
+
|
|
89
|
+
for i in range(node.gpus_total):
|
|
90
|
+
user = owners.get(i)
|
|
91
|
+
busy = i in busy_idx or user is not None
|
|
92
|
+
if not busy and orphans > 0:
|
|
93
|
+
busy, orphans = True, orphans - 1
|
|
94
|
+
if not busy:
|
|
95
|
+
out.append(FREE, style=pal.rule)
|
|
96
|
+
elif user:
|
|
97
|
+
out.append(occupants.shape(user), style=occupants.style(user))
|
|
98
|
+
else:
|
|
99
|
+
out.append(BUSY, style=pal.fill(0.8))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def chips(node, occupants) -> Text:
|
|
104
|
+
"""Colored chips for whoever is on the node: color plus name, never the
|
|
105
|
+
color alone. Beyond three, the rest is summed up."""
|
|
106
|
+
pal = _p()
|
|
107
|
+
out = Text()
|
|
108
|
+
totals = {}
|
|
109
|
+
for occ in node.occupants:
|
|
110
|
+
totals[occ.user] = totals.get(occ.user, 0) + occ.gpus
|
|
111
|
+
if not totals:
|
|
112
|
+
return Text(t('node.empty'), style=pal.ink_muted)
|
|
113
|
+
|
|
114
|
+
ranked = sorted(totals.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
115
|
+
for i, (user, gpus) in enumerate(ranked[:3]):
|
|
116
|
+
if i:
|
|
117
|
+
out.append(" ")
|
|
118
|
+
out.append(occupants.shape(user) + " ", style=occupants.style(user))
|
|
119
|
+
label = user if len(user) <= 14 else user[:13] + "…"
|
|
120
|
+
mine = user == occupants.me
|
|
121
|
+
out.append(label, style=("bold " + pal.ink) if mine else pal.ink_soft)
|
|
122
|
+
out.append(f"×{gpus}" if gpus else t('node.cpu_job'), style=pal.ink_muted)
|
|
123
|
+
if len(ranked) > 3:
|
|
124
|
+
out.append(f" +{len(ranked) - 3}", style=pal.ink_muted)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def status_cell(node) -> Text:
|
|
129
|
+
pal = _p()
|
|
130
|
+
if node.status == 'down':
|
|
131
|
+
key = 'down'
|
|
132
|
+
elif node.status == 'drain':
|
|
133
|
+
key = 'drain'
|
|
134
|
+
elif node.reserved != 'no':
|
|
135
|
+
key = 'reserved'
|
|
136
|
+
elif node.gpus_free == 0:
|
|
137
|
+
key = 'full'
|
|
138
|
+
elif node.gpus_used == 0:
|
|
139
|
+
# all GPUs free, but if the CPUs are taken the node isn't empty:
|
|
140
|
+
# just calling it "free" would be misleading
|
|
141
|
+
key = 'cpu_only' if node.cpus_alloc else 'free'
|
|
142
|
+
else:
|
|
143
|
+
key = 'partial'
|
|
144
|
+
icon, role = STATUS[key]
|
|
145
|
+
return Text(f"{icon} {t('node.' + key)}", style=getattr(pal, role))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def gb(mb: float) -> str:
|
|
149
|
+
return f"{mb / 1024:.0f}G"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def tb(mb: float) -> str:
|
|
153
|
+
return f"{mb / 1024 / 1024:.1f}T"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def join_nodes(nodes, limit: int = 6) -> str:
|
|
157
|
+
names = [n.name for n in nodes]
|
|
158
|
+
if len(names) <= limit:
|
|
159
|
+
return ", ".join(names)
|
|
160
|
+
return ", ".join(names[:limit]) + f" +{len(names) - limit}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _table(*columns, **kwargs) -> Table:
|
|
164
|
+
tbl = Table(box=box.SIMPLE_HEAD, expand=True, pad_edge=False,
|
|
165
|
+
header_style=f"bold {muted()}", **kwargs)
|
|
166
|
+
for col in columns:
|
|
167
|
+
tbl.add_column(**col)
|
|
168
|
+
return tbl
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------------------------
|
|
172
|
+
# "me" screen
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def my_jobs(snap: Snapshot):
|
|
176
|
+
pal = _p()
|
|
177
|
+
mine = snap.my_jobs
|
|
178
|
+
blocks = []
|
|
179
|
+
|
|
180
|
+
if not mine:
|
|
181
|
+
blocks.append(Text(t('job.none'), style=pal.ink_muted))
|
|
182
|
+
else:
|
|
183
|
+
tbl = _table(
|
|
184
|
+
dict(header=t('col.id'), style=pal.ink_muted, no_wrap=True),
|
|
185
|
+
dict(header=t('col.name'), ratio=3, no_wrap=True, overflow="ellipsis"),
|
|
186
|
+
dict(header=t('col.state'), justify="center", no_wrap=True),
|
|
187
|
+
dict(header=t('col.gpu'), justify="right", no_wrap=True),
|
|
188
|
+
dict(header=t('col.ram'), justify="right", no_wrap=True),
|
|
189
|
+
dict(header=t('col.time'), justify="right", no_wrap=True),
|
|
190
|
+
dict(header=t('col.where_why'), ratio=4, overflow="ellipsis"),
|
|
191
|
+
)
|
|
192
|
+
for job in sorted(mine, key=lambda j: (j.pending, j.jobid)):
|
|
193
|
+
if job.running:
|
|
194
|
+
state = Text(t('job.running_short'), style=f"bold {pal.good}")
|
|
195
|
+
where = Text(", ".join(sorted(set(job.nodes))) or "-", style=pal.ink)
|
|
196
|
+
else:
|
|
197
|
+
state = Text(t('job.pending_short'), style=f"bold {pal.warning}")
|
|
198
|
+
label, detail = an.explain_reason(job.reason)
|
|
199
|
+
where = Text(label, style=pal.warning)
|
|
200
|
+
if detail:
|
|
201
|
+
where.append(f" · {detail}", style=pal.ink_muted)
|
|
202
|
+
tbl.add_row(job.jobid, job.name, state, str(job.gpus),
|
|
203
|
+
gb(job.mem_mb), job.runtime, where)
|
|
204
|
+
blocks.append(tbl)
|
|
205
|
+
|
|
206
|
+
quota = an.my_quota(snap)
|
|
207
|
+
line = Text()
|
|
208
|
+
line.append(t('job.quota'), style=pal.ink_muted)
|
|
209
|
+
if quota.limit is None:
|
|
210
|
+
line.append(t('job.no_limit'), style=pal.ink_muted)
|
|
211
|
+
else:
|
|
212
|
+
line.append(f"{quota.used}/{int(quota.limit)} ", style=pal.ink)
|
|
213
|
+
line.append(meter(quota.used, int(quota.limit), 12))
|
|
214
|
+
room = quota.headroom
|
|
215
|
+
line.append(t('job.can_ask'), style=pal.ink_muted)
|
|
216
|
+
line.append(t('job.gpus', n=room),
|
|
217
|
+
style=f"bold {pal.good if room else pal.critical}")
|
|
218
|
+
blocks.append(line)
|
|
219
|
+
|
|
220
|
+
groups = an.gpus_by_group(snap)
|
|
221
|
+
if groups:
|
|
222
|
+
gl = Text(t('job.by_group'), style=pal.ink_muted)
|
|
223
|
+
for i, (name, n) in enumerate(groups[:5]):
|
|
224
|
+
if i:
|
|
225
|
+
gl.append(" · ", style=pal.ink_muted)
|
|
226
|
+
gl.append(f"{name} ", style=pal.ink_muted)
|
|
227
|
+
gl.append(str(n), style=f"bold {pal.ink}")
|
|
228
|
+
blocks.append(gl)
|
|
229
|
+
return Group(*blocks)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def where_do_i_fit(snap: Snapshot):
|
|
233
|
+
pal = _p()
|
|
234
|
+
rows = []
|
|
235
|
+
for fit in an.fits(snap):
|
|
236
|
+
label = Text(f"{fit.gpus:>2} {t('col.gpu')} ", style=f"bold {pal.ink}")
|
|
237
|
+
if fit.over_user_quota:
|
|
238
|
+
quota = an.my_quota(snap)
|
|
239
|
+
label.append(t('fit.over_quota', limit=int(quota.limit)), style=pal.critical)
|
|
240
|
+
label.append(t('fit.left', n=quota.headroom), style=f"bold {pal.critical}")
|
|
241
|
+
elif not fit.nodes:
|
|
242
|
+
label.append(t('fit.none'), style=pal.warning)
|
|
243
|
+
else:
|
|
244
|
+
best = fit.nodes[0]
|
|
245
|
+
label.append(t('fit.now_on'), style=pal.good)
|
|
246
|
+
label.append(join_nodes(fit.nodes), style=f"bold {pal.good}")
|
|
247
|
+
label.append(t('fit.roomiest', node=best.name, gpus=best.gpus_free,
|
|
248
|
+
mem=gb(best.mem_unalloc_mb)), style=pal.ink_muted)
|
|
249
|
+
rows.append(label)
|
|
250
|
+
|
|
251
|
+
roomy = sorted([n for n in snap.nodes if n.schedulable],
|
|
252
|
+
key=lambda n: -n.mem_unalloc_mb)[:3]
|
|
253
|
+
if roomy:
|
|
254
|
+
mem = Text(t('fit.free_ram'), style=pal.ink_muted)
|
|
255
|
+
mem.append(" · ".join(f"{n.name} {gb(n.mem_unalloc_mb)}" for n in roomy),
|
|
256
|
+
style=pal.ink_soft)
|
|
257
|
+
rows.append(mem)
|
|
258
|
+
return Group(*rows)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def cluster(snap: Snapshot):
|
|
262
|
+
pal = _p()
|
|
263
|
+
used, total = snap.gpus_used, snap.gpus_total
|
|
264
|
+
ratio = used / total if total else 0.0
|
|
265
|
+
|
|
266
|
+
head = Text()
|
|
267
|
+
head.append(t('col.gpu') + " ", style=pal.ink_muted)
|
|
268
|
+
head.append(f"{used}/{total} ", style=pal.ink)
|
|
269
|
+
head.append(meter(used, total, 20))
|
|
270
|
+
head.append(f" {ratio * 100:.0f}%", style=f"bold {pal.ink}")
|
|
271
|
+
head.append(t('cluster.free'), style=pal.ink_muted)
|
|
272
|
+
head.append(str(snap.gpus_free), style=f"bold {pal.good}")
|
|
273
|
+
head.append(t('cluster.queued'), style=pal.ink_muted)
|
|
274
|
+
head.append(str(len(snap.pending_jobs)), style=f"bold {pal.ink}")
|
|
275
|
+
head.append(t('cluster.running'), style=pal.ink_muted)
|
|
276
|
+
head.append(str(len(snap.running_jobs)), style=f"bold {pal.ink}")
|
|
277
|
+
|
|
278
|
+
blocks = [head]
|
|
279
|
+
|
|
280
|
+
story = an.headline(snap)
|
|
281
|
+
if story:
|
|
282
|
+
blocks.append(Text(story, style=f"bold {pal.fill(0.75)}"))
|
|
283
|
+
|
|
284
|
+
for level, text in an.warnings(snap):
|
|
285
|
+
line = Text(f" {LEVEL_ICON[level]} ", style=getattr(pal, LEVEL_ROLE[level]))
|
|
286
|
+
line.append(text, style=getattr(pal, LEVEL_ROLE[level]) if level != 'info' else pal.ink_muted)
|
|
287
|
+
blocks.append(line)
|
|
288
|
+
return Group(*blocks)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _pct_style(pct, warn=80.0, bad=95.0):
|
|
292
|
+
"""The color says how close you are to the wall; the number says it anyway."""
|
|
293
|
+
pal = _p()
|
|
294
|
+
if pct is None:
|
|
295
|
+
return pal.ink_muted
|
|
296
|
+
if pct >= bad:
|
|
297
|
+
return pal.critical
|
|
298
|
+
if pct >= warn:
|
|
299
|
+
return pal.warning
|
|
300
|
+
return pal.ink
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _gauge(label: str, used, total, pct, unit: str, width: int = 14,
|
|
304
|
+
warn=80.0, bad=95.0, note: str = "") -> Text:
|
|
305
|
+
pal = _p()
|
|
306
|
+
if len(label) > 23:
|
|
307
|
+
label = label[:22] + "…"
|
|
308
|
+
out = Text(f" {label:<24}", style=pal.ink_soft)
|
|
309
|
+
out.append(meter(used or 0, total or 0, width))
|
|
310
|
+
if used is not None and total:
|
|
311
|
+
out.append(f" {used:>9,.0f}/{total:<10,.0f}".replace(',', '.'), style=pal.ink_soft)
|
|
312
|
+
else:
|
|
313
|
+
out.append(f" {'?':>20}", style=pal.ink_muted)
|
|
314
|
+
out.append(f"{unit:<4}", style=pal.ink_muted)
|
|
315
|
+
if pct is not None:
|
|
316
|
+
out.append(f"{pct:>6.1f}%", style=f"bold {_pct_style(pct, warn, bad)}")
|
|
317
|
+
if note:
|
|
318
|
+
out.append(f" {note}", style=_pct_style(pct, warn, bad))
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def my_account(prof):
|
|
323
|
+
"""What the cluster knows about you, and that you rarely look at.
|
|
324
|
+
|
|
325
|
+
Budget, quotas and expiration don't change minute to minute, but when they
|
|
326
|
+
run out they block jobs without explaining why: worth keeping them in
|
|
327
|
+
sight, even if at the bottom of the page.
|
|
328
|
+
"""
|
|
329
|
+
pal = _p()
|
|
330
|
+
if prof is None:
|
|
331
|
+
return Text(t('app.loading'), style=pal.ink_muted)
|
|
332
|
+
if prof.empty:
|
|
333
|
+
return Text(t('acct.none'), style=pal.ink_muted)
|
|
334
|
+
|
|
335
|
+
blocks = []
|
|
336
|
+
|
|
337
|
+
# the alarm bells at the top, if there are any
|
|
338
|
+
alerts = []
|
|
339
|
+
for b in prof.budgets:
|
|
340
|
+
if b.pct is not None and b.pct >= 95:
|
|
341
|
+
alerts.append(t('acct.budget_spent', project=b.project, pct=f"{b.pct:.1f}"))
|
|
342
|
+
for q in prof.quotas:
|
|
343
|
+
if q.pct is not None and q.pct >= 90:
|
|
344
|
+
alerts.append(t('acct.disk_full', fs=q.filesystem, owner=q.owner,
|
|
345
|
+
pct=f"{q.pct:.0f}"))
|
|
346
|
+
exp = prof.expiration
|
|
347
|
+
if exp and exp.days_left is not None and exp.days_left <= 30:
|
|
348
|
+
alerts.append(t('acct.expiring', n=exp.days_left))
|
|
349
|
+
for text in alerts:
|
|
350
|
+
line = Text(" \u25b2 ", style=pal.warning)
|
|
351
|
+
line.append(text, style=f"bold {pal.warning}")
|
|
352
|
+
blocks.append(line)
|
|
353
|
+
|
|
354
|
+
if exp:
|
|
355
|
+
line = Text(t('acct.expiration'), style=pal.ink_muted)
|
|
356
|
+
line.append(exp.date.split()[0], style=pal.ink)
|
|
357
|
+
if exp.days_left is not None:
|
|
358
|
+
style = pal.critical if exp.days_left <= 14 else (
|
|
359
|
+
pal.warning if exp.days_left <= 30 else pal.ink_soft)
|
|
360
|
+
line.append(t('acct.in_days', n=exp.days_left), style=f"bold {style}")
|
|
361
|
+
blocks.append(line)
|
|
362
|
+
|
|
363
|
+
if prof.budgets:
|
|
364
|
+
blocks.append(Text(""))
|
|
365
|
+
blocks.append(Text(t('acct.budget_title'), style=f"bold {pal.ink_muted}")
|
|
366
|
+
.append(t('acct.budget_sub'), style=pal.ink_muted))
|
|
367
|
+
for b in sorted(prof.budgets, key=lambda x: -(x.pct or 0)):
|
|
368
|
+
note = t('acct.exhausted') if (b.pct or 0) >= 99 else ""
|
|
369
|
+
blocks.append(_gauge(b.project, b.used, b.total, b.pct, "h", note=note))
|
|
370
|
+
|
|
371
|
+
if prof.quotas:
|
|
372
|
+
blocks.append(Text(""))
|
|
373
|
+
blocks.append(Text(t('acct.quota_title'), style=f"bold {pal.ink_muted}"))
|
|
374
|
+
for q in sorted(prof.quotas, key=lambda x: -(x.pct or 0)):
|
|
375
|
+
blocks.append(_gauge(f"{q.filesystem:<7}{q.owner}", q.used_gb, q.quota_gb,
|
|
376
|
+
q.pct, "GB", warn=85.0, bad=95.0))
|
|
377
|
+
|
|
378
|
+
if prof.shares:
|
|
379
|
+
blocks.append(Text(""))
|
|
380
|
+
line = Text(t('acct.fairshare'), style=f"bold {pal.ink_muted}")
|
|
381
|
+
for i, sh in enumerate(prof.shares):
|
|
382
|
+
if i:
|
|
383
|
+
line.append(" \u00b7 ", style=pal.ink_muted)
|
|
384
|
+
line.append(f"{sh.account} ", style=pal.ink_soft)
|
|
385
|
+
factor = sh.factor
|
|
386
|
+
style = pal.ink
|
|
387
|
+
if factor is not None:
|
|
388
|
+
style = pal.critical if factor < 0.25 else (
|
|
389
|
+
pal.warning if factor < 0.5 else pal.good)
|
|
390
|
+
line.append(f"{factor:.2f}" if factor is not None else "?", style=f"bold {style}")
|
|
391
|
+
blocks.append(line)
|
|
392
|
+
blocks.append(Text(t('acct.fairshare_hint'), style=pal.ink_muted))
|
|
393
|
+
|
|
394
|
+
h = prof.history
|
|
395
|
+
if h and h.finished:
|
|
396
|
+
blocks.append(Text(""))
|
|
397
|
+
line = Text(t('acct.last_days', n=h.days), style=f"bold {pal.ink_muted}")
|
|
398
|
+
line.append(t('acct.completed', n=h.completed), style=pal.good)
|
|
399
|
+
if h.failed:
|
|
400
|
+
rate = h.failure_rate or 0
|
|
401
|
+
line.append(" \u00b7 ", style=pal.ink_muted)
|
|
402
|
+
line.append(t('acct.failed', n=h.failed),
|
|
403
|
+
style=pal.critical if rate >= 0.2 else pal.warning)
|
|
404
|
+
line.append(f" ({rate * 100:.0f}%)", style=pal.ink_muted)
|
|
405
|
+
if h.cancelled:
|
|
406
|
+
line.append(" \u00b7 ", style=pal.ink_muted)
|
|
407
|
+
line.append(t('acct.cancelled', n=h.cancelled), style=pal.ink_soft)
|
|
408
|
+
if h.timeout:
|
|
409
|
+
line.append(" \u00b7 ", style=pal.ink_muted)
|
|
410
|
+
line.append(t('acct.timeout', n=h.timeout), style=pal.warning)
|
|
411
|
+
blocks.append(line)
|
|
412
|
+
|
|
413
|
+
if prof.accounts:
|
|
414
|
+
blocks.append(Text(""))
|
|
415
|
+
line = Text(t('acct.accounts'), style=f"bold {pal.ink_muted}")
|
|
416
|
+
line.append(" \u00b7 ".join(prof.accounts), style=pal.ink_soft)
|
|
417
|
+
blocks.append(line)
|
|
418
|
+
qos = sorted({q for lst in prof.accounts.values() for q in lst})
|
|
419
|
+
if qos:
|
|
420
|
+
line = Text(t('acct.qos'), style=f"bold {pal.ink_muted}")
|
|
421
|
+
line.append(", ".join(qos), style=pal.ink_muted)
|
|
422
|
+
blocks.append(line)
|
|
423
|
+
|
|
424
|
+
return Group(*blocks)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --------------------------------------------------------------------------
|
|
428
|
+
# "cluster" screen
|
|
429
|
+
# --------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def totals_strip(snap: Snapshot):
|
|
432
|
+
"""The three numbers that sum up the cluster, plus what lies behind them."""
|
|
433
|
+
pal = _p()
|
|
434
|
+
rows = []
|
|
435
|
+
|
|
436
|
+
def line(label, used, total, unit_fn, extra: Text):
|
|
437
|
+
row = Text()
|
|
438
|
+
row.append(f"{label:<5}", style=pal.ink_muted)
|
|
439
|
+
row.append(f"{unit_fn(used):>7} / {unit_fn(total):<8}", style=pal.ink)
|
|
440
|
+
row.append(meter(used, total, 18))
|
|
441
|
+
row.append(f" {used / total * 100:>3.0f}%" if total else " -",
|
|
442
|
+
style=f"bold {pal.ink}")
|
|
443
|
+
row.append(" ")
|
|
444
|
+
row.append_text(extra)
|
|
445
|
+
return row
|
|
446
|
+
|
|
447
|
+
free_nodes = [n for n in snap.nodes if n.schedulable]
|
|
448
|
+
gpu_extra = Text()
|
|
449
|
+
gpu_extra.append(t('cluster.free_on'), style=pal.ink_muted)
|
|
450
|
+
gpu_extra.append(str(snap.gpus_free), style=f"bold {pal.good}")
|
|
451
|
+
gpu_extra.append(t('cluster.free_nodes', n=len(free_nodes)), style=pal.ink_muted)
|
|
452
|
+
rows.append(line(t('col.gpu'), snap.gpus_used, snap.gpus_total,
|
|
453
|
+
lambda v: f"{v:.0f}", gpu_extra))
|
|
454
|
+
|
|
455
|
+
load = sum(n.cpu_load for n in snap.nodes)
|
|
456
|
+
cpu_extra = Text()
|
|
457
|
+
cpu_extra.append(t('cluster.real_load'), style=pal.ink_muted)
|
|
458
|
+
cpu_extra.append(f"{load:.0f}", style=f"bold {pal.ink}")
|
|
459
|
+
if snap.cpus_used:
|
|
460
|
+
cpu_extra.append(t('cluster.of_allocated',
|
|
461
|
+
pct=f"{load / snap.cpus_used * 100:.0f}"), style=pal.ink_muted)
|
|
462
|
+
rows.append(line(t('col.cpu'), snap.cpus_used, snap.cpus_total,
|
|
463
|
+
lambda v: f"{v:.0f}", cpu_extra))
|
|
464
|
+
|
|
465
|
+
ram_extra = Text()
|
|
466
|
+
ram_extra.append(t('cluster.unallocated'), style=pal.ink_muted)
|
|
467
|
+
ram_extra.append(tb(snap.mem_total_mb - snap.mem_used_mb), style=f"bold {pal.good}")
|
|
468
|
+
rows.append(line(t('col.ram'), snap.mem_used_mb, snap.mem_total_mb, tb, ram_extra))
|
|
469
|
+
|
|
470
|
+
models = {n.gpu_model for n in snap.nodes}
|
|
471
|
+
down = [n for n in snap.nodes if n.status == 'down']
|
|
472
|
+
drain = [n for n in snap.nodes if n.status == 'drain']
|
|
473
|
+
tail = Text()
|
|
474
|
+
tail.append(t('cluster.n_nodes', n=len(snap.nodes)), style=pal.ink)
|
|
475
|
+
tail.append(" · ", style=pal.ink_muted)
|
|
476
|
+
tail.append(t('cluster.n_models', n=len(models)), style=pal.ink)
|
|
477
|
+
if down:
|
|
478
|
+
tail.append(" · ", style=pal.ink_muted)
|
|
479
|
+
tail.append(t('cluster.n_down', n=len(down)), style=f"bold {pal.critical}")
|
|
480
|
+
if drain:
|
|
481
|
+
tail.append(" · ", style=pal.ink_muted)
|
|
482
|
+
tail.append(t('cluster.n_drain', n=len(drain)), style=f"bold {pal.warning}")
|
|
483
|
+
rows.append(tail)
|
|
484
|
+
return Group(*rows)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def node_table(snap: Snapshot, occupants, only_free: bool = False) -> Table:
|
|
488
|
+
pal = _p()
|
|
489
|
+
tbl = _table(
|
|
490
|
+
dict(header=t('col.node'), no_wrap=True),
|
|
491
|
+
dict(header=t('col.gpu'), no_wrap=True, justify="left"),
|
|
492
|
+
dict(header=t('col.used'), no_wrap=True, justify="right"),
|
|
493
|
+
dict(header=t('col.model'), no_wrap=True, style=pal.ink_soft),
|
|
494
|
+
dict(header=t('col.cpu'), no_wrap=True),
|
|
495
|
+
dict(header=t('col.ram'), no_wrap=True),
|
|
496
|
+
dict(header=t('col.status'), no_wrap=True),
|
|
497
|
+
dict(header=t('col.occupied_by'), ratio=3, no_wrap=True, overflow="ellipsis"),
|
|
498
|
+
)
|
|
499
|
+
for node in sorted(snap.nodes, key=an.node_sort_key):
|
|
500
|
+
if only_free and not node.schedulable:
|
|
501
|
+
continue
|
|
502
|
+
|
|
503
|
+
cpu = Text()
|
|
504
|
+
cpu.append(meter(node.cpus_alloc, node.cpus_total, 6))
|
|
505
|
+
cpu.append(f" {node.cpus_alloc:>3}/{node.cpus_total:<4}", style=pal.ink_soft)
|
|
506
|
+
|
|
507
|
+
ram = Text()
|
|
508
|
+
ram.append(meter(node.mem_alloc_mb, node.mem_total_mb, 6))
|
|
509
|
+
ram.append(f" {gb(node.mem_alloc_mb):>5}/{gb(node.mem_total_mb):<6}", style=pal.ink_soft)
|
|
510
|
+
|
|
511
|
+
count = Text(f"{node.gpus_used}/{node.gpus_total}",
|
|
512
|
+
style=f"bold {pal.good}" if node.schedulable else pal.ink_soft)
|
|
513
|
+
|
|
514
|
+
who = chips(node, occupants) if node.healthy else Text(
|
|
515
|
+
node.reason or node.base_state.lower(), style=pal.critical)
|
|
516
|
+
|
|
517
|
+
tbl.add_row(Text(node.name, style=pal.ink), gpu_slots(node, occupants), count,
|
|
518
|
+
node.gpu_model, cpu, ram, status_cell(node), who)
|
|
519
|
+
return tbl
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def breakdown(snap: Snapshot, occupants) -> Table:
|
|
523
|
+
"""Who is using the cluster, by GPU model, unix group and account."""
|
|
524
|
+
pal = _p()
|
|
525
|
+
tbl = _table(
|
|
526
|
+
dict(header=t('col.gpu_model'), no_wrap=True),
|
|
527
|
+
dict(header=t('col.usage'), no_wrap=True),
|
|
528
|
+
dict(header=t('col.unix_group'), no_wrap=True),
|
|
529
|
+
# rich wants distinct headers: the trailing space tells them apart
|
|
530
|
+
dict(header=t('col.usage') + " ", no_wrap=True),
|
|
531
|
+
dict(header=t('col.account'), no_wrap=True),
|
|
532
|
+
dict(header=t('col.usage') + " ", no_wrap=True),
|
|
533
|
+
)
|
|
534
|
+
models = an.by_gpu_model(snap)
|
|
535
|
+
groups = an.gpus_by_group(snap)
|
|
536
|
+
accounts = an.gpus_by_account(snap)
|
|
537
|
+
|
|
538
|
+
def cell(label, used, total=None, color=None):
|
|
539
|
+
if label is None:
|
|
540
|
+
return Text("")
|
|
541
|
+
out = Text(f"{label}", style=color or pal.ink_soft)
|
|
542
|
+
return out
|
|
543
|
+
|
|
544
|
+
def usage(used, total=None):
|
|
545
|
+
out = Text()
|
|
546
|
+
if total:
|
|
547
|
+
out.append(meter(used, total, 6))
|
|
548
|
+
out.append(f" {used}/{total}", style=pal.ink_soft)
|
|
549
|
+
else:
|
|
550
|
+
out.append(f"{used}", style=f"bold {pal.ink}")
|
|
551
|
+
out.append(" GPU", style=pal.ink_muted)
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
rows = max(len(models), len(groups), len(accounts))
|
|
555
|
+
for i in range(rows):
|
|
556
|
+
m = models[i] if i < len(models) else None
|
|
557
|
+
g = groups[i] if i < len(groups) else None
|
|
558
|
+
a = accounts[i] if i < len(accounts) else None
|
|
559
|
+
tbl.add_row(
|
|
560
|
+
cell(m[0] if m else None, 0),
|
|
561
|
+
usage(m[1], m[2]) if m else Text(""),
|
|
562
|
+
cell(g[0] if g else None, 0),
|
|
563
|
+
usage(g[1]) if g else Text(""),
|
|
564
|
+
cell(a[0] if a else None, 0),
|
|
565
|
+
usage(a[1]) if a else Text(""),
|
|
566
|
+
)
|
|
567
|
+
return tbl
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def specs_table(snap: Snapshot) -> Table:
|
|
571
|
+
"""The fixed specs: what the node *is*, not what it's doing.
|
|
572
|
+
|
|
573
|
+
Keeping them apart from usage avoids a twenty-column table where you can't
|
|
574
|
+
find anything anymore.
|
|
575
|
+
"""
|
|
576
|
+
pal = _p()
|
|
577
|
+
tbl = _table(
|
|
578
|
+
dict(header=t('col.node'), no_wrap=True),
|
|
579
|
+
dict(header=t('col.gpu'), no_wrap=True),
|
|
580
|
+
dict(header=t('col.cpu'), justify="right", no_wrap=True),
|
|
581
|
+
dict(header=t('col.topology'), no_wrap=True, style=pal.ink_muted),
|
|
582
|
+
dict(header=t('col.ram'), justify="right", no_wrap=True),
|
|
583
|
+
dict(header=t('col.disk'), justify="right", no_wrap=True),
|
|
584
|
+
dict(header=t('col.partitions'), no_wrap=True, overflow="ellipsis"),
|
|
585
|
+
dict(header=t('col.weight'), justify="right", no_wrap=True),
|
|
586
|
+
dict(header=t('col.uptime'), justify="right", no_wrap=True),
|
|
587
|
+
)
|
|
588
|
+
for node in sorted(snap.nodes, key=lambda n: (-n.gpus_total, n.name)):
|
|
589
|
+
topo = (f"{node.sockets}×{node.cores_per_socket}c×{node.threads_per_core}t"
|
|
590
|
+
if node.sockets else "-")
|
|
591
|
+
tbl.add_row(
|
|
592
|
+
Text(node.name, style=pal.ink),
|
|
593
|
+
Text(f"{node.gpus_total} × {node.gpu_model}", style=pal.ink_soft),
|
|
594
|
+
Text(str(node.cpus_total), style=pal.ink_soft),
|
|
595
|
+
topo,
|
|
596
|
+
Text(gb(node.mem_total_mb), style=pal.ink_soft),
|
|
597
|
+
Text(gb(node.tmp_disk_mb) if node.tmp_disk_mb else "-", style=pal.ink_soft),
|
|
598
|
+
Text(", ".join(node.partitions) or "-", style=pal.ink_muted),
|
|
599
|
+
Text(str(node.weight), style=pal.ink_muted),
|
|
600
|
+
Text(an.uptime(node) or "-", style=pal.ink_muted),
|
|
601
|
+
)
|
|
602
|
+
return tbl
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def legend(occupants) -> Text:
|
|
606
|
+
pal = _p()
|
|
607
|
+
out = Text(t('node.legend'), style=pal.ink_muted)
|
|
608
|
+
named = occupants.named()
|
|
609
|
+
if not named:
|
|
610
|
+
return Text(t('node.no_gpu_used'), style=pal.ink_muted)
|
|
611
|
+
for i, (user, color, shape) in enumerate(named):
|
|
612
|
+
if i:
|
|
613
|
+
out.append(" ")
|
|
614
|
+
out.append(shape + " ", style=color)
|
|
615
|
+
mine = user == occupants.me
|
|
616
|
+
out.append(user + (t('node.you') if mine else ""),
|
|
617
|
+
style=f"bold {pal.ink}" if mine else pal.ink_soft)
|
|
618
|
+
if occupants.overflow:
|
|
619
|
+
out.append(" ")
|
|
620
|
+
out.append(BUSY + " ", style=pal.other)
|
|
621
|
+
out.append(t('node.others', n=len(occupants.overflow)), style=pal.ink_muted)
|
|
622
|
+
return out
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# --------------------------------------------------------------------------
|
|
626
|
+
# "manual" screen
|
|
627
|
+
# --------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
def manual():
|
|
630
|
+
"""The command cheat sheet: two lines each, description and example.
|
|
631
|
+
|
|
632
|
+
Columns don't wrap: this way an entry stays two lines even on a narrow
|
|
633
|
+
terminal, which is the whole point of the page.
|
|
634
|
+
"""
|
|
635
|
+
pal = _p()
|
|
636
|
+
blocks = []
|
|
637
|
+
for i, (title, items) in enumerate(manual_data.sections()):
|
|
638
|
+
if i:
|
|
639
|
+
blocks.append(Text(""))
|
|
640
|
+
blocks.append(Text(title, style=f"bold {pal.fill(1.0)}"))
|
|
641
|
+
tbl = Table(box=None, show_header=False, expand=True, pad_edge=False,
|
|
642
|
+
padding=(0, 1, 1, 0))
|
|
643
|
+
tbl.add_column(width=23, no_wrap=True)
|
|
644
|
+
tbl.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
|
|
645
|
+
for command, what, example in items:
|
|
646
|
+
cell = Text(what, style=pal.ink)
|
|
647
|
+
cell.append("\n$ ", style=pal.ink_muted)
|
|
648
|
+
cell.append(example, style=pal.fill(0.7))
|
|
649
|
+
tbl.add_row(Text(command, style=f"bold {pal.ink}"), cell)
|
|
650
|
+
blocks.append(tbl)
|
|
651
|
+
return Group(*blocks)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# --------------------------------------------------------------------------
|
|
655
|
+
# "queue" screen
|
|
656
|
+
# --------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
def queue_table(snap: Snapshot, mine_only: bool = False, needle: str = "") -> Table:
|
|
659
|
+
pal = _p()
|
|
660
|
+
tbl = _table(
|
|
661
|
+
dict(header=t('col.id'), style=pal.ink_muted, no_wrap=True),
|
|
662
|
+
dict(header=t('col.user'), no_wrap=True),
|
|
663
|
+
dict(header=t('col.group'), no_wrap=True, style=pal.ink_muted),
|
|
664
|
+
dict(header=t('col.name'), ratio=3, no_wrap=True, overflow="ellipsis"),
|
|
665
|
+
dict(header=t('col.state'), justify="center", no_wrap=True),
|
|
666
|
+
dict(header=t('col.gpu'), justify="right", no_wrap=True),
|
|
667
|
+
dict(header=t('col.ram'), justify="right", no_wrap=True),
|
|
668
|
+
dict(header=t('col.time'), justify="right", no_wrap=True),
|
|
669
|
+
dict(header=t('col.where_why'), ratio=4, overflow="ellipsis"),
|
|
670
|
+
)
|
|
671
|
+
needle = needle.lower().strip()
|
|
672
|
+
jobs = sorted(snap.jobs, key=lambda j: (j.pending, -(j.priority or 0), j.jobid))
|
|
673
|
+
for job in jobs:
|
|
674
|
+
if mine_only and not job.mine:
|
|
675
|
+
continue
|
|
676
|
+
if needle and needle not in f"{job.user} {job.name} {job.jobid} {job.account or ''}".lower():
|
|
677
|
+
continue
|
|
678
|
+
if job.running:
|
|
679
|
+
state = Text(t('job.running_short'), style=f"bold {pal.good}")
|
|
680
|
+
where = Text(", ".join(sorted(set(job.nodes))) or "-", style=pal.ink_soft)
|
|
681
|
+
else:
|
|
682
|
+
state = Text(t('job.pending_short'), style=f"bold {pal.warning}")
|
|
683
|
+
label, _ = an.explain_reason(job.reason)
|
|
684
|
+
where = Text(label, style=pal.warning)
|
|
685
|
+
user = Text(job.user, style=f"bold {pal.ink}" if job.mine else pal.ink_soft)
|
|
686
|
+
tbl.add_row(job.jobid, user, job.group or "-", job.name, state, str(job.gpus),
|
|
687
|
+
gb(job.mem_mb), job.runtime, where)
|
|
688
|
+
return tbl
|