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:
maziggy
2026-06-20 12:55:24 +02:00
parent a53dc20ca3
commit 090c180ebf
30 changed files with 1289 additions and 142 deletions
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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"}
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -193,6 +193,7 @@ async def init_db():
print_log,
print_queue,
printer,
printer_sensor_history,
project,
project_bom,
settings,
+4
View File
@@ -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,
+133
View File
@@ -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)
+2
View File
@@ -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",
+4
View File
@@ -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
+4
View File
@@ -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();
});
});
+28
View File
@@ -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>
);
}
+10
View File
@@ -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?',
+10
View File
@@ -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}}"?',
+10
View File
@@ -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}}"?',
+10
View File
@@ -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}}" ?',
+10
View File
@@ -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}}"?',
+10
View File
@@ -157,6 +157,16 @@ export default {
bed: 'ベッド',
chamber: 'チャンバー',
},
heaterHistory: {
title: 'ヒーター履歴',
openLabel: 'ヒーター履歴を表示',
nozzle: 'ノズル',
nozzle2: 'ノズル 2',
bed: 'ベッド',
chamber: 'チャンバー',
error: '履歴を読み込めませんでした',
empty: 'まだデータが記録されていません',
},
progress: '{{percent}}% 完了',
timeRemaining: '残り {{time}}',
deleteConfirm: '「{{name}}」を削除しますか?',
+10
View File
@@ -147,6 +147,16 @@ export default {
bed: '베드',
chamber: '챔버'
},
heaterHistory: {
title: '히터 기록',
openLabel: '히터 기록 보기',
nozzle: '노즐',
nozzle2: '노즐 2',
bed: '베드',
chamber: '챔버',
error: '기록을 불러올 수 없습니다',
empty: '아직 기록된 데이터가 없습니다'
},
progress: '{{percent}}% 완료',
timeRemaining: '{{time}} 남음',
deleteConfirm: '"{{name}}"을(를) 삭제하시겠습니까?',
+10
View File
@@ -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}}"?',
+10
View File
@@ -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?',
+10
View File
@@ -158,6 +158,16 @@ export default {
bed: '热床',
chamber: '腔室',
},
heaterHistory: {
title: '加热历史',
openLabel: '查看加热历史',
nozzle: '喷嘴',
nozzle2: '喷嘴 2',
bed: '热床',
chamber: '腔室',
error: '加载历史失败',
empty: '尚未记录数据',
},
progress: '{{percent}}% 完成',
timeRemaining: '剩余 {{time}}',
deleteConfirm: '确定要删除"{{name}}"吗?',
+10
View File
@@ -158,6 +158,16 @@ export default {
bed: '熱床',
chamber: '腔室',
},
heaterHistory: {
title: '加熱歷史',
openLabel: '查看加熱歷史',
nozzle: '噴嘴',
nozzle2: '噴嘴 2',
bed: '熱床',
chamber: '腔室',
error: '載入歷史失敗',
empty: '尚未記錄資料',
},
progress: '{{percent}}% 完成',
timeRemaining: '剩餘 {{time}}',
deleteConfirm: '確定要刪除"{{name}}"嗎?',
+60 -1
View File
@@ -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
View File
@@ -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>