From 2ba990d4ea9991b1d5bd4f1043e162fab44d5bac Mon Sep 17 00:00:00 2001 From: shlomi Date: Thu, 1 Jan 2026 12:35:46 +0200 Subject: [PATCH] Release version 1.4.3 --- .github/workflows/docker-publish.yml | 1 - CHANGELOG.md | 76 ++++++++ Dockerfile | 3 + README.md | 3 +- VERSION | 1 + backend/app/config.py | 50 ++--- backend/app/correlation.py | 4 - backend/app/mailcow_api.py | 64 +++++-- backend/app/main.py | 22 ++- backend/app/routers/logs.py | 23 ++- backend/app/routers/settings.py | 4 + backend/app/routers/status.py | 78 ++++++++ backend/app/scheduler.py | 174 +++++++---------- backend/app/version.py | 37 ++++ documentation/API.md | 112 +++++++++-- documentation/GETTING_STARTED.md | 3 +- env.example | 4 +- frontend/app.js | 273 +++++++++++++++++++++------ frontend/index.html | 28 ++- 19 files changed, 727 insertions(+), 233 deletions(-) create mode 100644 VERSION create mode 100644 backend/app/version.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 85e3664..60e4300 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,6 @@ name: Build and Publish Docker Image on: push: - branches: [ "main" ] tags: [ 'v*.*.*' ] paths: - 'backend/**' diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e3a8c..f18f7e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,82 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.3] - 2026-01-01 + +### Changed + +#### Configuration +- **Automatic Domain Detection**: Removed `MAILCOW_LOCAL_DOMAINS` environment variable requirement + - Active domains are now automatically fetched from Mailcow API (`/api/v1/get/domain/all`) + - Only active domains are used + - Domains are cached on application startup + - No manual domain configuration needed anymore + +#### UI Improvements +- **Local Domains Display**: Enhanced domains display in Settings page + - Changed from comma-separated list to grid layout (columns) + - Scrollable container for many domains + +#### Code Quality +- **Code Cleanup**: Removed unnecessary comments from codebase + - Removed verbose comments that don't add value + - Cleaned up phase markers and redundant inline comments + - Improved code readability + +### Fixed + +#### Security Tab +- **Timestamp Formatting**: Fixed timestamp display in Security tab to match Messages page format + - All timestamps now properly formatted with UTC timezone ('Z' suffix) + - Consistent date/time display across all tabs +- **Banned Filter**: Fixed filter not working correctly for "Banning" messages + - Now correctly identifies "Banning" (present tense) messages as banned actions + - Uses priority field ("crit") to determine ban status when message parsing is ambiguous + - Added support for CIDR notation in ban messages (e.g., "Banning 3.134.148.0/24") +- **View Consistency**: Removed old table view that was sometimes displayed + - Only card-based view is now used consistently + - Smart refresh now uses same rendering function as initial load +- **Duplicate Log Prevention**: Fixed duplicate security events appearing in Security tab + - Added deduplication logic based on message + time + priority combination + - Frontend filters duplicates before display (handles legacy data) + - Backend import now checks database for existing logs with same message + time + priority before inserting + - Prevents duplicate entries from being stored in database during import + +#### Import Status +- **Last Fetch Run Time**: Added tracking of when imports run (not just when data is imported) + - Status page now shows "Last Fetch Run" (when import job ran) separate from "Last Import" (when data was actually imported) + - Resolves confusion when imports run but no new logs are available + - All three log types (Postfix, Rspamd, Netfilter) now track fetch run times + +#### Netfilter Logging +- **Enhanced Logging**: Added detailed debug logs for Netfilter import process + - Logs show when fetch starts, how many logs received, how many imported, and how many skipped as duplicates + - Better error tracking for troubleshooting import delays +- **Import Deduplication**: Improved duplicate detection during Netfilter log import + - Now checks database for existing logs with same message + time + priority before inserting + - Uses combination of message + time + priority as unique identifier (instead of time + IP + message) + - Prevents duplicate entries from being stored in database + +### Added + +#### Version Management +- **VERSION File**: Version number now managed in single `VERSION` file instead of hardcoded in multiple places + - Supports both Docker and development environments + +#### Footer +- **Application Footer**: Added footer to all pages with: + - Application name and current version + - "Update Available" badge when new version is detected + +#### Settings Page +- **Version Information Section**: Added version display in Settings page + - Shows current installed version + - Shows latest available version from GitHub + - Displays "Update Available" or "Up to Date" status + - Link to release notes when update is available + +--- + ## [1.4.2] - 2025-12-31 ### Fixed diff --git a/Dockerfile b/Dockerfile index 39803ed..1b329d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ COPY backend/app/ /app/app/ # Copy frontend files COPY frontend/ /app/frontend/ +# Copy VERSION file +COPY VERSION /app/VERSION + # Create non-root user RUN useradd -m -u 1000 appuser && \ chown -R appuser:appuser /app diff --git a/README.md b/README.md index 324641a..a782497 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,10 @@ All settings via environment variables. See **[env.example](env.example)** for f |----------|-------------| | `MAILCOW_URL` | Mailcow instance URL | | `MAILCOW_API_KEY` | Mailcow API key | -| `MAILCOW_LOCAL_DOMAINS` | Your email domains | | `POSTGRES_PASSWORD` | Database password | +**Note:** Active domains are automatically fetched from Mailcow API - no configuration needed! + ### Key Optional Settings | Variable | Default | Description | diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3c80e4f --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.4.3 \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 2965992..bea4399 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,25 +1,21 @@ """ Configuration management using Pydantic Settings -ALL settings loaded from environment variables - NO hardcoded values! """ from pydantic_settings import BaseSettings from pydantic import Field, validator -from typing import List +from typing import List, Optional import logging logger = logging.getLogger(__name__) +_cached_active_domains: Optional[List[str]] = None + class Settings(BaseSettings): - """Application settings - ALL from environment variables""" + """Application settings""" - # Mailcow Configuration mailcow_url: str = Field(..., description="Mailcow instance URL") mailcow_api_key: str = Field(..., description="Mailcow API key") - mailcow_local_domains: str = Field( - default="sendmail.co.il", - description="Comma-separated list of local domains" - ) mailcow_api_timeout: int = Field(default=30, description="API request timeout in seconds") # Blacklist Configuration @@ -32,11 +28,11 @@ class Settings(BaseSettings): fetch_interval: int = Field(default=60, description="Seconds between log fetches") fetch_count_postfix: int = Field( default=2000, - description="Postfix logs to fetch per request (higher because each email = ~7-10 log lines)" + description="Postfix logs to fetch per request" ) fetch_count_rspamd: int = Field( default=500, - description="Rspamd logs to fetch per request (1 log = 1 email)" + description="Rspamd logs to fetch per request" ) fetch_count_netfilter: int = Field( default=500, @@ -44,7 +40,7 @@ class Settings(BaseSettings): ) retention_days: int = Field(default=7, description="Days to keep logs") - # Correlation Configuration (NEW!) + # Correlation Configuration max_correlation_age_minutes: int = Field( default=10, description="Stop searching for correlations older than this (minutes)" @@ -69,7 +65,7 @@ class Settings(BaseSettings): ) tz: str = Field( default="UTC", - description="Timezone (e.g. Asia/Jerusalem, America/New_York)" + description="Timezone" ) app_title: str = Field(default="Mailcow Logs Viewer", description="Application title") app_logo_url: str = Field(default="", description="Application logo URL (optional)") @@ -111,8 +107,12 @@ class Settings(BaseSettings): @property def local_domains_list(self) -> List[str]: - """Parse local domains into a list""" - return [d.strip() for d in self.mailcow_local_domains.split(',') if d.strip()] + """Get active domains from Mailcow API cache""" + global _cached_active_domains + if _cached_active_domains is None: + logger.warning("Local domains cache not yet populated") + return [] + return _cached_active_domains @property def blacklist_emails_list(self) -> List[str]: @@ -142,14 +142,11 @@ class Settings(BaseSettings): case_sensitive = False -# Global settings instance settings = Settings() def setup_logging(): - """Configure application logging based on LOG_LEVEL from .env""" - - # Simple format + """Configure application logging""" log_format = '%(levelname)s - %(message)s' logging.basicConfig( @@ -157,7 +154,6 @@ def setup_logging(): format=log_format ) - # Silence noisy third-party libraries logging.getLogger('httpx').setLevel(logging.ERROR) logging.getLogger('httpcore').setLevel(logging.ERROR) logging.getLogger('urllib3').setLevel(logging.ERROR) @@ -165,8 +161,20 @@ def setup_logging(): logging.getLogger('apscheduler').setLevel(logging.WARNING) if settings.debug: - logger.warning("[WARNING] Debug mode is enabled") + logger.warning("Debug mode is enabled") # Initialize logging -setup_logging() \ No newline at end of file +setup_logging() + + +def set_cached_active_domains(domains: List[str]) -> None: + """Set the cached active domains list""" + global _cached_active_domains + _cached_active_domains = domains + logger.info(f"Cached {len(domains)} active domains from Mailcow API") + + +def get_cached_active_domains() -> Optional[List[str]]: + """Get the cached active domains list""" + return _cached_active_domains \ No newline at end of file diff --git a/backend/app/correlation.py b/backend/app/correlation.py index de2b521..b2c0ed9 100644 --- a/backend/app/correlation.py +++ b/backend/app/correlation.py @@ -77,10 +77,6 @@ def parse_postfix_message(message: str) -> Dict[str, Any]: """ Parse Postfix log message to extract structured data - Handles two types of message-id lines: - 1. Standalone: "CFCF76E2F45: message-id=<83b54793-ae84-7f0f-5eb5-1a203fd1227e@sendmail.co.il>" - 2. Inline in delivery: "CFCF76E2F45: to=, relay=..., status=sent (250 2.6.0 ...)" - Args: message: Postfix log message string diff --git a/backend/app/mailcow_api.py b/backend/app/mailcow_api.py index c399ebd..a6ea3a5 100644 --- a/backend/app/mailcow_api.py +++ b/backend/app/mailcow_api.py @@ -253,27 +253,27 @@ class MailcowAPI: except MailcowAPIError as e: logger.error(f"Failed to fetch vmail status: {e}") return {} + + async def get_status_version(self) -> str: + """ + Fetch Mailcow version - async def get_status_version(self) -> str: - """ - Fetch Mailcow version + Returns: + Version string + """ + logger.info("Fetching Mailcow version") + try: + data = await self._make_request("/api/v1/get/status/version") - Returns: - Version string - """ - logger.info("Fetching Mailcow version") - try: - data = await self._make_request("/api/v1/get/status/version") - - if isinstance(data, list) and len(data) > 0: - return data[0].get('version', 'unknown') - - logger.warning(f"Unexpected version response format: {type(data)}") - return 'unknown' - - except MailcowAPIError as e: - logger.error(f"Failed to fetch version: {e}") - return 'unknown' + if isinstance(data, list) and len(data) > 0: + return data[0].get('version', 'unknown') + + logger.warning(f"Unexpected version response format: {type(data)}") + return 'unknown' + + except MailcowAPIError as e: + logger.error(f"Failed to fetch version: {e}") + return 'unknown' async def get_domains(self) -> List[Dict[str, Any]]: """ @@ -297,6 +297,31 @@ class MailcowAPI: logger.error(f"Failed to fetch domains: {e}") return [] + async def get_active_domains(self) -> List[str]: + """ + Fetch active domains from Mailcow and return domain names only + + Returns: + List of active domain names (where active=1) + """ + logger.info("Fetching active domains") + try: + domains = await self.get_domains() + + # Filter active domains and extract domain_name + active_domains = [ + domain.get('domain_name', '') + for domain in domains + if domain.get('active') == 1 and domain.get('domain_name') + ] + + logger.info(f"Found {len(active_domains)} active domains: {', '.join(active_domains)}") + return active_domains + + except Exception as e: + logger.error(f"Failed to fetch active domains: {e}") + return [] + async def get_mailboxes(self) -> List[Dict[str, Any]]: """ Fetch all mailboxes from Mailcow @@ -359,5 +384,4 @@ class MailcowAPI: return False -# Global API client instance mailcow_api = MailcowAPI() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 8368e17..839b5bf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager -from .config import settings +from .config import settings, set_cached_active_domains from .database import init_db, check_db_connection from .scheduler import start_scheduler, stop_scheduler from .mailcow_api import mailcow_api @@ -17,10 +17,10 @@ from .routers import logs, stats from .routers import export as export_router from .migrations import run_migrations from .auth import BasicAuthMiddleware +from .version import __version__ logger = logging.getLogger(__name__) -# Import status and messages routers try: from .routers import status as status_router from .routers import messages as messages_router @@ -63,11 +63,21 @@ async def lifespan(app: FastAPI): logger.error(f"Failed to initialize database: {e}") raise - # Test Mailcow API connection + # Test Mailcow API connection and fetch active domains try: api_ok = await mailcow_api.test_connection() if not api_ok: logger.warning("Mailcow API connection test failed - check your configuration") + else: + try: + active_domains = await mailcow_api.get_active_domains() + if active_domains: + set_cached_active_domains(active_domains) + logger.info(f"Loaded {len(active_domains)} active domains from Mailcow API") + else: + logger.warning("No active domains found in Mailcow - check your configuration") + except Exception as e: + logger.error(f"Failed to fetch active domains: {e}") except Exception as e: logger.error(f"Mailcow API test failed: {e}") @@ -92,7 +102,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Mailcow Logs Viewer", description="Modern dashboard for viewing and analyzing Mailcow mail server logs", - version="1.2.0", + version=__version__, lifespan=lifespan ) @@ -157,7 +167,7 @@ async def health_check(): return { "status": "healthy" if db_ok else "unhealthy", "database": "connected" if db_ok else "disconnected", - "version": "1.2.0", + "version": __version__, "config": { "fetch_interval": settings.fetch_interval, "retention_days": settings.retention_days, @@ -173,7 +183,7 @@ async def app_info(): """Application information endpoint""" return { "name": "Mailcow Logs Viewer", - "version": "1.2.0", + "version": __version__, "mailcow_url": settings.mailcow_url, "local_domains": settings.local_domains_list, "fetch_interval": settings.fetch_interval, diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py index 61a6dbf..664afd9 100644 --- a/backend/app/routers/logs.py +++ b/backend/app/routers/logs.py @@ -5,7 +5,7 @@ import logging from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.orm import Session from sqlalchemy import or_, and_, desc, func -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import List, Optional from ..database import get_db @@ -18,6 +18,25 @@ logger = logging.getLogger(__name__) router = APIRouter() +def format_datetime_utc(dt: Optional[datetime]) -> Optional[str]: + """ + Format datetime for API response with proper UTC timezone + Always returns ISO format with 'Z' suffix so browser knows it's UTC + """ + if dt is None: + return None + + # If naive (no timezone), assume UTC + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + # Convert to UTC if not already + dt_utc = dt.astimezone(timezone.utc) + + # Format as ISO string with 'Z' suffix for UTC + return dt_utc.replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + @router.get("/logs/postfix/by-queue/{queue_id}") async def get_postfix_logs_by_queue( queue_id: str, @@ -380,7 +399,7 @@ async def get_netfilter_logs( "data": [ { "id": log.id, - "time": log.time.isoformat(), + "time": format_datetime_utc(log.time), "priority": log.priority, "message": log.message, "ip": log.ip, diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index a9bff06..307ce63 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -12,6 +12,7 @@ from typing import Dict, Any, Optional from ..database import get_db from ..models import PostfixLog, RspamdLog, NetfilterLog, MessageCorrelation from ..config import settings +from ..scheduler import last_fetch_run_time logger = logging.getLogger(__name__) @@ -99,16 +100,19 @@ async def get_settings_info(db: Session = Depends(get_db)): "import_status": { "postfix": { "last_import": format_datetime_utc(last_postfix), + "last_fetch_run": format_datetime_utc(last_fetch_run_time.get('postfix')), "total_entries": total_postfix or 0, "oldest_entry": format_datetime_utc(oldest_postfix) }, "rspamd": { "last_import": format_datetime_utc(last_rspamd), + "last_fetch_run": format_datetime_utc(last_fetch_run_time.get('rspamd')), "total_entries": total_rspamd or 0, "oldest_entry": format_datetime_utc(oldest_rspamd) }, "netfilter": { "last_import": format_datetime_utc(last_netfilter), + "last_fetch_run": format_datetime_utc(last_fetch_run_time.get('netfilter')), "total_entries": total_netfilter or 0, "oldest_entry": format_datetime_utc(oldest_netfilter) } diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py index ff538ff..b75afc4 100644 --- a/backend/app/routers/status.py +++ b/backend/app/routers/status.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from typing import Dict, Any from ..mailcow_api import mailcow_api +from ..version import __version__ logger = logging.getLogger(__name__) @@ -22,6 +23,15 @@ version_cache = { "changelog": None } +# Cache for app version check (check once per day) +app_version_cache = { + "checked_at": None, + "current_version": __version__, # Read from VERSION file + "latest_version": None, + "update_available": False, + "changelog": None +} + @router.get("/status/containers") async def get_containers_status(): """ @@ -160,6 +170,74 @@ async def get_version_status(): } except Exception as e: logger.error(f"Error fetching version status: {e}") + + +@router.get("/status/app-version") +async def get_app_version_status(): + """ + Get current app version and check for updates from GitHub + Checks GitHub once per day and caches the result + """ + try: + global app_version_cache + + # Check if we need to refresh the cache (once per day) + now = datetime.utcnow() + if (app_version_cache["checked_at"] is None or + now - app_version_cache["checked_at"] > timedelta(days=1)): + + logger.info("Checking app version and updates from GitHub...") + + # Check GitHub for latest version + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get( + "https://api.github.com/repos/ShlomiPorush/mailcow-logs-viewer/releases/latest" + ) + + if response.status_code == 200: + release_data = response.json() + latest_version = release_data.get('tag_name', 'unknown') + # Remove 'v' prefix if present + if latest_version.startswith('v'): + latest_version = latest_version[1:] + changelog = release_data.get('body', '') + + app_version_cache["latest_version"] = latest_version + app_version_cache["changelog"] = changelog + + # Compare versions (simple string comparison) + app_version_cache["update_available"] = app_version_cache["current_version"] != latest_version + + logger.info(f"App version check: Current={app_version_cache['current_version']}, Latest={latest_version}") + else: + logger.warning(f"GitHub API returned status {response.status_code}") + app_version_cache["latest_version"] = "unknown" + app_version_cache["update_available"] = False + + except Exception as e: + logger.error(f"Failed to check GitHub for app updates: {e}") + app_version_cache["latest_version"] = "unknown" + app_version_cache["update_available"] = False + + app_version_cache["checked_at"] = now + + return { + "current_version": app_version_cache["current_version"], + "latest_version": app_version_cache["latest_version"], + "update_available": app_version_cache["update_available"], + "changelog": app_version_cache["changelog"], + "last_checked": app_version_cache["checked_at"].isoformat() if app_version_cache["checked_at"] else None + } + except Exception as e: + logger.error(f"Error fetching app version status: {e}") + return { + "current_version": app_version_cache["current_version"], + "latest_version": "unknown", + "update_available": False, + "changelog": None, + "last_checked": None + } raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 4834be3..38085da 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -1,11 +1,5 @@ """ -Background scheduler - REFACTORED - -Architecture: -1. IMPORT PHASE: Fetch logs from API and store in DB (NO correlation!) -2. CORRELATION PHASE: Separate job that links logs together - -This fixes timing issues where rspamd arrives before postfix. +Background scheduler """ import logging import asyncio @@ -29,11 +23,16 @@ logger = logging.getLogger(__name__) scheduler = AsyncIOScheduler() -# Track seen logs to avoid duplicates within session seen_postfix: Set[str] = set() seen_rspamd: Set[str] = set() seen_netfilter: Set[str] = set() +last_fetch_run_time: Dict[str, Optional[datetime]] = { + 'postfix': None, + 'rspamd': None, + 'netfilter': None +} + def is_blacklisted(email: Optional[str]) -> bool: """ @@ -61,21 +60,10 @@ def is_blacklisted(email: Optional[str]) -> bool: return is_blocked -# ============================================================================= -# PHASE 1: IMPORT LOGS (No correlation during import!) -# ============================================================================= - async def fetch_and_store_postfix(): - """ - Fetch Postfix logs from API and store in DB. - NO correlation here - that happens in a separate job. + """Fetch Postfix logs from API and store in DB""" + last_fetch_run_time['postfix'] = datetime.now(timezone.utc) - BLACKLIST LOGIC: - When we see a blacklisted email in any log, we: - 1. Mark that Queue ID as blacklisted - 2. Delete ALL existing logs with that Queue ID from DB - 3. Skip importing any future logs with that Queue ID - """ try: logs = await mailcow_api.get_postfix_logs(count=settings.fetch_count_postfix) @@ -87,7 +75,6 @@ async def fetch_and_store_postfix(): skipped_blacklist = 0 blacklisted_queue_ids: Set[str] = set() - # First pass: identify blacklisted queue IDs for log_entry in logs: message = log_entry.get('message', '') parsed = parse_postfix_message(message) @@ -119,7 +106,6 @@ async def fetch_and_store_postfix(): db.commit() - # Second pass: import non-blacklisted logs for log_entry in logs: try: time_str = str(log_entry.get('time', '')) @@ -148,7 +134,6 @@ async def fetch_and_store_postfix(): sender = parsed.get('sender') recipient = parsed.get('recipient') - # Create and save log (NO correlation here!) postfix_log = PostfixLog( time=timestamp, program=log_entry.get('program'), @@ -173,7 +158,6 @@ async def fetch_and_store_postfix(): logger.error(f"Error processing Postfix log: {e}") continue - # Commit all at once db.commit() if new_count > 0: @@ -182,7 +166,6 @@ async def fetch_and_store_postfix(): msg += f" (skipped {skipped_blacklist} blacklisted)" logger.info(msg) - # Clear cache if too large if len(seen_postfix) > 10000: seen_postfix.clear() @@ -191,19 +174,9 @@ async def fetch_and_store_postfix(): async def fetch_and_store_rspamd(): - """ - Fetch Rspamd logs from API and store in DB. - NO correlation here - that happens in a separate job. + """Fetch Rspamd logs from API and store in DB""" + last_fetch_run_time['rspamd'] = datetime.now(timezone.utc) - API returns: - - 'message-id' (with dash!) not 'message_id' - - 'sender_smtp' not 'from' - - 'rcpt_smtp' not 'rcpt' - - BLACKLIST LOGIC: - When we see a blacklisted email, we also delete any existing - correlations with that message_id to prevent orphaned data. - """ try: logs = await mailcow_api.get_rspamd_logs(count=settings.fetch_count_rspamd) @@ -218,16 +191,10 @@ async def fetch_and_store_rspamd(): for log_entry in logs: try: unix_time = log_entry.get('unix_time', 0) - - # FIXED: API returns 'message-id' with DASH! message_id = log_entry.get('message-id', '') if message_id == 'undef' or not message_id: message_id = None - - # FIXED: API returns 'sender_smtp' not 'from'! sender = log_entry.get('sender_smtp') - - # FIXED: API returns 'rcpt_smtp' not 'rcpt'! recipients = log_entry.get('rcpt_smtp', []) unique_id = f"{unix_time}:{message_id if message_id else 'no-id'}" @@ -235,7 +202,6 @@ async def fetch_and_store_rspamd(): if unique_id in seen_rspamd: continue - # Check blacklist - sender if is_blacklisted(sender): skipped_blacklist += 1 seen_rspamd.add(unique_id) @@ -243,7 +209,6 @@ async def fetch_and_store_rspamd(): blacklisted_message_ids.add(message_id) continue - # Check blacklist - any recipient if recipients and any(is_blacklisted(r) for r in recipients): skipped_blacklist += 1 seen_rspamd.add(unique_id) @@ -251,13 +216,9 @@ async def fetch_and_store_rspamd(): blacklisted_message_ids.add(message_id) continue - # Parse timestamp with timezone timestamp = datetime.fromtimestamp(unix_time, tz=timezone.utc) - - # Detect direction direction = detect_direction(log_entry) - # Create and save log (NO correlation here!) rspamd_log = RspamdLog( time=timestamp, message_id=message_id, @@ -287,9 +248,7 @@ async def fetch_and_store_rspamd(): logger.error(f"Error processing Rspamd log: {e}") continue - # Delete correlations for blacklisted message IDs if blacklisted_message_ids: - # Get queue IDs from correlations before deleting correlations_to_delete = db.query(MessageCorrelation).filter( MessageCorrelation.message_id.in_(blacklisted_message_ids) ).all() @@ -299,12 +258,10 @@ async def fetch_and_store_rspamd(): if corr.queue_id: queue_ids_to_delete.add(corr.queue_id) - # Delete correlations deleted_corr = db.query(MessageCorrelation).filter( MessageCorrelation.message_id.in_(blacklisted_message_ids) ).delete(synchronize_session=False) - # Delete Postfix logs for those queue IDs if queue_ids_to_delete: deleted_postfix = db.query(PostfixLog).filter( PostfixLog.queue_id.in_(queue_ids_to_delete) @@ -316,7 +273,6 @@ async def fetch_and_store_rspamd(): if deleted_corr > 0: logger.info(f"[BLACKLIST] Deleted {deleted_corr} correlations for blacklisted message IDs") - # Commit all at once db.commit() if new_count > 0: @@ -325,7 +281,6 @@ async def fetch_and_store_rspamd(): msg += f" (skipped {skipped_blacklist} blacklisted)" logger.info(msg) - # Clear cache if too large if len(seen_rspamd) > 10000: seen_rspamd.clear() @@ -333,68 +288,65 @@ async def fetch_and_store_rspamd(): logger.error(f"[ERROR] Rspamd fetch error: {e}") -def parse_netfilter_message(message: str) -> Dict[str, Any]: - """ - Parse Netfilter log message to extract structured data. - - Examples: - - "9 more attempts in the next 600 seconds until 80.178.113.140/32 is banned" - - "80.178.113.140 matched rule id 3 (warning: 80.178.113.140.adsl.012.net.il[80.178.113.140]: SASL LOGIN authentication failed: ...)" - - "Banned 80.178.113.140 for 600 seconds" - """ +def parse_netfilter_message(message: str, priority: Optional[str] = None) -> Dict[str, Any]: + result = {} + message_lower = message.lower() - # Extract IP address - multiple patterns - # Pattern 1: IP at start of message ip_match = re.match(r'^(\d+\.\d+\.\d+\.\d+)', message) if ip_match: result['ip'] = ip_match.group(1) - # Pattern 2: IP in "until X.X.X.X/32 is banned" if not result.get('ip'): ban_match = re.search(r'until\s+(\d+\.\d+\.\d+\.\d+)', message) if ban_match: result['ip'] = ban_match.group(1) - # Pattern 3: IP in brackets [X.X.X.X] if not result.get('ip'): bracket_match = re.search(r'\[(\d+\.\d+\.\d+\.\d+)\]', message) if bracket_match: result['ip'] = bracket_match.group(1) - # Pattern 4: "Banned X.X.X.X" if not result.get('ip'): - banned_match = re.search(r'Banned\s+(\d+\.\d+\.\d+\.\d+)', message) + banned_match = re.search(r'Ban(?:ned|ning)\s+(\d+\.\d+\.\d+\.\d+)', message, re.IGNORECASE) if banned_match: result['ip'] = banned_match.group(1) - # Extract username (sasl_username=xxx@yyy) + if not result.get('ip'): + cidr_match = re.search(r'Ban(?:ned|ning)\s+(\d+\.\d+\.\d+\.\d+/\d+)', message, re.IGNORECASE) + if cidr_match: + ip_part = cidr_match.group(1).split('/')[0] + result['ip'] = ip_part + username_match = re.search(r'sasl_username=([^\s,\)]+)', message) if username_match: result['username'] = username_match.group(1) - # Extract auth method (SASL LOGIN, SASL PLAIN, etc.) auth_match = re.search(r'SASL\s+(\w+)', message) if auth_match: result['auth_method'] = f"SASL {auth_match.group(1)}" - # Extract rule ID rule_match = re.search(r'rule id\s+(\d+)', message) if rule_match: result['rule_id'] = int(rule_match.group(1)) - # Extract attempts left attempts_match = re.search(r'(\d+)\s+more\s+attempt', message) if attempts_match: result['attempts_left'] = int(attempts_match.group(1)) - # Determine action - if 'is banned' in message.lower() or 'banned' in message.lower(): - if 'more attempts' in message.lower(): + if 'banning' in message_lower or 'banned' in message_lower: + if 'more attempts' in message_lower: result['action'] = 'warning' else: result['action'] = 'banned' - elif 'warning' in message.lower(): + elif priority and priority.lower() == 'crit': + if 'unbanning' in message_lower or 'unban' in message_lower: + result['action'] = 'info' + elif 'banning' in message_lower: + result['action'] = 'banned' + else: + result['action'] = 'banned' + elif 'warning' in message_lower: result['action'] = 'warning' else: result['action'] = 'info' @@ -403,35 +355,51 @@ def parse_netfilter_message(message: str) -> Dict[str, Any]: async def fetch_and_store_netfilter(): - """Fetch Netfilter logs from API and store in DB.""" + """Fetch Netfilter logs from API and store in DB""" + last_fetch_run_time['netfilter'] = datetime.now(timezone.utc) + try: + logger.debug(f"[NETFILTER] Starting fetch (count: {settings.fetch_count_netfilter})") logs = await mailcow_api.get_netfilter_logs(count=settings.fetch_count_netfilter) if not logs: + logger.debug("[NETFILTER] No logs returned from API") return + logger.debug(f"[NETFILTER] Received {len(logs)} logs from API") + with get_db_context() as db: new_count = 0 + skipped_count = 0 for log_entry in logs: try: time_val = log_entry.get('time', 0) message = log_entry.get('message', '') - - # Parse the message to extract structured data - parsed = parse_netfilter_message(message) - - ip = parsed.get('ip', '') - unique_id = f"{time_val}:{ip}:{message[:50]}" + priority = log_entry.get('priority', 'info') + unique_id = f"{time_val}:{priority}:{message}" if unique_id in seen_netfilter: + skipped_count += 1 continue timestamp = datetime.fromtimestamp(time_val, tz=timezone.utc) + existing = db.query(NetfilterLog).filter( + NetfilterLog.message == message, + NetfilterLog.time == timestamp, + NetfilterLog.priority == priority + ).first() + + if existing: + skipped_count += 1 + seen_netfilter.add(unique_id) + continue + + parsed = parse_netfilter_message(message, priority=priority) netfilter_log = NetfilterLog( time=timestamp, - priority=log_entry.get('priority', 'info'), + priority=priority, message=message, ip=parsed.get('ip'), username=parsed.get('username'), @@ -447,38 +415,45 @@ async def fetch_and_store_netfilter(): new_count += 1 except Exception as e: - logger.error(f"Error processing Netfilter log: {e}") + logger.error(f"[NETFILTER] Error processing log entry: {e}") continue db.commit() if new_count > 0: - logger.info(f"[OK] Imported {new_count} Netfilter logs") + logger.info(f"[OK] Imported {new_count} Netfilter logs (skipped {skipped_count} duplicates)") + elif skipped_count > 0: + logger.debug(f"[NETFILTER] All {skipped_count} logs were duplicates, nothing new to import") if len(seen_netfilter) > 10000: + logger.debug("[NETFILTER] Clearing seen_netfilter cache (size > 10000)") seen_netfilter.clear() except Exception as e: - logger.error(f"[ERROR] Netfilter fetch error: {e}") + logger.error(f"[ERROR] Netfilter fetch error: {e}", exc_info=True) async def fetch_all_logs(): """Fetch all log types concurrently""" try: - await asyncio.gather( + logger.debug("[FETCH] Starting fetch_all_logs") + results = await asyncio.gather( fetch_and_store_postfix(), fetch_and_store_rspamd(), fetch_and_store_netfilter(), return_exceptions=True ) + + for i, result in enumerate(results): + if isinstance(result, Exception): + log_type = ["Postfix", "Rspamd", "Netfilter"][i] + logger.error(f"[ERROR] {log_type} fetch failed: {result}", exc_info=result) + + logger.debug("[FETCH] Completed fetch_all_logs") except Exception as e: - logger.error(f"[ERROR] Fetch all logs error: {e}") + logger.error(f"[ERROR] Fetch all logs error: {e}", exc_info=True) -# ============================================================================= -# PHASE 2: CORRELATION (Separate from import!) -# ============================================================================= - async def cleanup_blacklisted_queues(): """ Clean up Postfix queues where the recipient is blacklisted. @@ -497,12 +472,9 @@ async def cleanup_blacklisted_queues(): try: with get_db_context() as db: - # Find queue_ids where recipient is blacklisted blacklisted_queue_ids = set() - # Query Postfix logs that have a recipient in the blacklist for email in blacklist: - # Find logs where recipient matches this blacklisted email logs_with_blacklisted_recipient = db.query(PostfixLog).filter( PostfixLog.recipient == email, PostfixLog.queue_id.isnot(None) @@ -515,7 +487,6 @@ async def cleanup_blacklisted_queues(): if not blacklisted_queue_ids: return - # Delete ALL Postfix logs with these queue_ids deleted_count = 0 for queue_id in blacklisted_queue_ids: count = db.query(PostfixLog).filter( @@ -550,7 +521,6 @@ async def run_correlation(): try: with get_db_context() as db: - # Find Rspamd logs without correlation (limit to avoid overload) uncorrelated_rspamd = db.query(RspamdLog).filter( RspamdLog.correlation_key.is_(None), RspamdLog.message_id.isnot(None), @@ -566,9 +536,7 @@ async def run_correlation(): for rspamd_log in uncorrelated_rspamd: try: - # Check blacklist (for legacy logs that were imported before blacklist was set) if is_blacklisted(rspamd_log.sender_smtp): - # Mark as correlated so we don't keep trying rspamd_log.correlation_key = "BLACKLISTED" db.commit() skipped_blacklist += 1 diff --git a/backend/app/version.py b/backend/app/version.py new file mode 100644 index 0000000..c62fbe9 --- /dev/null +++ b/backend/app/version.py @@ -0,0 +1,37 @@ +""" +Version management - reads version from VERSION file +""" +import os +from pathlib import Path + +def get_version() -> str: + """ + Read version from VERSION file in project root + + Returns: + Version string (e.g., "1.4.2") + """ + # Try multiple possible paths + possible_paths = [ + Path("/app/VERSION"), # Docker container path + Path(__file__).parent.parent.parent / "VERSION", # Development path + Path(__file__).parent.parent.parent.parent / "VERSION", # Alternative dev path + ] + + for version_path in possible_paths: + if version_path.exists(): + try: + with open(version_path, "r") as f: + version = f.read().strip() + if version: + return version + except Exception as e: + print(f"Error reading VERSION file from {version_path}: {e}") + continue + + # Fallback to default version if file not found + return "1.4.2" + +# Cache the version on import +__version__ = get_version() + diff --git a/documentation/API.md b/documentation/API.md index 6c98a57..1efd567 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -4,21 +4,66 @@ This document describes all available API endpoints for the Mailcow Logs Viewer **Base URL:** `http://your-server:8080/api` +**Authentication:** When `AUTH_ENABLED=true`, all API endpoints (except `/api/health`) require HTTP Basic Authentication. Include the `Authorization: Basic ` header in all requests. + --- ## Table of Contents -1. [Health & Info](#health--info) -2. [Messages (Unified View)](#messages-unified-view) -3. [Logs](#logs) +1. [Authentication](#authentication) +2. [Health & Info](#health--info) +3. [Messages (Unified View)](#messages-unified-view) +4. [Logs](#logs) - [Postfix Logs](#postfix-logs) - [Rspamd Logs](#rspamd-logs) - [Netfilter Logs](#netfilter-logs) -4. [Queue & Quarantine](#queue--quarantine) -5. [Statistics](#statistics) -6. [Status](#status) -7. [Settings](#settings) -8. [Export](#export) +5. [Queue & Quarantine](#queue--quarantine) +6. [Statistics](#statistics) +7. [Status](#status) +8. [Settings](#settings) +9. [Export](#export) + +--- + +## Authentication + +### Overview + +When authentication is enabled (`AUTH_ENABLED=true`), all API endpoints except `/api/health` require HTTP Basic Authentication. + +**Public Endpoints (No Authentication Required):** +- `GET /api/health` - Health check (for Docker monitoring) +- `GET /login` - Login page (HTML) + +**Protected Endpoints (Authentication Required):** +- All other `/api/*` endpoints + +### Authentication Method + +Use HTTP Basic Authentication with the credentials configured in your environment: +- Username: `AUTH_USERNAME` (default: `admin`) +- Password: `AUTH_PASSWORD` + +**Example Request:** +```bash +curl -u username:password http://your-server:8080/api/info +``` + +Or with explicit header: +```bash +curl -H "Authorization: Basic $(echo -n 'username:password' | base64)" \ + http://your-server:8080/api/info +``` + +### Login Endpoint + +#### GET /login + +Serves the login page (HTML). This endpoint is always publicly accessible. + +**Response:** HTML page with login form + +**Note:** When authentication is disabled, accessing this endpoint will automatically redirect to the main application. --- @@ -28,17 +73,20 @@ This document describes all available API endpoints for the Mailcow Logs Viewer Health check endpoint for monitoring and load balancers. +**Authentication:** Not required (public endpoint for Docker health checks) + **Response:** ```json { "status": "healthy", "database": "connected", - "version": "1.3.0", + "version": "1.4.3", "config": { "fetch_interval": 60, "retention_days": 7, "mailcow_url": "https://mail.example.com", - "blacklist_enabled": true + "blacklist_enabled": true, + "auth_enabled": false } } ``` @@ -53,7 +101,7 @@ Application information and configuration. ```json { "name": "Mailcow Logs Viewer", - "version": "1.3.0", + "version": "1.4.3", "mailcow_url": "https://mail.example.com", "local_domains": ["example.com", "mail.example.com"], "fetch_interval": 60, @@ -61,7 +109,8 @@ Application information and configuration. "timezone": "UTC", "app_title": "Mailcow Logs Viewer", "app_logo_url": "", - "blacklist_count": 3 + "blacklist_count": 3, + "auth_enabled": false } ``` @@ -661,6 +710,25 @@ Get Mailcow version and update status. --- +### GET /status/app-version + +Get application version and check for updates from GitHub. + +**Response:** +```json +{ + "current_version": "1.4.3", + "latest_version": "1.4.3", + "update_available": false, + "changelog": "Release notes...", + "last_checked": "2026-01-01T10:30:00Z" +} +``` + +**Note:** This endpoint checks GitHub once per day and caches the result. + +--- + ### GET /status/mailcow-info Get Mailcow system information. @@ -725,7 +793,9 @@ Get system configuration and status information. "mailcow_url": "https://mail.example.com", "local_domains": ["example.com"], "fetch_interval": 60, - "fetch_count": 500, + "fetch_count_postfix": 2000, + "fetch_count_rspamd": 500, + "fetch_count_netfilter": 500, "retention_days": 7, "timezone": "UTC", "app_title": "Mailcow Logs Viewer", @@ -734,21 +804,26 @@ Get system configuration and status information. "blacklist_count": 3, "max_search_results": 1000, "csv_export_limit": 10000, - "scheduler_workers": 4 + "scheduler_workers": 4, + "auth_enabled": false, + "auth_username": null }, "import_status": { "postfix": { "last_import": "2025-12-25T10:30:00Z", + "last_fetch_run": "2025-12-25T10:35:00Z", "total_entries": 50000, "oldest_entry": "2025-12-18T00:00:00Z" }, "rspamd": { "last_import": "2025-12-25T10:30:00Z", + "last_fetch_run": "2025-12-25T10:35:00Z", "total_entries": 45000, "oldest_entry": "2025-12-18T00:00:00Z" }, "netfilter": { "last_import": "2025-12-25T10:30:00Z", + "last_fetch_run": "2025-12-25T10:35:00Z", "total_entries": 1000, "oldest_entry": "2025-12-18T00:00:00Z" } @@ -929,6 +1004,15 @@ All endpoints may return the following error responses: } ``` +### 401 Unauthorized +```json +{ + "detail": "Authentication required" +} +``` + +**Note:** Returned when authentication is enabled but no valid credentials are provided. The response does not include `WWW-Authenticate` header to prevent browser popup dialogs. + ### 500 Internal Server Error ```json { diff --git a/documentation/GETTING_STARTED.md b/documentation/GETTING_STARTED.md index 973f9ad..a6106cc 100644 --- a/documentation/GETTING_STARTED.md +++ b/documentation/GETTING_STARTED.md @@ -46,9 +46,10 @@ nano .env |----------|-------------|---------| | `MAILCOW_URL` | Your Mailcow instance URL | `https://mail.example.com` | | `MAILCOW_API_KEY` | Your Mailcow API key | `abc123-def456...` | -| `MAILCOW_LOCAL_DOMAINS` | Your email domains | `example.com,domain.net` | | `POSTGRES_PASSWORD` | Database password
⚠️ Avoid special chars (`@:/?#`) - breaks connection strings
💡 Use UUID: Linux/Mac: `uuidgen`
or online https://it-tools.tech/uuid-generator | Example: `a7f3c8e2-4b1d-4f9a-8c3e-7d2f1a9b5e4c` | +**Note:** Active domains are automatically fetched from Mailcow API (`/api/v1/get/domain/all`) - no need to configure `MAILCOW_LOCAL_DOMAINS` anymore! + **Review all other settings** and adjust as needed for your environment (timezone, fetch intervals, retention period, etc.) **🔐 Optional: Enable Authentication** diff --git a/env.example b/env.example index 38c91a4..8b40917 100644 --- a/env.example +++ b/env.example @@ -12,8 +12,8 @@ MAILCOW_URL=https://mail.example.com # Required permissions: Read access to logs MAILCOW_API_KEY=your-api-key-here -# Your local email domains (comma-separated, no spaces) -MAILCOW_LOCAL_DOMAINS=example.com,example.net +# Note: Active domains are automatically fetched from Mailcow API +# No need to configure MAILCOW_LOCAL_DOMAINS anymore # ============================================================================= # DATABASE CONFIGURATION diff --git a/frontend/app.js b/frontend/app.js index c0cb885..09b750d 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -263,6 +263,12 @@ async function loadAppInfo() { if (data.app_title) { document.getElementById('app-title').textContent = data.app_title; document.title = data.app_title; + + // Update footer app name + const footerName = document.getElementById('app-name-footer'); + if (footerName) { + footerName.textContent = data.app_title; + } } if (data.app_logo_url) { @@ -272,6 +278,12 @@ async function loadAppInfo() { document.getElementById('default-logo').classList.add('hidden'); } + // Update footer version + const footerVersion = document.getElementById('app-version-footer'); + if (footerVersion && data.version) { + footerVersion.textContent = `v${data.version}`; + } + // Show/hide logout button based on auth status const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) { @@ -281,11 +293,33 @@ async function loadAppInfo() { logoutBtn.classList.add('hidden'); } } + + // Load app version status for update check + await loadAppVersionStatus(); } catch (error) { console.error('Failed to load app info:', error); } } +async function loadAppVersionStatus() { + try { + const response = await authenticatedFetch('/api/status/app-version'); + if (!response.ok) return; + + const data = await response.json(); + const updateBadge = document.getElementById('update-badge'); + + if (updateBadge && data.update_available) { + updateBadge.classList.remove('hidden'); + updateBadge.title = `Update available: v${data.latest_version}`; + } else if (updateBadge) { + updateBadge.classList.add('hidden'); + } + } catch (error) { + console.error('Failed to load app version status:', error); + } +} + // ============================================================================= // AUTO-REFRESH SYSTEM - Smart refresh (only updates when data changes) // ============================================================================= @@ -300,6 +334,12 @@ let lastDataCache = { settings: null }; +// Cache for version info (separate from settings cache, doesn't update on smart refresh) +let versionInfoCache = { + app_version: null, + version_info: null +}; + function startAutoRefresh() { // Clear existing timer if any if (autoRefreshTimer) { @@ -443,11 +483,71 @@ function renderMessagesData(data) { `; } +// Deduplicate netfilter logs based on message + time + priority +function deduplicateNetfilterLogs(logs) { + if (!logs || logs.length === 0) return []; + + const seen = new Set(); + const uniqueLogs = []; + + for (const log of logs) { + // Create unique key from message + time + priority + const key = `${log.message || ''}|${log.time || ''}|${log.priority || ''}`; + + if (!seen.has(key)) { + seen.add(key); + uniqueLogs.push(log); + } + } + + return uniqueLogs; +} + +// Render netfilter without loading spinner (for smart refresh) +function renderNetfilterData(data) { + const container = document.getElementById('netfilter-logs'); + if (!container) return; + + if (!data.data || data.data.length === 0) { + container.innerHTML = '

