mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI adds a 10x10 LineChart icon on each heater tile - click body opens the existing target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max + recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally gets an interaction. Retention configurable via printer_sensor_history_retention_days (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30 6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -164,6 +164,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
||||
- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
|
||||
- Dual external spool support for H2D (Ext-L / Ext-R)
|
||||
- HMS error monitoring with history and clear errors
|
||||
- **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
|
||||
- Print success rates & trends
|
||||
- Filament usage tracking
|
||||
- Cost analytics & failure analysis
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""API routes for printer heater (nozzle / bed / chamber) sensor history."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory
|
||||
from backend.app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/printer-sensor-history", tags=["printer-sensor-history"])
|
||||
|
||||
VALID_KINDS = {"nozzle", "nozzle_2", "bed", "chamber"}
|
||||
|
||||
|
||||
class HeaterHistoryPoint(BaseModel):
|
||||
recorded_at: datetime
|
||||
value: float | None
|
||||
target: float | None
|
||||
|
||||
|
||||
class HeaterSeries(BaseModel):
|
||||
sensor_kind: str
|
||||
data: list[HeaterHistoryPoint]
|
||||
min_value: float | None
|
||||
max_value: float | None
|
||||
avg_value: float | None
|
||||
|
||||
|
||||
class PrinterSensorHistoryResponse(BaseModel):
|
||||
printer_id: int
|
||||
series: list[HeaterSeries]
|
||||
|
||||
|
||||
@router.get("/{printer_id}", response_model=PrinterSensorHistoryResponse)
|
||||
async def get_printer_sensor_history(
|
||||
printer_id: int,
|
||||
hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
|
||||
kinds: str | None = Query(
|
||||
default=None,
|
||||
description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
|
||||
):
|
||||
"""Return per-sensor heater history for a printer."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
if kinds:
|
||||
requested = {k.strip() for k in kinds.split(",") if k.strip()}
|
||||
kinds_to_fetch = sorted(requested & VALID_KINDS)
|
||||
else:
|
||||
kinds_to_fetch = sorted(VALID_KINDS)
|
||||
|
||||
series_out: list[HeaterSeries] = []
|
||||
for kind in kinds_to_fetch:
|
||||
result = await db.execute(
|
||||
select(PrinterSensorHistory)
|
||||
.where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.sensor_kind == kind,
|
||||
PrinterSensorHistory.recorded_at >= since,
|
||||
)
|
||||
)
|
||||
.order_by(PrinterSensorHistory.recorded_at)
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
stats_result = await db.execute(
|
||||
select(
|
||||
func.min(PrinterSensorHistory.value).label("min_v"),
|
||||
func.max(PrinterSensorHistory.value).label("max_v"),
|
||||
func.avg(PrinterSensorHistory.value).label("avg_v"),
|
||||
).where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.sensor_kind == kind,
|
||||
PrinterSensorHistory.recorded_at >= since,
|
||||
)
|
||||
)
|
||||
)
|
||||
stats = stats_result.one()
|
||||
|
||||
series_out.append(
|
||||
HeaterSeries(
|
||||
sensor_kind=kind,
|
||||
data=[
|
||||
HeaterHistoryPoint(
|
||||
recorded_at=r.recorded_at,
|
||||
value=r.value,
|
||||
target=r.target,
|
||||
)
|
||||
for r in records
|
||||
],
|
||||
min_value=stats.min_v,
|
||||
max_value=stats.max_v,
|
||||
avg_value=round(stats.avg_v, 1) if stats.avg_v is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
return PrinterSensorHistoryResponse(printer_id=printer_id, series=series_out)
|
||||
|
||||
|
||||
@router.delete("/{printer_id}")
|
||||
async def delete_old_history(
|
||||
printer_id: int,
|
||||
days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
|
||||
):
|
||||
"""Delete old printer sensor history for a printer."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(PrinterSensorHistory.id)).where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.recorded_at < cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
count = result.scalar()
|
||||
|
||||
await db.execute(
|
||||
PrinterSensorHistory.__table__.delete().where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.recorded_at < cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}
|
||||
@@ -152,6 +152,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
||||
"ams_humidity_good",
|
||||
"ams_humidity_fair",
|
||||
"ams_history_retention_days",
|
||||
"printer_sensor_history_retention_days",
|
||||
"ftp_retry_count",
|
||||
"ftp_retry_delay",
|
||||
"ftp_timeout",
|
||||
|
||||
@@ -88,6 +88,7 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
||||
Permission.EXTERNAL_LINKS_READ: "can_read_status",
|
||||
Permission.FIRMWARE_READ: "can_read_status",
|
||||
Permission.AMS_HISTORY_READ: "can_read_status",
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status",
|
||||
Permission.STATS_READ: "can_read_status",
|
||||
Permission.STATS_FILTER_BY_USER: "can_read_status",
|
||||
Permission.SYSTEM_READ: "can_read_status",
|
||||
|
||||
@@ -193,6 +193,7 @@ async def init_db():
|
||||
print_log,
|
||||
print_queue,
|
||||
printer,
|
||||
printer_sensor_history,
|
||||
project,
|
||||
project_bom,
|
||||
settings,
|
||||
|
||||
@@ -133,6 +133,7 @@ class Permission(StrEnum):
|
||||
|
||||
# AMS History
|
||||
AMS_HISTORY_READ = "ams_history:read"
|
||||
PRINTER_SENSOR_HISTORY_READ = "printer_sensor_history:read"
|
||||
|
||||
# Stats/Metrics
|
||||
STATS_READ = "stats:read"
|
||||
@@ -295,6 +296,7 @@ PERMISSION_CATEGORIES = {
|
||||
],
|
||||
"Stats & History": [
|
||||
Permission.AMS_HISTORY_READ,
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ,
|
||||
Permission.STATS_READ,
|
||||
Permission.STATS_FILTER_BY_USER,
|
||||
],
|
||||
@@ -445,6 +447,7 @@ DEFAULT_GROUPS = {
|
||||
Permission.FIRMWARE_READ.value,
|
||||
# Stats & History
|
||||
Permission.AMS_HISTORY_READ.value,
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ.value,
|
||||
Permission.STATS_READ.value,
|
||||
Permission.SYSTEM_READ.value,
|
||||
# Settings - read only
|
||||
@@ -476,6 +479,7 @@ DEFAULT_GROUPS = {
|
||||
Permission.EXTERNAL_LINKS_READ.value,
|
||||
Permission.FIRMWARE_READ.value,
|
||||
Permission.AMS_HISTORY_READ.value,
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ.value,
|
||||
Permission.STATS_READ.value,
|
||||
Permission.SYSTEM_READ.value,
|
||||
Permission.SETTINGS_READ.value,
|
||||
|
||||
@@ -50,6 +50,7 @@ from backend.app.api.routes import (
|
||||
pending_uploads,
|
||||
print_log,
|
||||
print_queue,
|
||||
printer_sensor_history,
|
||||
printers,
|
||||
projects,
|
||||
settings as settings_routes,
|
||||
@@ -5282,6 +5283,133 @@ def stop_ams_history_recording():
|
||||
logging.getLogger(__name__).info("AMS history recording stopped")
|
||||
|
||||
|
||||
# Printer sensor history recording (nozzle / bed / chamber)
|
||||
_printer_sensor_history_task: asyncio.Task | None = None
|
||||
PRINTER_SENSOR_HISTORY_INTERVAL = 60 # Record every minute — heaters move faster than AMS humidity
|
||||
PRINTER_SENSOR_HISTORY_RETENTION_DAYS = 30
|
||||
_printer_sensor_cleanup_counter = 0
|
||||
# Sensor kinds tracked in state.temperatures — these are the normalised keys the
|
||||
# MQTT parser writes, so we don't need to handle per-model field aliases here
|
||||
# (nozzle_temper / left_nozzle_temper / right_nozzle_temper / chamber_temper
|
||||
# are all collapsed by services/bambu_mqtt.py before they reach this loop).
|
||||
_SENSOR_KINDS = ("nozzle", "nozzle_2", "bed", "chamber")
|
||||
_SENSOR_TARGET_KEYS = {
|
||||
"nozzle": "nozzle_target",
|
||||
"nozzle_2": "nozzle_2_target",
|
||||
"bed": "bed_target",
|
||||
"chamber": "chamber_target",
|
||||
}
|
||||
|
||||
|
||||
async def record_printer_sensor_history():
|
||||
"""Background task to record nozzle / bed / chamber readings.
|
||||
|
||||
Pulls from `state.temperatures` (already normalised across all printer
|
||||
models by the MQTT parser) rather than re-parsing raw_data, so we get
|
||||
free coverage of dual-nozzle H2D, sensor-only X1C chamber, etc.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
await asyncio.sleep(10)
|
||||
|
||||
while True:
|
||||
try:
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
|
||||
printers = result.scalars().all()
|
||||
|
||||
recorded_count = 0
|
||||
for printer in printers:
|
||||
state = printer_manager.get_status(printer.id)
|
||||
if not state or not state.connected:
|
||||
continue
|
||||
|
||||
temps = getattr(state, "temperatures", None) or {}
|
||||
if not isinstance(temps, dict):
|
||||
continue
|
||||
|
||||
for kind in _SENSOR_KINDS:
|
||||
if kind not in temps:
|
||||
continue
|
||||
try:
|
||||
value = float(temps[kind])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
target_raw = temps.get(_SENSOR_TARGET_KEYS[kind])
|
||||
target_val: float | None = None
|
||||
if target_raw is not None:
|
||||
try:
|
||||
target_val = float(target_raw)
|
||||
except (ValueError, TypeError):
|
||||
target_val = None
|
||||
|
||||
db.add(
|
||||
PrinterSensorHistory(
|
||||
printer_id=printer.id,
|
||||
sensor_kind=kind,
|
||||
value=value,
|
||||
target=target_val,
|
||||
)
|
||||
)
|
||||
recorded_count += 1
|
||||
|
||||
await db.commit()
|
||||
if recorded_count > 0:
|
||||
logger.debug("Recorded %s printer sensor history entries", recorded_count)
|
||||
|
||||
# Periodic cleanup — once every ~24h at this interval.
|
||||
global _printer_sensor_cleanup_counter
|
||||
_printer_sensor_cleanup_counter += 1
|
||||
cleanup_every = max(1, (24 * 60 * 60) // PRINTER_SENSOR_HISTORY_INTERVAL)
|
||||
if _printer_sensor_cleanup_counter >= cleanup_every:
|
||||
_printer_sensor_cleanup_counter = 0
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key == "printer_sensor_history_retention_days")
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
retention_days = int(setting.value) if setting else PRINTER_SENSOR_HISTORY_RETENTION_DAYS
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=retention_days)
|
||||
cleanup = await db.execute(
|
||||
delete(PrinterSensorHistory).where(PrinterSensorHistory.recorded_at < cutoff)
|
||||
)
|
||||
await db.commit()
|
||||
if cleanup.rowcount > 0:
|
||||
logger.info(
|
||||
"Cleaned up %s old printer sensor history entries (older than %s days)",
|
||||
cleanup.rowcount,
|
||||
retention_days,
|
||||
)
|
||||
|
||||
await asyncio.sleep(PRINTER_SENSOR_HISTORY_INTERVAL)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Printer sensor history recording failed: %s", e)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
def start_printer_sensor_history_recording():
|
||||
global _printer_sensor_history_task
|
||||
if _printer_sensor_history_task is None:
|
||||
_printer_sensor_history_task = asyncio.create_task(record_printer_sensor_history())
|
||||
logging.getLogger(__name__).info("Printer sensor history recording started")
|
||||
|
||||
|
||||
def stop_printer_sensor_history_recording():
|
||||
global _printer_sensor_history_task
|
||||
if _printer_sensor_history_task:
|
||||
_printer_sensor_history_task.cancel()
|
||||
_printer_sensor_history_task = None
|
||||
logging.getLogger(__name__).info("Printer sensor history recording stopped")
|
||||
|
||||
|
||||
# Printer runtime tracking
|
||||
_runtime_tracking_task: asyncio.Task | None = None
|
||||
RUNTIME_TRACKING_INTERVAL = 30 # Update every 30 seconds
|
||||
@@ -5882,6 +6010,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start AMS history recording
|
||||
start_ams_history_recording()
|
||||
|
||||
# Start printer sensor (nozzle / bed / chamber) history recording
|
||||
start_printer_sensor_history_recording()
|
||||
|
||||
# Start printer runtime tracking
|
||||
start_runtime_tracking()
|
||||
|
||||
@@ -5928,6 +6059,7 @@ async def lifespan(app: FastAPI):
|
||||
archive_purge_service.stop_scheduler()
|
||||
obico_detection_service.stop()
|
||||
stop_ams_history_recording()
|
||||
stop_printer_sensor_history_recording()
|
||||
stop_runtime_tracking()
|
||||
stop_spoolbuddy_watchdog()
|
||||
stop_camera_cleanup()
|
||||
@@ -6406,6 +6538,7 @@ app.include_router(makerworld.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(api_keys.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(webhook.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(ams_history.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(printer_sensor_history.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(system.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(support.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(websocket.router, prefix=app_settings.api_prefix)
|
||||
|
||||
@@ -20,6 +20,7 @@ from backend.app.models.orca_base_cache import OrcaBaseProfile
|
||||
from backend.app.models.pending_upload import PendingUpload
|
||||
from backend.app.models.print_batch import PrintBatch
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
@@ -51,6 +52,7 @@ __all__ = [
|
||||
"Project",
|
||||
"APIKey",
|
||||
"AMSSensorHistory",
|
||||
"PrinterSensorHistory",
|
||||
"AmsLabel",
|
||||
"PendingUpload",
|
||||
"PrintBatch",
|
||||
|
||||
@@ -58,6 +58,9 @@ class Printer(Base):
|
||||
)
|
||||
kprofile_notes: Mapped[list["KProfileNote"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
|
||||
ams_history: Mapped[list["AMSSensorHistory"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
|
||||
sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
|
||||
back_populates="printer", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
from backend.app.models.ams_history import AMSSensorHistory # noqa: E402
|
||||
@@ -65,4 +68,5 @@ from backend.app.models.archive import PrintArchive # noqa: E402
|
||||
from backend.app.models.kprofile_note import KProfileNote # noqa: E402
|
||||
from backend.app.models.maintenance import PrinterMaintenance # noqa: E402
|
||||
from backend.app.models.notification import NotificationProvider # noqa: E402
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory # noqa: E402
|
||||
from backend.app.models.smart_plug import SmartPlug # noqa: E402
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class PrinterSensorHistory(Base):
|
||||
"""Historical heater readings (nozzle / nozzle_2 / bed / chamber).
|
||||
|
||||
Parallel to AMSSensorHistory, but per-(printer, sensor_kind) rather
|
||||
than per-(printer, ams_id). Sensor counts vary by model (single vs
|
||||
dual nozzle, presence of chamber heater), so a long-format row per
|
||||
sensor reads cleanly and leaves room for future kinds (cpu, motor)
|
||||
without another migration.
|
||||
"""
|
||||
|
||||
__tablename__ = "printer_sensor_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
|
||||
sensor_kind: Mapped[str] = mapped_column(String(32)) # nozzle | nozzle_2 | bed | chamber
|
||||
value: Mapped[float | None] = mapped_column(Float) # current temperature, Celsius
|
||||
target: Mapped[float | None] = mapped_column(Float) # target temperature when set, Celsius
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_printer_sensor_history_printer_kind_time",
|
||||
"printer_id",
|
||||
"sensor_kind",
|
||||
"recorded_at",
|
||||
),
|
||||
)
|
||||
|
||||
printer: Mapped["Printer"] = relationship(back_populates="sensor_history")
|
||||
|
||||
|
||||
from backend.app.models.printer import Printer # noqa: E402
|
||||
@@ -72,6 +72,9 @@ class AppSettings(BaseModel):
|
||||
default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red"
|
||||
)
|
||||
ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
|
||||
printer_sensor_history_retention_days: int = Field(
|
||||
default=30, description="Number of days to keep printer heater history data (nozzle / bed / chamber)"
|
||||
)
|
||||
|
||||
# Queue auto-drying settings
|
||||
queue_drying_enabled: bool = Field(
|
||||
@@ -405,6 +408,7 @@ class AppSettingsUpdate(BaseModel):
|
||||
ams_temp_good: float | None = None
|
||||
ams_temp_fair: float | None = None
|
||||
ams_history_retention_days: int | None = None
|
||||
printer_sensor_history_retention_days: int | None = None
|
||||
queue_drying_enabled: bool | None = None
|
||||
queue_drying_block: bool | None = None
|
||||
ambient_drying_enabled: bool | None = None
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for printer heater (nozzle / bed / chamber) sensor history."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_per_sensor_series(async_client: AsyncClient, db_session: AsyncSession):
|
||||
"""Each sensor_kind returns its own series with stats."""
|
||||
printer = Printer(name="X1C", serial_number="X1C-TEST-001", ip_address="10.0.0.10", access_code="12345678")
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
|
||||
base = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
samples = [
|
||||
("nozzle", 210.0, 220.0, 0),
|
||||
("nozzle", 215.0, 220.0, 5),
|
||||
("nozzle", 220.0, 220.0, 10),
|
||||
("bed", 55.0, 60.0, 0),
|
||||
("bed", 60.0, 60.0, 5),
|
||||
("chamber", 38.0, 40.0, 0),
|
||||
]
|
||||
for kind, value, target, minutes in samples:
|
||||
db_session.add(
|
||||
PrinterSensorHistory(
|
||||
printer_id=printer.id,
|
||||
sensor_kind=kind,
|
||||
value=value,
|
||||
target=target,
|
||||
recorded_at=base + timedelta(minutes=minutes),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get(f"/api/v1/printer-sensor-history/{printer.id}?hours=24")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["printer_id"] == printer.id
|
||||
series_by_kind = {s["sensor_kind"]: s for s in body["series"]}
|
||||
|
||||
assert series_by_kind["nozzle"]["min_value"] == 210.0
|
||||
assert series_by_kind["nozzle"]["max_value"] == 220.0
|
||||
assert series_by_kind["nozzle"]["avg_value"] == pytest.approx(215.0, rel=0.01)
|
||||
assert len(series_by_kind["nozzle"]["data"]) == 3
|
||||
|
||||
assert series_by_kind["bed"]["min_value"] == 55.0
|
||||
assert series_by_kind["bed"]["max_value"] == 60.0
|
||||
assert len(series_by_kind["bed"]["data"]) == 2
|
||||
|
||||
assert series_by_kind["chamber"]["max_value"] == 38.0
|
||||
# nozzle_2 wasn't recorded — series present but empty
|
||||
assert series_by_kind["nozzle_2"]["data"] == []
|
||||
assert series_by_kind["nozzle_2"]["min_value"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_filters_by_kinds_query(async_client: AsyncClient, db_session: AsyncSession):
|
||||
"""`kinds=bed,chamber` only returns those series."""
|
||||
printer = Printer(name="X1C", serial_number="X1C-TEST-002", ip_address="10.0.0.11", access_code="12345678")
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
|
||||
db_session.add(PrinterSensorHistory(printer_id=printer.id, sensor_kind="bed", value=60.0, target=60.0))
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get(f"/api/v1/printer-sensor-history/{printer.id}?hours=24&kinds=bed,chamber")
|
||||
assert response.status_code == 200
|
||||
kinds_returned = {s["sensor_kind"] for s in response.json()["series"]}
|
||||
assert kinds_returned == {"bed", "chamber"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_clamps_to_hours_window(async_client: AsyncClient, db_session: AsyncSession):
|
||||
"""Rows older than the requested window are excluded."""
|
||||
printer = Printer(name="X1C", serial_number="X1C-TEST-003", ip_address="10.0.0.12", access_code="12345678")
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# One inside the window, one outside.
|
||||
db_session.add(
|
||||
PrinterSensorHistory(
|
||||
printer_id=printer.id,
|
||||
sensor_kind="bed",
|
||||
value=60.0,
|
||||
target=60.0,
|
||||
recorded_at=now - timedelta(minutes=30),
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
PrinterSensorHistory(
|
||||
printer_id=printer.id,
|
||||
sensor_kind="bed",
|
||||
value=55.0,
|
||||
target=60.0,
|
||||
recorded_at=now - timedelta(hours=10),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get(f"/api/v1/printer-sensor-history/{printer.id}?hours=1")
|
||||
body = response.json()
|
||||
bed_series = next(s for s in body["series"] if s["sensor_kind"] == "bed")
|
||||
assert len(bed_series["data"]) == 1
|
||||
assert bed_series["data"][0]["value"] == 60.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_old_rows(async_client: AsyncClient, db_session: AsyncSession):
|
||||
"""DELETE removes rows older than `days` for the given printer only."""
|
||||
keep_printer = Printer(name="Keep", serial_number="KEEP-001", ip_address="10.0.0.20", access_code="12345678")
|
||||
other_printer = Printer(name="Other", serial_number="OTHER-001", ip_address="10.0.0.21", access_code="12345678")
|
||||
db_session.add_all([keep_printer, other_printer])
|
||||
await db_session.commit()
|
||||
await db_session.refresh(keep_printer)
|
||||
await db_session.refresh(other_printer)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old = now - timedelta(days=40)
|
||||
db_session.add(PrinterSensorHistory(printer_id=keep_printer.id, sensor_kind="bed", value=60.0, recorded_at=old))
|
||||
db_session.add(PrinterSensorHistory(printer_id=keep_printer.id, sensor_kind="bed", value=60.0, recorded_at=now))
|
||||
db_session.add(PrinterSensorHistory(printer_id=other_printer.id, sensor_kind="bed", value=60.0, recorded_at=old))
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.delete(f"/api/v1/printer-sensor-history/{keep_printer.id}?days=30")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] == 1
|
||||
|
||||
# other printer's old row untouched.
|
||||
rows = (await db_session.execute(select(PrinterSensorHistory))).scalars().all()
|
||||
kinds_left = sorted((r.printer_id, r.value) for r in rows)
|
||||
assert kinds_left == sorted([(keep_printer.id, 60.0), (other_printer.id, 60.0)])
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests for the HeaterHistoryModal component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { HeaterHistoryModal } from '../../components/HeaterHistoryModal';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getPrinterSensorHistory: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('recharts', () => ({
|
||||
LineChart: ({ children }: { children: React.ReactNode }) => <div data-testid="line-chart">{children}</div>,
|
||||
Line: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
CartesianGrid: () => null,
|
||||
Tooltip: () => null,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Legend: () => null,
|
||||
}));
|
||||
|
||||
const mockResponse = {
|
||||
printer_id: 1,
|
||||
series: [
|
||||
{
|
||||
sensor_kind: 'nozzle' as const,
|
||||
data: [
|
||||
{ recorded_at: '2024-12-11T10:00:00Z', value: 210, target: 220 },
|
||||
{ recorded_at: '2024-12-11T10:05:00Z', value: 215, target: 220 },
|
||||
{ recorded_at: '2024-12-11T10:10:00Z', value: 220, target: 220 },
|
||||
],
|
||||
min_value: 210,
|
||||
max_value: 220,
|
||||
avg_value: 215,
|
||||
},
|
||||
{
|
||||
sensor_kind: 'bed' as const,
|
||||
data: [
|
||||
{ recorded_at: '2024-12-11T10:00:00Z', value: 55, target: 60 },
|
||||
{ recorded_at: '2024-12-11T10:05:00Z', value: 60, target: 60 },
|
||||
],
|
||||
min_value: 55,
|
||||
max_value: 60,
|
||||
avg_value: 57.5,
|
||||
},
|
||||
{
|
||||
sensor_kind: 'chamber' as const,
|
||||
data: [],
|
||||
min_value: null,
|
||||
max_value: null,
|
||||
avg_value: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
printerId: 1,
|
||||
printerName: 'Test Printer',
|
||||
initialKind: 'nozzle' as const,
|
||||
availableKinds: ['nozzle', 'bed', 'chamber'] as const,
|
||||
};
|
||||
|
||||
describe('HeaterHistoryModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(api.getPrinterSensorHistory as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
|
||||
});
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
render(<HeaterHistoryModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText(/Heater History/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders title + printer name when open', async () => {
|
||||
render(<HeaterHistoryModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Heater History/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Printer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows current nozzle value from last data point', async () => {
|
||||
render(<HeaterHistoryModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
// current=220, target=220, max=220 — multiple matches are expected.
|
||||
expect(screen.getAllByText(/220°C/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('switches series when bed mode button clicked', async () => {
|
||||
render(<HeaterHistoryModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Printer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const bedButtons = screen.getAllByText(/^Bed$/);
|
||||
fireEvent.click(bedButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
// 60 = current bed value (and max, and target).
|
||||
expect(screen.getAllByText(/60°C/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when series has no data', async () => {
|
||||
render(<HeaterHistoryModal {...defaultProps} initialKind="chamber" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No data recorded yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('close button triggers onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<HeaterHistoryModal {...defaultProps} onClose={onClose} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Printer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/Close/i));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5724,6 +5724,13 @@ export const api = {
|
||||
getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
|
||||
request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),
|
||||
|
||||
// Printer heater (nozzle / bed / chamber) sensor history
|
||||
getPrinterSensorHistory: (printerId: number, hours = 24, kinds?: string[]) => {
|
||||
const params = new URLSearchParams({ hours: String(hours) });
|
||||
if (kinds && kinds.length > 0) params.set('kinds', kinds.join(','));
|
||||
return request<PrinterSensorHistoryResponse>(`/printer-sensor-history/${printerId}?${params.toString()}`);
|
||||
},
|
||||
|
||||
// System Info
|
||||
getSystemInfo: () => request<SystemInfo>('/system/info'),
|
||||
getSystemHealth: () => request<SystemHealthResult>('/system/health'),
|
||||
@@ -6159,6 +6166,27 @@ export interface AMSHistoryResponse {
|
||||
avg_temperature: number | null;
|
||||
}
|
||||
|
||||
export type HeaterSensorKind = 'nozzle' | 'nozzle_2' | 'bed' | 'chamber';
|
||||
|
||||
export interface HeaterHistoryPoint {
|
||||
recorded_at: string;
|
||||
value: number | null;
|
||||
target: number | null;
|
||||
}
|
||||
|
||||
export interface HeaterSeries {
|
||||
sensor_kind: HeaterSensorKind;
|
||||
data: HeaterHistoryPoint[];
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
avg_value: number | null;
|
||||
}
|
||||
|
||||
export interface PrinterSensorHistoryResponse {
|
||||
printer_id: number;
|
||||
series: HeaterSeries[];
|
||||
}
|
||||
|
||||
// System Info types
|
||||
export interface SystemInfo {
|
||||
app: {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, Flame, Square, Box, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { api, type HeaterSensorKind, type PrinterSensorHistoryResponse } from '../api/client';
|
||||
import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HeaterHistoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
printerId: number;
|
||||
printerName: string;
|
||||
initialKind?: HeaterSensorKind;
|
||||
availableKinds?: HeaterSensorKind[];
|
||||
}
|
||||
|
||||
type TimeRange = '6h' | '24h' | '48h' | '7d';
|
||||
|
||||
const TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [
|
||||
{ value: '6h', label: '6h', hours: 6 },
|
||||
{ value: '24h', label: '24h', hours: 24 },
|
||||
{ value: '48h', label: '48h', hours: 48 },
|
||||
{ value: '7d', label: '7d', hours: 168 },
|
||||
];
|
||||
|
||||
const KIND_COLORS: Record<HeaterSensorKind, string> = {
|
||||
nozzle: '#fb923c',
|
||||
nozzle_2: '#f97316',
|
||||
bed: '#60a5fa',
|
||||
chamber: '#34d399',
|
||||
};
|
||||
|
||||
const KIND_TARGET_COLORS: Record<HeaterSensorKind, string> = {
|
||||
nozzle: '#fed7aa',
|
||||
nozzle_2: '#fdba74',
|
||||
bed: '#bfdbfe',
|
||||
chamber: '#a7f3d0',
|
||||
};
|
||||
|
||||
export function HeaterHistoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
printerId,
|
||||
printerName,
|
||||
initialKind = 'nozzle',
|
||||
availableKinds = ['nozzle', 'bed', 'chamber'],
|
||||
}: HeaterHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
|
||||
const [kind, setKind] = useState<HeaterSensorKind>(initialKind);
|
||||
|
||||
useEffect(() => {
|
||||
setKind(initialKind);
|
||||
}, [initialKind]);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
const timeFormat: TimeFormat = settings?.time_format || 'system';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const hours = TIME_RANGES.find(r => r.value === timeRange)?.hours || 24;
|
||||
|
||||
const { data, isLoading, error } = useQuery<PrinterSensorHistoryResponse>({
|
||||
queryKey: ['printer-sensor-history', printerId, hours, availableKinds.join(',')],
|
||||
queryFn: () => api.getPrinterSensorHistory(printerId, hours, availableKinds),
|
||||
enabled: isOpen,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const series = data?.series.find(s => s.sensor_kind === kind);
|
||||
|
||||
const rawPoints = (series?.data || []).map(p => {
|
||||
const date = parseUTCDate(p.recorded_at) || new Date();
|
||||
return {
|
||||
time: date.getTime(),
|
||||
value: p.value,
|
||||
target: p.target,
|
||||
};
|
||||
});
|
||||
|
||||
const domainStart = Date.now() - hours * 60 * 60 * 1000;
|
||||
const domainEnd = Date.now();
|
||||
const chartData = [...rawPoints];
|
||||
if (chartData.length > 0) {
|
||||
const first = chartData[0];
|
||||
if (first.time > domainStart) {
|
||||
chartData.unshift({ ...first, time: domainStart });
|
||||
}
|
||||
const last = chartData[chartData.length - 1];
|
||||
if (last.time < domainEnd) {
|
||||
chartData.push({ ...last, time: domainEnd });
|
||||
}
|
||||
}
|
||||
|
||||
const lastPoint = chartData[chartData.length - 1];
|
||||
const currentValue = lastPoint?.value;
|
||||
const currentTarget = lastPoint?.target;
|
||||
|
||||
const getTrend = (values: (number | null)[]) => {
|
||||
const filtered = values.filter((v): v is number => v != null);
|
||||
if (filtered.length < 4) return 'stable';
|
||||
const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4));
|
||||
const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4));
|
||||
const firstAvg = firstQuarter.reduce((a, b) => a + b, 0) / firstQuarter.length;
|
||||
const lastAvg = lastQuarter.reduce((a, b) => a + b, 0) / lastQuarter.length;
|
||||
const diff = lastAvg - firstAvg;
|
||||
if (Math.abs(diff) < 2) return 'stable';
|
||||
return diff > 0 ? 'up' : 'down';
|
||||
};
|
||||
|
||||
const trend = getTrend(chartData.map(d => d.value));
|
||||
|
||||
const TrendIcon = ({ trend }: { trend: string }) => {
|
||||
if (trend === 'up') return <TrendingUp className="w-4 h-4 text-red-400" />;
|
||||
if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-400" />;
|
||||
return <Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" />;
|
||||
};
|
||||
|
||||
const modalBg = 'var(--bg-secondary)';
|
||||
const cardBg = 'var(--bg-primary)';
|
||||
const borderColor = 'var(--border-color)';
|
||||
const textPrimary = 'var(--text-primary)';
|
||||
const textSecondary = 'var(--text-secondary)';
|
||||
const axisColor = 'var(--text-muted)';
|
||||
|
||||
const kindLabel = (k: HeaterSensorKind) => {
|
||||
switch (k) {
|
||||
case 'nozzle':
|
||||
return t('printers.heaterHistory.nozzle', 'Nozzle');
|
||||
case 'nozzle_2':
|
||||
return t('printers.heaterHistory.nozzle2', 'Nozzle 2');
|
||||
case 'bed':
|
||||
return t('printers.heaterHistory.bed', 'Bed');
|
||||
case 'chamber':
|
||||
return t('printers.heaterHistory.chamber', 'Chamber');
|
||||
}
|
||||
};
|
||||
|
||||
const KindIcon = ({ k }: { k: HeaterSensorKind }) => {
|
||||
if (k === 'nozzle' || k === 'nozzle_2') return <Flame className="w-4 h-4" />;
|
||||
if (k === 'bed') return <Square className="w-4 h-4" />;
|
||||
return <Box className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl"
|
||||
style={{ backgroundColor: modalBg }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor }}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: textPrimary }}>
|
||||
{t('printers.heaterHistory.title', 'Heater History')}
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: textSecondary }}>{printerName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg transition-colors"
|
||||
style={{ color: textSecondary }}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
<div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3">
|
||||
<div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
|
||||
{availableKinds.map(k => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setKind(k)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
kind === k ? 'text-white' : ''
|
||||
}`}
|
||||
style={kind === k ? { backgroundColor: KIND_COLORS[k] } : { color: textSecondary }}
|
||||
>
|
||||
<KindIcon k={k} />
|
||||
{kindLabel(k)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
|
||||
{TIME_RANGES.map(range => (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => setTimeRange(range.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
timeRange === range.value ? 'bg-bambu-green text-white' : ''
|
||||
}`}
|
||||
style={timeRange !== range.value ? { color: textSecondary } : undefined}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 max-[550px]:grid-cols-2">
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-2xl font-bold" style={{ color: KIND_COLORS[kind] }}>
|
||||
{currentValue != null ? `${Math.round(currentValue)}°C` : '—'}
|
||||
</p>
|
||||
<TrendIcon trend={trend} />
|
||||
</div>
|
||||
{currentTarget != null && currentTarget > 0 && (
|
||||
<p className="text-xs mt-1" style={{ color: textSecondary }}>
|
||||
{t('common.target', 'Target')}: {Math.round(currentTarget)}°C
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
|
||||
<p className="text-2xl font-bold" style={{ color: textPrimary }}>
|
||||
{series?.avg_value != null ? `${series.avg_value}°C` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
|
||||
<p className="text-2xl font-bold" style={{ color: textPrimary }}>
|
||||
{series?.min_value != null ? `${Math.round(series.min_value)}°C` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
<p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
|
||||
<p className="text-2xl font-bold" style={{ color: textPrimary }}>
|
||||
{series?.max_value != null ? `${Math.round(series.max_value)}°C` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
|
||||
{isLoading ? (
|
||||
<div className="h-64 flex items-center justify-center" style={{ color: textSecondary }}>
|
||||
{t('common.loading', 'Loading...')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-64 flex items-center justify-center text-red-400">
|
||||
{t('printers.heaterHistory.error', 'Failed to load history')}
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center" style={{ color: textSecondary }}>
|
||||
{t('printers.heaterHistory.empty', 'No data recorded yet')}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={borderColor} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
type="number"
|
||||
domain={[domainStart, domainEnd]}
|
||||
tickFormatter={ts =>
|
||||
new Date(ts).toLocaleTimeString(
|
||||
[],
|
||||
applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat),
|
||||
)
|
||||
}
|
||||
stroke={axisColor}
|
||||
fontSize={11}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={axisColor}
|
||||
fontSize={11}
|
||||
domain={[0, 'auto']}
|
||||
tickFormatter={v => `${Math.round(v)}°`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: modalBg,
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: 6,
|
||||
color: textPrimary,
|
||||
}}
|
||||
labelFormatter={(ts) =>
|
||||
new Date(ts as number).toLocaleString(
|
||||
undefined,
|
||||
applyTimeFormat(
|
||||
{ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
|
||||
timeFormat,
|
||||
),
|
||||
)
|
||||
}
|
||||
formatter={(value) => (value != null ? `${Math.round(Number(value))}°C` : '—')}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name={t('common.current', 'Current')}
|
||||
stroke={KIND_COLORS[kind]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="target"
|
||||
name={t('common.target', 'Target')}
|
||||
stroke={KIND_TARGET_COLORS[kind]}
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: 'Druckbett',
|
||||
chamber: 'Kammer',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'Heizungsverlauf',
|
||||
openLabel: 'Heizungsverlauf anzeigen',
|
||||
nozzle: 'Düse',
|
||||
nozzle2: 'Düse 2',
|
||||
bed: 'Druckbett',
|
||||
chamber: 'Kammer',
|
||||
error: 'Verlauf konnte nicht geladen werden',
|
||||
empty: 'Noch keine Daten aufgezeichnet',
|
||||
},
|
||||
progress: '{{percent}}% abgeschlossen',
|
||||
timeRemaining: 'Noch {{time}}',
|
||||
deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: 'Bed',
|
||||
chamber: 'Chamber',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'Heater History',
|
||||
openLabel: 'View heater history',
|
||||
nozzle: 'Nozzle',
|
||||
nozzle2: 'Nozzle 2',
|
||||
bed: 'Bed',
|
||||
chamber: 'Chamber',
|
||||
error: 'Failed to load history',
|
||||
empty: 'No data recorded yet',
|
||||
},
|
||||
progress: '{{percent}}% complete',
|
||||
timeRemaining: '{{time}} remaining',
|
||||
deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: 'Cama',
|
||||
chamber: 'Cámara',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'Historial de calentadores',
|
||||
openLabel: 'Ver historial de calentadores',
|
||||
nozzle: 'Boquilla',
|
||||
nozzle2: 'Boquilla 2',
|
||||
bed: 'Cama',
|
||||
chamber: 'Cámara',
|
||||
error: 'No se pudo cargar el historial',
|
||||
empty: 'Aún no hay datos registrados',
|
||||
},
|
||||
progress: '{{percent}}% completado',
|
||||
timeRemaining: '{{time}} restante',
|
||||
deleteConfirm: '¿Está seguro de que desea eliminar "{{name}}"?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: 'Plateau',
|
||||
chamber: 'Chambre',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'Historique des chauffages',
|
||||
openLabel: 'Voir l\'historique des chauffages',
|
||||
nozzle: 'Buse',
|
||||
nozzle2: 'Buse 2',
|
||||
bed: 'Plateau',
|
||||
chamber: 'Chambre',
|
||||
error: 'Échec du chargement de l\'historique',
|
||||
empty: 'Aucune donnée enregistrée',
|
||||
},
|
||||
progress: '{{percent}}% terminé',
|
||||
timeRemaining: '{{time}} restant',
|
||||
deleteConfirm: 'Êtes-vous sûr de vouloir supprimer "{{name}}" ?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: 'Piatto',
|
||||
chamber: 'Camera',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'Storico riscaldatori',
|
||||
openLabel: 'Visualizza storico riscaldatori',
|
||||
nozzle: 'Ugello',
|
||||
nozzle2: 'Ugello 2',
|
||||
bed: 'Piatto',
|
||||
chamber: 'Camera',
|
||||
error: 'Caricamento storico fallito',
|
||||
empty: 'Nessun dato registrato',
|
||||
},
|
||||
progress: '{{percent}}% completato',
|
||||
timeRemaining: '{{time}} rimanente',
|
||||
deleteConfirm: 'Sei sicuro di eliminare "{{name}}"?',
|
||||
|
||||
@@ -157,6 +157,16 @@ export default {
|
||||
bed: 'ベッド',
|
||||
chamber: 'チャンバー',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'ヒーター履歴',
|
||||
openLabel: 'ヒーター履歴を表示',
|
||||
nozzle: 'ノズル',
|
||||
nozzle2: 'ノズル 2',
|
||||
bed: 'ベッド',
|
||||
chamber: 'チャンバー',
|
||||
error: '履歴を読み込めませんでした',
|
||||
empty: 'まだデータが記録されていません',
|
||||
},
|
||||
progress: '{{percent}}% 完了',
|
||||
timeRemaining: '残り {{time}}',
|
||||
deleteConfirm: '「{{name}}」を削除しますか?',
|
||||
|
||||
@@ -147,6 +147,16 @@ export default {
|
||||
bed: '베드',
|
||||
chamber: '챔버'
|
||||
},
|
||||
heaterHistory: {
|
||||
title: '히터 기록',
|
||||
openLabel: '히터 기록 보기',
|
||||
nozzle: '노즐',
|
||||
nozzle2: '노즐 2',
|
||||
bed: '베드',
|
||||
chamber: '챔버',
|
||||
error: '기록을 불러올 수 없습니다',
|
||||
empty: '아직 기록된 데이터가 없습니다'
|
||||
},
|
||||
progress: '{{percent}}% 완료',
|
||||
timeRemaining: '{{time}} 남음',
|
||||
deleteConfirm: '"{{name}}"을(를) 삭제하시겠습니까?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: 'Cama',
|
||||
chamber: 'Câmara',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'Histórico de aquecedores',
|
||||
openLabel: 'Ver histórico de aquecedores',
|
||||
nozzle: 'Bico',
|
||||
nozzle2: 'Bico 2',
|
||||
bed: 'Cama',
|
||||
chamber: 'Câmara',
|
||||
error: 'Falha ao carregar histórico',
|
||||
empty: 'Nenhum dado registrado ainda',
|
||||
},
|
||||
progress: '{{percent}}% concluído',
|
||||
timeRemaining: '{{time}} restante',
|
||||
deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: 'Tabla',
|
||||
chamber: 'Hazne',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: 'Isıtıcı Geçmişi',
|
||||
openLabel: 'Isıtıcı geçmişini görüntüle',
|
||||
nozzle: 'Nozul',
|
||||
nozzle2: 'Nozul 2',
|
||||
bed: 'Tabla',
|
||||
chamber: 'Hazne',
|
||||
error: 'Geçmiş yüklenemedi',
|
||||
empty: 'Henüz veri kaydedilmedi',
|
||||
},
|
||||
progress: '%{{percent}} tamamlandı',
|
||||
timeRemaining: '{{time}} kaldı',
|
||||
deleteConfirm: '"{{name}}" silmek istediğinizden emin misiniz?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: '热床',
|
||||
chamber: '腔室',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: '加热历史',
|
||||
openLabel: '查看加热历史',
|
||||
nozzle: '喷嘴',
|
||||
nozzle2: '喷嘴 2',
|
||||
bed: '热床',
|
||||
chamber: '腔室',
|
||||
error: '加载历史失败',
|
||||
empty: '尚未记录数据',
|
||||
},
|
||||
progress: '{{percent}}% 完成',
|
||||
timeRemaining: '剩余 {{time}}',
|
||||
deleteConfirm: '确定要删除"{{name}}"吗?',
|
||||
|
||||
@@ -158,6 +158,16 @@ export default {
|
||||
bed: '熱床',
|
||||
chamber: '腔室',
|
||||
},
|
||||
heaterHistory: {
|
||||
title: '加熱歷史',
|
||||
openLabel: '查看加熱歷史',
|
||||
nozzle: '噴嘴',
|
||||
nozzle2: '噴嘴 2',
|
||||
bed: '熱床',
|
||||
chamber: '腔室',
|
||||
error: '載入歷史失敗',
|
||||
empty: '尚未記錄資料',
|
||||
},
|
||||
progress: '{{percent}}% 完成',
|
||||
timeRemaining: '剩餘 {{time}}',
|
||||
deleteConfirm: '確定要刪除"{{name}}"嗎?',
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
MoreHorizontal,
|
||||
SlidersHorizontal,
|
||||
Stethoscope,
|
||||
LineChart as LineChartIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -97,6 +98,8 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal';
|
||||
import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
|
||||
import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
|
||||
import { AMSHistoryModal } from '../components/AMSHistoryModal';
|
||||
import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
|
||||
import type { HeaterSensorKind } from '../api/client';
|
||||
import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
|
||||
import { LinkSpoolModal } from '../components/LinkSpoolModal';
|
||||
import { AssignSpoolModal } from '../components/AssignSpoolModal';
|
||||
@@ -1832,6 +1835,10 @@ function PrinterCard({
|
||||
amsLabel: string;
|
||||
mode: 'humidity' | 'temperature';
|
||||
} | null>(null);
|
||||
const [heaterHistoryModal, setHeaterHistoryModal] = useState<{
|
||||
initialKind: HeaterSensorKind;
|
||||
availableKinds: HeaterSensorKind[];
|
||||
} | null>(null);
|
||||
const [linkSpoolModal, setLinkSpoolModal] = useState<{
|
||||
tagUid: string;
|
||||
trayUuid: string;
|
||||
@@ -3549,6 +3556,13 @@ function PrinterCard({
|
||||
const bedHeating = status.temperatures.bed_heating || false;
|
||||
const chamberHeating = status.temperatures.chamber_heating || false;
|
||||
const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined;
|
||||
const availableHeaterKinds: HeaterSensorKind[] = (() => {
|
||||
const kinds: HeaterSensorKind[] = ['nozzle'];
|
||||
if (status.temperatures.nozzle_2 !== undefined) kinds.push('nozzle_2');
|
||||
kinds.push('bed');
|
||||
if (status.temperatures.chamber !== undefined) kinds.push('chamber');
|
||||
return kinds;
|
||||
})();
|
||||
// active_extruder: 0=right, 1=left
|
||||
const activeNozzle = status.active_extruder === 1 ? 'L' : 'R';
|
||||
// Extended nozzle data from nozzle_rack (H2 series: wear, serial, max_temp, etc.)
|
||||
@@ -3595,6 +3609,17 @@ function PrinterCard({
|
||||
title={statusControlTitle}
|
||||
onClick={() => canUseStatusControls && setStatusControlMenu(statusControlMenu === 'nozzle-temp' ? null : 'nozzle-temp')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded text-bambu-gray hover:text-white hover:bg-white/10 transition-colors"
|
||||
title={t('printers.heaterHistory.openLabel', 'View heater history')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHeaterHistoryModal({ initialKind: 'nozzle', availableKinds: availableHeaterKinds });
|
||||
}}
|
||||
>
|
||||
<LineChartIcon className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
<HeaterThermometer className="w-3.5 h-3.5 mb-0.5" color="text-orange-400" isHeating={nozzleHeating} />
|
||||
{status.temperatures.nozzle_2 !== undefined ? (
|
||||
<>
|
||||
@@ -3670,6 +3695,17 @@ function PrinterCard({
|
||||
title={statusControlTitle}
|
||||
onClick={() => canUseStatusControls && setStatusControlMenu(statusControlMenu === 'bed-temp' ? null : 'bed-temp')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded text-bambu-gray hover:text-white hover:bg-white/10 transition-colors"
|
||||
title={t('printers.heaterHistory.openLabel', 'View heater history')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHeaterHistoryModal({ initialKind: 'bed', availableKinds: availableHeaterKinds });
|
||||
}}
|
||||
>
|
||||
<LineChartIcon className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
<HeaterThermometer className="w-3.5 h-3.5 mb-0.5" color="text-blue-400" isHeating={bedHeating} />
|
||||
<p className="text-[9px] text-bambu-gray">{t('printers.temperatures.bed')}</p>
|
||||
<p className="text-[11px] text-white">
|
||||
@@ -3696,12 +3732,23 @@ function PrinterCard({
|
||||
<div
|
||||
className={hasChamberHeater
|
||||
? statusControlClass
|
||||
: 'text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center'}
|
||||
: 'relative text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center'}
|
||||
title={hasChamberHeater ? statusControlTitle : undefined}
|
||||
onClick={hasChamberHeater
|
||||
? () => canUseStatusControls && setStatusControlMenu(statusControlMenu === 'chamber-temp' ? null : 'chamber-temp')
|
||||
: undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded text-bambu-gray hover:text-white hover:bg-white/10 transition-colors"
|
||||
title={t('printers.heaterHistory.openLabel', 'View heater history')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHeaterHistoryModal({ initialKind: 'chamber', availableKinds: availableHeaterKinds });
|
||||
}}
|
||||
>
|
||||
<LineChartIcon className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
<HeaterThermometer className="w-3.5 h-3.5 mb-0.5" color="text-green-400" isHeating={chamberHeating} />
|
||||
<p className="text-[9px] text-bambu-gray">{t('printers.temperatures.chamber')}</p>
|
||||
<p className="text-[11px] text-white">
|
||||
@@ -5940,6 +5987,18 @@ function PrinterCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Heater History Modal (nozzle / bed / chamber) */}
|
||||
{heaterHistoryModal && (
|
||||
<HeaterHistoryModal
|
||||
isOpen={!!heaterHistoryModal}
|
||||
onClose={() => setHeaterHistoryModal(null)}
|
||||
printerId={printer.id}
|
||||
printerName={printer.name}
|
||||
initialKind={heaterHistoryModal.initialKind}
|
||||
availableKinds={heaterHistoryModal.availableKinds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Link Spool Modal */}
|
||||
{linkSpoolModal && (
|
||||
<LinkSpoolModal
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-B3zC_9bK.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DUIDqBr8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CEe69URA.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user