Просмотр исходного кода

Add Prometheus metrics endpoint for external monitoring (#161)

Expose printer telemetry at /api/v1/metrics in Prometheus text format for
integration with Grafana, Prometheus, and other monitoring systems.

Backend:
- Add metrics.py route with GET /api/v1/metrics endpoint
- Support optional bearer token authentication
- Export printer metrics: connection, state, temperatures, fans, WiFi
- Export print metrics: progress, remaining time, layer count
- Export statistics: prints by status, filament used, print time
- Export queue metrics: pending and active jobs
- Add prometheus_enabled and prometheus_token settings

Frontend:
- Add Prometheus Metrics card in Settings → Network tab
- Toggle to enable/disable metrics endpoint
- Optional bearer token field for authentication
- Display list of available metrics

Tests:
- Add test_metrics_api.py with 7 integration tests
- Test access control (disabled, enabled, token auth)
- Test metrics format and content validation
maziggy 4 месяцев назад
Родитель
Сommit
80d4b63939

+ 16 - 1
CHANGELOG.md

@@ -47,6 +47,17 @@ All notable changes to Bambuddy will be documented in this file.
 - **BOM Item Editing** - Bill of Materials items are now fully editable:
 - **BOM Item Editing** - Bill of Materials items are now fully editable:
   - Edit name, quantity, price, URL, and remarks after creation
   - Edit name, quantity, price, URL, and remarks after creation
   - Pencil icon on each BOM item to enter edit mode
   - Pencil icon on each BOM item to enter edit mode
+- **Prometheus Metrics Endpoint** - Export printer telemetry for external monitoring systems (Issue #161):
+  - Enable via Settings → Network → Prometheus Metrics
+  - Endpoint: `GET /api/v1/metrics` (Prometheus text format)
+  - Optional bearer token authentication for security
+  - Printer metrics: connection status, state, temperatures (bed, nozzle, chamber), fans, WiFi signal
+  - Print metrics: progress, remaining time, layer count
+  - Statistics: total prints by status, filament used, print time
+  - Queue metrics: pending and active jobs
+  - System metrics: connected printers count
+  - Labels include printer_id, printer_name, serial for filtering
+  - Ready for Grafana dashboards
 - **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
 - **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
   - Link archives to Printables, Thingiverse, or any other URL
   - Link archives to Printables, Thingiverse, or any other URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
@@ -98,7 +109,11 @@ All notable changes to Bambuddy will be documented in this file.
   - Added touch-based panning when zoomed in
   - Added touch-based panning when zoomed in
   - Both embedded camera viewer and standalone camera page updated
   - Both embedded camera viewer and standalone camera page updated
 - **Progress Milestone Time** - Fixed milestone notifications showing wrong time (e.g., "17m" instead of "17h 47m") by converting remaining_time from minutes to seconds (Issue #157)
 - **Progress Milestone Time** - Fixed milestone notifications showing wrong time (e.g., "17m" instead of "17h 47m") by converting remaining_time from minutes to seconds (Issue #157)
-- **File Manager Folder Tooltip** - Long folder names in File Manager navigation now show full name on hover (Issue #160)
+- **File Manager Folder Navigation** - Improved handling of long folder names (Issue #160):
+  - Resizable sidebar: Drag the edge to adjust width (200-500px), double-click to reset
+  - Text wrap toggle: "Wrap" button in header to wrap long names instead of truncating
+  - Both settings persist in localStorage
+  - Tooltip shows full name on hover
 
 
 ## [0.1.6b11] - 2026-01-22
 ## [0.1.6b11] - 2026-01-22
 
 

+ 1 - 0
README.md

@@ -119,6 +119,7 @@
 ### 🔧 Integrations
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - MQTT publishing for Home Assistant, Node-RED, etc.
+- **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - Bambu Cloud profile management
 - Bambu Cloud profile management
 - K-profiles (pressure advance)
 - K-profiles (pressure advance)
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub

+ 418 - 0
backend/app/api/routes/metrics.py

@@ -0,0 +1,418 @@
+"""Prometheus metrics endpoint for external monitoring."""
+
+from fastapi import APIRouter, Depends, Header, HTTPException, Response
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.services.printer_manager import printer_manager, supports_chamber_temp
+
+router = APIRouter(tags=["metrics"])
+
+
+async def get_prometheus_settings(db: AsyncSession) -> tuple[bool, str]:
+    """Get Prometheus settings from database."""
+    result = await db.execute(select(Settings).where(Settings.key.in_(["prometheus_enabled", "prometheus_token"])))
+    settings_dict = {s.key: s.value for s in result.scalars().all()}
+
+    enabled = settings_dict.get("prometheus_enabled", "false").lower() == "true"
+    token = settings_dict.get("prometheus_token", "")
+    return enabled, token
+
+
+def format_labels(**labels: str) -> str:
+    """Format label key-value pairs for Prometheus."""
+    if not labels:
+        return ""
+    pairs = [f'{k}="{v}"' for k, v in labels.items() if v is not None]
+    return "{" + ",".join(pairs) + "}"
+
+
+def state_to_numeric(state: str) -> int:
+    """Convert printer state string to numeric value."""
+    state_map = {
+        "unknown": 0,
+        "IDLE": 1,
+        "RUNNING": 2,
+        "PAUSE": 3,
+        "FINISH": 4,
+        "FAILED": 5,
+        "PREPARE": 6,
+        "SLICING": 7,
+    }
+    return state_map.get(state, 0)
+
+
+@router.get("/metrics", response_class=Response)
+async def get_metrics(
+    db: AsyncSession = Depends(get_db),
+    authorization: str | None = Header(None),
+):
+    """
+    Prometheus metrics endpoint.
+
+    Returns metrics in Prometheus text exposition format.
+    Requires prometheus_enabled setting to be true.
+    If prometheus_token is set, requires Bearer token authentication.
+    """
+    # Check if enabled
+    enabled, token = await get_prometheus_settings(db)
+
+    if not enabled:
+        raise HTTPException(status_code=404, detail="Prometheus metrics not enabled")
+
+    # Check authentication if token is set
+    if token:
+        if not authorization:
+            raise HTTPException(status_code=401, detail="Authorization required")
+        if not authorization.startswith("Bearer "):
+            raise HTTPException(status_code=401, detail="Bearer token required")
+        provided_token = authorization[7:]  # Remove "Bearer " prefix
+        if provided_token != token:
+            raise HTTPException(status_code=401, detail="Invalid token")
+
+    lines: list[str] = []
+
+    # =========================================================================
+    # Printer metrics
+    # =========================================================================
+
+    # Get all printers from DB
+    result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
+    printers = list(result.scalars().all())
+
+    # Build lookup for printer info
+    printer_info = {p.id: p for p in printers}
+
+    # Get all connected printer statuses
+    all_statuses = printer_manager.get_all_statuses()
+
+    # Printer connection status
+    lines.append("# HELP bambuddy_printer_connected Printer connection status (1=connected, 0=disconnected)")
+    lines.append("# TYPE bambuddy_printer_connected gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        connected = 1 if status and status.connected else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+            model=printer.model or "unknown",
+        )
+        lines.append(f"bambuddy_printer_connected{labels} {connected}")
+
+    # Printer state
+    lines.append("")
+    lines.append(
+        "# HELP bambuddy_printer_state Printer state (0=unknown, 1=idle, 2=running, 3=pause, 4=finish, 5=failed, 6=prepare, 7=slicing)"
+    )
+    lines.append("# TYPE bambuddy_printer_state gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        state_val = state_to_numeric(status.state) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_printer_state{labels} {state_val}")
+
+    # Print progress
+    lines.append("")
+    lines.append("# HELP bambuddy_print_progress Current print progress (0-100)")
+    lines.append("# TYPE bambuddy_print_progress gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        progress = status.progress if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_progress{labels} {progress:.1f}")
+
+    # Remaining time
+    lines.append("")
+    lines.append("# HELP bambuddy_print_remaining_seconds Estimated remaining print time in seconds")
+    lines.append("# TYPE bambuddy_print_remaining_seconds gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        remaining = status.remaining_time * 60 if status else 0  # Convert minutes to seconds
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_remaining_seconds{labels} {remaining}")
+
+    # Layer progress
+    lines.append("")
+    lines.append("# HELP bambuddy_print_layer_current Current layer number")
+    lines.append("# TYPE bambuddy_print_layer_current gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        layer = status.layer_num if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_layer_current{labels} {layer}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_print_layer_total Total layers in current print")
+    lines.append("# TYPE bambuddy_print_layer_total gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        total = status.total_layers if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_layer_total{labels} {total}")
+
+    # =========================================================================
+    # Temperature metrics
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_bed_temp_celsius Current bed temperature")
+    lines.append("# TYPE bambuddy_bed_temp_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("bed", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_bed_temp_celsius{labels} {temp:.1f}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_bed_target_celsius Target bed temperature")
+    lines.append("# TYPE bambuddy_bed_target_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("bed_target", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_bed_target_celsius{labels} {temp:.1f}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_nozzle_temp_celsius Current nozzle temperature")
+    lines.append("# TYPE bambuddy_nozzle_temp_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        # Primary nozzle
+        temp = status.temperatures.get("nozzle", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+            nozzle="0",
+        )
+        lines.append(f"bambuddy_nozzle_temp_celsius{labels} {temp:.1f}")
+        # Second nozzle if present
+        if status and "nozzle_2" in status.temperatures:
+            temp2 = status.temperatures.get("nozzle_2", 0)
+            labels2 = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                nozzle="1",
+            )
+            lines.append(f"bambuddy_nozzle_temp_celsius{labels2} {temp2:.1f}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_nozzle_target_celsius Target nozzle temperature")
+    lines.append("# TYPE bambuddy_nozzle_target_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("nozzle_target", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+            nozzle="0",
+        )
+        lines.append(f"bambuddy_nozzle_target_celsius{labels} {temp:.1f}")
+        if status and "nozzle_2_target" in status.temperatures:
+            temp2 = status.temperatures.get("nozzle_2_target", 0)
+            labels2 = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                nozzle="1",
+            )
+            lines.append(f"bambuddy_nozzle_target_celsius{labels2} {temp2:.1f}")
+
+    lines.append("")
+    lines.append(
+        "# HELP bambuddy_chamber_temp_celsius Current chamber temperature (only for models with chamber sensor)"
+    )
+    lines.append("# TYPE bambuddy_chamber_temp_celsius gauge")
+    for printer in printers:
+        # Only report chamber temp for models that have a real sensor
+        if not supports_chamber_temp(printer.model):
+            continue
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("chamber", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_chamber_temp_celsius{labels} {temp:.1f}")
+
+    # =========================================================================
+    # Fan speeds
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_fan_speed_percent Fan speed percentage")
+    lines.append("# TYPE bambuddy_fan_speed_percent gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        if not status:
+            continue
+        # Part cooling fan
+        if "part_fan" in status.temperatures:
+            val = status.temperatures["part_fan"]
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                fan="part",
+            )
+            lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
+        # Aux fan
+        if "aux_fan" in status.temperatures:
+            val = status.temperatures["aux_fan"]
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                fan="aux",
+            )
+            lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
+        # Chamber fan
+        if "chamber_fan" in status.temperatures:
+            val = status.temperatures["chamber_fan"]
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                fan="chamber",
+            )
+            lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
+
+    # =========================================================================
+    # WiFi signal
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_wifi_signal_dbm WiFi signal strength in dBm")
+    lines.append("# TYPE bambuddy_wifi_signal_dbm gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        if status and status.wifi_signal is not None:
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+            )
+            lines.append(f"bambuddy_wifi_signal_dbm{labels} {status.wifi_signal}")
+
+    # =========================================================================
+    # Print statistics (from database)
+    # =========================================================================
+
+    # Total prints by status
+    lines.append("")
+    lines.append("# HELP bambuddy_prints_total Total number of prints by result")
+    lines.append("# TYPE bambuddy_prints_total counter")
+    result = await db.execute(select(PrintArchive.status, func.count(PrintArchive.id)).group_by(PrintArchive.status))
+    for print_result, count in result.all():
+        result_label = print_result or "unknown"
+        labels = format_labels(result=result_label)
+        lines.append(f"bambuddy_prints_total{labels} {count}")
+
+    # Total prints per printer
+    lines.append("")
+    lines.append("# HELP bambuddy_printer_prints_total Total prints per printer")
+    lines.append("# TYPE bambuddy_printer_prints_total counter")
+    result = await db.execute(
+        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
+    )
+    for printer_id, count in result.all():
+        if printer_id and printer_id in printer_info:
+            p = printer_info[printer_id]
+            labels = format_labels(
+                printer_id=str(printer_id),
+                printer_name=p.name,
+                serial=p.serial_number,
+            )
+            lines.append(f"bambuddy_printer_prints_total{labels} {count}")
+
+    # Total filament used
+    lines.append("")
+    lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
+    lines.append("# TYPE bambuddy_filament_used_grams counter")
+    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    total_filament = result.scalar() or 0
+    lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
+
+    # Total print time
+    lines.append("")
+    lines.append("# HELP bambuddy_print_time_seconds Total print time in seconds")
+    lines.append("# TYPE bambuddy_print_time_seconds counter")
+    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.print_time_seconds), 0)))
+    total_time = result.scalar() or 0
+    lines.append(f"bambuddy_print_time_seconds {total_time}")
+
+    # =========================================================================
+    # Queue metrics
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_queue_pending Number of pending queue items")
+    lines.append("# TYPE bambuddy_queue_pending gauge")
+    result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending"))
+    pending_count = result.scalar() or 0
+    lines.append(f"bambuddy_queue_pending {pending_count}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_queue_printing Number of currently printing queue items")
+    lines.append("# TYPE bambuddy_queue_printing gauge")
+    result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "printing"))
+    printing_count = result.scalar() or 0
+    lines.append(f"bambuddy_queue_printing {printing_count}")
+
+    # =========================================================================
+    # System metrics
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_printers_connected Number of connected printers")
+    lines.append("# TYPE bambuddy_printers_connected gauge")
+    connected_count = sum(1 for s in all_statuses.values() if s.connected)
+    lines.append(f"bambuddy_printers_connected {connected_count}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_printers_total Total number of configured printers")
+    lines.append("# TYPE bambuddy_printers_total gauge")
+    lines.append(f"bambuddy_printers_total {len(printers)}")
+
+    # Add trailing newline
+    lines.append("")
+
+    content = "\n".join(lines)
+    return Response(content=content, media_type="text/plain; version=0.0.4; charset=utf-8")

