diff --git a/Dockerfile b/Dockerfile index af2af21..e399937 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,78 @@ -# Use Debian-based Python 3.13 slim image -FROM python:3.13-slim-bookworm +# Stage 1: +# This stage installs build dependencies and builds Python packages into wheels. +FROM python:3.13-slim-bookworm AS builder + +# Install build dependencies for rrdtool and Python packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + librrd-dev \ + build-essential \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* # Set working directory WORKDIR /app -# Install system dependencies for rrdtool -RUN apt-get update && apt-get install -y \ - rrdtool \ - librrd-dev \ - build-essential \ - python3-dev \ - wget \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements first for better Docker layer caching +# Copy requirements file COPY requirements.txt . -# Install Python dependencies +# Install Python dependencies into a wheelhouse +# This builds source distributions (like rrdtool) into wheels +# We don't need a venv here as we're just creating wheels, not installing them RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt + pip wheel --no-cache-dir --wheel-dir /tmp/wheels -r requirements.txt -# Remove build dependencies to reduce image size -RUN apt-get purge -y build-essential python3-dev && \ - apt-get autoremove -y && \ - apt-get clean + +# Stage 2: Runtime +# This stage takes the minimal base image and copies only the necessary runtime artifacts. +FROM python:3.13-slim-bookworm + +# Install runtime system dependencies for rrdtool and wget for healthcheck +# rrdtool and librrd8 are the runtime libraries for rrdtool (not librrd-dev) +RUN apt-get update && apt-get install -y --no-install-recommends \ + rrdtool \ + librrd8 \ + wget \ + # Final cleanup to reduce image size + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Set working directory +WORKDIR /app + +# Create a non-root user for security (before creating venv in their home if desired, or in /opt) +RUN useradd --create-home --shell /bin/bash appuser + +# Create a virtual environment for the application +# We'll put it in /opt/venv for consistency, and ensure appuser can access it +RUN python3 -m venv /opt/venv && \ + /opt/venv/bin/pip install --no-cache-dir --upgrade pip + +# Copy the built Python wheels from the builder stage +COPY --from=builder /tmp/wheels /tmp/wheels/ + +# Install Python dependencies from the wheels into the virtual environment +RUN /opt/venv/bin/pip install --no-cache-dir /tmp/wheels/*.whl && \ + rm -rf /tmp/wheels # Remove the wheels after installation to save space # Copy application code COPY app/ ./app/ -# Create directory for RRD data at /data (will be volume mounted) -RUN mkdir -p /data +# Set permissions for the appuser and data directory +RUN chown -R appuser:appuser /app && \ + chown -R appuser:appuser /opt/venv && \ + mkdir -p /data && \ + chown -R appuser:appuser /data && \ + chmod 777 /data # Ensure volume mount has write permissions + +# Switch to the non-root user +USER appuser # Expose port EXPOSE 8000 -# Create non-root user for security -RUN useradd --create-home --shell /bin/bash appuser && \ - chown -R appuser:appuser /app && \ - chown -R appuser:appuser /data && \ - chmod 777 /data -USER appuser - # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health || exit 1 -# Run the application -CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] - +# Run the application using the virtual environment's python interpreter +CMD ["/opt/venv/bin/python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/main.py b/app/main.py index 16948ab..6ba280d 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from typing import Dict, List, Annotated import uuid as uuid_lib from collections import deque -from dateutil.parser import isoparse +from dateutil.parser import isoparse # Import isoparse for robust date parsing from pythonjsonlogger import jsonlogger import sys @@ -27,19 +27,26 @@ database = RRDDatabase() logger = logging.getLogger() logger.setLevel(logging.INFO) + class BufferHandler(logging.Handler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.formatter = jsonlogger.JsonFormatter() + # Use the same formatter string as the StreamHandler for consistency + self.formatter = jsonlogger.JsonFormatter( + "%(asctime)s %(name)s %(levelname)s %(message)s" + ) def emit(self, record): try: - # Format the record as a JSON string and then parse it back to a dict - # This ensures consistency with the jsonlogger's output format - log_entry = json.loads(self.formatter.format(record)) + log_entry_str = self.formatter.format(record) + log_entry = json.loads(log_entry_str) log_buffer.add_log(log_entry) except Exception as e: - print(f"Error in BufferHandler: Could not process log record: {e}", file=sys.stderr) + print( + f"Error in BufferHandler: Could not process log record: {e}", + file=sys.stderr, + ) + class LogBuffer: def __init__(self, maxlen=1000): @@ -47,14 +54,35 @@ class LogBuffer: def add_log(self, record): # Assuming 'record' here is already a dictionary parsed from the JSON log string - timestamp = record.get('asctime') or datetime.utcnow().isoformat() - self.buffer.append({ - 'timestamp': timestamp, - 'level': record.get('levelname'), - 'message': record.get('message'), - 'extra': {k: v for k, v in record.items() - if k not in ['asctime', 'levelname', 'message', 'name', 'lineno', 'filename', 'pathname', 'funcName', 'process', 'processName', 'thread', 'threadName']} - }) + timestamp = record.get("asctime") or datetime.utcnow().isoformat() + self.buffer.append( + { + "timestamp": timestamp, + "level": record.get( + "levelname" + ), # This should now correctly get 'levelname' + "message": record.get("message"), + "extra": { + k: v + for k, v in record.items() + if k + not in [ + "asctime", + "levelname", + "message", + "name", + "lineno", + "filename", + "pathname", + "funcName", + "process", + "processName", + "thread", + "threadName", + ] + }, + } + ) def get_logs(self, limit=100, level=None, since=None): logger.debug(f"Fetching logs with limit={limit}, level={level}, since={since}") @@ -62,9 +90,9 @@ class LogBuffer: # Apply level filter if level and level.strip(): level = level.upper() - valid_levels = {'INFO', 'WARNING', 'ERROR', 'DEBUG'} + valid_levels = {"INFO", "WARNING", "ERROR", "DEBUG"} if level in valid_levels: - logs = [log for log in logs if log['level'].upper() == level] + logs = [log for log in logs if log["level"].upper() == level] else: logger.warning(f"Invalid log level: {level}") # Apply since filter @@ -72,7 +100,7 @@ class LogBuffer: try: # Use isoparse for robust parsing of ISO 8601 strings since_dt = isoparse(since) - + # If the parsed datetime is naive (no timezone info), assume it's UTC if since_dt.tzinfo is None: since_dt = since_dt.replace(tzinfo=timezone.utc) @@ -80,19 +108,24 @@ class LogBuffer: # If it has timezone info, convert it to UTC for consistent comparison since_dt = since_dt.astimezone(timezone.utc) - logs = [log for log in logs if - datetime.fromisoformat(log['timestamp'].replace('Z', '+00:00')).astimezone(timezone.utc) >= since_dt] + logs = [ + log + for log in logs + if datetime.fromisoformat( + log["timestamp"].replace("Z", "+00:00") + ).astimezone(timezone.utc) + >= since_dt + ] except ValueError: logger.warning(f"Invalid 'since' timestamp: {since}") logger.debug(f"Returning {len(logs[-limit:])} logs") return logs[-limit:] + log_buffer = LogBuffer() logHandler = logging.StreamHandler() -formatter = jsonlogger.JsonFormatter( - '%(asctime)s %(name)s %(levelname)s %(message)s' -) +formatter = jsonlogger.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") logHandler.setFormatter(formatter) if not logger.handlers: @@ -109,20 +142,23 @@ logging.getLogger("uvicorn.error").propagate = True # --- FastAPI Application --- app = FastAPI( title="Node Monitoring System", - description=f"A distributed monitoring system. Service UUID: {SERVICE_UUID}" + description=f"A distributed monitoring system. Service UUID: {SERVICE_UUID}", ) templates = Jinja2Templates(directory="app/web/templates") app.mount("/static", StaticFiles(directory="app/web/static"), name="static") + # --- Data Models --- class NodeStatusModel(BaseModel): uptime_seconds: int load_avg: Annotated[List[float], Field(min_length=3, max_length=3)] memory_usage_percent: float + class PingModel(BaseModel): - pings: Dict[Annotated[str, Field(pattern=r'^[0-9a-fA-F-]{36}$')], float] + pings: Dict[Annotated[str, Field(pattern=r"^[0-9a-fA-F-]{36}$")], float] + class StatusUpdate(BaseModel): node: str = Field(..., description="Node UUID") @@ -130,23 +166,24 @@ class StatusUpdate(BaseModel): status: NodeStatusModel pings: Dict[str, float] - @validator('node') + @validator("node") def validate_node_uuid(cls, v): try: uuid_lib.UUID(v) return v except ValueError: - raise ValueError('Invalid UUID format') + raise ValueError("Invalid UUID format") - @validator('pings') + @validator("pings") def validate_ping_uuids(cls, v): for key in v.keys(): try: uuid_lib.UUID(key) except ValueError: - raise ValueError(f'Invalid UUID format in pings: {key}') + raise ValueError(f"Invalid UUID format in pings: {key}") return v + # --- Node Management and Health Logic --- known_nodes_db: Dict[str, Dict] = {} @@ -155,13 +192,20 @@ LOAD_AVG_CRITICAL_THRESHOLD = 3.0 MEMORY_WARNING_THRESHOLD = 75.0 MEMORY_CRITICAL_THRESHOLD = 90.0 LAST_SEEN_CRITICAL_THRESHOLD_SECONDS = 30 -NODE_INACTIVE_REMOVAL_THRESHOLD_SECONDS = 300 # Remove node from UI after 5 minutes of inactivity +NODE_INACTIVE_REMOVAL_THRESHOLD_SECONDS = ( + 300 # Remove node from UI after 5 minutes of inactivity +) + def get_node_health(node_data: Dict) -> str: last_seen_str = node_data.get("last_seen") if last_seen_str: - last_seen_dt = datetime.fromisoformat(last_seen_str).replace(tzinfo=timezone.utc) - time_since_last_seen = (datetime.now(timezone.utc) - last_seen_dt).total_seconds() + last_seen_dt = datetime.fromisoformat(last_seen_str).replace( + tzinfo=timezone.utc + ) + time_since_last_seen = ( + datetime.now(timezone.utc) - last_seen_dt + ).total_seconds() if time_since_last_seen > LAST_SEEN_CRITICAL_THRESHOLD_SECONDS: return "critical" else: @@ -174,7 +218,10 @@ def get_node_health(node_data: Dict) -> str: try: status = NodeStatusModel(**status_model_data) except Exception: - logger.error(f"Could not parse status data for node {node_data.get('uuid')}", exc_info=True) + logger.error( + f"Could not parse status data for node {node_data.get('uuid')}", + exc_info=True, + ) return "unknown" load_1min = status.load_avg[0] @@ -190,6 +237,7 @@ def get_node_health(node_data: Dict) -> str: return "healthy" + # --- API Endpoints --- @app.get("/", response_class=HTMLResponse) async def read_root(request: Request): @@ -197,62 +245,61 @@ async def read_root(request: Request): client_ip = request.headers.get("x-forwarded-for", request.client.host) logger.info( "Web root accessed", - extra={'client_ip': client_ip, 'service_uuid': SERVICE_UUID} + extra={"client_ip": client_ip, "service_uuid": SERVICE_UUID}, ) return templates.TemplateResponse( "index.html", { "request": request, "service_uuid": SERVICE_UUID, - "url_for": request.url_for, # Pass url_for for dynamic URL generation - "root_path": request.scope.get('root_path', '') # Pass root_path for JS base URL - } + "url_for": request.url_for, # Pass url_for for dynamic URL generation + "root_path": request.scope.get( + "root_path", "" + ), # Pass root_path for JS base URL + }, ) + @app.get("/{service_uuid}/logs") async def get_logs( request: Request, service_uuid: str, limit: int = 100, - format: str = Query(None, description="Response format: 'json' for JSON, default is HTML"), + format: str = Query( + None, description="Response format: 'json' for JSON, default is HTML" + ), level: str = Query(None, description="Filter logs by level: INFO, WARNING, ERROR"), - since: str = Query(None, description="Fetch logs since ISO timestamp, e.g., 2025-06-11T13:32:00") + since: str = Query( + None, description="Fetch logs since ISO timestamp, e.g., 2025-06-11T13:32:00" + ), ): # Use X-Forwarded-For if available, otherwise client.host client_ip = request.headers.get("x-forwarded-for", request.client.host) logger.info( "Logs endpoint accessed", extra={ - 'service_uuid': service_uuid, - 'format': format, - 'level': level, - 'since': since, - 'limit': limit, - 'client_ip': client_ip - } + "service_uuid": service_uuid, + "format": format, + "level": level, + "since": since, + "limit": limit, + "client_ip": client_ip, + }, ) if service_uuid != SERVICE_UUID: logger.warning(f"Invalid service UUID: {service_uuid}") return JSONResponse( - status_code=404, - content={"error": "Service UUID not found"} + status_code=404, content={"error": "Service UUID not found"} ) try: logs = log_buffer.get_logs(limit=limit, level=level, since=since) - log_data = { - "service_uuid": service_uuid, - "log_count": len(logs), - "logs": logs - } + log_data = {"service_uuid": service_uuid, "log_count": len(logs), "logs": logs} logger.debug(f"Fetched {len(logs)} logs for response") except Exception as e: logger.error(f"Error fetching logs: {e}", exc_info=True) - return JSONResponse( - status_code=500, - content={"error": "Failed to fetch logs"} - ) + return JSONResponse(status_code=500, content={"error": "Failed to fetch logs"}) if format == "json": logger.debug("Returning JSON response") @@ -267,41 +314,40 @@ async def get_logs( "service_uuid": service_uuid, "logs": logs, "log_count": len(logs), - "url_for": request.url_for, # Pass url_for for dynamic URL generation - "root_path": request.scope.get('root_path', '') # Pass root_path for JS base URL - } + "url_for": request.url_for, # Pass url_for for dynamic URL generation + "root_path": request.scope.get( + "root_path", "" + ), # Pass root_path for JS base URL + }, ) except Exception as e: logger.error(f"Error rendering logs.html: {e}", exc_info=True) return JSONResponse( - status_code=500, - content={"error": "Failed to render logs page"} + status_code=500, content={"error": "Failed to render logs page"} ) + @app.put("/{service_uuid}/{node_uuid}/") async def update_node_status( - service_uuid: str, - node_uuid: str, - status_update: StatusUpdate, - request: Request + service_uuid: str, node_uuid: str, status_update: StatusUpdate, request: Request ): # Use X-Forwarded-For if available, otherwise client.host client_ip = request.headers.get("x-forwarded-for", request.client.host) logger.info( "Received node status update", extra={ - 'event_type': 'node_status_update', - 'client_ip': client_ip, - 'service_uuid': service_uuid, - 'node_uuid': node_uuid, - 'data': status_update.dict() - } + "event_type": "node_status_update", + "client_ip": client_ip, + "service_uuid": service_uuid, + "node_uuid": node_uuid, + "data": status_update.dict(), + }, ) if service_uuid != SERVICE_UUID: logger.warning( "Node sent status to wrong service UUID", - extra={'client_node_uuid': node_uuid, 'target_uuid': service_uuid} + extra={"client_node_uuid": node_uuid, "target_uuid": service_uuid}, ) return {"error": "Service UUID mismatch", "peers": []} @@ -311,7 +357,7 @@ async def update_node_status( timestamp=status_update.timestamp, uptime_seconds=status_update.status.uptime_seconds, load_avg=status_update.status.load_avg, - memory_usage_percent=status_update.status.memory_usage_percent + memory_usage_percent=status_update.status.memory_usage_percent, ) for target_uuid, latency in status_update.pings.items(): @@ -319,7 +365,7 @@ async def update_node_status( node_uuid=node_uuid, target_uuid=target_uuid, timestamp=status_update.timestamp, - latency_ms=latency + latency_ms=latency, ) except Exception as e: @@ -329,31 +375,39 @@ async def update_node_status( known_nodes_db[node_uuid] = { "last_seen": current_time_utc.isoformat(), - "ip": request.client.host, # Keep original client.host here as it's the direct connection + "ip": request.client.host, # Keep original client.host here as it's the direct connection "status": status_update.status.dict(), "uptime_seconds": status_update.status.uptime_seconds, "load_avg": status_update.status.load_avg, - "memory_usage_percent": status_update.status.memory_usage_percent + "memory_usage_percent": status_update.status.memory_usage_percent, } health_status_for_log = get_node_health(known_nodes_db[node_uuid]) logger.info(f"Node {node_uuid} updated. Health: {health_status_for_log}") - peer_list = {uuid: {"last_seen": data["last_seen"], "ip": data["ip"]} - for uuid, data in known_nodes_db.items() if uuid != node_uuid} + peer_list = { + uuid: {"last_seen": data["last_seen"], "ip": data["ip"]} + for uuid, data in known_nodes_db.items() + if uuid != node_uuid + } return {"message": "Status received", "peers": peer_list} + @app.get("/nodes/status") async def get_all_nodes_status(): logger.info("Fetching all nodes status for UI.") - + # Prune inactive nodes from known_nodes_db before processing current_time_utc = datetime.now(timezone.utc) nodes_to_remove = [] for node_uuid, data in known_nodes_db.items(): - last_seen_dt = datetime.fromisoformat(data["last_seen"]).replace(tzinfo=timezone.utc) - if (current_time_utc - last_seen_dt).total_seconds() > NODE_INACTIVE_REMOVAL_THRESHOLD_SECONDS: + last_seen_dt = datetime.fromisoformat(data["last_seen"]).replace( + tzinfo=timezone.utc + ) + if ( + current_time_utc - last_seen_dt + ).total_seconds() > NODE_INACTIVE_REMOVAL_THRESHOLD_SECONDS: nodes_to_remove.append(node_uuid) logger.info(f"Removing inactive node {node_uuid} from known_nodes_db.") @@ -365,31 +419,37 @@ async def get_all_nodes_status(): current_health = get_node_health(data) connections = {} - for target_uuid in known_nodes_db: # Only iterate over currently active nodes + for target_uuid in known_nodes_db: # Only iterate over currently active nodes if target_uuid != node_uuid: - ping_data = database.get_ping_data(node_uuid, target_uuid, start_time="-300s") + ping_data = database.get_ping_data( + node_uuid, target_uuid, start_time="-300s" + ) latency_ms = None - if ping_data and ping_data['data']['latency']: + if ping_data and ping_data["data"]["latency"]: # Get the most recent non-None latency - for latency in reversed(ping_data['data']['latency']): - if latency is not None and not (isinstance(latency, float) and latency == 0.0): # Exclude 0.0 which might be a default + for latency in reversed(ping_data["data"]["latency"]): + if latency is not None and not ( + isinstance(latency, float) and latency == 0.0 + ): # Exclude 0.0 which might be a default latency_ms = float(latency) break connections[target_uuid] = latency_ms - response_nodes.append({ - "uuid": node_uuid, - "last_seen": data["last_seen"], - "ip": data["ip"], - "health_status": current_health, - "uptime_seconds": data.get("uptime_seconds"), - "load_avg": data.get("load_avg"), - "memory_usage_percent": data.get("memory_usage_percent"), - "connections": connections - }) + response_nodes.append( + { + "uuid": node_uuid, + "last_seen": data["last_seen"], + "ip": data["ip"], + "health_status": current_health, + "uptime_seconds": data.get("uptime_seconds"), + "load_avg": data.get("load_avg"), + "memory_usage_percent": data.get("memory_usage_percent"), + "connections": connections, + } + ) return {"nodes": response_nodes} + @app.get("/health") async def health_check(): return {"status": "ok"} -# --- END OF FILE main.py --- diff --git a/app/web/static/logs.js b/app/web/static/logs.js index 1b3dce6..21076cb 100644 --- a/app/web/static/logs.js +++ b/app/web/static/logs.js @@ -24,9 +24,11 @@ document.addEventListener('DOMContentLoaded', () => { console.log('Fetch URL:', url); const response = await fetch(url); console.log('Response status:', response.status); + console.log('Response Content-Type:', response.headers.get('Content-Type')); // NEW: Log Content-Type + if (!response.ok) { const errorText = await response.text(); // Try to get response body as text - console.error('Response text on error:', errorText); // Log it + console.error('Raw response text on error:', errorText.substring(0, 500) + (errorText.length > 500 ? '...' : '')); // Log first 500 chars // If the server returns a 404, it might be due to a stale UUID. // Log a more specific message. if (response.status === 404) { @@ -39,7 +41,9 @@ document.addEventListener('DOMContentLoaded', () => { } return; // Stop further processing if error } - const data = await response.json(); + + // Attempt to parse JSON. This is where the error would occur if the content is HTML. + const data = await response.json(); console.log('Received logs:', data.logs.length); renderLogTable(data.logs); logCountSpan.textContent = data.log_count; @@ -51,7 +55,7 @@ document.addEventListener('DOMContentLoaded', () => { function renderLogTable(logs) { console.log('Rendering logs:', logs.length); - logTableContainer.innerHTML = ''; + logTableContainer.innerHTML = ''; // Clear existing content before rendering if (logs.length === 0) { logTableContainer.innerHTML = '

