dron 0.1.20241008__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.
- dron/__init__.py +6 -0
- dron/__main__.py +5 -0
- dron/api.py +106 -0
- dron/cli.py +382 -0
- dron/common.py +174 -0
- dron/conftest.py +18 -0
- dron/dron.py +510 -0
- dron/launchd.py +415 -0
- dron/launchd_wrapper.py +90 -0
- dron/monitor.py +157 -0
- dron/notify/common.py +53 -0
- dron/notify/email.py +43 -0
- dron/notify/ntfy_common.py +24 -0
- dron/notify/ntfy_desktop.py +15 -0
- dron/notify/ntfy_telegram.py +12 -0
- dron/notify/telegram.py +42 -0
- dron/py.typed +0 -0
- dron/systemd.py +542 -0
- dron/tests/test_dron.py +119 -0
- dron-0.1.20241008.dist-info/LICENSE.txt +21 -0
- dron-0.1.20241008.dist-info/METADATA +47 -0
- dron-0.1.20241008.dist-info/RECORD +25 -0
- dron-0.1.20241008.dist-info/WHEEL +5 -0
- dron-0.1.20241008.dist-info/entry_points.txt +2 -0
- dron-0.1.20241008.dist-info/top_level.txt +1 -0
dron/launchd.py
ADDED
@@ -0,0 +1,415 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import itertools
|
4
|
+
import json
|
5
|
+
import os
|
6
|
+
import re
|
7
|
+
import shlex
|
8
|
+
import sys
|
9
|
+
import textwrap
|
10
|
+
from datetime import timedelta
|
11
|
+
from pathlib import Path
|
12
|
+
from subprocess import PIPE, Popen, check_call, check_output
|
13
|
+
from tempfile import TemporaryDirectory
|
14
|
+
from typing import Any, Iterator, Sequence
|
15
|
+
|
16
|
+
from .api import (
|
17
|
+
OnCalendar,
|
18
|
+
OnFailureAction,
|
19
|
+
When,
|
20
|
+
)
|
21
|
+
from .common import (
|
22
|
+
ALWAYS,
|
23
|
+
MANAGED_MARKER,
|
24
|
+
Command,
|
25
|
+
LaunchdUnitState,
|
26
|
+
MonitorEntry,
|
27
|
+
MonitorParams,
|
28
|
+
State,
|
29
|
+
Unit,
|
30
|
+
UnitFile,
|
31
|
+
logger,
|
32
|
+
unwrap,
|
33
|
+
)
|
34
|
+
|
35
|
+
# TODO custom launchd domain?? maybe instead could do dron/ or something?
|
36
|
+
_LAUNCHD_DOMAIN = f'gui/{os.getuid()}'
|
37
|
+
|
38
|
+
|
39
|
+
# in principle not necessary...
|
40
|
+
# but makes it much easier to filter out logs & lobs from launchctl dump
|
41
|
+
DRON_PREFIX = 'dron.'
|
42
|
+
|
43
|
+
|
44
|
+
def _launchctl(*args: Path | str) -> list[Path | str]:
|
45
|
+
return ['launchctl', *args]
|
46
|
+
|
47
|
+
|
48
|
+
def _launch_agent(path: str) -> Path:
|
49
|
+
# symlink for autostart
|
50
|
+
assert path.endswith('.plist'), path # meh
|
51
|
+
assert not Path(path).is_absolute(), path
|
52
|
+
|
53
|
+
LA = Path('~/Library/LaunchAgents').expanduser()
|
54
|
+
link = LA / path
|
55
|
+
return link
|
56
|
+
|
57
|
+
|
58
|
+
def fqn(name: Unit) -> str:
|
59
|
+
return _LAUNCHD_DOMAIN + '/' + DRON_PREFIX + name
|
60
|
+
|
61
|
+
|
62
|
+
def launchctl_load(*, unit_file: UnitFile) -> None:
|
63
|
+
# bootstrap is nicer than load
|
64
|
+
# load is super defensive, returns code 0 on errors
|
65
|
+
check_call(_launchctl('bootstrap', _LAUNCHD_DOMAIN, unit_file))
|
66
|
+
_launch_agent(unit_file.name).symlink_to(unit_file)
|
67
|
+
|
68
|
+
|
69
|
+
def launchctl_unload(*, unit: Unit) -> None:
|
70
|
+
# bootout is more verbose than unload
|
71
|
+
# in addition unload is super defensive, returns code 0 on errors
|
72
|
+
check_call(_launchctl('bootout', fqn(unit)))
|
73
|
+
_launch_agent(unit + '.plist').unlink()
|
74
|
+
|
75
|
+
|
76
|
+
def launchctl_kickstart(*, unit: Unit) -> None:
|
77
|
+
check_call(_launchctl('kickstart', fqn(unit)))
|
78
|
+
|
79
|
+
|
80
|
+
def launchctl_reload(*, unit: Unit, unit_file: UnitFile) -> None:
|
81
|
+
# don't think there is a better way?
|
82
|
+
launchctl_unload(unit=unit)
|
83
|
+
launchctl_load(unit_file=unit_file)
|
84
|
+
|
85
|
+
|
86
|
+
def launchd_wrapper(*, job: str, on_failure: list[str]) -> list[str]:
|
87
|
+
# fmt: off
|
88
|
+
return [
|
89
|
+
sys.executable,
|
90
|
+
'-m',
|
91
|
+
'dron.launchd_wrapper',
|
92
|
+
*itertools.chain.from_iterable(('--notify', n) for n in on_failure),
|
93
|
+
'--job', job,
|
94
|
+
'--',
|
95
|
+
]
|
96
|
+
# fmt: on
|
97
|
+
|
98
|
+
|
99
|
+
def remove_launchd_wrapper(cmd: str) -> str:
|
100
|
+
if ' dron.launchd_wrapper ' not in cmd:
|
101
|
+
return cmd
|
102
|
+
# uhh... not super reliable, but this is only used for monitor so hopefully fine
|
103
|
+
[_, cmd] = cmd.split(' -- ', maxsplit=1)
|
104
|
+
return cmd
|
105
|
+
|
106
|
+
|
107
|
+
def plist(
|
108
|
+
*,
|
109
|
+
unit_name: str,
|
110
|
+
command: Command,
|
111
|
+
on_failure: Sequence[OnFailureAction],
|
112
|
+
when: When | None=None,
|
113
|
+
) -> str:
|
114
|
+
# TODO hmm, kinda mirrors 'escape' method, not sure
|
115
|
+
cmd: Sequence[str]
|
116
|
+
if isinstance(command, (list, tuple)):
|
117
|
+
cmd = tuple(map(str, command))
|
118
|
+
elif isinstance(command, Path):
|
119
|
+
cmd = [str(command)]
|
120
|
+
elif isinstance(command, str) and ' ' not in command:
|
121
|
+
cmd = [command]
|
122
|
+
else:
|
123
|
+
# unquoting and splitting is way trickier than quoting and joining...
|
124
|
+
# not sure how to implement it p
|
125
|
+
# maybe we just want bash -c in this case, dunno how to implement properly
|
126
|
+
raise RuntimeError(command)
|
127
|
+
del command
|
128
|
+
|
129
|
+
mschedule = ''
|
130
|
+
if when is None:
|
131
|
+
# support later
|
132
|
+
raise RuntimeError(unit_name)
|
133
|
+
|
134
|
+
if when == ALWAYS:
|
135
|
+
mschedule = '<key>KeepAlive</key>\n<true/>'
|
136
|
+
else:
|
137
|
+
assert isinstance(when, OnCalendar), when
|
138
|
+
# https://www.freedesktop.org/software/systemd/man/systemd.time.html#
|
139
|
+
seconds = {
|
140
|
+
'minutely': 60,
|
141
|
+
'hourly' : 60 * 60,
|
142
|
+
'daily' : 60 * 60 * 24,
|
143
|
+
}.get(when)
|
144
|
+
if seconds is None:
|
145
|
+
# ok, try systemd-like spec..
|
146
|
+
specs = [
|
147
|
+
(re.escape('*:0/') + r'(\d+)', 60),
|
148
|
+
(re.escape('*:*:0/') + r'(\d+)', 1 ),
|
149
|
+
]
|
150
|
+
for rgx, mult in specs:
|
151
|
+
m = re.fullmatch(rgx, when)
|
152
|
+
if m is not None:
|
153
|
+
num = m.group(1)
|
154
|
+
seconds = int(num) * mult
|
155
|
+
break
|
156
|
+
if seconds is None:
|
157
|
+
# try to parse as hh:mm at least
|
158
|
+
m = re.fullmatch(r'(\d\d):(\d\d)', when)
|
159
|
+
assert m is not None, when
|
160
|
+
hh = m.group(1)
|
161
|
+
mm = m.group(2)
|
162
|
+
mschedule = '\n'.join([
|
163
|
+
'<key>StartCalendarInterval</key>',
|
164
|
+
'<dict>',
|
165
|
+
'<key>Hour</key>' , f'<integer>{int(hh)}</integer>',
|
166
|
+
'<key>Minute</key>', f'<integer>{int(mm)}</integer>',
|
167
|
+
'</dict>',
|
168
|
+
])
|
169
|
+
else:
|
170
|
+
mschedule = '\n'.join(('<key>StartInterval</key>', f'<integer>{seconds}</integer>'))
|
171
|
+
|
172
|
+
assert mschedule != '', unit_name
|
173
|
+
|
174
|
+
# meh.. not sure how to reconcile it better with systemd
|
175
|
+
on_failure = [
|
176
|
+
x.replace('--job %n', f'--job {unit_name}') + ' --stdin'
|
177
|
+
for x in on_failure
|
178
|
+
]
|
179
|
+
|
180
|
+
# attempt to set argv[0] properly
|
181
|
+
# hmm I was hoping it would make desktop notifications ('background service added' nicer)
|
182
|
+
# but even after that it still only shows executable script name. ugh
|
183
|
+
# program_argv = (unit_name, *cmd[1:])
|
184
|
+
program_argv = (
|
185
|
+
*launchd_wrapper(job=unit_name, on_failure=on_failure),
|
186
|
+
*cmd,
|
187
|
+
)
|
188
|
+
del cmd
|
189
|
+
program_argvs = '\n'.join(f'<string>{c}</string>' for c in program_argv)
|
190
|
+
|
191
|
+
# TODO add log file, although mailer is already capturing stdout
|
192
|
+
# TODO hmm maybe use the same log file for all dron jobs? would make it easier to rotate?
|
193
|
+
res = f'''
|
194
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
195
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
196
|
+
<plist version="1.0">
|
197
|
+
<dict>
|
198
|
+
|
199
|
+
<key>Label</key>
|
200
|
+
<string>{DRON_PREFIX}{unit_name}</string>
|
201
|
+
<key>ProgramArguments</key>
|
202
|
+
<array>
|
203
|
+
{textwrap.indent(program_argvs, " " * 8)}
|
204
|
+
</array>
|
205
|
+
|
206
|
+
<key>RunAtLoad</key>
|
207
|
+
<true/>
|
208
|
+
|
209
|
+
{textwrap.indent(mschedule, " " * 8)}
|
210
|
+
|
211
|
+
<key>Comment</key>
|
212
|
+
<string>{MANAGED_MARKER}</string>
|
213
|
+
</dict>
|
214
|
+
</plist>
|
215
|
+
'''.lstrip()
|
216
|
+
return res
|
217
|
+
|
218
|
+
|
219
|
+
from .common import LaunchdUnitState
|
220
|
+
|
221
|
+
|
222
|
+
def launchd_state(*, with_body: bool) -> Iterator[LaunchdUnitState]:
|
223
|
+
# sadly doesn't look like it has json interface??
|
224
|
+
dump = check_output(['launchctl', 'dumpstate']).decode('utf8')
|
225
|
+
|
226
|
+
name: str | None = None
|
227
|
+
extras: dict[str, Any] = {}
|
228
|
+
arguments: list[str] | None = None
|
229
|
+
all_props: str | None = None
|
230
|
+
fields = [
|
231
|
+
'path',
|
232
|
+
'last exit code',
|
233
|
+
'pid',
|
234
|
+
'run interval',
|
235
|
+
]
|
236
|
+
for line in dump.splitlines():
|
237
|
+
if name is None:
|
238
|
+
# start of job description group
|
239
|
+
name = line.removesuffix(' = {')
|
240
|
+
all_props = ''
|
241
|
+
continue
|
242
|
+
elif line == '}':
|
243
|
+
# end of job description group
|
244
|
+
path: str | None = extras.get('path')
|
245
|
+
if path is not None and 'dron' in path:
|
246
|
+
# otherwsie likely some sort of system unit
|
247
|
+
unit_file = Path(path)
|
248
|
+
body = unit_file.read_text() if with_body else None
|
249
|
+
|
250
|
+
# TODO extract 'state'??
|
251
|
+
|
252
|
+
periodic_schedule = extras.get('run interval')
|
253
|
+
calendal_schedule = 'com.apple.launchd.calendarinterval' in unwrap(all_props)
|
254
|
+
|
255
|
+
schedule: str | None = None
|
256
|
+
if periodic_schedule is not None:
|
257
|
+
schedule = 'every ' + periodic_schedule
|
258
|
+
elif calendal_schedule:
|
259
|
+
# TODO parse properly
|
260
|
+
schedule = 'calendar'
|
261
|
+
else:
|
262
|
+
# NOTE: seems like keepalive attribute isn't present in launcd dumpstate output
|
263
|
+
schedule = 'always'
|
264
|
+
|
265
|
+
yield LaunchdUnitState(
|
266
|
+
unit_file=Path(path),
|
267
|
+
body=body,
|
268
|
+
cmdline=tuple(extras['arguments']),
|
269
|
+
# might not be present when we killed process manually?
|
270
|
+
last_exit_code=extras.get('last exit code'),
|
271
|
+
# pid might not be present (presumably when it's not running)
|
272
|
+
pid=extras.get('pid'),
|
273
|
+
schedule=schedule,
|
274
|
+
)
|
275
|
+
name = None
|
276
|
+
all_props = None
|
277
|
+
extras = {}
|
278
|
+
continue
|
279
|
+
|
280
|
+
all_props = unwrap(all_props) + line + '\n'
|
281
|
+
|
282
|
+
if arguments is not None:
|
283
|
+
if line == '\t}':
|
284
|
+
extras['arguments'] = arguments
|
285
|
+
arguments = None
|
286
|
+
else:
|
287
|
+
arg = line.removeprefix('\t\t')
|
288
|
+
arguments.append(arg)
|
289
|
+
else:
|
290
|
+
xx = line.removeprefix('\t')
|
291
|
+
for f in fields:
|
292
|
+
zz = f'{f} = '
|
293
|
+
if xx.startswith(zz):
|
294
|
+
extras[f] = xx.removeprefix(zz)
|
295
|
+
break
|
296
|
+
# special handling..
|
297
|
+
if xx.startswith('arguments = '):
|
298
|
+
arguments = []
|
299
|
+
|
300
|
+
|
301
|
+
def verify_unit(*, unit_name: str, body: str) -> None:
|
302
|
+
with TemporaryDirectory() as tdir:
|
303
|
+
tfile = Path(tdir) / unit_name
|
304
|
+
tfile.write_text(body)
|
305
|
+
check_call([
|
306
|
+
'plutil', '-lint',
|
307
|
+
'-s', # silent on success
|
308
|
+
tfile,
|
309
|
+
])
|
310
|
+
|
311
|
+
|
312
|
+
def cmd_past(unit: Unit) -> None:
|
313
|
+
sub = fqn('dron.' + unit)
|
314
|
+
cmd = [
|
315
|
+
# todo maybe use 'stream'??
|
316
|
+
'log', 'show', '--info',
|
317
|
+
# '--last', '24h',
|
318
|
+
# hmm vvv that doesn't work, if we pass pid, predicate is ignored?
|
319
|
+
# '--process', '1',
|
320
|
+
# hmm, oddly enough "&&" massively slows the predicate??
|
321
|
+
#'--predicate', f'processIdentifier=1 && (subsystem contains "gui/501/dron.{unit}")',
|
322
|
+
'--predicate', f'subsystem contains "{sub}"',
|
323
|
+
'--style', 'ndjson',
|
324
|
+
'--color', 'always',
|
325
|
+
]
|
326
|
+
with Popen(cmd, stdout=PIPE, encoding='utf8') as p:
|
327
|
+
out = p.stdout; assert out is not None
|
328
|
+
for line in out:
|
329
|
+
j = json.loads(line)
|
330
|
+
if j.get('finished') == 1:
|
331
|
+
# last event at the very end
|
332
|
+
continue
|
333
|
+
subsystem = j['subsystem']
|
334
|
+
# sometimes subsystem contains pid at the end, need to chop it off
|
335
|
+
# also that's wjy we can't use "subsystem = " predicate :(
|
336
|
+
subsystem = subsystem.split(' ')[0]
|
337
|
+
if sub != subsystem:
|
338
|
+
continue
|
339
|
+
msg = j['eventMessage']
|
340
|
+
|
341
|
+
interesting = re.search(' spawned .* because', msg) or 'exited ' in msg
|
342
|
+
if not interesting:
|
343
|
+
continue
|
344
|
+
ts = j['timestamp']
|
345
|
+
print(ts, sub, msg)
|
346
|
+
|
347
|
+
|
348
|
+
def cmd_run(*, unit: Unit, do_exec: bool) -> None:
|
349
|
+
if not do_exec:
|
350
|
+
launchctl_kickstart(unit=unit)
|
351
|
+
return
|
352
|
+
|
353
|
+
states = []
|
354
|
+
for s in launchd_state(with_body=False):
|
355
|
+
if s.unit_file.stem == unit:
|
356
|
+
states.append(s)
|
357
|
+
[state] = states
|
358
|
+
cmdline = state.cmdline
|
359
|
+
assert cmdline is not None, unit
|
360
|
+
|
361
|
+
## cut off launchd wrapper
|
362
|
+
sep_i = cmdline.index('--')
|
363
|
+
cmdline = cmdline[sep_i + 1 :]
|
364
|
+
##
|
365
|
+
|
366
|
+
cmds = ' '.join(map(shlex.quote, cmdline))
|
367
|
+
logger.info(f'running: {cmds}')
|
368
|
+
os.execvp(
|
369
|
+
cmdline[0],
|
370
|
+
list(cmdline),
|
371
|
+
)
|
372
|
+
|
373
|
+
|
374
|
+
def get_entries_for_monitor(managed: State, *, params: MonitorParams) -> list[MonitorEntry]:
|
375
|
+
# for now kinda copy pasted from systemd
|
376
|
+
|
377
|
+
entries: list[MonitorEntry] = []
|
378
|
+
for s in managed:
|
379
|
+
assert isinstance(s, LaunchdUnitState), s
|
380
|
+
|
381
|
+
unit_file = s.unit_file
|
382
|
+
name = unit_file.name.removesuffix('.plist')
|
383
|
+
|
384
|
+
is_seconds = re.fullmatch(r'every (\d+) seconds', s.schedule or '')
|
385
|
+
if is_seconds is not None:
|
386
|
+
delta = timedelta(seconds=int(is_seconds.group(1)))
|
387
|
+
# meh, but works for now
|
388
|
+
ss = f'every {delta}'
|
389
|
+
else:
|
390
|
+
ss = str(s.schedule)
|
391
|
+
|
392
|
+
schedule = ss
|
393
|
+
command = None
|
394
|
+
if params.with_command:
|
395
|
+
cmdline = s.cmdline
|
396
|
+
assert cmdline is not None, name # not None for launchd units
|
397
|
+
command = ' '.join(map(shlex.quote, cmdline))
|
398
|
+
command = remove_launchd_wrapper(command)
|
399
|
+
|
400
|
+
status_ok = s.last_exit_code == '0'
|
401
|
+
status = 'success' if status_ok else f'exitcode {s.last_exit_code}'
|
402
|
+
|
403
|
+
pid = s.pid
|
404
|
+
|
405
|
+
entries.append(MonitorEntry(
|
406
|
+
unit=name,
|
407
|
+
status=status,
|
408
|
+
left='n/a',
|
409
|
+
next='n/a',
|
410
|
+
schedule=schedule,
|
411
|
+
command=command,
|
412
|
+
pid=pid,
|
413
|
+
status_ok=status_ok,
|
414
|
+
))
|
415
|
+
return entries
|
dron/launchd_wrapper.py
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
import argparse
|
3
|
+
import shlex
|
4
|
+
import sys
|
5
|
+
from pathlib import Path
|
6
|
+
from subprocess import PIPE, STDOUT, Popen
|
7
|
+
from typing import Iterator, NoReturn
|
8
|
+
|
9
|
+
from loguru import logger
|
10
|
+
|
11
|
+
LOG_DIR = Path('~/Library/Logs/dron').expanduser()
|
12
|
+
|
13
|
+
|
14
|
+
def main() -> NoReturn:
|
15
|
+
p = argparse.ArgumentParser()
|
16
|
+
p.add_argument('--notify', action='append')
|
17
|
+
p.add_argument('--job', required=True)
|
18
|
+
# hmm, this doesn't work with keyword args??
|
19
|
+
# p.add_argument('cmd', nargs=argparse.REMAINDER)
|
20
|
+
args, rest = p.parse_known_args()
|
21
|
+
|
22
|
+
assert rest[0] == '--', rest
|
23
|
+
cmd = rest[1:]
|
24
|
+
|
25
|
+
notify_cmds = [] if args.notify is None else args.notify
|
26
|
+
job = args.job
|
27
|
+
|
28
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
29
|
+
log_file = LOG_DIR / f'{job}.log'
|
30
|
+
|
31
|
+
logger.add(log_file, rotation='100 MB') # todo configurable? or rely on osx rotation?
|
32
|
+
|
33
|
+
# hmm, a bit crap transforming everything to stdout? but not much we can do?
|
34
|
+
captured_log = []
|
35
|
+
try:
|
36
|
+
with Popen(cmd, stdout=PIPE, stderr=STDOUT) as po:
|
37
|
+
out = po.stdout
|
38
|
+
assert out is not None
|
39
|
+
for line in out:
|
40
|
+
captured_log.append(line)
|
41
|
+
sys.stdout.buffer.write(line)
|
42
|
+
rc = po.poll()
|
43
|
+
|
44
|
+
if rc == 0:
|
45
|
+
# short circuit
|
46
|
+
sys.exit(0)
|
47
|
+
except Exception as e:
|
48
|
+
# Popen istelf still fail due to permission denied or something
|
49
|
+
logger.exception(e)
|
50
|
+
captured_log.append(str(e).encode('utf8'))
|
51
|
+
rc = 123
|
52
|
+
|
53
|
+
|
54
|
+
def payload() -> Iterator[bytes]:
|
55
|
+
yield f"exit code: {rc}\n".encode()
|
56
|
+
yield b'command: \n'
|
57
|
+
yield (' '.join(map(shlex.quote, cmd)) + '\n').encode('utf8')
|
58
|
+
yield f'log file: {log_file}\n'.encode()
|
59
|
+
yield b'\n'
|
60
|
+
yield b'output (stdout + stderr):\n\n'
|
61
|
+
# TODO shit -- if multiple notifications, can't use generator for captured_log
|
62
|
+
# unless we notify simultaneously?
|
63
|
+
yield from captured_log
|
64
|
+
|
65
|
+
for line in payload():
|
66
|
+
logger.info(line.decode('utf8').rstrip('\n')) # meh
|
67
|
+
|
68
|
+
for notify_cmd in notify_cmds:
|
69
|
+
logger.info(f'notifying: {notify_cmd}')
|
70
|
+
try:
|
71
|
+
with Popen(notify_cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE) as po:
|
72
|
+
sin = po.stdin
|
73
|
+
assert sin is not None
|
74
|
+
for line in payload():
|
75
|
+
sin.write(line)
|
76
|
+
(sout, serr) = po.communicate()
|
77
|
+
for l in sout.decode('utf8').splitlines():
|
78
|
+
logger.debug(l)
|
79
|
+
for l in serr.decode('utf8').splitlines():
|
80
|
+
logger.debug(l)
|
81
|
+
assert po.poll() == 0, notify_cmd
|
82
|
+
except Exception as e:
|
83
|
+
logger.error(f'notificaiton failed: {notify_cmd}')
|
84
|
+
logger.exception(e)
|
85
|
+
|
86
|
+
sys.exit(rc)
|
87
|
+
|
88
|
+
|
89
|
+
if __name__ == '__main__':
|
90
|
+
main()
|
dron/monitor.py
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import asdict, fields
|
4
|
+
from functools import lru_cache
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
from textual import events, work
|
8
|
+
from textual.app import App, ComposeResult
|
9
|
+
from textual.widgets import DataTable, Log
|
10
|
+
|
11
|
+
from .common import MonitorEntry, MonitorParams, unwrap
|
12
|
+
from .dron import get_entries_for_monitor, managed_units
|
13
|
+
|
14
|
+
|
15
|
+
# todo try RichLog? https://textual.textualize.io/guide/input
|
16
|
+
class LogHeader(Log):
|
17
|
+
DEFAULT_CSS = """
|
18
|
+
LogHeader {
|
19
|
+
height: 5;
|
20
|
+
dock: top;
|
21
|
+
}
|
22
|
+
"""
|
23
|
+
|
24
|
+
|
25
|
+
MonitorEntries = dict[str, MonitorEntry]
|
26
|
+
|
27
|
+
|
28
|
+
@lru_cache(None)
|
29
|
+
def get_columns() -> list[str]:
|
30
|
+
cols: list[str] = []
|
31
|
+
for f in fields(MonitorEntry):
|
32
|
+
cols.append(f.name)
|
33
|
+
|
34
|
+
# TODO how to check it statically? MonitorEntry.pid isn't giving anything?
|
35
|
+
cols.remove('pid') # probs don't need?
|
36
|
+
cols.remove('status_ok')
|
37
|
+
return cols
|
38
|
+
|
39
|
+
|
40
|
+
def as_row(entry: MonitorEntry) -> dict[str, Any]:
|
41
|
+
cols = get_columns()
|
42
|
+
res = {k: v for k, v in asdict(entry).items() if k in cols}
|
43
|
+
|
44
|
+
v = res['status']
|
45
|
+
color = 'green' if entry.status_ok else 'red'
|
46
|
+
res['status'] = f'[{color}]' + res['status'] + f'[/{color}]'
|
47
|
+
|
48
|
+
# meh. workaround for next/left being max datetime
|
49
|
+
if res['next'].startswith('9999-'):
|
50
|
+
res['left'] = '--'
|
51
|
+
res['next'] = '[yellow]' + 'never' + '[/yellow]'
|
52
|
+
|
53
|
+
if entry.pid is not None:
|
54
|
+
res['left'] = '--'
|
55
|
+
res['next'] = '[yellow]' + 'running' + '[/yellow]'
|
56
|
+
return res
|
57
|
+
|
58
|
+
|
59
|
+
class MonitorApp(App):
|
60
|
+
|
61
|
+
def __init__(self, *, monitor_params: MonitorParams, refresh_every: int) -> None:
|
62
|
+
super().__init__()
|
63
|
+
self.monitor_params = monitor_params
|
64
|
+
self.refresh_every = refresh_every
|
65
|
+
|
66
|
+
def compose(self) -> ComposeResult:
|
67
|
+
# TODO self.log is already defined?? what is it?
|
68
|
+
self.logx = LogHeader(auto_scroll=True, highlight=True)
|
69
|
+
|
70
|
+
# TODO input field to filter out jobs?
|
71
|
+
|
72
|
+
# doesn't do anything??
|
73
|
+
# self.log("HELLOOO")
|
74
|
+
|
75
|
+
self.table: DataTable = DataTable(
|
76
|
+
cursor_type='row',
|
77
|
+
zebra_stripes=True, # alternating colours
|
78
|
+
)
|
79
|
+
|
80
|
+
# NOTE: useful for debugging
|
81
|
+
# yield self.logx
|
82
|
+
yield self.table
|
83
|
+
|
84
|
+
def on_mount(self) -> None:
|
85
|
+
table = self.table
|
86
|
+
|
87
|
+
for col in get_columns():
|
88
|
+
table.add_column(label=col, key=col)
|
89
|
+
|
90
|
+
# todo how to do async here as well?
|
91
|
+
entries = self.get_entries()
|
92
|
+
self.update(entries)
|
93
|
+
|
94
|
+
self.set_focus(table)
|
95
|
+
|
96
|
+
# TODO run and update it continuously? not sure
|
97
|
+
# what if it isn't computed within interval?
|
98
|
+
self.set_interval(interval=self.refresh_every, callback=self.update_in_background)
|
99
|
+
|
100
|
+
def get_entries(self) -> MonitorEntries:
|
101
|
+
managed = list(managed_units(with_body=False)) # body slows down this call quite a bit
|
102
|
+
entries = get_entries_for_monitor(managed=managed, params=self.monitor_params)
|
103
|
+
return {e.unit: e for e in entries}
|
104
|
+
|
105
|
+
def update(self, entries: MonitorEntries) -> None:
|
106
|
+
table = self.table
|
107
|
+
|
108
|
+
# self.logx.write_line(f"HI {datetime.now().isoformat()}")
|
109
|
+
|
110
|
+
current_rows: set[str] = {unwrap(x.value) for x in table.rows}
|
111
|
+
to_remove = {x for x in current_rows if x not in entries}
|
112
|
+
to_add = {x: entry for x, entry in entries.items() if x not in current_rows}
|
113
|
+
to_update = {x: entry for x, entry in entries.items() if x in current_rows}
|
114
|
+
|
115
|
+
for row_key in to_remove:
|
116
|
+
table.remove_row(row_key=row_key)
|
117
|
+
|
118
|
+
for row_key, entry in to_add.items():
|
119
|
+
vals = as_row(entry).values()
|
120
|
+
table.add_row(*vals, key=row_key)
|
121
|
+
|
122
|
+
for row_key, entry in to_update.items():
|
123
|
+
for col, value in as_row(entry).items():
|
124
|
+
table.update_cell(row_key=row_key, column_key=col, value=value, update_width=True)
|
125
|
+
|
126
|
+
columns = get_columns()
|
127
|
+
|
128
|
+
def sort_key(row):
|
129
|
+
data = dict(zip(columns, row))
|
130
|
+
is_running = 'running' in data['next']
|
131
|
+
failed = 'exit-code' in data['status']
|
132
|
+
return (not is_running, not failed, data['unit'])
|
133
|
+
|
134
|
+
# TODO hmm kinda annoying, doesn't look like it preserves cursor position
|
135
|
+
# if the item pops on top of the list when a service is running?
|
136
|
+
# but I guess not a huge deal now
|
137
|
+
table.sort(*columns, key=sort_key)
|
138
|
+
|
139
|
+
@work(exclusive=True, thread=True)
|
140
|
+
def update_in_background(self) -> None:
|
141
|
+
entries = self.get_entries()
|
142
|
+
self.call_from_thread(self.update, entries)
|
143
|
+
|
144
|
+
def on_key(self, event: events.Key) -> None:
|
145
|
+
actions = {
|
146
|
+
'j': self.table.action_cursor_down,
|
147
|
+
'k': self.table.action_cursor_up,
|
148
|
+
'h': self.table.action_cursor_left,
|
149
|
+
'l': self.table.action_cursor_right,
|
150
|
+
}
|
151
|
+
action = actions.get(event.key)
|
152
|
+
if action is not None:
|
153
|
+
action()
|
154
|
+
return
|
155
|
+
if event.key == 'q':
|
156
|
+
self.exit()
|
157
|
+
# TODO would be nice to display log on enter press or something?
|