diff --git a/CHANGELOG.md b/CHANGELOG.md index d6181ba..3fc81ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to StatusWatch will be documented in this file. +## 1.0.4 +- Improve history page + ## 1.0.3 - Includes APIs to store healthcheck data - Adds setupadmin command to ensure credentials can be shared file a file diff --git a/app/main.py b/app/main.py index 3a7e28a..ea6f8a2 100644 --- a/app/main.py +++ b/app/main.py @@ -18,7 +18,14 @@ from .config import STATIC_DIR, TEMPLATE_DIR, get_settings from .database import ServiceHealthCheck, get_db from .services.monitor import StatusMonitor -from .schemas import StatusType, HealthCheckCreate, HealthCheckResponse, RecoveryCreate, NotificationCreate, RecoveryDataResponse +from .schemas import ( + StatusType, + HealthCheckCreate, + HealthCheckResponse, + RecoveryCreate, + NotificationCreate, + RecoveryDataResponse, +) from .services.health_checks import HealthCheckService # Setup FastAPI app @@ -103,23 +110,36 @@ async def index(request: Request): for group_name, services in sorted_groups.items(): display_name, ip = format_group_display(group_name) - groups[group_name] = { - "info": {"display": display_name, "ip": ip}, - "services": [], - } + + # Initialize group services list + group_services = [] + all_operational = True # Track if all services are operational for service_name, history_data in services.items(): latest_status = ( history_data[-1] if history_data else {"y": 1, "response_time": 0} ) - groups[group_name]["services"].append( + service_status = latest_status["y"] == 1 + + # If any service is down, the group is not fully operational + if not service_status: + all_operational = False + + group_services.append( { "name": service_name, - "status": latest_status["y"] == 1, + "status": service_status, "url": get_url_from_status(latest_status), "response_time": latest_status.get("response_time", 0), } ) + + groups[group_name] = { + "info": {"display": display_name, "ip": ip}, + "services": group_services, + "operational": all_operational, # Add operational status to group + } + return templates.TemplateResponse( "index.html.theme", { @@ -244,7 +264,7 @@ async def health_check(): async def create_health_check( request: Request, data: Union[HealthCheckCreate, RecoveryCreate, NotificationCreate] = Body(...), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Submit a service health check with support for all data types""" try: @@ -357,6 +377,7 @@ async def get_health_check_history( logging.error(f"Error getting health check history: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/recovery") async def get_recovery_data( request: Request, @@ -373,7 +394,7 @@ async def get_recovery_data( service_name=service_name, service_group=service_group, public_ip=public_ip, - status=status + status=status, ) data = RecoveryDataResponse.model_validate(results) if results else {} return data @@ -425,28 +446,29 @@ async def update_recovery_state( @app.get("/api/public/status") -async def get_public_status(db: Session = Depends(get_db)) -> Dict[str, Dict[str, Union[bool, List[str], str]]]: +async def get_public_status( + db: Session = Depends(get_db), +) -> Dict[str, Dict[str, Union[bool, List[str], str]]]: """Public endpoint showing minimal system status from database - no auth required""" try: settings = get_settings() - cutoff_time = datetime.utcnow() - timedelta(minutes=settings.PUBLIC_STATUS_MAX_AGE_MINUTES) - + cutoff_time = datetime.utcnow() - timedelta( + minutes=settings.PUBLIC_STATUS_MAX_AGE_MINUTES + ) + # Get latest status for each service latest_checks = ( db.query( ServiceHealthCheck.service_group, ServiceHealthCheck.service_name, ServiceHealthCheck.status, - func.max(ServiceHealthCheck.timestamp).label('latest_timestamp') - ) - .group_by( - ServiceHealthCheck.service_group, - ServiceHealthCheck.service_name + func.max(ServiceHealthCheck.timestamp).label("latest_timestamp"), ) + .group_by(ServiceHealthCheck.service_group, ServiceHealthCheck.service_name) .having(func.max(ServiceHealthCheck.timestamp) >= cutoff_time) .all() ) - + if not latest_checks: return JSONResponse( status_code=400, @@ -454,46 +476,43 @@ async def get_public_status(db: Session = Depends(get_db)) -> Dict[str, Dict[str "status": { "healthy": False, "down_services": ["no_recent_data"], - "last_check": None + "last_check": None, } - } + }, ) - + response = { - "status": { - "healthy": True, - "down_services": [], - "last_check": None - } + "status": {"healthy": True, "down_services": [], "last_check": None} } - + latest_timestamp = None - + for check in latest_checks: # Skip system group if check.service_group.lower() == "system": continue - + # Update latest timestamp if latest_timestamp is None or check.latest_timestamp > latest_timestamp: latest_timestamp = check.latest_timestamp - + # Check if service is down if check.status.lower() != "up": response["status"]["healthy"] = False response["status"]["down_services"].append( f"{check.service_group}/{check.service_name}" ) - + # Add last check timestamp - response["status"]["last_check"] = latest_timestamp.isoformat() if latest_timestamp else None - + response["status"]["last_check"] = ( + latest_timestamp.isoformat() if latest_timestamp else None + ) + # Return appropriate status code based on health return JSONResponse( - status_code=200 if response["status"]["healthy"] else 400, - content=response + status_code=200 if response["status"]["healthy"] else 400, content=response ) - + except Exception as e: logging.error(f"Error getting public status from database: {str(e)}") return JSONResponse( @@ -502,7 +521,7 @@ async def get_public_status(db: Session = Depends(get_db)) -> Dict[str, Dict[str "status": { "healthy": False, "down_services": ["system_error"], - "last_check": None + "last_check": None, } - } + }, ) diff --git a/app/templates/index.html.theme b/app/templates/index.html.theme index 85086b0..d4c3b1f 100644 --- a/app/templates/index.html.theme +++ b/app/templates/index.html.theme @@ -19,8 +19,10 @@ --card-shadow: rgba(0,0,0,0.05); --footer-color: #64748b; --link-color: #3b82f6; - --status-down-color: #ef4444; - --status-up-color: #22c55e; + --status-down-color: #dc2626; + --status-up-color: #16a34a; + --status-down-bg: #fee2e2; + --status-up-bg: #dcfce7; --border-color: #e2e8f0; } @media (prefers-color-scheme: dark) { @@ -32,8 +34,10 @@ --card-shadow: rgba(0,0,0,0.3); --footer-color: #94a3b8; --link-color: #60a5fa; - --status-down-color: #f87171; - --status-up-color: #4ade80; + --status-down-color: #dc2626; + --status-up-color: #16a34a; + --status-down-bg: #450a0a; + --status-up-bg: #052e16; --border-color: #334155; } } @@ -121,13 +125,12 @@ } .service-item { display: flex; - justify-content: space-between; align-items: center; - padding: 12px; + padding: 12px 16px; border-radius: 8px; margin-bottom: 10px; - background: var(--bg-color); transition: all 0.3s ease; + gap: 12px; } .service-item:hover { transform: translateX(5px); @@ -136,6 +139,8 @@ display: flex; align-items: center; gap: 12px; + flex: 1; + min-width: 0; } .service-name .link-icon { opacity: 0.6; @@ -145,21 +150,19 @@ opacity: 1; } .status-badge { - padding: 6px 12px; - border-radius: 20px; - font-size: 0.9em; - font-weight: 500; display: flex; align-items: center; - gap: 6px; + } + .status-badge span { + display: none; } .status-up { - background: var(--status-up-color); - color: white; + background: var(--status-up-bg); + border: 1px solid var(--status-up-color); } .status-down { - background: var(--status-down-color); - color: white; + background: var(--status-down-bg); + border: 1px solid var(--status-down-color); } .incidents { background: var(--card-bg); @@ -227,20 +230,23 @@ .response-time { font-size: 0.9em; color: var(--footer-color); - margin-left: 8px; padding: 2px 6px; background: var(--timeline-bg); border-radius: 4px; + white-space: nowrap; + margin-left: auto; } - .uptime-badge { - background: var(--status-up-color); - color: white; - padding: 4px 8px; - border-radius: 12px; - font-size: 0.9em; - font-weight: 500; + .group-status { + font-weight: 600; + font-size: 0.95em; + color: var(--status-up-color); + } + + .group-status.down { + color: var(--status-down-color); } + .group-title-container { display: flex; align-items: center; @@ -263,6 +269,22 @@ border-radius: 6px; white-space: nowrap; } + + .status-up .status-badge svg { + color: var(--status-up-color); + } + + .status-down .status-badge svg { + color: var(--status-down-color); + } + + .service-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } @@ -299,40 +321,46 @@ {{ content.info.ip }} {% endif %} - {{ uptimes[group_name] }}% Uptime + + {% if content.operational %} + OPERATIONAL + {% else %} + DEGRADED + {% endif %} + {% for service in content.services %} -
+
- {% if service.url %} - - {{ service.name }} - - - - + + {% if service.status %} + + - - {% else %} - {{ service.name }} - {% endif %} - {% if service.response_time %} - {{ "%.3f"|format(service.response_time) }}ms - {% endif %} + {% else %} + + + + {% endif %} + +
+ {% if service.url %} + + {{ service.name }} + + + + + + + {% else %} + {{ service.name }} + {% endif %} +
- - {% if service.status %} - - - - Operational - {% else %} - - - - Down - {% endif %} - + {% if service.response_time %} + {{ "%.1f"|format(service.response_time) }}ms + {% endif %}
{% endfor %}