No logs available.

'; @@ -86,7 +90,7 @@ document.addEventListener('DOMContentLoaded', () => { const row = document.createElement('tr'); row.innerHTML = ` ${new Date(log.timestamp).toLocaleString()} - ${log.level} + ${log.level || 'N/A'} ${escapeHtml(log.message)} ${log.extra ? ` @@ -158,6 +162,8 @@ document.addEventListener('DOMContentLoaded', () => { }); console.log('Initializing logs page'); + // Call fetchLogs immediately on page load to populate the table with fresh data + // and handle the initial refresh logic. fetchLogs(); setInterval(fetchLogs, POLLING_INTERVAL_MS); }); diff --git a/app/web/static/script.js b/app/web/static/script.js index 398b49b..670205f 100644 --- a/app/web/static/script.js +++ b/app/web/static/script.js @@ -19,8 +19,8 @@ document.addEventListener('DOMContentLoaded', () => { } function renderNodeGrid(nodes) { - nodeGridContainer.innerHTML = ''; - nodeCountSpan.textContent = nodes.length; + nodeGridContainer.innerHTML = ''; // Clear existing content + nodeCountSpan.textContent = nodes.length; // Update total node count if (nodes.length === 0) { nodeGridContainer.innerHTML = '

No nodes reporting yet. Start a client!

