-
Notifications
You must be signed in to change notification settings - Fork 0
/
jouno.py
executable file
·3996 lines (3381 loc) · 173 KB
/
jouno.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/python3
"""
jouno: Journal notifications forwarder
======================================
A GUI Systemd-Journal viewer with Freedesktop-Notifications forwarding including burst-handling and filtering.
Usage:
======
jouno [-h]
[--about] [--detailed-help]
[--install] [--uninstall]
Optional arguments:
-------------------
-h, --help show this help message and exit
--detailed-help full help in markdown format
--about about jouno
--install installs the jouno in the current user's path and desktop application menu.
--uninstall uninstalls the jouno application menu file and script for the current user.
Description
===========
``Jouno`` is a GUI ``systemd-journal`` monitoring and viewing tool. Jouno can filter and bundle messages for
forwarding to the desktop as standard *Freedesktop DBUS Notifications* (most linux desktop environments present
DBUS Notifications as popup messages). Jouno's feature set includes:
* Journal live-view.
+ Overview table with live view.
+ Plain-text or regular-expression incremental-search and select.
+ Double-click access to the all 50+ journal entry fields, including easy cut and paste the text.
+ Configurable history length, configurable full or filtered view.
* Journal forwarding.
+ Forwarding of filtered messages to the desktop as DBUS-notifications.
+ Journal message-burst bundling to minimise desktop notifications.
+ Controls and options to enable/disable forwarding.
+ Optional forwarding of the xorg-session.log or wayland-session.log to the systemd-journal (consolidated desktop
logging).
* Filtering
+ Filtering to include or exclude messages.
+ Plain-text and regular-expression filtering.
+ Easy filter creation from any selected journal entry.
+ Filters may be edited, deleted, reordered, or selectively enabled or disabled.
+ Filter editing feedback via incremental-search of past journal entries as you edit.
+ Filters are saved to the config file and reloaded at startup.
* User interface and configuration
+ Panels undock for maximised or customised viewing.
+ Customised panel and window geometries are saved across application-restart and panel-docking.
+ Dynamic (no restart) support for desktop theme changes, including light/dark theme switching.
+ An option to run minimised in the system-tray with a quick-access tray context-menu.
+ Full configuration UI, editing of config INI files is not required.
+ If Config INI files are externally edited, the changes are automatically reloaded without requiring a restart.
``jouno`` is a tool designed to increase awareness of background activity by monitoring
the journal and raising interesting journal-entries as desktop notifications. Possibilities for
it use include:
* Monitoring specific jobs, such as the progress of the daily backups.
* Watching for specific events, such as background core dumps.
* Investigating desktop actions that raise journal log entries.
* Discovering unnecessary daemon activity and unnecessary services.
* Notifying access attempts, such as su, ssh, samba, or pam events.
* Prevention of adverse desktop activity, such as shutting down during the backups.
* Detecting hardware events.
* Providing timer and cron jobs with a simple way to raise desktop notifications.
* Raising general awareness of what is going on in the background.
Getting started
===============
Clicking on the ``jouno`` system-tray icon brings up an ``options and filters`` panel which includes three
tabs:
1. Options: settings that adjust how to display messages and how to collate of bursts af messages
2. Match Filters: filters that restrict notifications to only journal entries they match.
3. Ignore Filters: filters that restrict notifications by ignoring journal entries they match.
Match-filtering is most useful when only minimal journal entries are of interest and the other entries aren't
of interest. For example, a match-filter might be set for core-dump journal entries only.
Ignore-filtering is most useful when almost any journal entries might be of interest and only a few journal
items need to be ignored. For example, if any unexpected messages might be of interest, ignore-filters could
be set up for any that are routine.
It's common to require a few ignore-filters to discard any messages generated by the desktop notification system
in response to notices being posted.
The user interface includes a few conveniences to assist with creating new patterns:
* Any current message in the *Recently Notified* panel can be used as basis for a new filter rule by
selecting the message's row and then pressing the *New Filter* button. The message's text will
be copied to the filter *pattern* field for editing into a pattern.
* If no filter row is selected, new filters will at the end of the filter table, otherwise new filters
are inserted above the current selection.
* Filter ordering can be altered by drag and drop.
* Filters can be temporarily enabled or disabled via the checkbox in the *Rule-ID* column.
* Filters can be regular-expressions (Python-variant), just check the regular-expression checkbox in
the *Pattern* column.
* During incremental entry of the filter pattern, the *Recently notified* panel will highlight any
messages matched by the pattern.
Match and Ignore Patterns
-------------------------
* Journal entries are filtered by list of rules. Each rule defines a ``Rule ID`` and a ``pattern.``
* A rule can be a Match-Filters (notifying matching message) or an Ignore-Filter (ignore matching messages).
* Match-Filters override Ignore-Filters, so you can ignore all the noise, and selectively override this for
specific sources or items of interest.
* Each rule is identified by a ``Rule ID`` which is text identifier compliant with commonly accepted
variable naming conventions, for example:
```
my_id
my-id
myId21
```
* Patterns may match any fragment of text seen in actual journal entries, double click any journal
entry's icon to see the full journal text that is available for matching. Some examples:
```
coredump
NotificationPopup.
sudo
No object for name "alsa_output.usb-FiiO_DigiHug_USB_Audio-01.analog-stereo.monitor
kernel
/usr/sbin/cron
daily-backup
smartd
/etc/services
* If a pattern's regular expression checkbox is ticked, the pattern will be treated as a regular expression.
* Patterns may be forced to match specific journal entry fields by using quotes to confine the match, for example:
```
'SYSLOG_IDENTIFIER=su'
'_GID=500',
'_HOSTNAME=kosmos1'
'PRIORITY=[123]',
'_CMDLINE=/usr/bin/kded5'
'_PID=2143',
```
When attempting to match specific fields surround the pattern with single-quotes
to ensure that complete values are matched, for example: ``'_GID=500'`` will
only match the intended field and value, it won't match ``PARENT_GID=5000``.
The list of possible field names can be found by double-clicking a message or by usined the names found at:
[https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html](https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html)
Not all fields are found in all messages, it's best to use actual messages as a basis for creating new
patterns.
* When matching, in the text being matched, field names appear in alphabetical order. This allows
regular expressions to be written to match a combination of fields. For example, the regular expression
(?<='PRIORITY=[45]')(.*)(?='_UID=1027') would match PRIORITY 4 or 5 and _UID 1027 with
any amount of text in between.
Config files
------------
All settings made in the *Configuration* panel are saved to a config file. There is no need to manually
edit the config file, but if it is externally edited the application will automatically reload the changes.
Although the config file is theoretically optional, some filtering of the journal is likely to be necessary.
The application will cope with cascades of messages and won't cause infinite cascades, but filtering may be
necessary to eliminate excessive bursts caused by the desktop when it processes the notifications generated
by jouno.
The config file is in INI-format divided into a number of sections as outlined below::
```
# The options section controls notice timeouts, burst treatment
[options]
# Polling interval, how often to wait for journal entries between checking for config changes
poll_seconds = 2
# Wait at lease burst_seconds before declaring a burst of messages to have finished
burst_seconds = 5
# Only show the the first burst_truncate_messages of a burst
burst_truncate_messages = 3
# Set journo messages to timeout/auto-dismiss after notification-seconds
notification_seconds = 30
# The maximum number of journal items to display in the "Recently notified" table.
journal_history_max = 100
# Run out of the system tray
system_tray_enabled = yes
# Start the application with notifications enabled (disable notifications from start up).
start_with_notifications_enabled = yes
# List all messages in the "Recently notified" table, not just the ones that passed the filters.
list_all_enabled = yes
# Show older messages from boot onward
from_boot_enabled = no
# For debugging the application
debug_enabled = yes
[match]
# Each filter rule has an id and the message text to match
my_rule_id = forward journal entry if this string matches
# Each filter rule can be disabled by a corresponding my_rule_id_enabled = no option
my_rule_id_enabled = no
# A filter id that ends in _regexp is treated as a python regular-expression
my_other_rule_id_regexp = forward journal [Ee]ntry if this python-regexp matches
[ignore]
my_ignore_rule_id = ignore journal entry if this string matches
my_ignore_other_rule_id_regexp = ignore [Jj]ournal entry if this python-regexp matches
```
The config file is normally save to a standard desktop location:
$HOME/.config/jouno/jouno.conf
In addition to the application config file, window geometry and state is saved to:
$HOME/.config/jouno.qt.state/jouno.conf
Prerequisites
=============
All the following runtime dependencies are likely to be available pre-packaged on any modern Linux distribution
(``jouno`` was originally developed on OpenSUSE Tumbleweed).
* python 3.8: ``jouno`` is written in python and may depend on some features present only in 3.8 onward.
* python 3.8 QtPy: the python GUI library used by ``jouno``.
* python 3.8 systemd: python module for native access to the systemd facilities.
* python 3.8 dbus: python module for dbus used for issuing notifications
Dependency installation on ``OpenSUSE``:
zypper install python38-QtPy python38-systemd python38-dbus
If you want to be able to read all of a system's journal entries you will need to be a member of the Linux
systemd-journal group.
Optional Accessories
====================
A suggested accessory is [KDE Connect](https://kdeconnect.kde.org/). If you enabled the appropriate permissions on
your phone, KDE Connect can forward desktop notifications to the phone. Use Jouno to forward Systemd-Journal
messages to Desktop-Notifications, and use KDE Connect to forward them to your phone.
jouno Copyright (C) 2021 Michael Hamilton
===========================================
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>.
**Contact:** m i c h a e l @ a c t r i x . g e n . n z
----------
"""
# TODO figure out why QIntValidator is only working approximately.
# TODO refine Apply/Revert and dynamically enable/disable the buttons.
import argparse
import configparser
import datetime as DT
import grp
import os
import pwd
import re
import select
import signal
import stat
import sys
import textwrap
import time
import traceback
import typing
import weakref
from enum import Enum
from functools import partial
from html import escape
from io import StringIO
from pathlib import Path
from typing import Mapping, Any, List, Type, Callable, Tuple, Union, Iterator, TextIO
import dbus
import pytz
from PyQt5.QtCore import QCoreApplication, QProcess, Qt, pyqtSignal, QThread, QModelIndex, QItemSelectionModel, QSize, \
QEvent, QSettings, QObject, QItemSelection, QPoint, QDateTime, QDate
from PyQt5.QtGui import QPixmap, QIcon, QImage, QPainter, QStandardItemModel, QStandardItem, QIntValidator, \
QFontDatabase, QGuiApplication, QCloseEvent, QPalette, QTextCursor, QColor
from PyQt5.QtSvg import QSvgRenderer
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QMessageBox, QLineEdit, QLabel, \
QPushButton, QSystemTrayIcon, QMenu, QTextEdit, QDialog, QTabWidget, \
QCheckBox, QGridLayout, QTableView, \
QAbstractItemView, QHeaderView, QMainWindow, QSizePolicy, QStyledItemDelegate, QToolBar, QDockWidget, \
QHBoxLayout, QStyleFactory, QToolButton, QScrollArea, QLayout, QStatusBar, QDateTimeEdit, QCalendarWidget, \
QFormLayout, QGroupBox, QSpacerItem, QTableWidgetItem, QTableWidget, \
QProgressDialog
from systemd import journal
JOUNO_VERSION = '1.3.6'
JOUNO_CONSOLIDATED_TEXT_KEY = '___JOURNO_FULL_TEXT___'
# On Plasma Wayland the system tray may not be immediately available at login - so keep trying for...
SYSTEM_TRAY_WAIT_SECONDS = 20
# The icons can either be:
# 1) str: named icons from the freedesktop theme which should all be available on most Linux desktops.
# https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html
# 1) bytes: SVG strings for any icons that are custom to this application.
# The load_icon() function dynamically figures out which so we can
# switch from one source to another without editing the code proper
# TODO: consider moving the icon definitions to a file read at startup.
ICON_HELP_ABOUT = "help-about"
ICON_HELP_CONTENTS = "help-contents"
ICON_APPLICATION_EXIT = "application-exit"
ICON_CONTEXT_MENU_LISTENING_ENABLE = "view-refresh"
ICON_CONTEXT_MENU_LISTENING_DISABLE = "process-stop"
ICON_TRAY_LISTENING_DISABLED = ICON_CONTEXT_MENU_LISTENING_DISABLE
ICON_COPY_TO_CLIPBOARD = "edit-copy"
ICON_SEARCH_TEXT = "system-search"
ICON_UNDOCK = "window-new"
ICON_DOCK = "view-restore"
ICON_GO_NEXT = "go-down"
ICON_GO_PREVIOUS = "go-up"
ICON_CLEAR_RECENTS = "edit-clear-all"
ICON_REVERT = 'edit-undo'
ICON_APPLY_AND_RESTART = 'edit-redo'
ICON_WINDOW_CLOSE = 'window-close'
# This might only be KDE/Linux icons - not in Freedesktop Standard.
ICON_APPLY = "dialog-ok-apply"
ICON_VIEW_JOURNAL_ENTRY = 'view-fullscreen'
ICON_CLEAR_SELECTION = 'edit-undo'
ICON_COPY_SELECTED = 'edit-copy'
ICON_PLAIN_TEXT_SEARCH = 'insert-text'
ICON_REGEXP_SEARCH = 'list-add'
ICON_SETTINGS_CONFIGURE = 'settings-configure'
SVG_LIGHT_THEME_COLOR = b"#232629"
SVG_DARK_THEME_COLOR = b"#f3f3f3"
SVG_JOUNO = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#232629" style="fill:currentColor;fill-opacity:1;stroke:none"
d="M 4 2 L 4 3 L 13 3 L 13 13 L 4 13 L 4 14 L 13 14 L 14 14 L 14 3 L 14 2 L 7 2 z"
class="ColorScheme-Text"
/>
<path fill="#3491e1" style="fill-opacity:1;stroke:none"
d="M 8 6 L 8 8 L 12 8 L 12 7 L 8 7 z M 8 8 L 8 10 L 12 10 L 12 9 L 8 9 z M 8 11 L 8 12 L 12 12 L 12 11 L 10 11 z "
/>
</svg>
"""
SVG_JOUNO_LIGHT = SVG_JOUNO.replace(SVG_LIGHT_THEME_COLOR, b'#bbbbbb')
SVG_TOOLBAR_RUN_DISABLED = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
<path d="m3 3v16l16-8z" class="ColorScheme-Text" fill="currentColor"/>
</svg>
"""
SVG_TOOLBAR_RUN_ENABLED = SVG_TOOLBAR_RUN_DISABLED.replace(b"#232629;", b"#3daee9;")
SVG_TOOLBAR_STOP = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#da4453;
}
</style>
<path d="m3 3h16v16h-16z" class="ColorScheme-Text" fill="currentColor"/>
</svg>
"""
SVG_TOOLBAR_NOTIFIER_ENABLED = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#379fd3;
}
</style>
</defs>
<path style="fill:currentColor;fill-opacity:1;stroke:none"
d="M 3 4 L 3 16 L 6 20 L 6 17 L 6 16 L 19 16 L 19 4 L 3 4 z M 4 5 L 18 5 L 18 15 L 4 15 L 4 5 z M 16 6 L 9.5 12.25 L 7 10 L 6 11 L 9.5 14 L 17 7 L 16 6 z "
class="ColorScheme-Text"
/>
</svg>
"""
SVG_TOOLBAR_NOTIFIER_DISABLED = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#da4453;
}
</style>
</defs>
<path style="fill:currentColor;fill-opacity:1;stroke:none"
d="M 3 4 L 3 16 L 6 20 L 6 17 L 6 16 L 19 16 L 19 4 L 3 4 z M 4 5 L 18 5 L 18 15 L 4 15 L 4 5 z M 8 6 L 7 7 L 10 10 L 7 13 L 8 14 L 11 11 L 14 14 L 15 13 L 12 10 L 15 7 L 14 6 L 11 9 L 8 6 z "
class="ColorScheme-Text"
/>
</svg>
"""
SVG_TOOLBAR_ADD_FILTER = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<path
style="fill:currentColor;fill-opacity:1;stroke:none"
d="M 5 3 L 4 4 L 4 5 L 4 5.3046875 L 9 12.367188 L 9 16 L 9 16.039062 L 12.990234 19 L 13 19 L 13 12.367188 L 18 5.3046875 L 18 4 L 17 3 L 5 3 z M 5 4 L 17 4 L 17 4.9882812 L 12.035156 12 L 12 12 L 12 12.048828 L 12 13 L 12 17.019531 L 10 15.535156 L 10 13 L 10 12.048828 L 10 12 L 9.9648438 12 L 5 4.9882812 L 5 4 z M 6 5 L 8 8 L 8 6 L 10 5 L 6 5 z M 16 14 L 16 16 L 14 16 L 14 17 L 16 17 L 16 19 L 17 19 L 17 17 L 19 17 L 19 16 L 17 16 L 17 14 L 16 14 z "
class="ColorScheme-Text"
/>
</svg>
"""
SVG_TOOLBAR_DEL_FILTER = b"""
<svg id="svg8" version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
<defs id="defs3051">
<style id="current-color-scheme" type="text/css">.ColorScheme-Text {
color:#232629;
}</style>
</defs>
<path id="path4" class="ColorScheme-Text" d="m5 3-1 1v1.3046875l5 7.0625005v3.671872l3 2.226563v-1.246092l-2-1.484375v-3.535156h-0.035156l-4.964844-7.0117188v-0.9882812h12v0.9882812l-4.964844 7.0117188h1.22461l4.740234-6.6953125v-1.3046875l-1-1zm1 2 2 3v-2l2-1z" fill="currentColor"/>
<path id="path6" d="M 13.990234,13 13,13.990234 15.009766,16 13,18.009766 13.990234,19 16,16.990234 18.009766,19 19,18.009766 16.990234,16 19,13.990234 18.009766,13 16,15.009766 Z" fill="#da4453"/>
</svg>
"""
SVG_TOOLBAR_TEST_FILTERS = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<path style="fill:currentColor;fill-opacity:1;stroke:none"
d="M 7 2 L 7 3.1015625 A 5 5 0 0 0 5.2460938 3.8320312 L 4.4648438 3.0507812 L 3.0507812 4.4648438 L 3.8320312 5.2460938 A 5 5 0 0 0 3.1054688 7 L 2 7 L 2 9 L 3.1015625 9 A 5 5 0 0 0 3.8320312 10.753906 L 3.0507812 11.535156 L 4.4648438 12.949219 L 5.2460938 12.167969 A 5 5 0 0 0 7 12.894531 L 7 14 L 9 14 L 9 12.898438 L 9 11.869141 A 4 4 0 0 1 8 12 A 4 4 0 0 1 5.1308594 10.787109 A 4 4 0 0 1 4 8 A 4 4 0 0 1 5.2128906 5.1308594 A 4 4 0 0 1 8 4 A 4 4 0 0 1 10.869141 5.2128906 A 4 4 0 0 1 12 8 L 14 8 L 14 7 L 12.898438 7 A 5 5 0 0 0 12.167969 5.2460938 L 12.949219 4.4648438 L 11.535156 3.0507812 L 10.753906 3.8320312 A 5 5 0 0 0 9 3.1054688 L 9 2 L 7 2 z M 7 6 L 7 10 L 10 8 L 7 6 z M 10 9 L 10 10 L 14 10 L 14 9 L 10 9 z M 10 11 L 10 12 L 14 12 L 14 11 L 10 11 z M 10 13 L 10 14 L 14 14 L 14 13 L 10 13 z "
class="ColorScheme-Text"
/>
</svg>
"""
SVG_TOOLBAR_QUERY_JOURNAL = b"""
<!DOCTYPE svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 22 22">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<path style="fill:currentColor; fill-opacity:1; stroke:none" class="ColorScheme-Text" d="M 6 3 C 4.929 3 3.93784 3.57249 3.40234 4.5 C 3.13459 4.96375 3 5.48188 3 6 L 3 16 C 3 16.5181 3.1346 17.0362 3.40234 17.5 C 3.93784 18.4275 4.929 19 6 19 L 16 19 L 16 18 L 6 18 C 5.28467 18 4.62524 17.6195 4.26758 17 C 3.90991 16.3805 3.90991 15.6195 4.26758 15 C 4.62524 14.3805 5.28467 14 6 14 L 7 14 L 7 7 L 17 7 L 17 15 L 18 15 L 18 6 L 7 6 L 7 3 L 6 3 Z M 6 4 L 6 6 L 6 7 L 6 13 C 5.24975 13 4.5427 13.2863 4 13.7734 L 4 6 C 4 5.65487 4.08874 5.30975 4.26758 5 C 4.62524 4.3805 5.28467 4 6 4 Z"/>
<path style="fill:currentColor; fill-opacity:1; stroke:none" class="ColorScheme-Text" d="M 12 8 C 9.79086 8 8 9.79086 8 12 C 8 14.2091 9.79086 16 12 16 C 12.8874 15.9982 13.749 15.7014 14.4492 15.1563 L 18.293 19 L 19 18.293 L 15.1582 14.4512 C 15.7031 13.7502 15.9992 12.8878 16 12 C 16 9.79086 14.2091 8 12 8 Z M 12 9 C 13.6569 9 15 10.3431 15 12 C 15 13.6569 13.6569 15 12 15 C 10.3431 15 9 13.6569 9 12 C 9 10.3431 10.3431 9 12 9 Z"/>
</svg>
"""
SVG_TOOLBAR_HAMBURGER_MENU = b"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<path
style="fill:currentColor;fill-opacity:1;stroke:none"
d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16"
class="ColorScheme-Text"
/>
</svg>
"""
TABLE_HEADER_STYLE = "font-weight: bold;font-size: 9pt;"
ABOUT_TEXT = f"""
<b>jouno version {JOUNO_VERSION}</b>
<p>
A Systemd-Journal viewer with Freedesktop-Notifications forwarding including burst-handling and filtering.
<p>
Visit <a href="https://github.com/digitaltrails/jouno">https://github.com/digitaltrails/jouno</a> for
more details.
<p><p>
<b>jouno Copyright (C) 2021 Michael Hamilton</b>
<p>DEFAULT_QUERY_FIELDS = ['_UID', '_GID', 'QT_CATEGORY', 'PRIORITY', 'SYSLOG_IDENTIFIER',
'_COM', '_EXE', '_HOSTNAME', 'COREDUMP_COMM', 'COREDUMP_EXE']
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, version 3.
<p>
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.
<p>
You should have received a copy of the GNU General Public License along
with this program. If not, see <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>.
"""
STATUS_TIMEOUT_MSEC = 10000
STATUS_SHORT_TIMEOUT_MSEC = 5000
STATUS_LONG_TIMEOUT_MSEC = 30000
ERROR_DBUS_NOTIFICATIONS_UNAVAILABLE = "DBUS notification service unavailable"
ERROR_DBUS_NOTIFICATION_FAILED = "DBUS notification failed"
DEFAULT_QUERY_FIELDS = ['_UID', '_GID', 'QT_CATEGORY', 'PRIORITY', 'SYSLOG_IDENTIFIER',
'_COM', '_EXE', 'COREDUMP_COMM', 'COREDUMP_EXE', '_HOSTNAME', ]
DEFAULT_CONFIG = f'''
[options]
poll_seconds = 5
burst_seconds = 5
burst_truncate_messages = 6
notification_seconds = 30
journal_history_max = 500
system_tray_enabled = no
dark_tray_enabled = no
start_with_notifications_enabled = yes
list_all_enabled = no
forward_session_log_enabled = no
debug_enabled = no
query_field_list = {' '.join(DEFAULT_QUERY_FIELDS)}
[ignore]
kwin_bad_damage = XCB error: 152 (BadDamage)
kwin_bad_window = kwin_core: XCB error: 3 (BadWindow)
self_caused = NotificationPopup.
qt_kde_binding_loop = Binding loop detected for property
[match]
'''
class ConfigOption:
def __init__(self, option_id: str, tooltip: str, int_range: Tuple[int, int] = None):
self.option_id = option_id
self.int_range = int_range
self._tooltip = tooltip
def label(self):
return tr(self.option_id).replace('_', ' ').capitalize()
def tooltip(self):
fmt = tr(self._tooltip)
return fmt.format(self.int_range[0], self.int_range[1]) if self.int_range is not None else fmt
CONFIG_OPTIONS_LIST: List[ConfigOption] = [
ConfigOption('poll_seconds', 'How often to poll for new messages ({}..{} seconds).', (1, 30)),
ConfigOption('burst_seconds', 'How long to wait for a burst of messages to complete ({}..{} seconds).', (1, 30)),
ConfigOption('burst_truncate_messages',
'How many messages from a burst should be bundled into its desktop notification ({}..{} messages).',
(1, 50)),
ConfigOption('notification_seconds',
'How long should a desktop notification remain visible, zero for no timeout ({}..{} seconds)',
(0, 60)),
ConfigOption('journal_history_max',
'How many journal entries should be shown in the Recently Notified panel.', None),
ConfigOption('system_tray_enabled', 'Jouno should start minimised in the system-tray.'),
ConfigOption('dark_tray_enabled', 'System tray is dark colored.'),
ConfigOption('start_with_notifications_enabled', 'Jouno should start with desktop notifications enabled.'),
ConfigOption('list_all_enabled', 'The Recent notifications panel should show all entries, including non-notified.'),
ConfigOption('from_boot_enabled', 'Show old journal entries from boot onward.'),
ConfigOption('forward_session_log_enabled',
'Forward xorg-session.log or wayland-session.log to the systemd-journal (if it exists).'),
ConfigOption('debug_enabled', 'Enable extra debugging output to standard-out.'),
ConfigOption('query_field_list', 'Default query fields.'),
]
# ######################## MONITOR SUB PROCESS CODE ###############################################################
# TODO The monitor code has been written so it can be extracted to a future non pyqt command line utility.
class Priority(Enum):
EMERGENCY = 0
ALERT = 1
CRITICAL = 2
ERR = 3
WARNING = 4
NOTICE = 5
INFO = 6
DEBUG = 7
NOTIFICATION_ICONS = {
Priority.EMERGENCY: 'dialog-error',
Priority.ALERT: 'dialog-error',
Priority.CRITICAL: 'dialog-error',
Priority.ERR: 'dialog-error',
Priority.WARNING: 'dialog-warning',
Priority.NOTICE: 'dialog-information',
Priority.INFO: 'dialog-information',
Priority.DEBUG: 'dialog-information',
}
debugging = True
def debug(*arg):
if debugging:
print('DEBUG:', *arg)
def info(*arg):
print('INFO:', *arg)
def warning(*arg):
print('WARNING:', *arg)
def error(*arg):
print('ERROR:', *arg)
class NotifyFreeDesktop:
def __init__(self):
self.notify_interface = dbus.Interface(
object=dbus.SessionBus().get_object("org.freedesktop.Notifications", "/org/freedesktop/Notifications"),
dbus_interface="org.freedesktop.Notifications")
def notify_desktop(self, app_name: str, summary: str, message: str, priority: Priority, timeout: int):
if self.notify_interface == None:
self.notify_interface = dbus.Interface(
object=dbus.SessionBus().get_object("org.freedesktop.Notifications", "/org/freedesktop/Notifications"),
dbus_interface="org.freedesktop.Notifications")
# https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html
if self.notify_interface is not None:
replace_id = 0
notification_icon = NOTIFICATION_ICONS[priority] + ".png"
action_requests = []
# extra_hints = {"urgency": 1, "sound-name": "dialog-warning", }
extra_hints = {}
try:
self.notify_interface.Notify(app_name,
replace_id,
notification_icon,
escape(summary).encode('UTF-8'),
escape(message).encode('UTF-8'),
action_requests,
extra_hints,
timeout)
except dbus.exceptions.DBusException as e:
# Force reinit on next use
self.notify_interface = None
raise e
def get_config_path() -> Path:
config_dir_path = Path.home().joinpath('.config').joinpath('jouno')
if not config_dir_path.parent.is_dir() or not config_dir_path.is_dir():
os.makedirs(config_dir_path)
path = config_dir_path.joinpath('jouno.conf')
return path
class Config(configparser.ConfigParser):
def __init__(self):
super().__init__()
self.path = get_config_path()
self.modified_time = 0.0
self.read_string(DEFAULT_CONFIG)
def save(self):
if self.path.exists():
self.path.rename(self.path.with_suffix('.bak'))
with self.path.open('w') as config_file:
self.write(config_file)
def refresh(self) -> bool:
if self.path.is_file():
modified_time = self.path.lstat().st_mtime
if self.modified_time == modified_time:
return False
self.modified_time = modified_time
info(f"Config: reading {self.path}")
config_text = self.path.read_text()
for section in ['match', 'ignore']:
self.remove_section(section)
self.read_string(config_text)
for section in ['options', 'match', 'ignore']:
if section not in self:
self[section] = {}
return True
if self.modified_time > 0.0:
info(f"Config file has been deleted: {self.path}")
self.modified_time = 0.0
return False
def is_different(self, other: 'Config'):
with StringIO() as io1, StringIO() as io2:
self.write(io1)
other.write(io2)
return io1.getvalue() != io2.getvalue()
def determine_source(journal_entry):
for key in ['_KERNEL_SUBSYSTEM', 'SYSLOG_IDENTIFIER', '_COMM', '_EXE', '_CMDLINE', ]:
if key in journal_entry:
value = str(journal_entry[key])
if key == '_KERNEL_SUBSYSTEM':
value = 'kernel: ' + value
return value
return 'unknown'
def consolidate_text(journal_entry):
# Use an easy a format that is easy to pattern match
# The sort is going to cost us - this seems to be the fastest way to do it
fields_str = ', '.join((f"'{key}={journal_entry[key]}'" for key in sorted(journal_entry.keys())))
# Prepend the source, so it's searchable by entering what is seen in the UI
journal_entry[JOUNO_CONSOLIDATED_TEXT_KEY] = f"source={determine_source(journal_entry)}, {fields_str}"
return fields_str
def determine_priority(journal_entries: List[Mapping[str, Any]]) -> Priority:
current_level = Priority.NOTICE
for journal_entry in journal_entries:
if 'PRIORITY' in journal_entry:
priority = journal_entry['PRIORITY']
if priority < current_level.value and (Priority.EMERGENCY.value <= priority <= Priority.DEBUG.value):
current_level = Priority(priority)
return current_level
class JournalWatcher:
def __init__(self, supervisor: 'JournalWatcherTask'):
self.burst_truncate: int = 3
self.polling_millis: int = 2_000
self.notification_timeout_millis: int = 60_000
self.burst_max_millis = 10_000
self.ignore_regexp: Mapping[str, re] = {}
self.match_regexp: Mapping[str, re] = {}
self.forward_all = False
self.max_historical_entries = 500
self.from_boot_enabled = False
self.limit_from_boot = 0
self.config = Config()
self.update_settings_from_config()
self._stop = False
self.supervisor = supervisor
self.notifications_enabled = True
self.deliver_history = True
def is_notifying(self) -> bool:
return self.notifications_enabled
def enable_notifications(self, enable: bool):
self.notifications_enabled = enable
def enable_forward_all(self, enable: bool):
self.forward_all = enable
def update_settings_from_config(self):
info('JournalWatcher reading config.')
self.config.refresh()
if 'poll_seconds' in self.config['options']:
self.polling_millis = 1_000 * self.config.getint('options', 'poll_seconds')
if 'burst_truncate_messages' in self.config['options']:
self.burst_truncate = self.config.getint('options', 'burst_truncate_messages')
if 'burst_seconds' in self.config['options']:
self.burst_max_millis = 1_000 * self.config.getint('options', 'burst_seconds')
if 'notification_seconds' in self.config['options']:
self.notification_timeout_millis = 1_000 * self.config.getint('options', 'notification_seconds')
if 'list_all_enabled' in self.config['options']:
self.forward_all = self.config.getboolean('options', 'list_all_enabled')
if 'journal_history_max' in self.config['options']:
self.max_historical_entries = self.config.getint('options', 'journal_history_max')
if 'from_boot_enabled' in self.config['options']:
self.from_boot_enabled = self.config.getboolean('options', 'from_boot_enabled')
if 'debug' in self.config['options']:
global debugging
debugging = self.config.getboolean('options', 'debug')
info("Debugging output is disabled.") if not debugging else None
self.ignore_regexp: Mapping[str, re] = {}
self.match_regexp: Mapping[str, re] = {}
self.compile_patterns(self.config['match'], self.match_regexp)
self.compile_patterns(self.config['ignore'], self.ignore_regexp)
def compile_patterns(self, rules_map: Mapping[str, str], patterns_map: Mapping[str, re.Pattern]):
for rule_id, rule_text in rules_map.items():
if rule_id.endswith('_enabled'):
pass
else:
rule_enabled_key = rule_id + "_enabled"
re_indicator_key = rule_id + "_regexp_enabled"
if rule_enabled_key not in rules_map or rules_map[rule_enabled_key].lower() == 'yes':
if re_indicator_key in rules_map and rules_map[re_indicator_key].lower() == 'yes':
patterns_map[rule_id] = re.compile(rule_text, flags=re.DOTALL)
else:
patterns_map[rule_id] = re.compile(re.escape(rule_text), flags=re.DOTALL)
def determine_source(self, journal_entry):
for key in ['_COMM', '_EXE', '_CMDLINE', '_KERNEL_SUBSYSTEM', 'SYSLOG_IDENTIFIER', ]:
if key in journal_entry:
value = str(journal_entry[key])
if key == '_KERNEL_SUBSYSTEM':
value = 'kern: ' + value
return value
return 'unknown'
def determine_app_names(self, journal_entries: List[Mapping[str, Any]]):
app_name_info = ''
sep = '\u25b3'
for journal_entry in journal_entries:
source = determine_source(journal_entry)
if app_name_info.find(source) < 0:
app_name_info += sep + source
sep = '; '
if app_name_info == '':
app_name_info = sep + 'unknown'
return app_name_info
def determine_summary(self, journal_entries: List[Mapping[str, Any]]):
journal_entry = journal_entries[0]
realtime = journal_entry['__REALTIME_TIMESTAMP']
transport = f" {journal_entry['_TRANSPORT']}" if '_TRANSPORT' in journal_entry else ''
number_of_entries = len(journal_entries)
if number_of_entries > 1:
summary = f"\u25F4{realtime:%H:%M:%S}:{transport} Burst of {number_of_entries} messages"
else:
text = ''
sep = ''
for key, prefix in {'SYSLOG_IDENTIFIER': '', '_PID': 'PID ', '_KERNEL_SUBSYSTEM': 'kernel ', }.items():
if key in journal_entry:
value = str(journal_entry[key])
if text.find(value) < 0:
text += sep + prefix + value
sep = ' '
summary = f"\u25F4{realtime:%H:%M:%S}: {text} (\u21e8{transport})"
# debug(f"realtime='{realtime}' summary='{summary}'") if debugging else None
return summary
def determine_message(self, journal_entries: List[Mapping[str, Any]]) -> str:
message = ''
sep = ''
previous_message = ''
duplicates = 0
reported = 0
for journal_entry in journal_entries:
new_message = journal_entry['MESSAGE']
if new_message == previous_message:
duplicates += 1
else:
message += f"{sep}\u25B7{new_message}"
previous_message = new_message
reported += 1
if reported == self.burst_truncate and reported < len(journal_entries):
message += f"\n[Only showing first {self.burst_truncate} messages]"
break
sep = '\n'
if duplicates > 0:
message += f'\n[{duplicates + 1} duplicate messages]'
# debug(f'message={message}') if debugging else None
return message
def is_notable(self, fields_str: str):
# debug(fields_str) if debugging else None
# If there is nothing to match, then by default the entry is notable
notable = True
# Filter ignores first and see if the entry should be ignored
for rule_id, ignore_re in self.ignore_regexp.items():
if ignore_re.search(fields_str) is not None:
# debug(f"rule=ignore.{rule_id}: ") if debugging else None
notable = False
break
# Lastly, if we're going to ignore this entry, see if a match overrides this:
if not notable:
for rule_id, match_re in self.match_regexp.items():
if match_re.search(fields_str) is not None:
# debug(f"rule=match.{rule_id}: ") if debugging else None
notable = True
break
return notable
def is_stop_requested(self) -> bool:
return self.supervisor.isInterruptionRequested()
def watch_journal(self):
self._stop = False
self.update_settings_from_config()
with journal.Reader() as journal_reader:
if self.deliver_history:
self.load_past_entries(journal_reader)
self.deliver_history = False
journal_reader.seek_tail()
journal_reader.get_previous()
journal_reader_poll = select.poll()
journal_reader_poll.register(journal_reader, journal_reader.get_events())
journal_reader.add_match()
notifier = None
while True:
if self.is_stop_requested():
return
if self.config.refresh():
self.update_settings_from_config()
if self.notifications_enabled and notifier is None:
try:
notifier = NotifyFreeDesktop()
except dbus.exceptions.DBusException as e:
self.supervisor.signal_error.emit(ERROR_DBUS_NOTIFICATIONS_UNAVAILABLE, e)
self.notifications_enabled = False
burst_count = 0
notable_list = []
limit_time_ns = self.burst_max_millis * 1_000_000 + time.time_ns()
while journal_reader_poll.poll(self.polling_millis) and time.time_ns() < limit_time_ns:
if self.is_stop_requested():
return
if journal_reader.process() == journal.APPEND:
for journal_entry in journal_reader:
if self.is_stop_requested():
return
burst_count += 1
notable = self.is_notable(consolidate_text(journal_entry))
notable_list.append(journal_entry) if notable else None
if notable or self.forward_all:
self.supervisor.new_journal_entry(journal_entry, notable)
if self.notifications_enabled and len(notable_list):
try:
notifier.notify_desktop(app_name=self.determine_app_names(notable_list),
summary=self.determine_summary(notable_list),
message=self.determine_message(notable_list),
priority=determine_priority(notable_list),
timeout=self.notification_timeout_millis)
except dbus.exceptions.DBusException as e:
self.supervisor.signal_error.emit(ERROR_DBUS_NOTIFICATION_FAILED, e)
def load_past_entries(self, journal_reader):
data = []
if self.from_boot_enabled:
journal_reader.this_boot()
elif self.max_historical_entries != 0:
journal_reader.add_match()
journal_reader.seek_tail()
journal_reader.get_next(-self.max_historical_entries - 1)
else:
self.supervisor.deliver_historical_entries([])
return
results = []
count = 0
last_time = 0.0
for journal_entry in journal_reader:
notable = self.is_notable(consolidate_text(journal_entry))
if notable or self.forward_all:
results.append((journal_entry, notable,))
count += 1
if self.max_historical_entries != 0 and count > self.max_historical_entries:
results.pop(0)
now = time.time()
if now - last_time > 0.2:
self.supervisor.report_historical_progress(count)
last_time = now
self.supervisor.report_historical_progress(count)
self.supervisor.deliver_historical_entries(results)
def extract_source_from_considated_text(consolidated_text: str):
return consolidated_text[len('source='):consolidated_text.index(',')]
def tr(source_text: str):