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/app.py ADDED
@@ -0,0 +1,423 @@
1
+ """The interactive TUI.
2
+
3
+ Three screens: "me" (the main one), "cluster" and "queue". The content comes
4
+ from render.py; here there's only the scaffolding: navigation, periodic refresh
5
+ and data fetching in a separate thread, so the interface never freezes while
6
+ it talks to Slurm.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from datetime import datetime
12
+
13
+ from functools import partial
14
+
15
+ from textual import work
16
+ from textual.app import App, ComposeResult
17
+ from textual.binding import Binding
18
+ from textual.command import DiscoveryHit, Hit, Hits, Provider
19
+ from textual.containers import Container, VerticalScroll
20
+ from textual.reactive import reactive
21
+ from textual.screen import Screen
22
+ from textual.widgets import Footer, Header, Input, Static
23
+
24
+ from . import analysis as an
25
+ from . import config
26
+ from . import i18n
27
+ from .i18n import t
28
+ from . import palette
29
+ from . import render
30
+ from .profile import Profile, read_profile
31
+ from .source import Snapshot, fetch
32
+
33
+
34
+ def _retranslate(cls, bindings) -> None:
35
+ """Replace a class's key bindings with those in the active language.
36
+
37
+ Textual computes the key map only once, when the class is created
38
+ (`_merged_bindings` in `__init_subclass__`): reassigning BINDINGS isn't
39
+ enough, new instances would still read the old one.
40
+ """
41
+ cls.BINDINGS = bindings
42
+ cls._merged_bindings = cls._merge_bindings()
43
+
44
+
45
+ def app_bindings():
46
+ return [
47
+ Binding("q", "quit", t('bind.quit')),
48
+ Binding("r", "reload", t('bind.reload')),
49
+ Binding("i", "switch_mode('main')", t('bind.me')),
50
+ Binding("n", "switch_mode('cluster')", t('bind.cluster')),
51
+ Binding("c", "switch_mode('queue')", t('bind.queue')),
52
+ Binding("m", "switch_mode('manual')", t('bind.manual')),
53
+ Binding("t", "cycle_theme", t('bind.theme')),
54
+ ]
55
+
56
+
57
+ def cluster_bindings():
58
+ return [Binding("f", "toggle_free", t('bind.only_free'))]
59
+
60
+
61
+ def queue_bindings():
62
+ # "only mine" is on s rather than m: screen bindings take precedence
63
+ # over global ones, and m here would shadow the manual
64
+ return [
65
+ Binding("s", "toggle_mine", t('bind.only_mine')),
66
+ Binding("slash", "search", t('bind.search')),
67
+ Binding("escape", "leave_search", t('bind.leave_search'), show=False),
68
+ ]
69
+
70
+
71
+ class LanguageCommands(Provider):
72
+ """"Language" entry in the command palette (ctrl+p)."""
73
+
74
+ def _choices(self):
75
+ for code, label in i18n.LANGUAGES.items():
76
+ yield (code, label,
77
+ t('app.cmd_language', lang=label),
78
+ t('app.cmd_language_help', lang=label))
79
+
80
+ async def discover(self) -> Hits:
81
+ for code, _label, text, help_text in self._choices():
82
+ yield DiscoveryHit(text, partial(self.app.set_language, code), help=help_text)
83
+
84
+ async def search(self, query: str) -> Hits:
85
+ matcher = self.matcher(query)
86
+ for code, _label, text, help_text in self._choices():
87
+ score = matcher.match(text)
88
+ if score > 0:
89
+ yield Hit(score, matcher.highlight(text),
90
+ partial(self.app.set_language, code), help=help_text)
91
+
92
+
93
+ class Card(Container):
94
+ """Panel with its title drawn on the border."""
95
+
96
+ def __init__(self, title: str, **kwargs) -> None:
97
+ super().__init__(**kwargs)
98
+ self._title = title
99
+
100
+ def on_mount(self) -> None:
101
+ self.border_title = self._title
102
+
103
+
104
+ class DataScreen(Screen):
105
+ """Base: refreshes when the app has new data or when it comes back to front."""
106
+
107
+ # Focus goes to the scrollable area, not the first focusable widget:
108
+ # on the queue that would be the search field, which would swallow the
109
+ # single-key shortcuts (m, i, n, c). This way the arrows scroll the list
110
+ # and you enter the field on purpose, with /.
111
+ AUTO_FOCUS = "VerticalScroll"
112
+
113
+ def apply(self, snap: Snapshot) -> None: # pragma: no cover - overridden
114
+ raise NotImplementedError
115
+
116
+ def _pull(self) -> None:
117
+ snap = getattr(self.app, "snapshot", None)
118
+ if snap is not None:
119
+ self.apply(snap)
120
+
121
+ def on_mount(self) -> None:
122
+ self._pull()
123
+
124
+ def on_screen_resume(self) -> None:
125
+ self._pull()
126
+
127
+
128
+ class MainScreen(DataScreen):
129
+ """'Me first': my jobs, where I fit right now, then the cluster."""
130
+
131
+ def compose(self) -> ComposeResult:
132
+ yield Header(show_clock=True)
133
+ with VerticalScroll(id="body"):
134
+ with Card(t('card.my_jobs'), classes="card"):
135
+ yield Static(id="mine")
136
+ with Card(t('card.fit'), classes="card"):
137
+ yield Static(id="fit")
138
+ with Card(t('card.cluster'), classes="card"):
139
+ yield Static(id="cluster")
140
+ # at the bottom on purpose: this is info you rarely look at
141
+ with Card(t('card.account'), classes="card"):
142
+ yield Static(id="account")
143
+ yield Footer()
144
+
145
+ def apply(self, snap: Snapshot) -> None:
146
+ self.query_one("#mine", Static).update(render.my_jobs(snap))
147
+ self.query_one("#fit", Static).update(render.where_do_i_fit(snap))
148
+ self.query_one("#cluster", Static).update(render.cluster(snap))
149
+ self.apply_profile(getattr(self.app, "profile", None))
150
+
151
+ def apply_profile(self, prof) -> None:
152
+ try:
153
+ widget = self.query_one("#account", Static)
154
+ except Exception:
155
+ return
156
+ widget.update(render.my_account(prof))
157
+
158
+
159
+ class ClusterScreen(DataScreen):
160
+ """Everything the cluster exposes, on a single page."""
161
+
162
+ BINDINGS = cluster_bindings()
163
+
164
+ only_free = reactive(False)
165
+
166
+ def compose(self) -> ComposeResult:
167
+ yield Header(show_clock=True)
168
+ with VerticalScroll(id="body"):
169
+ with Card(t('card.totals'), classes="card"):
170
+ yield Static(id="totals")
171
+ with Card(t('card.nodes'), classes="card", id="nodes-card"):
172
+ yield Static(id="nodes")
173
+ with Card(t('card.usage'), classes="card"):
174
+ yield Static(id="breakdown")
175
+ yield Static(id="legend")
176
+ with Card(t('card.specs'), classes="card"):
177
+ yield Static(id="specs")
178
+ yield Footer()
179
+
180
+ def apply(self, snap: Snapshot) -> None:
181
+ occupants = palette.Occupants(an.occupant_counts(snap), me=snap.me)
182
+ self.query_one("#totals", Static).update(render.totals_strip(snap))
183
+ self.query_one("#nodes", Static).update(
184
+ render.node_table(snap, occupants, only_free=self.only_free))
185
+ self.query_one("#breakdown", Static).update(render.breakdown(snap, occupants))
186
+ self.query_one("#legend", Static).update(render.legend(occupants))
187
+ self.query_one("#specs", Static).update(render.specs_table(snap))
188
+ self.query_one("#nodes-card", Card).border_title = (
189
+ t('card.nodes_free') if self.only_free else t('card.nodes'))
190
+
191
+ def action_toggle_free(self) -> None:
192
+ self.only_free = not self.only_free
193
+ self._pull()
194
+
195
+
196
+ class QueueScreen(DataScreen):
197
+ BINDINGS = queue_bindings()
198
+
199
+ mine_only = reactive(False)
200
+
201
+ def compose(self) -> ComposeResult:
202
+ yield Header(show_clock=True)
203
+ yield Input(placeholder=t('app.search_placeholder'), id="search")
204
+ with Card(t('card.queue'), classes="card grow"):
205
+ with VerticalScroll():
206
+ yield Static(id="queue")
207
+ yield Footer()
208
+
209
+ def apply(self, snap: Snapshot) -> None:
210
+ needle = self.query_one("#search", Input).value
211
+ self.query_one("#queue", Static).update(
212
+ render.queue_table(snap, mine_only=self.mine_only, needle=needle))
213
+ card = self.query_one(Card)
214
+ card.border_title = t('card.queue_mine') if self.mine_only else t('card.queue')
215
+
216
+ def action_search(self) -> None:
217
+ self.query_one("#search", Input).focus()
218
+
219
+ def action_leave_search(self) -> None:
220
+ self.query_one(VerticalScroll).focus()
221
+
222
+ def action_toggle_mine(self) -> None:
223
+ self.mine_only = not self.mine_only
224
+ self._pull()
225
+
226
+ def on_input_submitted(self, event: Input.Submitted) -> None:
227
+ self.query_one(VerticalScroll).focus()
228
+
229
+ def on_input_changed(self, event: Input.Changed) -> None:
230
+ self._pull()
231
+
232
+
233
+ class ManualScreen(DataScreen):
234
+ """Cheat sheet of cluster commands. It doesn't depend on the data, but it
235
+ redraws on theme change to follow the palette."""
236
+
237
+ def compose(self) -> ComposeResult:
238
+ yield Header(show_clock=True)
239
+ with VerticalScroll(id="body"):
240
+ with Card(t('card.manual'), classes="card"):
241
+ yield Static(id="manual")
242
+ yield Footer()
243
+
244
+ def apply(self, snap: Snapshot | None = None) -> None:
245
+ self.query_one("#manual", Static).update(render.manual())
246
+
247
+ def on_mount(self) -> None:
248
+ self.apply()
249
+
250
+
251
+ class NodeView(App):
252
+ """Cluster GPU status, from the point of view of the people using it."""
253
+
254
+ CSS_PATH = "app.tcss"
255
+ TITLE = "nodeview"
256
+
257
+ MODES = {"main": MainScreen, "cluster": ClusterScreen,
258
+ "queue": QueueScreen, "manual": ManualScreen}
259
+
260
+ BINDINGS = app_bindings()
261
+ COMMANDS = App.COMMANDS | {LanguageCommands}
262
+
263
+ snapshot: reactive = reactive(None, always_update=True)
264
+
265
+ def __init__(self, interval: float = 10.0) -> None:
266
+ super().__init__()
267
+ self.interval = interval
268
+ self._error: str | None = None
269
+ self._restored = False
270
+ self.profile: Profile | None = None
271
+
272
+ def on_mount(self) -> None:
273
+ self._restore_prefs()
274
+ self._sync_palette()
275
+ self.switch_mode("main")
276
+ self.action_reload()
277
+ self.set_interval(self.interval, self.action_reload)
278
+ # budget, quotas and expiration cost ~1.7s and don't change minute to
279
+ # minute: they're read only once at startup, outside the loop
280
+ self._load_profile()
281
+
282
+ # --- data -------------------------------------------------------------
283
+
284
+ def action_reload(self) -> None:
285
+ self.sub_title = t('app.loading')
286
+ self._load()
287
+
288
+ @work(exclusive=True, thread=True)
289
+ def _load(self) -> None:
290
+ """Runs in a thread: fetch() calls sinfo/squeue and blocks for ~0.2s."""
291
+ try:
292
+ snap = fetch()
293
+ except Exception as exc: # the UI must stay alive and say what's wrong
294
+ self.call_from_thread(self._on_error, exc)
295
+ else:
296
+ self.call_from_thread(self._on_data, snap)
297
+
298
+ @work(thread=True, group="profile")
299
+ def _load_profile(self) -> None:
300
+ try:
301
+ prof = read_profile()
302
+ except Exception:
303
+ return
304
+ self.call_from_thread(self._on_profile, prof)
305
+
306
+ def _on_profile(self, prof: Profile) -> None:
307
+ self.profile = prof
308
+ screen = self.screen
309
+ if isinstance(screen, MainScreen):
310
+ screen.apply_profile(prof)
311
+
312
+ def _on_data(self, snap: Snapshot) -> None:
313
+ self._error = None
314
+ self.snapshot = snap
315
+ when = datetime.fromtimestamp(snap.taken_at).strftime('%H:%M:%S')
316
+ self.sub_title = t('app.subtitle', me=snap.me, nodes=len(snap.nodes),
317
+ when=when, secs=f"{snap.duration:.2f}")
318
+ screen = self.screen
319
+ if isinstance(screen, DataScreen):
320
+ screen.apply(snap)
321
+
322
+ def _on_error(self, exc: Exception) -> None:
323
+ self._error = str(exc)
324
+ self.sub_title = t('app.read_error')
325
+ self.notify(str(exc), title=t('app.slurm_down'), severity="error", timeout=8)
326
+
327
+ # --- misc -------------------------------------------------------------
328
+
329
+ def _sync_palette(self) -> None:
330
+ """Colors follow the theme, measuring its actual background.
331
+
332
+ Knowing whether a theme is dark isn't enough: nord's panel is #434C5E
333
+ and textual-dark's is #242F38, and on the former the dark ramp and the
334
+ status colors vanished."""
335
+ theme = self.current_theme
336
+ if theme is None:
337
+ palette.use(palette.default())
338
+ else:
339
+ palette.use(palette.from_theme(theme))
340
+
341
+ def _apply_language(self, lang: str) -> None:
342
+ """Apply the language to texts and key bindings.
343
+
344
+ Binding descriptions are class attributes evaluated at import time:
345
+ they must be rebuilt, together with the app's key map, otherwise the
346
+ footer stays in the old language.
347
+ """
348
+ i18n.use(lang)
349
+ _retranslate(NodeView, app_bindings())
350
+ _retranslate(ClusterScreen, cluster_bindings())
351
+ _retranslate(QueueScreen, queue_bindings())
352
+ # Only our own bindings, updated in place. Replacing the whole map
353
+ # would throw away the ones Textual injects at runtime, starting with
354
+ # the palette's ctrl+p.
355
+ for binding in app_bindings():
356
+ self._bindings.key_to_bindings[binding.key] = [binding]
357
+
358
+ def _restore_prefs(self) -> None:
359
+ """Restore the language and theme chosen last time, if they still exist.
360
+
361
+ A saved theme may disappear between Textual versions, and a language
362
+ may have been removed: in that case the value is ignored and the
363
+ default is kept. The language is applied before the screens are
364
+ created, so they come out already translated."""
365
+ saved = config.load()
366
+ lang = saved.get('lang')
367
+ self._apply_language(lang if isinstance(lang, str) and lang in i18n.LANGUAGES
368
+ else i18n.DEFAULT)
369
+ theme = saved.get('theme')
370
+ if isinstance(theme, str) and theme in self.available_themes:
371
+ self.theme = theme
372
+ self._restored = True
373
+
374
+ def set_language(self, lang: str) -> None:
375
+ """Switch language, remember it and rebuild the interface."""
376
+ if lang not in i18n.LANGUAGES or lang == i18n.active():
377
+ return
378
+ self._apply_language(lang)
379
+ config.save(lang=lang)
380
+ self.run_worker(self._rebuild_screens(), exclusive=True, group="relang")
381
+
382
+ async def _rebuild_screens(self) -> None:
383
+ """Recreate the screens in the new language.
384
+
385
+ Panel titles, placeholders and bindings are fixed when the screen is
386
+ composed: redrawing isn't enough to translate them, it has to be
387
+ recreated. Textual doesn't allow removing the active mode, so we first
388
+ switch to one that has already been rebuilt.
389
+ """
390
+ target = self.current_mode
391
+ others = [m for m in self.MODES if m != target]
392
+ for mode in others:
393
+ await self.remove_mode(mode)
394
+ self.add_mode(mode, self.MODES[mode])
395
+ if others:
396
+ await self.switch_mode(others[0])
397
+ await self.remove_mode(target)
398
+ self.add_mode(target, self.MODES[target])
399
+ await self.switch_mode(target)
400
+ self.refresh_bindings()
401
+ if self.snapshot is not None and isinstance(self.screen, DataScreen):
402
+ self.screen.apply(self.snapshot)
403
+ self.notify(t('app.lang_set', lang=i18n.language_name(i18n.active())), timeout=2)
404
+
405
+ def watch_theme(self, theme: str) -> None:
406
+ self._sync_palette()
407
+ if self.snapshot is not None and isinstance(self.screen, DataScreen):
408
+ self.screen.apply(self.snapshot)
409
+ # don't save during startup, or the factory theme would overwrite the
410
+ # preference before it has even been read
411
+ if getattr(self, '_restored', False):
412
+ config.save(theme=theme)
413
+
414
+ def action_cycle_theme(self) -> None:
415
+ available = list(self.available_themes)
416
+ if not available:
417
+ return
418
+ try:
419
+ idx = available.index(self.theme)
420
+ except ValueError:
421
+ idx = -1
422
+ self.theme = available[(idx + 1) % len(available)]
423
+ self.notify(t('app.theme_set', theme=self.theme), timeout=2)
nodeview/app.tcss ADDED
@@ -0,0 +1,35 @@
1
+ /* Little decoration, lots of hierarchy: the cards separate the three questions
2
+ the screen answers, and Textual's theme does the rest. */
3
+
4
+ Screen {
5
+ background: $surface;
6
+ }
7
+
8
+ #body {
9
+ padding: 1 2;
10
+ }
11
+
12
+ .card {
13
+ height: auto;
14
+ margin: 0 0 1 0;
15
+ padding: 0 1;
16
+ border: round $primary;
17
+ background: $panel;
18
+ border-title-color: $accent;
19
+ border-title-style: bold;
20
+ }
21
+
22
+ /* the nodes and queue cards take all the remaining space */
23
+ .card.grow {
24
+ height: 1fr;
25
+ margin: 1 2;
26
+ }
27
+
28
+ .card > Static {
29
+ height: auto;
30
+ }
31
+
32
+ #search {
33
+ margin: 1 2 0 2;
34
+ border: tall $primary;
35
+ }
nodeview/config.py ADDED
@@ -0,0 +1,61 @@
1
+ """Preferences that survive closing the app.
2
+
3
+ A single, tiny file in $XDG_CONFIG_HOME/nodeview/config.json (or
4
+ ~/.config/nodeview/config.json). It is never essential: if it cannot be read or
5
+ written, the application carries on with the defaults, without complaining. A
6
+ read-only home or a full disk must not stop anyone from looking at the cluster
7
+ state.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+
17
+ def config_dir() -> Path:
18
+ base = os.environ.get('XDG_CONFIG_HOME') or (Path.home() / '.config')
19
+ return Path(base) / 'nodeview'
20
+
21
+
22
+ def config_file() -> Path:
23
+ return config_dir() / 'config.json'
24
+
25
+
26
+ def load() -> dict:
27
+ try:
28
+ with open(config_file(), encoding='utf-8') as fh:
29
+ data = json.load(fh)
30
+ return data if isinstance(data, dict) else {}
31
+ except (OSError, ValueError):
32
+ # file missing, unreadable or malformed: fall back to the factory defaults
33
+ return {}
34
+
35
+
36
+ def save(**changes) -> bool:
37
+ """Update the given keys, leaving the others untouched.
38
+
39
+ Atomic write: write alongside and rename, so an interruption never leaves a
40
+ half-written file that would have to be thrown away on the next start.
41
+ """
42
+ data = load()
43
+ data.update(changes)
44
+ try:
45
+ directory = config_dir()
46
+ directory.mkdir(parents=True, exist_ok=True)
47
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix='.config-', suffix='.json')
48
+ try:
49
+ with os.fdopen(fd, 'w', encoding='utf-8') as fh:
50
+ json.dump(data, fh, indent=2, sort_keys=True)
51
+ fh.write('\n')
52
+ os.replace(tmp, config_file())
53
+ except BaseException:
54
+ try:
55
+ os.unlink(tmp)
56
+ except OSError:
57
+ pass
58
+ raise
59
+ return True
60
+ except OSError:
61
+ return False