+ 1 - 0
backend/app/api/routes/settings.py

@@ -80,6 +80,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "mqtt_use_tls",
                 "mqtt_use_tls",
                 "ha_enabled",
                 "ha_enabled",
                 "per_printer_mapping_expanded",
                 "per_printer_mapping_expanded",
+                "prometheus_enabled",
             ]:
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
             elif setting.key in [

+ 2 - 0
backend/app/main.py

@@ -186,6 +186,7 @@ from backend.app.api.routes import (
     kprofiles,
     kprofiles,
     library,
     library,
     maintenance,
     maintenance,
+    metrics,
     notification_templates,
     notification_templates,
     notifications,
     notifications,
     pending_uploads,
     pending_uploads,
@@ -2491,6 +2492,7 @@ app.include_router(discovery.router, prefix=app_settings.api_prefix)
 app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 app.include_router(firmware.router, prefix=app_settings.api_prefix)
 app.include_router(firmware.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
+app.include_router(metrics.router, prefix=app_settings.api_prefix)
 
 
 
 
 # Serve static files (React build)
 # Serve static files (React build)

+ 8 - 0
backend/app/schemas/settings.py

@@ -113,6 +113,12 @@ class AppSettings(BaseModel):
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
     )
     )
 
 
+    # Prometheus metrics endpoint
+    prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
+    prometheus_token: str = Field(
+        default="", description="Bearer token for Prometheus metrics authentication (optional)"
+    )
+
 
 
 class AppSettingsUpdate(BaseModel):
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
     """Schema for updating settings (all fields optional)."""
@@ -164,3 +170,5 @@ class AppSettingsUpdate(BaseModel):
     library_archive_mode: str | None = None
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
     camera_view_mode: str | None = None
+    prometheus_enabled: bool | None = None
+    prometheus_token: str | None = None

+ 139 - 0
backend/tests/integration/test_metrics_api.py

@@ -0,0 +1,139 @@
+"""Integration tests for Prometheus Metrics API endpoint.
+
+Tests the /api/v1/metrics endpoint for Prometheus scraping.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestMetricsAPI:
+    """Integration tests for /api/v1/metrics endpoint."""
+
+    # ========================================================================
+    # Metrics endpoint access control
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_disabled_returns_404(self, async_client: AsyncClient):
+        """Verify metrics endpoint returns 404 when disabled."""
+        # Ensure prometheus is disabled
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": False})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 404
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_enabled_without_token(self, async_client: AsyncClient):
+        """Verify metrics endpoint works when enabled without token."""
+        # Enable prometheus without token
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        assert response.headers["content-type"].startswith("text/plain")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_with_token_requires_auth(self, async_client: AsyncClient):
+        """Verify metrics endpoint requires auth when token is set."""
+        # Enable prometheus with token
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": "secret123"})
+
+        # Request without auth
+        response = await async_client.get("/api/v1/metrics")
+        assert response.status_code == 401
+
+        # Request with wrong token
+        response = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer wrongtoken"})
+        assert response.status_code == 401
+
+        # Request with correct token
+        response = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer secret123"})
+        assert response.status_code == 200
+
+    # ========================================================================
+    # Metrics content validation
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_format(self, async_client: AsyncClient):
+        """Verify metrics are in Prometheus text format."""
+        # Enable prometheus
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        content = response.text
+
+        # Check for Prometheus format markers
+        assert "# HELP" in content
+        assert "# TYPE" in content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_contains_expected_metrics(self, async_client: AsyncClient):
+        """Verify expected metrics are present."""
+        # Enable prometheus
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        content = response.text
+
+        # Check for key metrics
+        assert "bambuddy_printers_connected" in content
+        assert "bambuddy_printers_total" in content
+        assert "bambuddy_prints_total" in content
+        assert "bambuddy_filament_used_grams" in content
+        assert "bambuddy_print_time_seconds" in content
+        assert "bambuddy_queue_pending" in content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_printer_metrics_when_no_printers(self, async_client: AsyncClient):
+        """Verify printer metrics work when no printers configured."""
+        # Enable prometheus
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        content = response.text
+
+        # Should still have system metrics
+        assert "bambuddy_printers_total" in content
+        assert "bambuddy_printers_connected" in content
+
+    # ========================================================================
+    # Settings persistence
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_prometheus_settings_persist(self, async_client: AsyncClient):
+        """Verify prometheus settings are saved correctly."""
+        # Update settings
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": "mytoken"})
+
+        # Read back settings
+        response = await async_client.get("/api/v1/settings/")
+        settings = response.json()
+
+        assert settings["prometheus_enabled"] is True
+        assert settings["prometheus_token"] == "mytoken"
+
+        # Disable and verify
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": False})
+        response = await async_client.get("/api/v1/settings/")
+        settings = response.json()
+
+        assert settings["prometheus_enabled"] is False

+ 3 - 0
frontend/src/api/client.ts

@@ -727,6 +727,9 @@ export interface AppSettings {
   library_disk_warning_gb: number;
   library_disk_warning_gb: number;
   // Camera view settings
   // Camera view settings
   camera_view_mode: 'window' | 'embedded';
   camera_view_mode: 'window' | 'embedded';
+  // Prometheus metrics
+  prometheus_enabled: boolean;
+  prometheus_token: string;
 }
 }
 
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 69 - 1
frontend/src/pages/SettingsPage.tsx

@@ -434,7 +434,9 @@ export function SettingsPage() {
       settings.ha_token !== localSettings.ha_token ||
       settings.ha_token !== localSettings.ha_token ||
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
       Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
-      (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window');
+      (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
+      settings.prometheus_enabled !== localSettings.prometheus_enabled ||
+      settings.prometheus_token !== localSettings.prometheus_token;
 
 
     if (!hasChanges) {
     if (!hasChanges) {
       return;
       return;
@@ -495,6 +497,8 @@ export function SettingsPage() {
         library_archive_mode: localSettings.library_archive_mode,
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         camera_view_mode: localSettings.camera_view_mode,
         camera_view_mode: localSettings.camera_view_mode,
+        prometheus_enabled: localSettings.prometheus_enabled,
+        prometheus_token: localSettings.prometheus_token,
       };
       };
       updateMutation.mutate(settingsToSave);
       updateMutation.mutate(settingsToSave);
     }, 500);
     }, 500);
@@ -1854,6 +1858,70 @@ export function SettingsPage() {
               )}
               )}
             </CardContent>
             </CardContent>
           </Card>
           </Card>
+
+          {/* Prometheus Metrics */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <TrendingUp className="w-5 h-5 text-orange-400" />
+                Prometheus Metrics
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Expose printer metrics at <code className="bg-bambu-dark px-1 rounded">/api/v1/metrics</code> for Prometheus/Grafana monitoring.
+              </p>
+
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Enable Metrics Endpoint</p>
+                  <p className="text-xs text-bambu-gray">Expose printer data in Prometheus format</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.prometheus_enabled ?? false}
+                    onChange={(e) => updateSetting('prometheus_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {localSettings.prometheus_enabled && (
+                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Bearer Token (optional)
+                    </label>
+                    <input
+                      type="password"
+                      value={localSettings.prometheus_token ?? ''}
+                      onChange={(e) => updateSetting('prometheus_token', e.target.value)}
+                      placeholder="Leave empty for no authentication"
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    />
+                    <p className="text-xs text-bambu-gray mt-1">
+                      If set, requests must include <code className="bg-bambu-dark px-1 rounded">Authorization: Bearer &lt;token&gt;</code>
+                    </p>
+                  </div>
+
+                  <div className="pt-2 border-t border-bambu-dark-tertiary">
+                    <p className="text-sm text-white mb-2">Available Metrics</p>
+                    <div className="text-xs text-bambu-gray space-y-1">
+                      <p><code className="text-orange-400">bambuddy_printer_connected</code> - Connection status</p>
+                      <p><code className="text-orange-400">bambuddy_printer_state</code> - Printer state (idle/printing/etc)</p>
+                      <p><code className="text-orange-400">bambuddy_print_progress</code> - Print progress 0-100%</p>
+                      <p><code className="text-orange-400">bambuddy_bed_temp_celsius</code> - Bed temperature</p>
+                      <p><code className="text-orange-400">bambuddy_nozzle_temp_celsius</code> - Nozzle temperature</p>
+                      <p><code className="text-orange-400">bambuddy_prints_total</code> - Total prints by result</p>
+                      <p className="text-bambu-gray/70 italic">...and more (layers, fans, queue, filament usage)</p>
+                    </div>
+                  </div>
+                </div>
+              )}
+            </CardContent>
+          </Card>
         </div>
         </div>
       </div>
       </div>
       )}
       )}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CJ-thy_3.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CGiVeylo.js"></script>
+    <script type="module" crossorigin src="/assets/index-CJ-thy_3.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-nwJjDqT-.css">
     <link rel="stylesheet" crossorigin href="/assets/index-nwJjDqT-.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов