woninet 2.0.1__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.
Files changed (53) hide show
  1. woninet/__init__.py +2 -0
  2. woninet/__main__.py +4 -0
  3. woninet/core/__init__.py +2 -0
  4. woninet/core/alerts.py +251 -0
  5. woninet/core/collectors.py +303 -0
  6. woninet/core/engine.py +353 -0
  7. woninet/core/graph.py +63 -0
  8. woninet/core/manual_ip_enumerator.py +33 -0
  9. woninet/core/models.py +124 -0
  10. woninet/core/storage.py +163 -0
  11. woninet/core/subnet_enumerator.py +32 -0
  12. woninet/database/__init__.py +2 -0
  13. woninet/database/base.py +9 -0
  14. woninet/database/engine.py +44 -0
  15. woninet/database/repositories/__init__.py +2 -0
  16. woninet/database/repositories/alert_event_repository.py +69 -0
  17. woninet/database/repositories/alert_state_repository.py +81 -0
  18. woninet/database/repositories/device_repository.py +105 -0
  19. woninet/database/repositories/metric_repository.py +75 -0
  20. woninet/database/tables.py +89 -0
  21. woninet/logging.yaml +49 -0
  22. woninet/main.py +225 -0
  23. woninet/server/__init__.py +2 -0
  24. woninet/server/app.py +101 -0
  25. woninet/server/dependencies.py +25 -0
  26. woninet/server/routes/__init__.py +2 -0
  27. woninet/server/routes/api/__init__.py +0 -0
  28. woninet/server/routes/api/devices.py +43 -0
  29. woninet/server/routes/api/stats.py +34 -0
  30. woninet/server/routes/page/__init__.py +0 -0
  31. woninet/server/routes/page/devices.py +28 -0
  32. woninet/server/static/css/404.css +164 -0
  33. woninet/server/static/css/style.css +368 -0
  34. woninet/server/static/js/app.js +242 -0
  35. woninet/server/static/js/device_info.js +95 -0
  36. woninet/server/static/swagger-ui/favicon-32x32.png +0 -0
  37. woninet/server/static/swagger-ui/swagger-ui-bundle.js +2 -0
  38. woninet/server/static/swagger-ui/swagger-ui.css +3 -0
  39. woninet/server/templates/404.html +76 -0
  40. woninet/server/templates/dashboard.html +118 -0
  41. woninet/server/templates/device_info.html +93 -0
  42. woninet/utilities/__init__.py +2 -0
  43. woninet/utilities/arguments.py +94 -0
  44. woninet/utilities/cli_parser.py +90 -0
  45. woninet/utilities/detect_ip_range.py +21 -0
  46. woninet/utilities/ip_validator.py +39 -0
  47. woninet/utilities/logger.py +97 -0
  48. woninet-2.0.1.dist-info/METADATA +243 -0
  49. woninet-2.0.1.dist-info/RECORD +53 -0
  50. woninet-2.0.1.dist-info/WHEEL +5 -0
  51. woninet-2.0.1.dist-info/entry_points.txt +2 -0
  52. woninet-2.0.1.dist-info/licenses/LICENSE +21 -0
  53. woninet-2.0.1.dist-info/top_level.txt +1 -0
