orafail 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
orafail/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """orafail package."""
2
+
3
+ __version__ = "0.1.0"
orafail/config.py ADDED
@@ -0,0 +1,47 @@
1
+ """Configuration models for the Oracle login failure monitor."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class DatabaseConfig(BaseModel):
7
+ """Represent a single Oracle database connection target.
8
+
9
+ Attributes:
10
+ name (str): Human-friendly display name for the dashboard.
11
+ dsn (str): Oracle DSN used by the connector.
12
+ user (str): Database username.
13
+ password (str): Database user password.
14
+ """
15
+
16
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
17
+
18
+ name: str
19
+ dsn: str
20
+ user: str
21
+ password: str
22
+
23
+
24
+ class AppConfig(BaseModel):
25
+ """Define runtime configuration for the dashboard process.
26
+
27
+ Attributes:
28
+ databases (list[DatabaseConfig]): Database definitions to poll.
29
+ max_workers (int): Thread pool size used for concurrent polling.
30
+ refresh_seconds (int): Delay between polling cycles.
31
+ highlight_ttl (int): Number of cycles to keep new-row highlighting.
32
+ log_file (str | None): Log file path.
33
+ log_level (str): Logging level.
34
+ tcp_connect_timeout (int): Timeout in seconds to establish TCP connection.
35
+ query_timeout (int): Timeout in seconds to wait for polling queries.
36
+ """
37
+
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ databases: list[DatabaseConfig]
41
+ max_workers: int = Field(default=5, ge=1)
42
+ refresh_seconds: int = Field(default=15, ge=1)
43
+ highlight_ttl: int = Field(default=3, ge=1)
44
+ log_file: str | None = Field(default=None)
45
+ log_level: str = Field(default="INFO")
46
+ tcp_connect_timeout: int = Field(default=10, ge=1)
47
+ query_timeout: int = Field(default=10, ge=1)
orafail/main.py ADDED
@@ -0,0 +1,653 @@
1
+ """Render a live dashboard of failed Oracle login events."""
2
+
3
+ import argparse
4
+ from collections import deque
5
+ import concurrent.futures
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ import threading
9
+ import time
10
+ from typing import Any, TypeAlias
11
+
12
+ import oracledb
13
+ import yaml
14
+ from loguru import logger
15
+ from rich.align import Align
16
+ from rich.box import ROUNDED, SIMPLE
17
+ from rich.console import Group
18
+ from rich.layout import Layout
19
+ from rich.live import Live
20
+ from rich.panel import Panel
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+
24
+ from orafail.config import AppConfig, DatabaseConfig
25
+
26
+ FailureDetail: TypeAlias = dict[str, Any]
27
+ DatabaseResult: TypeAlias = dict[str, int | str | list[FailureDetail]]
28
+ AllResults: TypeAlias = dict[str, DatabaseResult]
29
+ EventKey: TypeAlias = tuple[str, Any, Any, Any]
30
+
31
+
32
+ SUMMARY_QUERY = """
33
+ SELECT
34
+ SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' MINUTE THEN 1 ELSE 0 END),
35
+ SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '10' MINUTE THEN 1 ELSE 0 END),
36
+ SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' HOUR THEN 1 ELSE 0 END)
37
+ FROM unified_audit_trail
38
+ WHERE action_name = 'LOGON'
39
+ AND return_code != 0
40
+ """
41
+
42
+ DETAIL_QUERY = """
43
+ SELECT *
44
+ FROM (
45
+ SELECT
46
+ dbusername,
47
+ userhost,
48
+ MAX(event_timestamp) AS last_failed_at,
49
+ SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' MINUTE THEN 1 ELSE 0 END) AS cnt_1m,
50
+ SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '10' MINUTE THEN 1 ELSE 0 END) AS cnt_10m,
51
+ SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' HOUR THEN 1 ELSE 0 END) AS cnt_1h
52
+ FROM unified_audit_trail
53
+ WHERE action_name = 'LOGON'
54
+ AND return_code != 0
55
+ GROUP BY dbusername, userhost
56
+ ORDER BY last_failed_at DESC
57
+ )
58
+ WHERE ROWNUM <= 5
59
+ """
60
+
61
+
62
+ class OracleLoginFailureMonitor:
63
+ """Monitor and render Oracle login failures in a live terminal dashboard.
64
+
65
+ Attributes:
66
+ app_config (AppConfig): Runtime configuration for refresh and display behavior.
67
+ databases (list[DatabaseConfig]): Target Oracle databases to query.
68
+ highlight_ttl (int): Number of refresh cycles to keep row highlights visible.
69
+ previous_results (AllResults): Most recent successful snapshot for trend arrows.
70
+ seen_events (dict[str, set[EventKey]]): Per-database event keys already observed.
71
+ event_age (dict[EventKey, int]): Highlight age counters for known events.
72
+ """
73
+
74
+ @staticmethod
75
+ def load_config(path: str = "config.yaml") -> AppConfig:
76
+ """Load and validate dashboard configuration from YAML.
77
+
78
+ Args:
79
+ path (str): Path to the YAML file.
80
+
81
+ Returns:
82
+ AppConfig: Parsed and validated application configuration.
83
+
84
+ Raises:
85
+ FileNotFoundError: If the YAML file path does not exist.
86
+ ValueError: If the YAML file is empty or invalid for AppConfig.
87
+ """
88
+ config_path = Path(path)
89
+ if not config_path.exists():
90
+ raise FileNotFoundError(f"Config file not found: {config_path}")
91
+
92
+ with config_path.open("r", encoding="utf-8") as config_file:
93
+ raw_config: dict[str, Any] | None = yaml.safe_load(config_file)
94
+
95
+ if raw_config is None:
96
+ raise ValueError(f"Config file is empty: {config_path}")
97
+
98
+ return AppConfig.model_validate(raw_config)
99
+
100
+ def __init__(
101
+ self, config_path: str = "config.yaml", headless: bool = False
102
+ ) -> None:
103
+ """Initialize monitor state by loading configuration.
104
+
105
+ Args:
106
+ config_path (str): Path to the YAML configuration file.
107
+ headless (bool): Run in headless daemon mode (no terminal UI).
108
+ """
109
+ self.app_config = self.load_config(config_path)
110
+ self.databases = self.app_config.databases
111
+ self.highlight_ttl = self.app_config.highlight_ttl
112
+ self.previous_results: AllResults = {}
113
+ self.seen_events: dict[str, set[EventKey]] = {}
114
+ self.event_age: dict[EventKey, int] = {}
115
+ self.log_messages: deque[str] = deque(maxlen=5)
116
+ self.headless = headless
117
+ self._connections: dict[str, oracledb.Connection] = {}
118
+ self._conn_lock = threading.Lock()
119
+
120
+ if not self.headless:
121
+ # Remove default logger to prevent screen corruption in alternate screen mode
122
+ logger.remove()
123
+
124
+ if self.app_config.log_file:
125
+ logger.add(
126
+ self.app_config.log_file,
127
+ level=self.app_config.log_level,
128
+ rotation="10 MB",
129
+ retention="5 days",
130
+ )
131
+
132
+ if not self.headless:
133
+ # Capture loguru logs in the in-memory queue for display in the dashboard
134
+ logger.add(
135
+ lambda msg: self.log_messages.append(str(msg).strip()),
136
+ level="INFO",
137
+ format="{time:HH:mm:ss} | {level: <7} | {message}",
138
+ backtrace=False,
139
+ diagnose=False,
140
+ )
141
+
142
+ def _fetch_failed_logins(self, db: DatabaseConfig) -> DatabaseResult:
143
+ """Query one database for failed login summaries and details.
144
+
145
+ Args:
146
+ db (DatabaseConfig): Connection details for the target database.
147
+
148
+ Returns:
149
+ DatabaseResult: Summary counters and detail rows for one database.
150
+ """
151
+ start_time = time.perf_counter()
152
+ conn = None
153
+ try:
154
+ with self._conn_lock:
155
+ conn = self._connections.get(db.name)
156
+ if conn is not None:
157
+ try:
158
+ conn.ping()
159
+ except Exception:
160
+ try:
161
+ conn.close()
162
+ except Exception:
163
+ pass
164
+ conn = None
165
+
166
+ if conn is None:
167
+ conn = oracledb.connect(
168
+ user=db.user,
169
+ password=db.password,
170
+ dsn=db.dsn,
171
+ tcp_connect_timeout=self.app_config.tcp_connect_timeout,
172
+ )
173
+ self._connections[db.name] = conn
174
+
175
+ cursor = conn.cursor()
176
+
177
+ cursor.execute(SUMMARY_QUERY)
178
+ summary_row = cursor.fetchone()
179
+
180
+ cursor.execute(DETAIL_QUERY)
181
+ detail_rows = cursor.fetchall()
182
+ details = [
183
+ {
184
+ "user": row[0],
185
+ "ip": row[1],
186
+ "last_failed_at": row[2],
187
+ "1m": row[3] or 0,
188
+ "10m": row[4] or 0,
189
+ "1h": row[5] or 0,
190
+ }
191
+ for row in detail_rows
192
+ ]
193
+
194
+ cursor.close()
195
+
196
+ latency_ms = int((time.perf_counter() - start_time) * 1000)
197
+
198
+ return {
199
+ "status": "ONLINE",
200
+ "latency_ms": latency_ms,
201
+ "1m": summary_row[0] or 0,
202
+ "10m": summary_row[1] or 0,
203
+ "1h": summary_row[2] or 0,
204
+ "details": details,
205
+ }
206
+ except Exception as e:
207
+ logger.error(f"Failed to query database {db.name}: {e}")
208
+ with self._conn_lock:
209
+ if db.name in self._connections:
210
+ try:
211
+ self._connections[db.name].close()
212
+ except Exception:
213
+ pass
214
+ del self._connections[db.name]
215
+ return {
216
+ "status": "OFFLINE",
217
+ "latency_ms": None,
218
+ "1m": "ERR",
219
+ "10m": "ERR",
220
+ "1h": "ERR",
221
+ "details": [],
222
+ }
223
+
224
+ def close(self) -> None:
225
+ """Close all cached database connections."""
226
+ with self._conn_lock:
227
+ for db_name, conn in list(self._connections.items()):
228
+ try:
229
+ conn.close()
230
+ except Exception as e:
231
+ logger.debug(f"Error closing connection for {db_name}: {e}")
232
+ self._connections.clear()
233
+
234
+ def _fetch_all(self) -> AllResults:
235
+ """Fetch failed-login data for all configured databases concurrently.
236
+
237
+ Returns:
238
+ AllResults: Per-database query results for the dashboard.
239
+ """
240
+ with concurrent.futures.ThreadPoolExecutor(
241
+ max_workers=self.app_config.max_workers
242
+ ) as executor:
243
+ futures = {
244
+ executor.submit(self._fetch_failed_logins, db): db
245
+ for db in self.databases
246
+ }
247
+
248
+ done, not_done = concurrent.futures.wait(
249
+ futures.keys(),
250
+ timeout=float(self.app_config.query_timeout),
251
+ )
252
+
253
+ results: AllResults = {}
254
+
255
+ # Process successfully completed queries
256
+ for future in done:
257
+ db = futures[future]
258
+ try:
259
+ results[db.name] = future.result()
260
+ except Exception as e:
261
+ logger.error(f"Error fetching logs for {db.name}: {e}")
262
+ results[db.name] = {
263
+ "status": "OFFLINE",
264
+ "latency_ms": None,
265
+ "1m": "ERR",
266
+ "10m": "ERR",
267
+ "1h": "ERR",
268
+ "details": [],
269
+ }
270
+
271
+ # Process timed out queries
272
+ for future in not_done:
273
+ db = futures[future]
274
+ logger.warning(
275
+ f"Query timed out for database {db.name} after {self.app_config.query_timeout}s"
276
+ )
277
+ results[db.name] = {
278
+ "status": "OFFLINE",
279
+ "latency_ms": None,
280
+ "1m": "TIMEOUT",
281
+ "10m": "TIMEOUT",
282
+ "1h": "TIMEOUT",
283
+ "details": [],
284
+ }
285
+
286
+ return results
287
+
288
+ @staticmethod
289
+ def _trend_arrow(current: Any, previous: Any) -> str:
290
+ """Get trend arrow for visual indicator.
291
+
292
+ Args:
293
+ current (Any): Current count.
294
+ previous (Any): Previous count.
295
+
296
+ Returns:
297
+ str: Rich formatted trend arrow symbol.
298
+ """
299
+ if not isinstance(current, int) or not isinstance(previous, int):
300
+ return ""
301
+ if current > previous:
302
+ return "[#ff5555]↑[/#ff5555]"
303
+ if current < previous:
304
+ return "[#50fa7b]↓[/#50fa7b]"
305
+ return "[#ffb86c]→[/#ffb86c]"
306
+
307
+ def _build_summary_table(self, results: AllResults, previous: AllResults) -> Table:
308
+ """Build the summary table for all databases.
309
+
310
+ Args:
311
+ results (AllResults): Current polling results.
312
+ previous (AllResults): Previous polling results.
313
+
314
+ Returns:
315
+ Table: Summary table to be rendered.
316
+ """
317
+ table = Table(box=SIMPLE, expand=True)
318
+
319
+ table.add_column("Database", style="bold #8be9fd")
320
+ table.add_column("Status", justify="center")
321
+ table.add_column("Latency", justify="right", style="#50fa7b")
322
+ table.add_column("1 min", justify="right")
323
+ table.add_column("10 min", justify="right")
324
+ table.add_column("1 hour", justify="right")
325
+
326
+ for db_name, data in results.items():
327
+ prev = previous.get(db_name, {})
328
+ status = data.get("status", "OFFLINE")
329
+ latency_ms = data.get("latency_ms")
330
+
331
+ if status == "ONLINE":
332
+ status_str = "[#50fa7b]● ONLINE[/#50fa7b]"
333
+ latency_str = f"{latency_ms}ms" if latency_ms is not None else "-"
334
+ else:
335
+ status_str = "[#ff5555]● OFFLINE[/#ff5555]"
336
+ latency_str = "[dim]-[/dim]"
337
+
338
+ def fmt(val: int | str, prev_val: Any) -> str:
339
+ arrow = self._trend_arrow(val, prev_val)
340
+ if not isinstance(val, int):
341
+ return f"[#ff5555]{val}[/#ff5555]"
342
+ if val == 0:
343
+ return "[dim #6272a4]-[/dim #6272a4]"
344
+ if val > 20:
345
+ return f"[bold #ff5555]{val}[/bold #ff5555] {arrow}"
346
+ return f"[#ffb86c]{val}[/#ffb86c] {arrow}"
347
+
348
+ table.add_row(
349
+ db_name,
350
+ status_str,
351
+ latency_str,
352
+ fmt(data.get("1m", "ERR"), prev.get("1m")),
353
+ fmt(data.get("10m", "ERR"), prev.get("10m")),
354
+ fmt(data.get("1h", "ERR"), prev.get("1h")),
355
+ )
356
+ return table
357
+
358
+ @staticmethod
359
+ def _build_event_key(db_name: str, row: FailureDetail) -> EventKey:
360
+ """Build a unique event key to track highlight age.
361
+
362
+ Args:
363
+ db_name (str): Name of the database.
364
+ row (FailureDetail): Failed login row details.
365
+
366
+ Returns:
367
+ EventKey: Unique key identifying the failure event.
368
+ """
369
+ return (db_name, row["last_failed_at"], row["user"], row["ip"])
370
+
371
+ def _mark_new_rows(
372
+ self,
373
+ db_name: str,
374
+ rows: list[FailureDetail],
375
+ ) -> list[tuple[FailureDetail, str]]:
376
+ """Mark row styles based on whether they are new or aging.
377
+
378
+ Args:
379
+ db_name (str): Name of the database.
380
+ rows (list[FailureDetail]): Row details to styles.
381
+
382
+ Returns:
383
+ list[tuple[FailureDetail, str]]: List of tuples with row and its style string.
384
+ """
385
+ if db_name not in self.seen_events:
386
+ self.seen_events[db_name] = set()
387
+
388
+ marked: list[tuple[FailureDetail, str]] = []
389
+ for row in rows:
390
+ key = self._build_event_key(db_name, row)
391
+ if key not in self.seen_events[db_name]:
392
+ self.seen_events[db_name].add(key)
393
+ self.event_age[key] = 0
394
+ style = "bold red"
395
+ else:
396
+ age = self.event_age.get(key, 0)
397
+ if age < self.highlight_ttl:
398
+ style = "bold yellow"
399
+ self.event_age[key] = age + 1
400
+ else:
401
+ style = ""
402
+ marked.append((row, style))
403
+ return marked
404
+
405
+ def _build_detail_tables(self, results: AllResults) -> Group:
406
+ """Build the panel group containing detailed tables for all databases.
407
+
408
+ Args:
409
+ results (AllResults): Current polling results.
410
+
411
+ Returns:
412
+ Group: Group of panels representing detail tables.
413
+ """
414
+ components: list[Any] = []
415
+ for db_name, data in results.items():
416
+ table = Table(
417
+ title=f"[bold #8be9fd]{db_name}[/bold #8be9fd]",
418
+ box=SIMPLE,
419
+ expand=True,
420
+ )
421
+ table.title_align = "left"
422
+ table.add_column("User", style="bold #ffb86c")
423
+ table.add_column("Source IP", style="dim white")
424
+ table.add_column("Last Failed", style="#50fa7b")
425
+ table.add_column("1m", justify="right")
426
+ table.add_column("10m", justify="right")
427
+ table.add_column("1h", justify="right")
428
+
429
+ rows = data.get("details", [])
430
+ if rows:
431
+ marked_rows = self._mark_new_rows(db_name, rows)
432
+ for row, style in marked_rows:
433
+ if style == "bold red":
434
+ row_style = "bold #ff5555"
435
+ elif style == "bold yellow":
436
+ row_style = "bold #ffb86c"
437
+ else:
438
+ row_style = ""
439
+
440
+ table.add_row(
441
+ str(row["user"]),
442
+ str(row["ip"]),
443
+ str(row["last_failed_at"]),
444
+ str(row["1m"]),
445
+ str(row["10m"]),
446
+ str(row["1h"]),
447
+ style=row_style,
448
+ )
449
+ else:
450
+ table.add_row(
451
+ "[dim]No failures[/dim]",
452
+ "[dim]-[/dim]",
453
+ "[dim]-[/dim]",
454
+ "[dim]-[/dim]",
455
+ "[dim]-[/dim]",
456
+ "[dim]-[/dim]",
457
+ )
458
+
459
+ components.append(Panel(table, border_style="#4e5a65", box=ROUNDED))
460
+
461
+ if not components:
462
+ return Group(
463
+ Align.center(
464
+ Text(
465
+ "Poller starting or no database configured...",
466
+ style="dim white",
467
+ )
468
+ )
469
+ )
470
+
471
+ return Group(*components)
472
+
473
+ def _build_layout(self, results: AllResults, previous: AllResults) -> Layout:
474
+ """Create the dashboard layout with header, body, logs, and footer.
475
+
476
+ Args:
477
+ results (AllResults): Current polling results.
478
+ previous (AllResults): Previous polling results.
479
+
480
+ Returns:
481
+ Layout: Rich layout containing the structured dashboard components.
482
+ """
483
+ layout = Layout()
484
+
485
+ layout.split(
486
+ Layout(name="header", size=3),
487
+ Layout(name="body", ratio=1),
488
+ Layout(name="logs", size=7),
489
+ Layout(name="footer", size=1),
490
+ )
491
+
492
+ layout["body"].split_row(
493
+ Layout(name="overview", ratio=4),
494
+ Layout(name="details", ratio=6),
495
+ )
496
+
497
+ # Header Text
498
+ header_text = Text()
499
+ header_text.append(
500
+ " 🔒 ORACLE LOGIN FAILURE MONITOR ", style="bold white on #4e5a65"
501
+ )
502
+ header_text.append(
503
+ f" Refresh: {self.app_config.refresh_seconds}s | Workers: {self.app_config.max_workers} | Highlight TTL: {self.highlight_ttl} cycles",
504
+ style="dim white",
505
+ )
506
+
507
+ layout["header"].update(
508
+ Panel(
509
+ Align.center(header_text, vertical="middle"),
510
+ border_style="#4e5a65",
511
+ box=ROUNDED,
512
+ )
513
+ )
514
+
515
+ # Overview Pane
516
+ overview_table = self._build_summary_table(results, previous)
517
+ layout["body"]["overview"].update(
518
+ Panel(
519
+ overview_table,
520
+ title="[bold #8be9fd]Database Overview[/bold #8be9fd]",
521
+ border_style="#6272a4",
522
+ box=ROUNDED,
523
+ )
524
+ )
525
+
526
+ # Details Pane
527
+ detail_tables = self._build_detail_tables(results)
528
+ layout["body"]["details"].update(
529
+ Panel(
530
+ detail_tables,
531
+ title="[bold #8be9fd]Recent Logon Failures (Audit Trail)[/bold #8be9fd]",
532
+ border_style="#6272a4",
533
+ box=ROUNDED,
534
+ )
535
+ )
536
+
537
+ # Logs Pane
538
+ logs_text = Text()
539
+ if self.log_messages:
540
+ for idx, msg in enumerate(self.log_messages):
541
+ if idx > 0:
542
+ logs_text.append("\n")
543
+ logs_text.append(msg, style="dim white")
544
+ else:
545
+ logs_text.append("Waiting for logs...", style="dim white")
546
+
547
+ layout["logs"].update(
548
+ Panel(
549
+ logs_text,
550
+ title="[bold #8be9fd]System Logs[/bold #8be9fd]",
551
+ border_style="#4e5a65",
552
+ box=ROUNDED,
553
+ )
554
+ )
555
+
556
+ # Footer Pane
557
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
558
+ footer_text = Text()
559
+ footer_text.append(" Ctrl+C ", style="bold black on #8be9fd")
560
+ footer_text.append(" Quit ", style="bold white on #282a36")
561
+ footer_text.append(" Last Polled: ", style="dim white")
562
+ footer_text.append(now_str, style="bold #50fa7b")
563
+
564
+ layout["footer"].update(Align.left(footer_text))
565
+
566
+ return layout
567
+
568
+ def run(self) -> None:
569
+ """Run the live dashboard refresh loop (TUI or headless)."""
570
+ results: AllResults = {}
571
+ self.previous_results = {}
572
+
573
+ logger.info("Oracle Login Failure Monitor starting...")
574
+
575
+ if self.headless:
576
+ try:
577
+ while True:
578
+ self.previous_results = results.copy()
579
+ results = self._fetch_all()
580
+
581
+ for db_name, data in results.items():
582
+ status = data.get("status", "OFFLINE")
583
+ latency = data.get("latency_ms")
584
+ cnt_1m = data.get("1m")
585
+ cnt_10m = data.get("10m")
586
+ cnt_1h = data.get("1h")
587
+
588
+ latency_str = f"{latency}ms" if latency is not None else "-"
589
+ logger.info(
590
+ f"Database: {db_name} | Status: {status} | Latency: {latency_str} | "
591
+ f"Failures (1m/10m/1h): {cnt_1m}/{cnt_10m}/{cnt_1h}"
592
+ )
593
+
594
+ details = data.get("details", [])
595
+ if details:
596
+ marked = self._mark_new_rows(db_name, details)
597
+ for row, style in marked:
598
+ if style == "bold red":
599
+ logger.warning(
600
+ f"NEW FAILURE on {db_name} | User: {row['user']} | "
601
+ f"IP: {row['ip']} | Last Failed: {row['last_failed_at']}"
602
+ )
603
+
604
+ time.sleep(self.app_config.refresh_seconds)
605
+ finally:
606
+ self.close()
607
+ else:
608
+ try:
609
+ with Live(
610
+ self._build_layout(results, self.previous_results),
611
+ refresh_per_second=1,
612
+ screen=True,
613
+ ) as live:
614
+ # Run the first query immediately
615
+ results = self._fetch_all()
616
+ live.update(self._build_layout(results, self.previous_results))
617
+
618
+ while True:
619
+ self.previous_results = results.copy()
620
+ time.sleep(self.app_config.refresh_seconds)
621
+ results = self._fetch_all()
622
+ live.update(self._build_layout(results, self.previous_results))
623
+ finally:
624
+ self.close()
625
+
626
+
627
+ def main() -> None:
628
+ """Create the monitor and start the dashboard loop."""
629
+ parser = argparse.ArgumentParser(description="Oracle Login Failure Monitor")
630
+ parser.add_argument(
631
+ "--config",
632
+ default="config.yaml",
633
+ help="Path to the YAML configuration file (default: config.yaml)",
634
+ )
635
+ parser.add_argument(
636
+ "--headless",
637
+ action="store_true",
638
+ help="Run in headless daemon mode (no terminal UI)",
639
+ )
640
+ args = parser.parse_args()
641
+
642
+ try:
643
+ monitor = OracleLoginFailureMonitor(
644
+ config_path=args.config,
645
+ headless=args.headless,
646
+ )
647
+ monitor.run()
648
+ except KeyboardInterrupt:
649
+ pass
650
+
651
+
652
+ if __name__ == "__main__":
653
+ main()
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: orafail
3
+ Version: 0.1.0
4
+ Summary: Oracle login failure monitor dashboard
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: loguru>=0.7.3
7
+ Requires-Dist: oracledb>=3.4.2
8
+ Requires-Dist: pydantic>=2.13.1
9
+ Requires-Dist: pyyaml>=6.0.3
10
+ Requires-Dist: rich>=14.3.3
@@ -0,0 +1,7 @@
1
+ orafail/__init__.py,sha256=oF6v_L033caRpZ0VCTku7pvZ-y5dzlxHqM0axVV8eEw,46
2
+ orafail/config.py,sha256=FbUYPidzNjmRsiGSfyRg21CC5NekElW-QW1C1vtz43Y,1647
3
+ orafail/main.py,sha256=7XYXDlpZwV00OWMGgcVGa3caV-0-9MkqCAJzUOlqSfE,22991
4
+ orafail-0.1.0.dist-info/METADATA,sha256=ByquHsi7C6-7RcS73eem68uO8FxrsJrLRy-DjwpjF0Y,272
5
+ orafail-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ orafail-0.1.0.dist-info/entry_points.txt,sha256=zl5fezhkUKkE6R7SV0T8pY8k_gXEPMARn5jGydYJ6j8,46
7
+ orafail-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orafail = orafail.main:main