awatch 1.0.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.
awatch/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """awatch - Terminal log monitoring dashboard."""
2
+ __version__ = "1.0.0"
3
+ __author__ = "Ashish"
4
+
5
+ from .dashboard import main
awatch/dashboard.py ADDED
@@ -0,0 +1,963 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ # Release version: 2/jan/2026
5
+ # created by: Ashish
6
+ # python dash.py
7
+ # python dash.py --no-limit (for no log limit)
8
+
9
+
10
+ import curses
11
+ import time
12
+ import re
13
+ import os
14
+ from datetime import datetime
15
+ from collections import deque, Counter
16
+ import sys
17
+ import threading
18
+
19
+ class LogParser:
20
+ SYSLOG_PATTERN = re.compile(
21
+ r'(?P<timestamp>\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|'
22
+ r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)\s+'
23
+ r'(?P<host>\S+)\s+(?P<process>\S+?)(?:\[(?P<pid>\d+)\])?:\s+(?P<message>.*)'
24
+ )
25
+
26
+ SEVERITY_MAP = {
27
+ 'CRITICAL': re.compile(r'\b(CRITICAL|CRIT|FATAL|EMERG|EMERGENCY)\b', re.I),
28
+ 'ALERT': re.compile(r'\bALERT\b', re.I),
29
+ 'ERROR': re.compile(r'\b(ERROR|ERR|FAILURE|FAILED)\b', re.I),
30
+ 'WARN': re.compile(r'\b(WARN|WARNING)\b', re.I),
31
+ 'INFO': re.compile(r'\b(INFO|INFORMATION|NOTICE)\b', re.I),
32
+ 'DEBUG': re.compile(r'\b(DEBUG|TRACE)\b', re.I),
33
+ }
34
+
35
+ @staticmethod
36
+ def parse(line):
37
+ line = line.strip()
38
+ if not line:
39
+ return None
40
+
41
+ match = LogParser.SYSLOG_PATTERN.match(line)
42
+ data = match.groupdict() if match else {
43
+ 'timestamp': '', 'host': '', 'process': '', 'pid': '', 'message': line
44
+ }
45
+
46
+ for sev, pat in LogParser.SEVERITY_MAP.items():
47
+ if pat.search(line):
48
+ data['severity'] = sev
49
+ break
50
+ else:
51
+ data['severity'] = 'INFO'
52
+
53
+ data['raw'] = line
54
+ return data
55
+
56
+ class LogMonitor:
57
+ def __init__(self, filepath, callback, max_history=10000, no_limit=False):
58
+ self.filepath = filepath
59
+ self.callback = callback
60
+ self.max_history = max_history
61
+ self.no_limit = no_limit
62
+ self.running = False
63
+ self.thread = None
64
+ self.file_handle = None
65
+ self.chunk_size = 8192
66
+
67
+ def start(self):
68
+ self.running = True
69
+ self.thread = threading.Thread(target=self._monitor, daemon=True)
70
+ self.thread.start()
71
+
72
+ def stop(self):
73
+ self.running = False
74
+ if self.file_handle:
75
+ self.file_handle.close()
76
+
77
+ def _monitor(self):
78
+ try:
79
+ if os.path.exists(self.filepath):
80
+ with open(self.filepath, 'r', errors='ignore') as f:
81
+ # CRITICAL FIX: Always read from the END of file to get LATEST logs
82
+ f.seek(0, 2) # Go to end
83
+ file_size = f.tell()
84
+
85
+ if self.no_limit:
86
+ # If no limit, read entire file from start
87
+ f.seek(0)
88
+ else:
89
+ # IMPORTANT: For recent logs, read ONLY the last portion
90
+ # Read last 2MB or 10000 lines, whichever comes first
91
+ read_size = min(2 * 1024 * 1024, file_size) # Last 2MB max
92
+ f.seek(max(0, file_size - read_size))
93
+
94
+ # Skip partial first line
95
+ if file_size > read_size:
96
+ f.readline()
97
+
98
+ # Read all remaining lines into memory first
99
+ all_lines = []
100
+ for line in f:
101
+ if self.running and line.strip():
102
+ all_lines.append(line)
103
+
104
+ # If we have more lines than max_history, take only the LAST ones
105
+ if not self.no_limit and len(all_lines) > self.max_history:
106
+ all_lines = all_lines[-self.max_history:]
107
+
108
+ # Now process these lines
109
+ for line in all_lines:
110
+ if not self.running:
111
+ return
112
+ self.callback(line, False)
113
+
114
+ # Open handle for monitoring NEW logs from current position
115
+ self.file_handle = open(self.filepath, 'r', errors='ignore')
116
+ self.file_handle.seek(0, 2) # Start at absolute END
117
+
118
+ # Monitor for new lines
119
+ while self.running:
120
+ current_pos = self.file_handle.tell()
121
+ line = self.file_handle.readline()
122
+
123
+ if line:
124
+ if line.strip():
125
+ self.callback(line, True)
126
+ else:
127
+ # Check for file rotation/truncation
128
+ try:
129
+ current_size = os.path.getsize(self.filepath)
130
+
131
+ if current_size < current_pos:
132
+ # File was rotated or truncated
133
+ self.file_handle.close()
134
+ time.sleep(0.1)
135
+
136
+ if os.path.exists(self.filepath):
137
+ self.file_handle = open(self.filepath, 'r', errors='ignore')
138
+ self.file_handle.seek(0, 2)
139
+ else:
140
+ # No new data yet
141
+ self.file_handle.seek(current_pos)
142
+ time.sleep(0.05)
143
+ except (OSError, IOError):
144
+ time.sleep(0.1)
145
+
146
+ except Exception as e:
147
+ import traceback
148
+ error_msg = f"ERROR: {str(e)}\n{traceback.format_exc()}\n"
149
+ self.callback(error_msg, True)
150
+
151
+ class LogDashboard:
152
+ def __init__(self, stdscr, logfile=None, no_limit=False):
153
+ self.stdscr = stdscr
154
+ self.logfile = logfile or "/var/log/syslog"
155
+ self.no_limit = no_limit
156
+ self.search_term = ""
157
+ self.search_severity = ""
158
+ self.input_mode = None
159
+ self.input_buffer = ""
160
+ self.loading = False
161
+ self.needs_redraw = True
162
+ self.last_draw_time = 0
163
+
164
+ curses.start_color()
165
+ curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK)
166
+ curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK)
167
+ curses.init_pair(3, curses.COLOR_CYAN, curses.COLOR_BLACK)
168
+ curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)
169
+ curses.init_pair(5, curses.COLOR_GREEN, curses.COLOR_BLACK)
170
+ curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLACK)
171
+ curses.init_pair(7, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
172
+ curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Highlight color
173
+
174
+ max_len = None if no_limit else 10000
175
+ self.logs = deque(maxlen=max_len)
176
+ self.filtered_logs = deque(maxlen=max_len)
177
+ self.severity_counts = Counter()
178
+ self.total_logs = 0
179
+
180
+ self.critical_history = deque([0]*50, maxlen=50)
181
+ self.warn_history = deque([0]*50, maxlen=50)
182
+ self.error_history = deque([0]*50, maxlen=50)
183
+ self.info_history = deque([0]*50, maxlen=50)
184
+
185
+ self.system_name = os.uname().nodename if hasattr(os, 'uname') else "SYSTEM"
186
+ self.status = "LOADING"
187
+ self.monitor = None
188
+ self.scroll_pos = 0
189
+ self.detail_view = False
190
+ self.detail_scroll = 0
191
+ self.detail_search = ""
192
+ self.detail_input_mode = False
193
+ self.detail_input_buffer = ""
194
+ self.last_log_time = None # Track when last log was received
195
+
196
+ curses.curs_set(0)
197
+ self.stdscr.nodelay(1)
198
+ self.start_monitoring()
199
+
200
+ def get_file_size(self):
201
+ try:
202
+ if os.path.exists(self.logfile):
203
+ size = os.path.getsize(self.logfile)
204
+ if size < 1024:
205
+ return f"{size}B"
206
+ elif size < 1024*1024:
207
+ return f"{size/1024:.1f}KB"
208
+ elif size < 1024*1024*1024:
209
+ return f"{size/(1024*1024):.1f}MB"
210
+ else:
211
+ return f"{size/(1024*1024*1024):.1f}GB"
212
+ except:
213
+ pass
214
+ return "N/A"
215
+
216
+ def start_monitoring(self):
217
+ if self.monitor:
218
+ self.monitor.stop()
219
+
220
+ self.status = "LOADING"
221
+
222
+ # CRITICAL: Clear ALL old data before loading new
223
+ self.logs.clear()
224
+ self.filtered_logs.clear()
225
+ self.severity_counts.clear()
226
+ self.total_logs = 0
227
+ self.scroll_pos = 0
228
+ self.last_log_time = None
229
+
230
+ # Clear time series
231
+ self.critical_history.clear()
232
+ self.warn_history.clear()
233
+ self.error_history.clear()
234
+ self.info_history.clear()
235
+ for _ in range(50):
236
+ self.critical_history.append(0)
237
+ self.warn_history.append(0)
238
+ self.error_history.append(0)
239
+ self.info_history.append(0)
240
+
241
+ self.loading = True
242
+ self.needs_redraw = True
243
+
244
+ try:
245
+ if os.path.exists(self.logfile):
246
+ self.monitor = LogMonitor(self.logfile, self.on_log_line, no_limit=self.no_limit)
247
+ self.monitor.start()
248
+ time.sleep(0.5)
249
+ self.status = "LIVE"
250
+ else:
251
+ self.status = "FILE NOT FOUND"
252
+ except Exception as e:
253
+ self.status = f"ERROR: {str(e)}"
254
+ finally:
255
+ self.loading = False
256
+ self.needs_redraw = True
257
+
258
+ def on_log_line(self, line, is_new=False):
259
+ parsed = LogParser.parse(line)
260
+ if not parsed:
261
+ return
262
+
263
+ self.logs.append(parsed)
264
+ self.total_logs += 1
265
+ self.severity_counts[parsed['severity']] += 1
266
+ self.last_log_time = datetime.now() # Update last log time
267
+
268
+ if self.total_logs % 10 == 0:
269
+ self.update_time_series()
270
+
271
+ self.update_filtered_logs()
272
+ self.needs_redraw = True
273
+
274
+ def update_time_series(self):
275
+ recent = list(self.logs)[-100:] if len(self.logs) > 100 else list(self.logs)
276
+
277
+ self.critical_history.append(sum(1 for l in recent if l['severity'] in ['CRITICAL', 'ALERT']))
278
+ self.warn_history.append(sum(1 for l in recent if l['severity'] == 'WARN'))
279
+ self.error_history.append(sum(1 for l in recent if l['severity'] == 'ERROR'))
280
+ self.info_history.append(sum(1 for l in recent if l['severity'] in ['INFO', 'DEBUG']))
281
+
282
+ def update_filtered_logs(self):
283
+ self.filtered_logs.clear()
284
+ for log in self.logs:
285
+ if self.search_severity and log['severity'] != self.search_severity:
286
+ continue
287
+ if self.search_term and self.search_term.lower() not in log['raw'].lower():
288
+ continue
289
+ self.filtered_logs.append(log)
290
+ self.needs_redraw = True
291
+
292
+ def safe_addch(self, y, x, ch, attr=0):
293
+ max_y, max_x = self.stdscr.getmaxyx()
294
+ if 0 <= y < max_y and 0 <= x < max_x:
295
+ try:
296
+ self.stdscr.addch(y, x, ch, attr)
297
+ except curses.error:
298
+ pass
299
+
300
+ def safe_addstr(self, y, x, text, attr=0):
301
+ max_y, max_x = self.stdscr.getmaxyx()
302
+ if 0 <= y < max_y and 0 <= x < max_x:
303
+ available = max_x - x - 1
304
+ if len(text) > available:
305
+ text = text[:available]
306
+ try:
307
+ self.stdscr.addstr(y, x, text, attr)
308
+ except curses.error:
309
+ pass
310
+
311
+ def highlight_text(self, y, x, text, base_color, search_term, search_severity):
312
+ """Draw text with highlighted search terms"""
313
+ max_y, max_x = self.stdscr.getmaxyx()
314
+ if y < 0 or y >= max_y or x < 0 or x >= max_x:
315
+ return
316
+
317
+ available = max_x - x - 1
318
+ if available <= 0:
319
+ return
320
+
321
+ # Truncate text if needed
322
+ if len(text) > available:
323
+ text = text[:available]
324
+
325
+ if not search_term and not search_severity:
326
+ # No highlighting needed
327
+ self.safe_addstr(y, x, text, base_color)
328
+ return
329
+
330
+ # Convert to lowercase for case-insensitive search
331
+ text_lower = text.lower()
332
+ search_lower = search_term.lower() if search_term else ""
333
+ severity_lower = search_severity.lower() if search_severity else ""
334
+
335
+ # Find all matches
336
+ matches = []
337
+ if search_lower:
338
+ start = 0
339
+ while True:
340
+ pos = text_lower.find(search_lower, start)
341
+ if pos == -1:
342
+ break
343
+ matches.append((pos, pos + len(search_lower)))
344
+ start = pos + 1
345
+
346
+ if severity_lower:
347
+ start = 0
348
+ while True:
349
+ pos = text_lower.find(severity_lower, start)
350
+ if pos == -1:
351
+ break
352
+ matches.append((pos, pos + len(severity_lower)))
353
+ start = pos + 1
354
+
355
+ if not matches:
356
+ # No matches found, draw normally
357
+ self.safe_addstr(y, x, text, base_color)
358
+ return
359
+
360
+ # Merge overlapping ranges
361
+ matches.sort()
362
+ merged = []
363
+ for start, end in matches:
364
+ if merged and start <= merged[-1][1]:
365
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
366
+ else:
367
+ merged.append((start, end))
368
+
369
+ # Draw text with highlights
370
+ current_x = x
371
+ last_end = 0
372
+
373
+ for match_start, match_end in merged:
374
+ # Draw text before match
375
+ if match_start > last_end:
376
+ before = text[last_end:match_start]
377
+ self.safe_addstr(y, current_x, before, base_color)
378
+ current_x += len(before)
379
+
380
+ # Draw highlighted match
381
+ match = text[match_start:match_end]
382
+ self.safe_addstr(y, current_x, match, curses.color_pair(8) | curses.A_BOLD)
383
+ current_x += len(match)
384
+
385
+ last_end = match_end
386
+
387
+ # Draw remaining text
388
+ if last_end < len(text):
389
+ remaining = text[last_end:]
390
+ self.safe_addstr(y, current_x, remaining, base_color)
391
+
392
+ def draw_box(self, y, x, h, w, title="", bottom_hint=""):
393
+ max_y, max_x = self.stdscr.getmaxyx()
394
+ if y < 0 or x < 0 or y + h > max_y or x + w > max_x:
395
+ return
396
+
397
+ self.safe_addstr(y, x, "╭")
398
+ self.safe_addstr(y, x + w - 1, "╮")
399
+ self.safe_addstr(y + h - 1, x, "╰")
400
+ self.safe_addstr(y + h - 1, x + w - 1, "╯")
401
+
402
+ for i in range(1, w - 1):
403
+ self.safe_addstr(y, x + i, "─")
404
+ self.safe_addstr(y + h - 1, x + i, "─")
405
+
406
+ for i in range(1, h - 1):
407
+ self.safe_addstr(y + i, x, "│")
408
+ self.safe_addstr(y + i, x + w - 1, "│")
409
+
410
+ if title and len(title) < w - 4:
411
+ self.safe_addstr(y, x + 2, f" {title} ", curses.A_BOLD)
412
+
413
+ if bottom_hint and len(bottom_hint) < w - 4:
414
+ hint_x = x + (w - len(bottom_hint) - 2) // 2
415
+ self.safe_addstr(y + h - 1, hint_x, f" {bottom_hint} ", curses.A_DIM)
416
+
417
+ def draw_bar_chart(self, y, x, h, w, data):
418
+ if not data or h < 3 or w < 5:
419
+ return
420
+
421
+ chart_h = h - 2
422
+ chart_w = w - 4
423
+ if chart_w <= 0 or chart_h <= 0:
424
+ return
425
+
426
+ num_bars = min(len(data), chart_w)
427
+ display = list(data)[-num_bars:]
428
+ max_val = max(display) if display else 1
429
+
430
+ for i, val in enumerate(display):
431
+ bar_h = int((val / max_val) * chart_h) if max_val > 0 else 0
432
+ bar_h = min(bar_h, chart_h)
433
+ bar_x = x + 2 + i
434
+
435
+ if bar_x >= x + w - 2:
436
+ break
437
+
438
+ for j in range(bar_h):
439
+ bar_y = y + h - 2 - j
440
+ if bar_y > y and bar_x < x + w - 2:
441
+ self.safe_addch(bar_y, bar_x, '|')
442
+
443
+ def get_severity_color(self, severity):
444
+ colors = {
445
+ 'CRITICAL': curses.color_pair(1),
446
+ 'ALERT': curses.color_pair(7),
447
+ 'ERROR': curses.color_pair(1),
448
+ 'WARN': curses.color_pair(2),
449
+ 'INFO': curses.color_pair(3),
450
+ 'DEBUG': curses.color_pair(4),
451
+ }
452
+ return colors.get(severity, curses.color_pair(6))
453
+
454
+ def draw_loading(self, max_y, max_x):
455
+ main_width = max_x - 2
456
+ self.draw_box(0, 1, max_y, main_width, "LOADING")
457
+
458
+ mario = [
459
+ "██╗ ██╗███████╗██╗ ██╗ █████╗ ███╗ ██╗████████╗ ",
460
+ "██║ ██║██╔════╝██║ ██║██╔══██╗████╗ ██║╚══██╔══╝ ",
461
+ "██║ ██║█████╗ ███████║███████║██╔██╗ ██║ ██║ ",
462
+ "╚██╗ ██╔╝██╔══╝ ██╔══██║██╔══██║██║╚██╗██║ ██║ ",
463
+ " ╚████╔╝ ███████╗██║ ██║██║ ██║██║ ╚████║ ██║ ",
464
+ " ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ "
465
+ ]
466
+
467
+ start_y = max_y // 2 - len(mario) // 2
468
+ for i, line in enumerate(mario):
469
+ self.safe_addstr(start_y + i, (max_x - len(line)) // 2, line, curses.color_pair(1) | curses.A_BOLD)
470
+
471
+ msg = "LOADING LOGS..."
472
+ self.safe_addstr(start_y + len(mario) + 2, (max_x - len(msg)) // 2, msg, curses.A_BOLD)
473
+
474
+ if self.no_limit:
475
+ limit_msg = "Mode: NO LIMIT (Loading all logs)"
476
+ self.safe_addstr(start_y + len(mario) + 3, (max_x - len(limit_msg)) // 2, limit_msg, curses.color_pair(2))
477
+ else:
478
+ limit_msg = "Mode: LIMITED (Last 10,000 logs)"
479
+ self.safe_addstr(start_y + len(mario) + 3, (max_x - len(limit_msg)) // 2, limit_msg, curses.color_pair(3))
480
+
481
+ def draw_main_dashboard(self, max_y, max_x):
482
+ main_width = max_x - 2
483
+
484
+ self.draw_box(0, 1, max_y, main_width, "")
485
+
486
+ self.draw_box(1, 2, 3, main_width - 2, "")
487
+ status_color = curses.color_pair(5) if self.status == "LIVE" else curses.color_pair(1)
488
+ self.safe_addstr(2, 4, f"STATUS: {self.status}", status_color | curses.A_BOLD)
489
+
490
+ # Show last log time in the middle
491
+ if self.last_log_time and self.logs:
492
+ last_log = list(self.logs)[-1] # Get the most recent log
493
+ last_ts = last_log.get('timestamp', '')
494
+
495
+ # Also get first log to show range
496
+ first_log = list(self.logs)[0]
497
+ first_ts = first_log.get('timestamp', '')
498
+
499
+ if last_ts:
500
+ # Extract just the date part for comparison
501
+ last_date = last_ts[:10] if len(last_ts) >= 10 else last_ts[:8]
502
+ last_info = f"LAST LOG: {last_ts[:19]}"
503
+
504
+ # Color code based on how recent it is
505
+ log_color = curses.color_pair(3)
506
+ current_date = datetime.now().strftime("%Y-%m-%d")
507
+ if last_date != current_date[:10] and last_date != current_date[:8]:
508
+ # Log is not from today - show in yellow/red
509
+ log_color = curses.color_pair(2) | curses.A_BOLD
510
+
511
+ self.safe_addstr(2, (main_width // 2) - 15, last_info, log_color)
512
+
513
+ date_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
514
+ self.safe_addstr(2, max_x - len(date_time) - 10, f"TIME: {date_time}")
515
+
516
+ has_graphs = max_x >= 130
517
+ left_width = (max_x // 2) - 3 if has_graphs else main_width - 2
518
+
519
+ system_y = 5
520
+ system_h = 5 # Increased from 4 to 5 for date range line
521
+ self.draw_box(system_y, 2, system_h, left_width, "SYSTEM INFO")
522
+
523
+ self.safe_addstr(system_y + 1, 4, f"SYSTEM: {self.system_name}")
524
+ file_size = self.get_file_size()
525
+ self.safe_addstr(system_y + 1, left_width - len(file_size) - 10, f"SIZE: {file_size}")
526
+
527
+ crit = self.severity_counts['CRITICAL'] + self.severity_counts['ALERT']
528
+ row2_text = f"TOTAL LOGS: {self.total_logs} | CRITICAL: {crit} ERROR: {self.severity_counts['ERROR']} WARN: {self.severity_counts['WARN']} INFO: {self.severity_counts['INFO']}"
529
+ self.safe_addstr(system_y + 2, 4, row2_text[:left_width-8], curses.A_BOLD)
530
+
531
+ # Show log date range if available
532
+ if len(self.logs) > 0:
533
+ first_ts = list(self.logs)[0].get('timestamp', '')[:10]
534
+ last_ts = list(self.logs)[-1].get('timestamp', '')[:10]
535
+ if first_ts and last_ts:
536
+ if first_ts == last_ts:
537
+ date_range = f"Date: {last_ts}"
538
+ else:
539
+ date_range = f"Range: {first_ts} to {last_ts}"
540
+ self.safe_addstr(system_y + 3, 4, date_range, curses.color_pair(2))
541
+
542
+ filter_y = system_y + system_h + 1
543
+ filter_h = 3
544
+
545
+ if self.input_mode == 'search_text':
546
+ self.draw_box(filter_y, 2, filter_h, left_width, "SEARCH & FILTER", " t:text | v:severity | c:clear ")
547
+ prompt = f"Text: {self.input_buffer}_"
548
+ self.safe_addstr(filter_y + 1, 4, prompt, curses.A_BOLD | curses.A_REVERSE)
549
+ elif self.input_mode == 'search_severity':
550
+ self.draw_box(filter_y, 2, filter_h, left_width, "SEARCH & FILTER", " t:text | v:severity | c:clear ")
551
+ prompt = f"Severity: {self.input_buffer}_"
552
+ self.safe_addstr(filter_y + 1, 4, prompt, curses.A_BOLD | curses.A_REVERSE)
553
+ else:
554
+ self.draw_box(filter_y, 2, filter_h, left_width, "SEARCH & FILTER", "t:text | v:severity | c:clear")
555
+
556
+ log_y = filter_y + filter_h + 1
557
+ log_h = max_y - log_y - 5
558
+ if log_h < 10:
559
+ log_h = 10
560
+
561
+ self.draw_box(log_y, 2, log_h, left_width, "RECENT LOGS")
562
+
563
+ # Show count of logs and filtering info
564
+ filter_info = ""
565
+ if self.search_term or self.search_severity:
566
+ filter_info = f"[Filtered: {len(self.filtered_logs)}/{len(self.logs)}] "
567
+
568
+ self.safe_addstr(log_y, left_width - 40, f"{filter_info}l:details | ↑↓:scroll", curses.A_DIM)
569
+
570
+ # FIX 1: Show logs newest first (from the end of deque)
571
+ display = list(self.filtered_logs)
572
+ visible = log_h - 3
573
+
574
+ # Calculate proper indices - newest logs are at the END of the deque
575
+ total_logs = len(display)
576
+ if total_logs > 0:
577
+ # Start from the end and go backwards
578
+ start_idx = max(0, total_logs - visible - self.scroll_pos)
579
+ end_idx = total_logs - self.scroll_pos
580
+
581
+ # Get the slice and reverse it to show newest at top
582
+ display_slice = display[start_idx:end_idx]
583
+ display_slice.reverse()
584
+ else:
585
+ display_slice = []
586
+
587
+ for i, log in enumerate(display_slice):
588
+ y = log_y + 1 + i
589
+ if y >= log_y + log_h - 1:
590
+ break
591
+
592
+ ts = log.get('timestamp', '')[:15]
593
+ sev = log.get('severity', 'INFO')[:4]
594
+ msg = log.get('message', log.get('raw', ''))
595
+
596
+ max_msg = left_width - 28
597
+ if len(msg) > max_msg:
598
+ msg = msg[:max_msg - 3] + "..."
599
+
600
+ line = f"[{ts}] [{sev:4s}] {msg}"
601
+ color = self.get_severity_color(log.get('severity', 'INFO'))
602
+
603
+ # Use highlighting function
604
+ self.highlight_text(y, 4, line, color, self.search_term, self.search_severity)
605
+
606
+ if self.scroll_pos > 0:
607
+ self.safe_addstr(log_y + log_h - 1, left_width - 18, f"↑↓ scroll:{self.scroll_pos}")
608
+
609
+ if has_graphs:
610
+ right_x = left_width + 3
611
+ total_right_width = main_width - 2 - left_width - 1
612
+ graph_w = (total_right_width - 2) // 2
613
+
614
+ total_available = max_y - 5 - 5
615
+ gap_between = 1
616
+ graph_h = (total_available - gap_between) // 2
617
+
618
+ graph_start_y = 5
619
+ self.draw_box(graph_start_y, right_x, graph_h, graph_w, "CRITICAL")
620
+ self.safe_addstr(graph_start_y + 1, right_x + 2, f"Count: {crit}", curses.color_pair(1))
621
+ self.draw_bar_chart(graph_start_y + 2, right_x, graph_h - 2, graph_w, self.critical_history)
622
+
623
+ self.draw_box(graph_start_y, right_x + graph_w + 1, graph_h, graph_w + 1, "WARNINGS")
624
+ self.safe_addstr(graph_start_y + 1, right_x + graph_w + 3, f"Count: {self.severity_counts['WARN']}", curses.color_pair(2))
625
+ self.draw_bar_chart(graph_start_y + 2, right_x + graph_w + 1, graph_h - 2, graph_w + 1, self.warn_history)
626
+
627
+ bottom_y = graph_start_y + graph_h + gap_between
628
+
629
+ self.draw_box(bottom_y, right_x, graph_h, graph_w, "ERRORS")
630
+ self.safe_addstr(bottom_y + 1, right_x + 2, f"Count: {self.severity_counts['ERROR']}", curses.color_pair(1))
631
+ self.draw_bar_chart(bottom_y + 2, right_x, graph_h - 2, graph_w, self.error_history)
632
+
633
+ self.draw_box(bottom_y, right_x + graph_w + 1, graph_h, graph_w + 1, "INFO")
634
+ self.safe_addstr(bottom_y + 1, right_x + graph_w + 3, f"Count: {self.severity_counts['INFO']}", curses.color_pair(3))
635
+ self.draw_bar_chart(bottom_y + 2, right_x + graph_w + 1, graph_h - 2, graph_w + 1, self.info_history)
636
+
637
+ path_y = max_y - 4
638
+ if self.input_mode == 'path':
639
+ self.draw_box(path_y, 2, 3, main_width - 2, "FILE PATH", "Press Enter to apply, ESC to cancel")
640
+ prompt = f"Enter: {self.input_buffer}_"
641
+ self.safe_addstr(path_y + 1, 4, prompt, curses.A_BOLD | curses.A_REVERSE)
642
+ else:
643
+ self.draw_box(path_y, 2, 3, main_width - 2, "FILE PATH")
644
+ filename = os.path.basename(self.logfile)
645
+ if len(filename) > 40:
646
+ filename = "..." + filename[-37:]
647
+ self.safe_addstr(path_y + 1, 4, f"Current: {filename}")
648
+ options_text = "q:quit | r:reload | p:path | t:text | v:severity | c:clear | l:details | ↑↓:scroll"
649
+ self.safe_addstr(path_y + 1, main_width - len(options_text) - 4, options_text, curses.A_DIM)
650
+
651
+ def draw_detail_view(self, max_y, max_x):
652
+ main_width = max_x - 2
653
+ self.draw_box(0, 1, max_y, main_width, "LOG DETAIL VIEW")
654
+
655
+ total = f"TOTAL LOGS: {len(self.filtered_logs)}"
656
+ self.safe_addstr(1, max_x - len(total) - 4, total, curses.A_BOLD)
657
+
658
+ header_y = 3
659
+ self.safe_addstr(header_y, 4, "TIMESTAMP", curses.A_BOLD | curses.A_UNDERLINE)
660
+ self.safe_addstr(header_y, 30, "SEVERITY", curses.A_BOLD | curses.A_UNDERLINE)
661
+ self.safe_addstr(header_y, 45, "LOGS", curses.A_BOLD | curses.A_UNDERLINE)
662
+ self.safe_addstr(header_y + 1, 3, "─" * (main_width - 4))
663
+
664
+ # FIX 1: Show logs newest first in detail view too
665
+ display = list(self.filtered_logs)
666
+ if self.detail_search:
667
+ search_lower = self.detail_search.lower()
668
+ display = [l for l in display if search_lower in l.get('raw', '').lower() or
669
+ search_lower in l.get('severity', '').lower() or
670
+ search_lower in l.get('timestamp', '').lower()]
671
+
672
+ log_start = header_y + 2
673
+ log_h = max_y - log_start - 5
674
+
675
+ # Calculate proper indices for newest-first display
676
+ total_logs = len(display)
677
+ if total_logs > 0:
678
+ start_idx = max(0, total_logs - log_h - self.detail_scroll)
679
+ end_idx = total_logs - self.detail_scroll
680
+
681
+ display_slice = display[start_idx:end_idx]
682
+ display_slice.reverse()
683
+ else:
684
+ display_slice = []
685
+
686
+ for i, log in enumerate(display_slice):
687
+ y = log_start + i
688
+ if y >= max_y - 5:
689
+ break
690
+
691
+ ts = log.get('timestamp', '')
692
+ sev = log.get('severity', 'INFO')
693
+ msg = log.get('message', log.get('raw', ''))
694
+
695
+ max_msg = main_width - 50
696
+ if len(msg) > max_msg:
697
+ msg = msg[:max_msg - 3] + "..."
698
+
699
+ color = self.get_severity_color(sev)
700
+
701
+ # Highlight timestamp
702
+ self.highlight_text(y, 4, ts[:24], curses.A_NORMAL, self.detail_search, "")
703
+
704
+ # Highlight severity
705
+ self.highlight_text(y, 30, sev, color | curses.A_BOLD, self.detail_search, "")
706
+
707
+ # Highlight message
708
+ self.highlight_text(y, 45, msg, color, self.detail_search, "")
709
+
710
+ search_y = max_y - 4
711
+ self.draw_box(search_y, 2, 3, main_width - 2, "")
712
+
713
+ if self.detail_input_mode:
714
+ prompt = f"SEARCH: {self.detail_input_buffer}_"
715
+ self.safe_addstr(search_y + 1, 4, prompt, curses.A_BOLD | curses.A_REVERSE)
716
+ else:
717
+ if self.detail_search:
718
+ self.safe_addstr(search_y + 1, 4, f"ACTIVE: {self.detail_search}", curses.A_BOLD)
719
+ else:
720
+ self.safe_addstr(search_y + 1, 4, "SEARCH: ")
721
+
722
+ if total_logs > 0:
723
+ showing_start = max(1, total_logs - self.detail_scroll - log_h + 1)
724
+ showing_end = total_logs - self.detail_scroll
725
+ scroll_info = f"Showing {showing_start}-{showing_end} of {total_logs}"
726
+ else:
727
+ scroll_info = "No logs"
728
+ self.safe_addstr(search_y + 1, main_width - len(scroll_info) - 4, scroll_info, curses.A_DIM)
729
+
730
+ help_text = "ESC:back | s:search | c:clear | ↑↓:scroll | PgUp/PgDn:page | q:quit"
731
+ self.safe_addstr(max_y - 2, 3, help_text, curses.A_DIM)
732
+
733
+ def draw(self):
734
+ current_time = time.time()
735
+ if not self.needs_redraw and (current_time - self.last_draw_time) < 1.0:
736
+ return
737
+
738
+ max_y, max_x = self.stdscr.getmaxyx()
739
+
740
+ self.stdscr.erase()
741
+
742
+ if self.loading:
743
+ self.draw_loading(max_y, max_x)
744
+ self.stdscr.refresh()
745
+ self.needs_redraw = False
746
+ self.last_draw_time = current_time
747
+ return
748
+
749
+ if max_y < 30 or max_x < 80:
750
+ self.safe_addstr(max_y // 2, (max_x - 30) // 2, "Terminal too small!")
751
+ self.safe_addstr(max_y // 2 + 1, (max_x - 30) // 2, f"Current: {max_x}x{max_y}")
752
+ self.safe_addstr(max_y // 2 + 2, (max_x - 30) // 2, "Minimum: 80x30")
753
+ self.stdscr.refresh()
754
+ self.needs_redraw = False
755
+ self.last_draw_time = current_time
756
+ return
757
+
758
+ if self.detail_view:
759
+ self.draw_detail_view(max_y, max_x)
760
+ else:
761
+ self.draw_main_dashboard(max_y, max_x)
762
+
763
+ self.stdscr.refresh()
764
+ self.needs_redraw = False
765
+ self.last_draw_time = current_time
766
+
767
+ def handle_input_mode(self, key):
768
+ if key in (10, curses.KEY_ENTER):
769
+ if self.input_mode == 'path':
770
+ if os.path.exists(self.input_buffer):
771
+ self.logfile = self.input_buffer
772
+ self.start_monitoring()
773
+ elif self.input_mode == 'search_text':
774
+ self.search_term = self.input_buffer
775
+ self.update_filtered_logs()
776
+ self.scroll_pos = 0 # Reset scroll when searching
777
+ elif self.input_mode == 'search_severity':
778
+ sev = self.input_buffer.upper()
779
+ if sev in ['CRITICAL', 'ALERT', 'ERROR', 'WARN', 'INFO', 'DEBUG', '']:
780
+ self.search_severity = sev
781
+ self.update_filtered_logs()
782
+ self.scroll_pos = 0 # Reset scroll when filtering
783
+ self.input_mode = None
784
+ self.input_buffer = ""
785
+ self.needs_redraw = True
786
+ elif key == 27:
787
+ self.input_mode = None
788
+ self.input_buffer = ""
789
+ self.needs_redraw = True
790
+ elif key in (curses.KEY_BACKSPACE, 127, 8):
791
+ self.input_buffer = self.input_buffer[:-1]
792
+ self.needs_redraw = True
793
+ elif 32 <= key <= 126:
794
+ self.input_buffer += chr(key)
795
+ self.needs_redraw = True
796
+
797
+ def handle_detail_input(self, key):
798
+ if key in (10, curses.KEY_ENTER):
799
+ self.detail_search = self.detail_input_buffer
800
+ self.detail_input_mode = False
801
+ self.detail_scroll = 0
802
+ self.needs_redraw = True
803
+ elif key == 27:
804
+ self.detail_input_mode = False
805
+ self.detail_input_buffer = ""
806
+ self.needs_redraw = True
807
+ elif key in (curses.KEY_BACKSPACE, 127, 8):
808
+ self.detail_input_buffer = self.detail_input_buffer[:-1]
809
+ self.needs_redraw = True
810
+ elif 32 <= key <= 126:
811
+ self.detail_input_buffer += chr(key)
812
+ self.needs_redraw = True
813
+
814
+ def run(self):
815
+ scroll_delay = 0
816
+
817
+ try:
818
+ curses.mousemask(0)
819
+ except:
820
+ pass
821
+
822
+ while True:
823
+ try:
824
+ key = self.stdscr.getch()
825
+ current_time = time.time()
826
+
827
+ if key == curses.KEY_MOUSE:
828
+ continue
829
+
830
+ if self.detail_view:
831
+ if self.detail_input_mode:
832
+ self.handle_detail_input(key)
833
+ else:
834
+ if key in (27, ord('b'), ord('B')):
835
+ self.loading = True
836
+ self.draw()
837
+ time.sleep(0.3)
838
+ self.detail_view = False
839
+ self.detail_scroll = 0
840
+ self.detail_search = ""
841
+ self.loading = False
842
+ self.needs_redraw = True
843
+ elif key in (ord('q'), ord('Q')):
844
+ break
845
+ elif key in (ord('s'), ord('S')):
846
+ self.detail_input_mode = True
847
+ self.detail_input_buffer = self.detail_search
848
+ self.needs_redraw = True
849
+ elif key in (ord('c'), ord('C')):
850
+ self.detail_search = ""
851
+ self.detail_scroll = 0
852
+ self.needs_redraw = True
853
+ elif key == curses.KEY_UP and current_time - scroll_delay > 0.05:
854
+ # Scroll down to see older logs
855
+ max_scroll = max(0, len(self.filtered_logs) - 10)
856
+ self.detail_scroll = min(self.detail_scroll + 1, max_scroll)
857
+ scroll_delay = current_time
858
+ self.needs_redraw = True
859
+ elif key == curses.KEY_DOWN and current_time - scroll_delay > 0.05:
860
+ # Scroll up to see newer logs
861
+ self.detail_scroll = max(self.detail_scroll - 1, 0)
862
+ scroll_delay = current_time
863
+ self.needs_redraw = True
864
+ elif key == curses.KEY_PPAGE:
865
+ self.detail_scroll = min(self.detail_scroll + 10, max(0, len(self.filtered_logs) - 10))
866
+ self.needs_redraw = True
867
+ elif key == curses.KEY_NPAGE:
868
+ self.detail_scroll = max(self.detail_scroll - 10, 0)
869
+ self.needs_redraw = True
870
+
871
+ elif self.input_mode:
872
+ self.handle_input_mode(key)
873
+ else:
874
+ if key in (ord('q'), ord('Q')):
875
+ break
876
+ elif key in (ord('r'), ord('R')):
877
+ # Force reload logs from file
878
+ self.start_monitoring()
879
+ self.needs_redraw = True
880
+ elif key in (ord('l'), ord('L')):
881
+ self.loading = True
882
+ self.draw()
883
+ time.sleep(0.3)
884
+ self.detail_view = True
885
+ self.detail_scroll = 0
886
+ self.loading = False
887
+ self.needs_redraw = True
888
+ elif key in (ord('p'), ord('P')):
889
+ self.input_mode = 'path'
890
+ self.input_buffer = self.logfile
891
+ self.needs_redraw = True
892
+ elif key in (ord('t'), ord('T')):
893
+ self.input_mode = 'search_text'
894
+ self.input_buffer = self.search_term
895
+ self.needs_redraw = True
896
+ elif key in (ord('v'), ord('V')):
897
+ self.input_mode = 'search_severity'
898
+ self.input_buffer = self.search_severity
899
+ self.needs_redraw = True
900
+ elif key in (ord('c'), ord('C')):
901
+ self.search_term = ""
902
+ self.search_severity = ""
903
+ self.scroll_pos = 0
904
+ self.update_filtered_logs()
905
+ self.needs_redraw = True
906
+ elif key == curses.KEY_UP and current_time - scroll_delay > 0.05:
907
+ # Scroll down to see older logs
908
+ max_scroll = max(0, len(self.filtered_logs) - 1)
909
+ self.scroll_pos = min(self.scroll_pos + 1, max_scroll)
910
+ scroll_delay = current_time
911
+ self.needs_redraw = True
912
+ elif key == curses.KEY_DOWN and current_time - scroll_delay > 0.05:
913
+ # Scroll up to see newer logs
914
+ self.scroll_pos = max(self.scroll_pos - 1, 0)
915
+ scroll_delay = current_time
916
+ self.needs_redraw = True
917
+ except:
918
+ pass
919
+
920
+ self.draw()
921
+ time.sleep(0.05)
922
+
923
+ if self.monitor:
924
+ self.monitor.stop()
925
+
926
+ # NEW
927
+ def main(stdscr, logfile="/var/log/syslog", no_limit=False):
928
+ dashboard = LogDashboard(stdscr, logfile, no_limit=no_limit)
929
+ dashboard.run()
930
+
931
+ __version__ = "1.0.0"
932
+
933
+ def cli():
934
+ """Entry point for CLI."""
935
+ args = sys.argv[1:]
936
+
937
+ # Handle version flag before curses starts
938
+ if "-v" in args or "--version" in args:
939
+ print(f"awtach v{__version__}")
940
+ sys.exit(0)
941
+
942
+ logfile = "/var/log/syslog"
943
+ no_limit = False
944
+
945
+ for arg in args:
946
+ if arg in ['--no-limit', '--on-limit']:
947
+ no_limit = True
948
+ elif not arg.startswith('--') and not arg.startswith('-'):
949
+ logfile = arg
950
+
951
+ try:
952
+ curses.wrapper(lambda stdscr: main(stdscr, logfile, no_limit))
953
+ except KeyboardInterrupt:
954
+ print("\nDashboard closed.")
955
+ except Exception as e:
956
+ print(f"Error: {e}")
957
+
958
+ def cli_no_limit():
959
+ sys.argv.append("--no-limit")
960
+ cli()
961
+
962
+ if __name__ == "__main__":
963
+ cli()
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: awatch
3
+ Version: 1.0.0
4
+ Summary: Terminal TUI dashboard for real-time log monitoring
5
+ Author-email: Ashish <your@email.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Ashish
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
23
+
24
+ Project-URL: Homepage, https://github.com/ashishnxt/awatch
25
+ Project-URL: Repository, https://github.com/ashishnxt/awatch
26
+ Project-URL: Bug Tracker, https://github.com/ashishnxt/awatch/issues
27
+ Keywords: log,monitoring,tui,terminal,dashboard,syslog
28
+ Classifier: Development Status :: 4 - Beta
29
+ Classifier: Environment :: Console :: Curses
30
+ Classifier: Intended Audience :: System Administrators
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: POSIX :: Linux
34
+ Classifier: Operating System :: MacOS
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.8
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Topic :: System :: Logging
42
+ Classifier: Topic :: System :: Monitoring
43
+ Classifier: Topic :: Terminals
44
+ Requires-Python: >=3.8
45
+ Description-Content-Type: text/markdown
46
+ License-File: LICENSE
47
+ Dynamic: license-file
48
+
49
+ # awatch 🖥️
50
+
51
+ > **A** **W**atchful **T**erminal **A**nalytics for **C**ontinuous **H**osting logs
52
+
53
+ Real-time TUI (Terminal UI) log monitoring dashboard for Linux and macOS. Monitor any log file live — with severity filtering, text search, bar charts, and a scrollable detail view — all inside your terminal.
54
+
55
+ ---
56
+
57
+ ## Screenshot
58
+
59
+ ```
60
+ ╭──────────────────────────────────────────────────────────────────────────────────────╮
61
+ │ ╭─────────────────────────────────────────────────────────────────────────────────╮ │
62
+ │ │ STATUS: LIVE LAST LOG: 2026-01-02T14:32:01 TIME: 2026-01-02 ... │ │
63
+ │ ╰─────────────────────────────────────────────────────────────────────────────────╯ │
64
+ │ ╭─ SYSTEM INFO ─────────────────────────╮ ╭─ CRITICAL ──╮ ╭─ WARNINGS ──╮ │
65
+ │ │ SYSTEM: prod-server-01 SIZE: 42.3MB │ │ Count: 2 │ │ Count: 14 │ │
66
+ │ │ TOTAL: 8432 | CRIT:2 ERR:11 WARN:14 │ │ | | │ │ | || | │ │
67
+ │ │ Range: 2026-01-01 to 2026-01-02 │ ╰─────────────╯ ╰─────────────╯ │
68
+ │ ╰───────────────────────────────────────╯ │
69
+ │ ╭─ RECENT LOGS ──────────────────────── [Filtered: 120/8432] l:details | ↑↓:scroll ─╮│
70
+ │ │ [Jan 2 14:32:01] [CRIT] kernel: BIOS bug: ... ││
71
+ │ │ [Jan 2 14:31:55] [ERR ] nginx: connect() failed (111: Connection refused) ││
72
+ │ │ [Jan 2 14:31:40] [WARN] systemd: Unit ssh.service is degraded. ││
73
+ │ │ [Jan 2 14:31:22] [INFO] sshd[2341]: Accepted publickey for ashish ││
74
+ │ ╰────────────────────────────────────────────────────────────────────────────────────╯│
75
+ ╰──────────────────────────────────────────────────────────────────────────────────────╯
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Features
81
+
82
+ - **Live tail** — monitors any log file in real-time, handles file rotation and truncation automatically
83
+ - **Severity detection** — auto-classifies each line as `CRITICAL`, `ALERT`, `ERROR`, `WARN`, `INFO`, or `DEBUG` using regex pattern matching
84
+ - **Text search** — filter logs by any keyword with highlighted matches
85
+ - **Severity filter** — show only the severity level you care about
86
+ - **Bar charts** — live sparkline-style bar charts for CRITICAL, WARNINGS, ERRORS, and INFO counts (visible on wide terminals ≥ 130 cols)
87
+ - **Detail view** — full-width table with timestamp, severity, and message columns; searchable separately
88
+ - **Scroll** — scroll through history in both main and detail views
89
+ - **Date range** — shows the date range of logs currently loaded
90
+ - **File rotation handling** — detects truncation/rotation and re-opens the file automatically
91
+ - **No-limit mode** — load the entire log file instead of the default last 10,000 lines
92
+ - **Zero dependencies** — uses only Python stdlib (`curses`, `threading`, `re`, `collections`)
93
+
94
+ ---
95
+
96
+ ## Install
97
+
98
+ ```bash
99
+ pip install awatch
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Usage
105
+
106
+ ```bash
107
+ # Monitor default syslog
108
+ awatch
109
+
110
+ # Monitor a specific file
111
+ awatch /var/log/nginx/error.log
112
+ awatch /var/log/auth.log
113
+ awatch /var/log/postgresql/postgresql.log
114
+
115
+ # Load entire file (no 10,000 line cap)
116
+ awatch --no-limit
117
+ awatch /var/log/syslog --no-limit
118
+
119
+ # Check version
120
+ awatch -v
121
+ awatch --version
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Keybindings
127
+
128
+ ### Main View
129
+
130
+ | Key | Action |
131
+ |-----|--------|
132
+ | `t` | Enter text search — type keyword, press `Enter` to apply |
133
+ | `v` | Filter by severity — type `ERROR`, `WARN`, `INFO`, `CRITICAL`, `DEBUG`, or `ALERT` |
134
+ | `c` | Clear all active filters |
135
+ | `l` | Open detail view (full table with all fields) |
136
+ | `r` | Reload — re-reads file from disk |
137
+ | `p` | Change log file path — type new path, press `Enter` |
138
+ | `↑` | Scroll up (older logs) |
139
+ | `↓` | Scroll down (newer logs) |
140
+ | `q` | Quit |
141
+ | `ESC` | Cancel current input |
142
+
143
+ ### Detail View
144
+
145
+ | Key | Action |
146
+ |-----|--------|
147
+ | `s` | Search within detail view |
148
+ | `c` | Clear detail search |
149
+ | `↑` / `↓` | Scroll one line |
150
+ | `PgUp` / `PgDn` | Scroll ten lines |
151
+ | `ESC` or `b` | Back to main dashboard |
152
+ | `q` | Quit |
153
+
154
+ ---
155
+
156
+ ## Severity Levels
157
+
158
+ | Level | Color | Matches |
159
+ |-------|-------|---------|
160
+ | `CRITICAL` | 🔴 Red | `CRITICAL`, `CRIT`, `FATAL`, `EMERG`, `EMERGENCY` |
161
+ | `ALERT` | 🟣 Magenta | `ALERT` |
162
+ | `ERROR` | 🔴 Red | `ERROR`, `ERR`, `FAILURE`, `FAILED` |
163
+ | `WARN` | 🟡 Yellow | `WARN`, `WARNING` |
164
+ | `INFO` | 🔵 Cyan | `INFO`, `INFORMATION`, `NOTICE` |
165
+ | `DEBUG` | 🔷 Blue | `DEBUG`, `TRACE` |
166
+
167
+ ---
168
+
169
+ ## Supported Log Formats
170
+
171
+ awatch parses standard syslog format automatically:
172
+
173
+ ```
174
+ Jan 2 14:32:01 hostname process[pid]: message
175
+ 2026-01-02T14:32:01.123Z hostname process[pid]: message
176
+ ```
177
+
178
+ Any line that doesn't match syslog format is still displayed — the full raw line is shown as the message. This means awatch works with any plain-text log file.
179
+
180
+ ---
181
+
182
+ ## Terminal Requirements
183
+
184
+ | Requirement | Minimum |
185
+ |-------------|---------|
186
+ | Terminal size | 80 × 30 |
187
+ | Terminal size (with graphs) | 130+ columns wide |
188
+ | OS | Linux, macOS |
189
+ | Python | 3.8+ |
190
+
191
+ ---
192
+
193
+ ## Performance
194
+
195
+ | Mode | Behavior |
196
+ |------|----------|
197
+ | Default | Reads last 2MB of file → keeps last 10,000 lines in memory |
198
+ | `--no-limit` | Reads entire file from start, no line cap |
199
+
200
+ New lines are polled every 50ms. Screen redraws are throttled to once per second minimum unless new data arrives.
201
+
202
+ ---
203
+
204
+ ## Common Log Files
205
+
206
+ ```bash
207
+ # System
208
+ awatch /var/log/syslog
209
+ awatch /var/log/messages
210
+ awatch /var/log/kern.log
211
+
212
+ # Auth
213
+ awatch /var/log/auth.log
214
+ awatch /var/log/secure
215
+
216
+ # Web servers
217
+ awatch /var/log/nginx/error.log
218
+ awatch /var/log/nginx/access.log
219
+ awatch /var/log/apache2/error.log
220
+
221
+ # Database
222
+ awatch /var/log/postgresql/postgresql-16-main.log
223
+ awatch /var/log/mysql/error.log
224
+
225
+ # Application / Docker
226
+ awatch /var/log/myapp/app.log
227
+ awatch /var/lib/docker/containers/<id>/<id>-json.log
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Requirements
233
+
234
+ - Python 3.8 or higher
235
+ - Linux or macOS (Windows not supported — `curses` is unavailable)
236
+ - No third-party packages required
237
+
238
+ ---
239
+
240
+ ## License
241
+
242
+ MIT License — see [LICENSE](LICENSE) for details.
243
+
244
+ ---
245
+
246
+ ## Author
247
+
248
+ Built by **Ashish** — [github.com/ashishnxt](https://github.com/ashishnxt)
@@ -0,0 +1,8 @@
1
+ awatch/__init__.py,sha256=st1LDqnzm7nIM08DA6Xv2S1_tY46atr9ByOuQH26nwc,123
2
+ awatch/dashboard.py,sha256=Lr550WwGJBaqSf1pFQY5um_dy5ptca77cGrgYpjoSCA,40057
3
+ awatch-1.0.0.dist-info/licenses/LICENSE,sha256=qx7BgQNwDCrOZNufdW_CZBKALIieS_aCJSWOM5IrDYI,804
4
+ awatch-1.0.0.dist-info/METADATA,sha256=WHG3SAdf3uJenigFUAWdv4x3sdtTmmqwyTz-2BFnT50,9532
5
+ awatch-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ awatch-1.0.0.dist-info/entry_points.txt,sha256=cRb5DiYL9mDOCgn4CYpyakHKS6kcKh0OXvbdBgKVfSw,95
7
+ awatch-1.0.0.dist-info/top_level.txt,sha256=qyUH1KPNUpsK_vCwFTz2mQLyckPA--MIAerHqZ6ZHJM,7
8
+ awatch-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ awatch = awatch.dashboard:cli
3
+ awatch-nolimit = awatch.dashboard:cli_no_limit
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ashish
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
@@ -0,0 +1 @@
1
+ awatch