mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
441 lines
18 KiB
Python
441 lines
18 KiB
Python
"""Prometheus metrics endpoint for external monitoring."""
|
|
|
|
import platform
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Response
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.config import APP_VERSION
|
|
from backend.app.core.database import get_db
|
|
from backend.app.models.print_log import PrintLogEntry
|
|
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
|
|
# Constant-time comparison closes the byte-by-byte timing oracle that
|
|
# plain ``!=`` opens on a LAN-attached attacker (audit finding I2).
|
|
if not secrets.compare_digest(provided_token.encode("utf-8"), token.encode("utf-8")):
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
lines: list[str] = []
|
|
|
|
# =========================================================================
|
|
# Build info
|
|
# =========================================================================
|
|
|
|
lines.append("# HELP bambuddy_build_info Build and version information")
|
|
lines.append("# TYPE bambuddy_build_info gauge")
|
|
build_labels = format_labels(
|
|
version=APP_VERSION,
|
|
python_version=platform.python_version(),
|
|
platform=platform.system(),
|
|
architecture=platform.machine(),
|
|
)
|
|
lines.append(f"bambuddy_build_info{build_labels} 1")
|
|
|
|
# =========================================================================
|
|
# 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 — count print events from PrintLogEntry so
|
|
# reprints contribute new rows instead of overwriting the source archive
|
|
# (#1378).
|
|
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(PrintLogEntry.status, func.count(PrintLogEntry.id)).group_by(PrintLogEntry.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(PrintLogEntry.printer_id, func.count(PrintLogEntry.id)).group_by(PrintLogEntry.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 — sum per-run actuals from PrintLogEntry.
|
|
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(PrintLogEntry.filament_used_grams), 0)))
|
|
total_filament = result.scalar() or 0
|
|
lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
|
|
|
|
# Total print time — sum per-run elapsed durations.
|
|
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(PrintLogEntry.duration_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")
|