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/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