'; @@ -132,4 +132,3 @@ document.addEventListener('DOMContentLoaded', () => { fetchNodeData(); setInterval(fetchNodeData, POLLING_INTERVAL_MS); }); - diff --git a/app/web/static/style.css b/app/web/static/style.css index 0fe9ab4..0585446 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -38,10 +38,10 @@ body { border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); margin-bottom: 20px; - width: 80vw; - max-width: 1200px; - margin-left: auto; - margin-right: auto; + width: 80vw; /* Keep this fixed width for the header */ + max-width: 1200px; /* Keep this max-width for the header */ + margin-left: auto; /* Center the header */ + margin-right: auto; /* Center the header */ } h1 { @@ -65,17 +65,18 @@ code { } #node-grid-container, #log-table-container { - width: 95vw; - max-width: 1600px; - min-width: 400px; + /* Adjusted width/max-width to allow dynamic resizing and scrolling */ + width: 95vw; /* Allow it to take up to 95% of viewport width */ + max-width: 1800px; /* Increased max-width to accommodate more columns */ + min-width: 400px; /* Keep a minimum width */ padding: 20px; background-color: var(--nord3); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - margin-bottom: 20px; - margin-left: auto; - margin-right: auto; - overflow-x: auto; + margin-bottom: 20px; /* Spacing below the container */ + margin-left: auto; /* Center the block */ + margin-right: auto; /* Center the block */ + overflow-x: auto; /* Enable horizontal scrolling if content overflows */ } .connection-grid { @@ -263,7 +264,7 @@ code { color: var(--nord11); /* Red */ } -.log-level-debug { /* Added for potential debug logs */ +.log-level-debug { color: var(--nord9); /* Blue */ } diff --git a/app/web/templates/index.html b/app/web/templates/index.html index bb3aed1..6a09db3 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -10,7 +10,7 @@