woninet/logging.yaml ADDED
@@ -0,0 +1,49 @@
1
+ version: 1
2
+ disable_existing_loggers: False
3
+
4
+ formatters:
5
+ default:
6
+ format: "%(asctime)s [%(levelname)s] %(message)s"
7
+ datefmt: "%Y-%m-%d %H:%M:%S"
8
+
9
+ colored:
10
+ (): woninet.utilities.logger.ColorFormatter
11
+ format: "%(asctime)s [%(levelname)s] %(message)s"
12
+ datefmt: "%Y-%m-%d %H:%M:%S"
13
+
14
+ handlers:
15
+ file:
16
+ class: logging.FileHandler
17
+ filename: PLACEHOLDER.txt
18
+ formatter: default
19
+ level: INFO
20
+
21
+ console:
22
+ class: logging.StreamHandler
23
+ formatter: colored
24
+ level: INFO
25
+
26
+ loggers:
27
+ uvicorn:
28
+ handlers: [file, console]
29
+ level: INFO
30
+ propagate: False
31
+
32
+ uvicorn.error:
33
+ handlers: [file, console]
34
+ level: INFO
35
+ propagate: False
36
+
37
+ uvicorn.access:
38
+ handlers: [file, console]
39
+ level: INFO
40
+ propagate: False
41
+
42
+ core:
43
+ handlers: [file, console]
44
+ level: ERROR
45
+ propagate: False
46
+
47
+ root:
48
+ handlers: [console]
49
+ level: INFO
woninet/main.py ADDED
@@ -0,0 +1,225 @@
1
+ import socket
2
+ import logging
3
+ import uvicorn
4
+ import json
5
+ from pathlib import Path
6
+ from yaml import safe_load
7
+ from argparse import Namespace
8
+ from sqlalchemy.exc import OperationalError
9
+ from woninet.core.engine import NetworkMonitorCore
10
+ from woninet.utilities.arguments import args
11
+ from woninet.utilities.logger import get_core_logger, TRACE_LEVEL
12
+
13
+ # Global Variables
14
+ LOGGING_YAML_PATH = Path(__file__).parent / "logging.yaml"
15
+ CONFIG_JSON_PATH = Path(__file__).parent / "config.json"
16
+ REMOTE_PROBE_IP = "8.8.8.8"
17
+ monitor = None
18
+
19
+
20
+ def detect_local_ip(logger: logging.Logger) -> str:
21
+ """
22
+ Determine the local IP address by opening a dummy UDP connection
23
+ to `8.8.8.8:80`.
24
+
25
+ Args:
26
+ logger (Logger): Logger used for recording logs.
27
+
28
+ Returns:
29
+ str: IP address of device.
30
+ """
31
+ try:
32
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
33
+ logger.trace(f"Sending a dummy UDP request to {REMOTE_PROBE_IP}:80")
34
+ s.connect((REMOTE_PROBE_IP, 80))
35
+ return s.getsockname()[0]
36
+ except OSError:
37
+ logger.error("Network is unreachable. Quitting.")
38
+ raise SystemExit(1)
39
+ except Exception as e:
40
+ logger.error(f"Local IP detection failed: {e}")
41
+ raise SystemExit(1)
42
+
43
+
44
+ def load_logging_yaml(log_output: str | None) -> dict:
45
+ """
46
+ Load logging YAML configuration and adjust file handler if needed.
47
+
48
+ Args:
49
+ log_output (str): The file path to save logs.
50
+
51
+ Returns:
52
+ dict: Logging configuration for uvicorn.
53
+ """
54
+ with open(LOGGING_YAML_PATH) as file:
55
+ config = safe_load(file)
56
+
57
+ if log_output:
58
+ config["handlers"]["file"]["filename"] = log_output
59
+ else:
60
+ config["handlers"].pop("file", None)
61
+
62
+ for logger in config.get("loggers", {}).values():
63
+ handlers = logger.get("handlers", [])
64
+ if "file" in handlers:
65
+ handlers.remove("file")
66
+
67
+ return config
68
+
69
+
70
+ def load_config_json() -> dict:
71
+ try:
72
+ with open(CONFIG_JSON_PATH) as file:
73
+ config = json.load(file)
74
+ return config
75
+ except FileNotFoundError:
76
+ raise
77
+
78
+
79
+ def configure_logger(arguments: Namespace) -> logging.Logger:
80
+ """
81
+ Initialize the core logger and adjust verbosity.
82
+ """
83
+ log_output = arguments.logs
84
+ use_color = arguments.color != "false"
85
+
86
+ core_logger = get_core_logger(log_output, use_color)
87
+
88
+ if arguments.verbose == 1:
89
+ core_logger.setLevel(logging.DEBUG)
90
+ elif arguments.verbose >= 2:
91
+ core_logger.setLevel(TRACE_LEVEL)
92
+
93
+ if not arguments.cli:
94
+ core_logger.setLevel(logging.ERROR)
95
+
96
+ return core_logger
97
+
98
+
99
+ def create_monitor(
100
+ local_ip: str,
101
+ target_ip: str,
102
+ database_path: str,
103
+ arp_noise_limit: float,
104
+ max_thread_workers: int,
105
+ logger: logging.Logger,
106
+ alert_rules: dict,
107
+ ) -> NetworkMonitorCore:
108
+ """
109
+ Create an instance of NetworkMonitoreCore.
110
+
111
+ Args:
112
+ local_ip (str): IP address of device.
113
+ target_ip (str): A single or range of user-provided IP addresses to scan.
114
+ database_path (str): Path to SQLite database.
115
+ arp_noise_limit (float): Threshold above which ARP fluctuations are treated as noise.
116
+ max_thread_workers (int): The concurrency limit for the
117
+ `ThreadPoolExecutor` handling ICMP probes
118
+ logger (Logger): Logger used for recording logs
119
+ alert_rules (dict): alert_rules loaded from config.json.
120
+
121
+ Returns:
122
+ NetworkMonitorCore: Instance of NetworkMonitorCore.
123
+ """
124
+ global monitor
125
+ if monitor is None:
126
+ logger.debug(f"ARP noise limit set to {arp_noise_limit}")
127
+ logger.trace(f"Using {database_path} as SQLite database.")
128
+ logger.trace(f"Max thread workers for scanning set to {max_thread_workers}.")
129
+ monitor = NetworkMonitorCore(
130
+ local_ip=local_ip,
131
+ target_ip=target_ip,
132
+ arp_noise_limit=arp_noise_limit,
133
+ database_path=database_path,
134
+ max_thread_workers=max_thread_workers,
135
+ alert_rules=alert_rules,
136
+ )
137
+ return monitor
138
+
139
+
140
+ def get_monitor() -> NetworkMonitorCore:
141
+ """
142
+ Returns the instance of NetworkMonitorCore for easier access in both CLI mode
143
+ and web dashboard.
144
+ """
145
+ global monitor
146
+ return monitor
147
+
148
+
149
+ def run_server(ip: str, port: int, log_yaml: dict) -> None:
150
+ """
151
+ Initialize and run the uvicorn ASGI server.
152
+
153
+ Args:
154
+ ip (str): Host used for running the server.
155
+ port (int): Port number to serve the host.
156
+ log_yaml (dict): Logging configuration for uvicorn.
157
+ """
158
+ from woninet.server.app import app
159
+
160
+ config = uvicorn.Config(app, host=ip, port=port, log_config=log_yaml)
161
+ server = uvicorn.Server(config)
162
+ app.state.uvicorn_server = server
163
+ server.run()
164
+
165
+
166
+ def main() -> None:
167
+ """
168
+ Entry point for woninet. Launch either the web dashboard or the CLI
169
+ monitor based on command-line arguments.
170
+ """
171
+ global monitor
172
+
173
+ arguments = args()
174
+
175
+ if arguments.version:
176
+ from woninet.__init__ import __version__
177
+
178
+ print(__version__)
179
+ return
180
+
181
+ configuration = load_config_json()
182
+
183
+ target_ip = configuration["target_ip_list"]
184
+ port = arguments.port
185
+
186
+ core_logger = configure_logger(arguments=arguments)
187
+ log_yaml = load_logging_yaml(log_output=arguments.logs)
188
+
189
+ local_ip = detect_local_ip(logger=core_logger)
190
+
191
+ try:
192
+ if hasattr(arguments, "func"):
193
+ core_logger.setLevel(logging.ERROR)
194
+
195
+ monitor = create_monitor(
196
+ local_ip=local_ip,
197
+ target_ip=target_ip,
198
+ database_path=configuration["database"],
199
+ arp_noise_limit=configuration["monitoring"]["arp_noise_limit"],
200
+ max_thread_workers=configuration["monitoring"]["max_workers"],
201
+ logger=core_logger,
202
+ alert_rules=configuration["alert_rules"],
203
+ )
204
+
205
+ if hasattr(arguments, "func"):
206
+ arguments.monitor = monitor
207
+ arguments.func(arguments)
208
+ return
209
+
210
+ if arguments.cli:
211
+ monitor.start()
212
+ monitor.wait()
213
+ else:
214
+ run_server(ip=local_ip, port=port, log_yaml=log_yaml)
215
+ except KeyboardInterrupt:
216
+ core_logger.info("Keyboard interrupted. Wait for shut down...")
217
+ except ValueError as e:
218
+ core_logger.error(f"{e} Quitting.")
219
+ except FileNotFoundError:
220
+ core_logger.error("Can not find the config.json file. Quitting.")
221
+ except OperationalError as e:
222
+ core_logger.error(e)
223
+ finally:
224
+ if hasattr(monitor, "stop"):
225
+ monitor.stop()
@@ -0,0 +1,2 @@
1
+ # woninet/core/__init__.py
2
+ # (This file intentionally left blank)
woninet/server/app.py ADDED
@@ -0,0 +1,101 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+ from fastapi import FastAPI, Request, HTTPException, status
4
+ from fastapi.responses import HTMLResponse
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.templating import Jinja2Templates
7
+ from fastapi.openapi.docs import get_swagger_ui_html
8
+ from contextlib import asynccontextmanager
9
+ from woninet.server.routes.api.devices import router as devices_api
10
+ from woninet.server.routes.api.stats import router as stats_api
11
+ from woninet.server.routes.page.devices import router as devices_page
12
+ from woninet.server.dependencies import get_static_path
13
+ from woninet.main import get_monitor
14
+ from woninet.__init__ import __version__
15
+
16
+ # Global variables
17
+ TEMPLATES_DIR, STATIC_DIR = get_static_path()
18
+ CHARTS_DIR = Path(__file__).parent.parent / "charts"
19
+
20
+ monitor = None
21
+
22
+
23
+ @asynccontextmanager
24
+ async def lifespan(app: FastAPI):
25
+ global monitor
26
+
27
+ # Startup
28
+ monitor = get_monitor()
29
+ monitor.start()
30
+
31
+ # Assign the instance of Server
32
+ server = app.state.uvicorn_server
33
+
34
+ async def watch():
35
+ """
36
+ Periodic monitor watchdog. Triggers server shutdown when the monitor
37
+ is no longer alive.
38
+ """
39
+ while True:
40
+ await asyncio.sleep(1)
41
+ if not monitor.is_alive():
42
+ server.should_exit = True
43
+ return
44
+
45
+ task = asyncio.create_task(watch())
46
+
47
+ try:
48
+ yield
49
+ finally:
50
+ # Shutdown
51
+ task.cancel()
52
+ monitor.stop()
53
+
54
+
55
+ app = FastAPI(
56
+ title="woninet",
57
+ description="Network Monitoring Dashboard",
58
+ lifespan=lifespan,
59
+ version=__version__,
60
+ docs_url=None,
61
+ redoc_url=None,
62
+ )
63
+
64
+ # Templates
65
+ templates = Jinja2Templates(directory=TEMPLATES_DIR)
66
+
67
+ # Static files
68
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
69
+ app.mount("/charts", StaticFiles(directory=CHARTS_DIR), name="charts")
70
+
71
+ # Routes
72
+ app.include_router(devices_api)
73
+ app.include_router(stats_api)
74
+ app.include_router(devices_page)
75
+
76
+
77
+ # The root path
78
+ @app.get("/", response_class=HTMLResponse)
79
+ def root(request: Request):
80
+ return templates.TemplateResponse(request=request, name="dashboard.html")
81
+
82
+
83
+ @app.get("/docs", include_in_schema=False)
84
+ def custom_swagger_ui():
85
+ return get_swagger_ui_html(
86
+ openapi_url=app.openapi_url,
87
+ title="woninet - Swagger UI",
88
+ swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
89
+ swagger_css_url="/static/swagger-ui/swagger-ui.css",
90
+ swagger_favicon_url="/static/swagger-ui/favicon-32x32.png",
91
+ )
92
+
93
+
94
+ @app.exception_handler(404)
95
+ def custom_not_found_exception(request: Request, exc: HTTPException):
96
+ return templates.TemplateResponse(
97
+ request=request,
98
+ name="404.html",
99
+ context={"request": request},
100
+ status_code=status.HTTP_404_NOT_FOUND,
101
+ )
@@ -0,0 +1,25 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def get_static_path() -> tuple[Path, Path]:
5
+ """
6
+ Return the paths to the templates and static directories.
7
+
8
+ Returns:
9
+ tuple[Path, Path]: A tuple containing (TEMPLATES_DIR, STATIC_DIR)
10
+ """
11
+ STATIC_DIR = Path(__file__).parent / "static"
12
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
13
+
14
+ return TEMPLATES_DIR, STATIC_DIR
15
+
16
+
17
+ def get_monitor_gracefully():
18
+ """
19
+ Call get_monitor() and return the intance of NetworkMonitorCore class
20
+ to prevent circular import error.
21
+ """
22
+ from woninet.main import get_monitor
23
+
24
+ monitor = get_monitor()
25
+ return monitor
@@ -0,0 +1,2 @@
1
+ # woninet/core/__init__.py
2
+ # (This file intentionally left blank)
File without changes
@@ -0,0 +1,43 @@
1
+ from fastapi import APIRouter, Request, Path, status
2
+ from fastapi.responses import JSONResponse
3
+ from fastapi.encoders import jsonable_encoder
4
+ from woninet.server.dependencies import get_monitor_gracefully
5
+
6
+ router = APIRouter(prefix="/api/devices", tags=["devices"])
7
+
8
+
9
+ @router.get("")
10
+ def list_devices(request: Request):
11
+ monitor = get_monitor_gracefully()
12
+ return JSONResponse(
13
+ content={"devices": jsonable_encoder(monitor.get_device_history())},
14
+ status_code=status.HTTP_200_OK,
15
+ )
16
+
17
+
18
+ @router.get("/{ip}")
19
+ def device_info_api(
20
+ request: Request,
21
+ ip: str = Path(
22
+ ...,
23
+ title="IP address",
24
+ description="IP address of the device.",
25
+ min_length=7,
26
+ max_length=15,
27
+ ),
28
+ ):
29
+ monitor = get_monitor_gracefully()
30
+ device, device_alert_state, device_recent_alert_events = monitor.get_device_info(
31
+ ip=ip
32
+ )
33
+ monitor.graph_engine.design_device_latency_events(
34
+ ip=ip, recent_device_alert_events=device_recent_alert_events
35
+ )
36
+ return JSONResponse(
37
+ content={
38
+ "device": jsonable_encoder(device),
39
+ "device_alert_state": jsonable_encoder(device_alert_state),
40
+ "device_recent_alert_events": jsonable_encoder(device_recent_alert_events),
41
+ },
42
+ status_code=status.HTTP_200_OK,
43
+ )
@@ -0,0 +1,34 @@
1
+ from fastapi import APIRouter, status
2
+ from fastapi.responses import JSONResponse
3
+ from woninet.server.dependencies import get_monitor_gracefully
4
+
5
+ router = APIRouter(prefix="/api/stats", tags=["stats"])
6
+
7
+
8
+ @router.get("")
9
+ def stats():
10
+ from woninet.__init__ import __version__
11
+
12
+ monitor = get_monitor_gracefully()
13
+ devices_total, metrics_total = monitor.count_resources()
14
+
15
+ return JSONResponse(
16
+ content={
17
+ "identity": {
18
+ "service": "woninet",
19
+ "version": __version__,
20
+ "server_ip": monitor.local_ip,
21
+ },
22
+ "health": {
23
+ "engine_alive": monitor.is_alive(),
24
+ "database": monitor.database_health(),
25
+ },
26
+ "stats": {
27
+ "devices_total": devices_total,
28
+ "metrics_total": metrics_total,
29
+ "uptime_seconds": monitor.uptime(),
30
+ },
31
+ "recent_alert_events": monitor.classify_recent_alert_events(),
32
+ },
33
+ status_code=status.HTTP_200_OK,
34
+ )
File without changes
@@ -0,0 +1,28 @@
1
+ from fastapi import APIRouter, Request, Path, HTTPException, status
2
+ from fastapi.responses import HTMLResponse
3
+ from fastapi.templating import Jinja2Templates
4
+ from woninet.server.dependencies import get_static_path, get_monitor_gracefully
5
+
6
+ TEMPLATES_DIR, STATIC_DIR = get_static_path()
7
+ templates = Jinja2Templates(directory=TEMPLATES_DIR)
8
+
9
+ router = APIRouter(prefix="/devices", tags=["devices"])
10
+
11
+
12
+ @router.get("/{ip}", response_class=HTMLResponse)
13
+ def device_info(
14
+ request: Request,
15
+ ip: str = Path(
16
+ ...,
17
+ title="IP address",
18
+ description="IP address of the device.",
19
+ min_length=7,
20
+ max_length=15,
21
+ ),
22
+ ):
23
+ monitor = get_monitor_gracefully()
24
+
25
+ if not monitor.device_exists(ip=ip):
26
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found")
27
+
28
+ return templates.TemplateResponse(request=request, name="device_info.html")
@@ -0,0 +1,164 @@
1
+ :root{
2
+ --bg:#08111f;
3
+ --panel:#101b31;
4
+ --panel-light:#172541;
5
+
6
+ --text:#edf3ff;
7
+ --muted:#8ea1c3;
8
+
9
+ --accent:#4f8cff;
10
+
11
+ --good:#22c55e;
12
+ --warn:#f59e0b;
13
+ --bad:#ef4444;
14
+
15
+ --border:rgba(255,255,255,.08);
16
+
17
+ }
18
+
19
+ *{
20
+ margin:0;
21
+ padding:0;
22
+ box-sizing:border-box;
23
+ }
24
+
25
+ body{
26
+ font-family:Inter,sans-serif;
27
+ color:var(--text);
28
+ min-height:100vh;
29
+ display:flex;
30
+ justify-content:center;
31
+ align-items:center;
32
+ background:
33
+ radial-gradient(circle at top left,
34
+ rgba(79,140,255,.15),
35
+ transparent 25%),
36
+
37
+ radial-gradient(circle at bottom right,
38
+ rgba(34,197,94,.08),
39
+ transparent 30%),
40
+
41
+ linear-gradient(180deg,#08111f,#050b15);
42
+ }
43
+
44
+ .card{
45
+ width:min(700px,90vw);
46
+ background:var(--panel);
47
+ border:1px solid var(--border);
48
+ border-radius:24px;
49
+ padding:50px;
50
+ text-align:center;
51
+ box-shadow:
52
+ 0 20px 60px rgba(0,0,0,.35);
53
+ }
54
+
55
+ .network-icon{
56
+ width:120px;
57
+ height:120px;
58
+ margin:auto;
59
+ border-radius:50%;
60
+ background:rgba(79,140,255,.12);
61
+ display:flex;
62
+ align-items:center;
63
+ justify-content:center;
64
+ font-size:52px;
65
+ color:var(--accent);
66
+ margin-bottom:30px;
67
+ }
68
+
69
+ h1{
70
+ font-size:100px;
71
+ line-height:1;
72
+ margin-bottom:10px;
73
+ }
74
+
75
+ h2{
76
+ font-size:32px;
77
+ margin-bottom:15px;
78
+ }
79
+
80
+ p{
81
+ color:var(--muted);
82
+ line-height:1.7;
83
+ max-width:520px;
84
+ margin:auto;
85
+ }
86
+
87
+ .divider{
88
+ width:100%;
89
+ height:1px;
90
+ background:var(--border);
91
+ margin:40px 0;
92
+ }
93
+
94
+ .actions{
95
+ display:flex;
96
+ justify-content:center;
97
+ gap:15px;
98
+ flex-wrap:wrap;
99
+ }
100
+
101
+ .btn{
102
+ display:inline-flex;
103
+ align-items:center;
104
+ gap:10px;
105
+ text-decoration:none;
106
+ padding:14px 22px;
107
+ border-radius:12px;
108
+ transition:.25s;
109
+ font-weight:600;
110
+ }
111
+
112
+ .btn-primary{
113
+ background:var(--accent);
114
+ color:white;
115
+ }
116
+
117
+ .btn-primary:hover{
118
+ transform:translateY(-2px);
119
+ box-shadow:0 8px 20px rgba(79,140,255,.35);
120
+ }
121
+
122
+ .btn-secondary{
123
+ background:var(--panel-light);
124
+ color:var(--text);
125
+ border:1px solid var(--border);
126
+ }
127
+
128
+ .btn-secondary:hover{
129
+ background:#213250;
130
+ }
131
+
132
+ .footer{
133
+ margin-top:35px;
134
+ color:var(--muted);
135
+ font-size:.85rem;
136
+ }
137
+
138
+ .status{
139
+ display:inline-flex;
140
+ align-items:center;
141
+ gap:8px;
142
+ color:var(--warn);
143
+ margin-top:20px;
144
+ font-size:.9rem;
145
+ }
146
+
147
+ .status-dot{
148
+ width:10px;
149
+ height:10px;
150
+ border-radius:50%;
151
+ background:var(--warn);
152
+ animation:pulse 1.8s infinite;
153
+ }
154
+
155
+ @keyframes pulse{
156
+ 0%{
157
+ transform:scale(1);
158
+ opacity:1;
159
+ }
160
+ 100%{
161
+ transform:scale(2);
162
+ opacity:0;
163
+ }
164
+ }