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