No logs found

'; + return; + } + + // Deduplicate logs + const uniqueLogs = deduplicateNetfilterLogs(data.data); + + // Update count display with deduplicated count + const countEl = document.getElementById('security-count'); + if (countEl) { + countEl.textContent = uniqueLogs.length > 0 ? `(${uniqueLogs.length.toLocaleString()} results)` : ''; + } + + container.innerHTML = ` +
+ ${uniqueLogs.map(log => ` +
+
+
+ ${log.ip || '-'} + ${log.username && log.username !== '-' ? `${escapeHtml(log.username)}` : ''} + ${log.action || 'warning'} + ${log.attempts_left !== null && log.attempts_left !== undefined ? `${log.attempts_left} attempts left` : ''} +
+ ${formatTime(log.time)} +
+

${escapeHtml(log.message || '-')}

+
+ `).join('')} +
+ ${renderPagination('netfilter', data.page, data.pages)} + `; +} + // Smart refresh for Netfilter async function smartRefreshNetfilter() { const filters = currentFilters.netfilter || {}; const params = new URLSearchParams({ - page: currentPage.netfilter, + page: currentPage.netfilter || 1, limit: 50, ...filters }); @@ -460,52 +560,11 @@ async function smartRefreshNetfilter() { if (hasDataChanged(data, 'netfilter')) { console.log('[REFRESH] Netfilter data changed, updating UI'); lastDataCache.netfilter = data; + // Use renderNetfilterData to update content without loading spinner (like Messages page) renderNetfilterData(data); } } -// Render netfilter without loading spinner -function renderNetfilterData(data) { - const container = document.getElementById('netfilter-logs'); - if (!container) return; - - if (!data.data || data.data.length === 0) { - container.innerHTML = '

No logs found

'; - return; - } - - container.innerHTML = ` -
- - - - - - - - - - - - - ${data.data.map(log => ` - - - - - - - - - `).join('')} - -
TimeIPUsernameAuth MethodActionAttempts Left
${formatTime(log.time)}${log.ip || '-'}${escapeHtml(log.username || '-')}${log.auth_method || '-'} - ${log.action || 'warning'} - ${log.attempts_left !== null ? log.attempts_left : '-'}
-
- ${renderPagination('netfilter', data.page, data.pages)} - `; -} // Smart refresh for Queue async function smartRefreshQueue() { @@ -610,6 +669,14 @@ async function smartRefreshSettings() { const content = document.getElementById('settings-content'); if (content && !content.classList.contains('hidden')) { + // Preserve version info from cache (don't reload it on smart refresh) + if (versionInfoCache.app_version) { + data.app_version = versionInfoCache.app_version; + } + if (versionInfoCache.version_info) { + data.version_info = versionInfoCache.version_info; + } + renderSettings(content, data); } } @@ -1103,20 +1170,25 @@ async function loadNetfilterLogs(page = 1) { const data = await response.json(); console.log('Netfilter data:', data); - // Update count display - const countEl = document.getElementById('security-count'); - if (countEl) { - countEl.textContent = data.total ? `(${data.total.toLocaleString()} results)` : ''; - } - if (!data.data || data.data.length === 0) { container.innerHTML = '

No logs found

'; + const countEl = document.getElementById('security-count'); + if (countEl) countEl.textContent = ''; return; } + // Deduplicate logs based on message + time + priority + const uniqueLogs = deduplicateNetfilterLogs(data.data); + + // Update count display with deduplicated count + const countEl = document.getElementById('security-count'); + if (countEl) { + countEl.textContent = uniqueLogs.length > 0 ? `(${uniqueLogs.length.toLocaleString()} results)` : ''; + } + container.innerHTML = `
- ${data.data.map(log => ` + ${uniqueLogs.map(log => `
@@ -2574,14 +2646,33 @@ async function loadSettings() { content.classList.add('hidden'); try { - const response = await authenticatedFetch('/api/settings/info'); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + // Load settings info and app version status in parallel + const [settingsResponse, appInfoResponse, versionResponse] = await Promise.all([ + authenticatedFetch('/api/settings/info'), + authenticatedFetch('/api/info'), + authenticatedFetch('/api/status/app-version') + ]); + + if (!settingsResponse.ok) { + throw new Error(`HTTP ${settingsResponse.status}`); } - const data = await response.json(); + const data = await settingsResponse.json(); + const appInfo = appInfoResponse.ok ? await appInfoResponse.json() : null; + const versionInfo = versionResponse.ok ? await versionResponse.json() : null; + console.log('Settings loaded:', data); + // Add version info to data and cache it + if (appInfo) { + data.app_version = appInfo.version; + versionInfoCache.app_version = appInfo.version; + } + if (versionInfo) { + data.version_info = versionInfo; + versionInfoCache.version_info = versionInfo; + } + renderSettings(content, data); loading.classList.add('hidden'); @@ -2603,8 +2694,55 @@ async function loadSettings() { function renderSettings(content, data) { const config = data.configuration || {}; + const appVersion = data.app_version || 'Unknown'; + const versionInfo = data.version_info || {}; content.innerHTML = ` + +
+
+

+ + + + Version Information +

+
+
+
+
+

Current Version

+

v${appVersion}

+
+
+

Latest Version

+
+

${versionInfo.latest_version ? `v${versionInfo.latest_version}` : 'Checking...'}

+ ${versionInfo.update_available ? ` + + Update Available + + ` : versionInfo.latest_version && !versionInfo.update_available ? ` + + Up to Date + + ` : ''} +
+
+
+ ${versionInfo.update_available ? ` +
+

+ Update available! A new version (v${versionInfo.latest_version}) is available on GitHub. +

+ + View release notes → + +
+ ` : ''} +
+
+
@@ -2622,9 +2760,26 @@ function renderSettings(content, data) {

Mailcow URL

${escapeHtml(config.mailcow_url || 'N/A')}

-
-

Local Domains

-

${config.local_domains ? config.local_domains.join(', ') : 'N/A'}

+
+

+ Local Domains + ${config.local_domains && config.local_domains.length > 0 ? + `(${config.local_domains.length})` : + '' + } +

+ ${config.local_domains && config.local_domains.length > 0 ? + `
+
+ ${config.local_domains.map(domain => ` +
+ ${escapeHtml(domain)} +
+ `).join('')} +
+
` : + '

N/A

' + }

Fetch Interval

@@ -2714,6 +2869,10 @@ function renderImportCard(title, data, color) {

${title}

+
+

Last Fetch Run

+

${data.last_fetch_run ? formatTime(data.last_fetch_run) : 'Never'}

+

Last Import

${data.last_import ? formatTime(data.last_import) : 'Never'}

diff --git a/frontend/index.html b/frontend/index.html index ce381f2..3aec304 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -65,7 +65,7 @@ -
+
+ + +
+
+
+
+ Mailcow Logs Viewer + + v1.4.2 + +
+
+ Created with ❤️ + + + + + + GitHub + +
+
+
+