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 +5 -0
- awatch/dashboard.py +963 -0
- awatch-1.0.0.dist-info/METADATA +248 -0
- awatch-1.0.0.dist-info/RECORD +8 -0
- awatch-1.0.0.dist-info/WHEEL +5 -0
- awatch-1.0.0.dist-info/entry_points.txt +3 -0
- awatch-1.0.0.dist-info/licenses/LICENSE +17 -0
- awatch-1.0.0.dist-info/top_level.txt +1 -0
awatch/__init__.py
ADDED
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,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
|