Node Monitoring System

Total Nodes: 0

-

Service UUID: {{ service_uuid }}

+

Service UUID: {{ service_uuid }}

diff --git a/app/web/templates/logs.html b/app/web/templates/logs.html index 5e53a75..7b6296b 100644 --- a/app/web/templates/logs.html +++ b/app/web/templates/logs.html @@ -24,6 +24,7 @@
+ {# The initial logs are rendered by Jinja2 here #} {% if logs %} @@ -63,4 +64,3 @@ - diff --git a/docker-compose.yml b/docker-compose.yml index cc9f478..f929701 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,18 +2,34 @@ version: '3.8' services: node-monitor: - build: . + image: node-monitor:latest + container_name: node-monitor-app + ports: - "8000:8000" + + # Mount the 'data' directory for RRD files. + # The left side './data' refers to a 'data' directory in the same location + # as this docker-compose.yml file. + # For Podman, if you encounter SELinux issues, you might need to append ':Z' or ':z' + # to the host path, e.g., './data:/data:Z' volumes: - - ./data:/data + - ../data:/data:Z + + # Environment variables for the application environment: - - DATA_DIR=/data - - SERVICE_UUID=${SERVICE_UUID:-auto-generated} + # Set a fixed SERVICE_UUID here. Replace this with your desired UUID. + # This UUID will be used by the FastAPI app and passed to the frontend. + SERVICE_UUID: "ab73d00a-8169-46bb-997d-f13e5f760973" + DATA_DIR: "/data" # Inform the application where its data volume is mounted + + # Restart the container if it stops for any reason, unless explicitly stopped restart: unless-stopped + + # Healthcheck to ensure the container is running and responsive healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000/health"] interval: 30s timeout: 10s - retries: 3 start_period: 5s + retries: 3