Browse Source

Add AMS humidity/temperature history tracking with charts

  - Add database model for storing AMS sensor history (humidity, temperature)
  - Record sensor data every 5 minutes via background task
  - Add API endpoint to retrieve history with min/max/avg statistics
  - Create AMSHistoryModal component with Recharts line charts
  - Make humidity/temperature indicators clickable to open history
  - Support time range selection (6h, 24h, 48h, 7d)
  - Show threshold reference lines on charts (Good/Fair levels)
  - Add configurable data retention (default 30 days) in Settings
  - Auto-cleanup old data daily
  - Fix dark theme styling in modal using inline styles
  - Add Escape key to close modal
maziggy 5 months ago
parent
commit
0dc76746b4

+ 129 - 0
backend/app/api/routes/ams_history.py

@@ -0,0 +1,129 @@
+"""API routes for AMS sensor history."""
+
+from datetime import datetime, timedelta
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import select, func, and_
+from sqlalchemy.ext.asyncio import AsyncSession
+from pydantic import BaseModel
+
+from backend.app.core.database import get_db
+from backend.app.models.ams_history import AMSSensorHistory
+
+router = APIRouter(prefix="/ams-history", tags=["ams-history"])
+
+
+class AMSHistoryPoint(BaseModel):
+    recorded_at: datetime
+    humidity: float | None
+    humidity_raw: float | None
+    temperature: float | None
+
+
+class AMSHistoryResponse(BaseModel):
+    printer_id: int
+    ams_id: int
+    data: list[AMSHistoryPoint]
+    min_humidity: float | None
+    max_humidity: float | None
+    avg_humidity: float | None
+    min_temperature: float | None
+    max_temperature: float | None
+    avg_temperature: float | None
+
+
+@router.get("/{printer_id}/{ams_id}", response_model=AMSHistoryResponse)
+async def get_ams_history(
+    printer_id: int,
+    ams_id: int,
+    hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get AMS sensor history for a specific printer and AMS unit."""
+    since = datetime.now() - timedelta(hours=hours)
+
+    # Get data points
+    result = await db.execute(
+        select(AMSSensorHistory)
+        .where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.ams_id == ams_id,
+                AMSSensorHistory.recorded_at >= since,
+            )
+        )
+        .order_by(AMSSensorHistory.recorded_at)
+    )
+    records = result.scalars().all()
+
+    # Calculate stats
+    stats_result = await db.execute(
+        select(
+            func.min(AMSSensorHistory.humidity).label("min_humidity"),
+            func.max(AMSSensorHistory.humidity).label("max_humidity"),
+            func.avg(AMSSensorHistory.humidity).label("avg_humidity"),
+            func.min(AMSSensorHistory.temperature).label("min_temp"),
+            func.max(AMSSensorHistory.temperature).label("max_temp"),
+            func.avg(AMSSensorHistory.temperature).label("avg_temp"),
+        )
+        .where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.ams_id == ams_id,
+                AMSSensorHistory.recorded_at >= since,
+            )
+        )
+    )
+    stats = stats_result.one()
+
+    return AMSHistoryResponse(
+        printer_id=printer_id,
+        ams_id=ams_id,
+        data=[
+            AMSHistoryPoint(
+                recorded_at=r.recorded_at,
+                humidity=r.humidity,
+                humidity_raw=r.humidity_raw,
+                temperature=r.temperature,
+            )
+            for r in records
+        ],
+        min_humidity=stats.min_humidity,
+        max_humidity=stats.max_humidity,
+        avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity else None,
+        min_temperature=stats.min_temp,
+        max_temperature=stats.max_temp,
+        avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp else None,
+    )
+
+
+@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),
+):
+    """Delete old AMS history data for a printer."""
+    cutoff = datetime.now() - timedelta(days=days)
+
+    result = await db.execute(
+        select(func.count(AMSSensorHistory.id))
+        .where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.recorded_at < cutoff,
+            )
+        )
+    )
+    count = result.scalar()
+
+    await db.execute(
+        AMSSensorHistory.__table__.delete().where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.recorded_at < cutoff,
+            )
+        )
+    )
+    await db.commit()
+
+    return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}

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

@@ -67,7 +67,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 settings_dict[setting.key] = setting.value.lower() == "true"
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 settings_dict[setting.key] = float(setting.value)
                 settings_dict[setting.key] = float(setting.value)
-            elif setting.key in ["ams_humidity_good", "ams_humidity_fair"]:
+            elif setting.key in ["ams_humidity_good", "ams_humidity_fair", "ams_history_retention_days"]:
                 settings_dict[setting.key] = int(setting.value)
                 settings_dict[setting.key] = int(setting.value)
             elif setting.key == "default_printer_id":
             elif setting.key == "default_printer_id":
                 # Handle nullable integer
                 # Handle nullable integer

+ 141 - 3
backend/app/main.py

@@ -1,7 +1,7 @@
 import asyncio
 import asyncio
 import logging
 import logging
 import os
 import os
-from datetime import datetime
+from datetime import datetime, timedelta
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from pathlib import Path
 from pathlib import Path
 from logging.handlers import RotatingFileHandler
 from logging.handlers import RotatingFileHandler
@@ -52,9 +52,9 @@ from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
 
 
 from backend.app.core.database import init_db, async_session
 from backend.app.core.database import init_db, async_session
-from sqlalchemy import select, or_
+from sqlalchemy import select, or_, delete
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history
 from backend.app.api.routes import settings as settings_routes
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
@@ -949,6 +949,139 @@ async def on_print_complete(printer_id: int, data: dict):
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
 
 
 
 
+# AMS sensor history recording
+_ams_history_task: asyncio.Task | None = None
+AMS_HISTORY_INTERVAL = 300  # Record every 5 minutes
+AMS_HISTORY_RETENTION_DAYS = 30  # Keep data for 30 days
+_ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
+
+
+async def record_ams_history():
+    """Background task to record AMS humidity and temperature data."""
+    import logging
+    logger = logging.getLogger(__name__)
+
+    # Wait a short time for MQTT connections to establish on startup
+    await asyncio.sleep(10)
+
+    while True:
+        try:
+            from backend.app.models.ams_history import AMSSensorHistory
+            from backend.app.models.printer import Printer
+
+            async with async_session() as db:
+                # Get all active printers
+                result = await db.execute(
+                    select(Printer).where(Printer.is_active == True)
+                )
+                printers = result.scalars().all()
+
+                recorded_count = 0
+                for printer in printers:
+                    # Get current state from printer manager
+                    state = printer_manager.get_status(printer.id)
+                    if not state or not state.raw_data:
+                        continue
+
+                    raw_data = state.raw_data
+                    if "ams" not in raw_data or not isinstance(raw_data["ams"], list):
+                        continue
+
+                    # Record data for each AMS unit
+                    for ams_data in raw_data["ams"]:
+                        ams_id = int(ams_data.get("id", 0))
+
+                        # Get humidity (prefer humidity_raw)
+                        humidity_raw = ams_data.get("humidity_raw")
+                        humidity_idx = ams_data.get("humidity")
+                        humidity = None
+                        if humidity_raw is not None:
+                            try:
+                                humidity = float(humidity_raw)
+                            except (ValueError, TypeError):
+                                pass
+                        if humidity is None and humidity_idx is not None:
+                            try:
+                                humidity = float(humidity_idx)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Get temperature
+                        temperature = None
+                        temp_str = ams_data.get("temp")
+                        if temp_str is not None:
+                            try:
+                                temperature = float(temp_str)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Skip if no data
+                        if humidity is None and temperature is None:
+                            continue
+
+                        # Record the data point
+                        history = AMSSensorHistory(
+                            printer_id=printer.id,
+                            ams_id=ams_id,
+                            humidity=humidity,
+                            humidity_raw=float(humidity_raw) if humidity_raw else None,
+                            temperature=temperature,
+                        )
+                        db.add(history)
+                        recorded_count += 1
+
+                await db.commit()
+                if recorded_count > 0:
+                    logger.info(f"Recorded {recorded_count} AMS sensor history entries")
+
+                # Periodic cleanup of old data (every ~288 recordings = ~24 hours at 5min interval)
+                global _ams_cleanup_counter
+                _ams_cleanup_counter += 1
+                if _ams_cleanup_counter >= 288:
+                    _ams_cleanup_counter = 0
+                    # Get retention days from settings
+                    from backend.app.models.settings import Settings
+                    result = await db.execute(
+                        select(Settings).where(Settings.key == "ams_history_retention_days")
+                    )
+                    setting = result.scalar_one_or_none()
+                    retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS
+
+                    cutoff = datetime.now() - timedelta(days=retention_days)
+                    result = await db.execute(
+                        delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff)
+                    )
+                    await db.commit()
+                    if result.rowcount > 0:
+                        logger.info(f"Cleaned up {result.rowcount} old AMS sensor history entries (older than {retention_days} days)")
+
+            # Wait until next recording interval
+            await asyncio.sleep(AMS_HISTORY_INTERVAL)
+
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logger.warning(f"AMS history recording failed: {e}")
+            await asyncio.sleep(60)  # Wait a bit before retrying
+
+
+def start_ams_history_recording():
+    """Start the AMS history recording background task."""
+    global _ams_history_task
+    if _ams_history_task is None:
+        _ams_history_task = asyncio.create_task(record_ams_history())
+        logging.getLogger(__name__).info("AMS history recording started")
+
+
+def stop_ams_history_recording():
+    """Stop the AMS history recording background task."""
+    global _ams_history_task
+    if _ams_history_task:
+        _ams_history_task.cancel()
+        _ams_history_task = None
+        logging.getLogger(__name__).info("AMS history recording stopped")
+
+
 @asynccontextmanager
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 async def lifespan(app: FastAPI):
     # Startup
     # Startup
@@ -991,12 +1124,16 @@ async def lifespan(app: FastAPI):
     # Start the notification digest scheduler
     # Start the notification digest scheduler
     notification_service.start_digest_scheduler()
     notification_service.start_digest_scheduler()
 
 
+    # Start AMS history recording
+    start_ams_history_recording()
+
     yield
     yield
 
 
     # Shutdown
     # Shutdown
     print_scheduler.stop()
     print_scheduler.stop()
     smart_plug_manager.stop_scheduler()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
     notification_service.stop_digest_scheduler()
+    stop_ams_history_recording()
     printer_manager.disconnect_all()
     printer_manager.disconnect_all()
     await close_spoolman_client()
     await close_spoolman_client()
 
 
@@ -1027,6 +1164,7 @@ app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.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(webhook.router, prefix=app_settings.api_prefix)
+app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 
 
 

+ 2 - 0
backend/app/models/__init__.py

@@ -9,6 +9,7 @@ from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification import NotificationLog
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
+from backend.app.models.ams_history import AMSSensorHistory
 
 
 __all__ = [
 __all__ = [
     "Printer",
     "Printer",
@@ -24,4 +25,5 @@ __all__ = [
     "NotificationLog",
     "NotificationLog",
     "Project",
     "Project",
     "APIKey",
     "APIKey",
+    "AMSSensorHistory",
 ]
 ]

+ 31 - 0
backend/app/models/ams_history.py

@@ -0,0 +1,31 @@
+from datetime import datetime
+from sqlalchemy import Integer, Float, DateTime, ForeignKey, String, func, Index
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class AMSSensorHistory(Base):
+    """Historical sensor data from AMS units (humidity and temperature)."""
+    __tablename__ = "ams_sensor_history"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    ams_id: Mapped[int] = mapped_column(Integer)  # AMS unit index (0, 1, 2, 3)
+    humidity: Mapped[float | None] = mapped_column(Float)  # Humidity percentage
+    humidity_raw: Mapped[float | None] = mapped_column(Float)  # Raw humidity value
+    temperature: Mapped[float | None] = mapped_column(Float)  # Temperature in Celsius
+    recorded_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), index=True
+    )
+
+    # Indexes for efficient querying
+    __table_args__ = (
+        Index('ix_ams_history_printer_ams_time', 'printer_id', 'ams_id', 'recorded_at'),
+    )
+
+    # Relationship
+    printer: Mapped["Printer"] = relationship(back_populates="ams_history")
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 4 - 0
backend/app/models/printer.py

@@ -42,9 +42,13 @@ class Printer(Base):
     kprofile_notes: Mapped[list["KProfileNote"]] = relationship(
     kprofile_notes: Mapped[list["KProfileNote"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
         back_populates="printer", cascade="all, delete-orphan"
     )
     )
+    ams_history: Mapped[list["AMSSensorHistory"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.ams_history import AMSSensorHistory  # noqa: E402
 from backend.app.models.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402

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

@@ -28,6 +28,7 @@ class AppSettings(BaseModel):
     ams_humidity_fair: int = Field(default=60, description="Humidity threshold for fair (orange): <= this value, > is red")
     ams_humidity_fair: int = Field(default=60, description="Humidity threshold for fair (orange): <= this value, > is red")
     ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
     ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
     ams_temp_fair: float = Field(default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red")
     ams_temp_fair: float = Field(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")
 
 
     # Date/time display format
     # Date/time display format
     date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
     date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
@@ -56,6 +57,7 @@ class AppSettingsUpdate(BaseModel):
     ams_humidity_fair: int | None = None
     ams_humidity_fair: int | None = None
     ams_temp_good: float | None = None
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
     ams_temp_fair: float | None = None
+    ams_history_retention_days: int | None = None
     date_format: str | None = None
     date_format: str | None = None
     time_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
     default_printer_id: int | None = None

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

@@ -432,6 +432,7 @@ export interface AppSettings {
   ams_humidity_fair: number;  // <= this is orange, > is red
   ams_humidity_fair: number;  // <= this is orange, > is red
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_fair: number;      // <= this is orange, > is red
   ams_temp_fair: number;      // <= this is orange, > is red
+  ams_history_retention_days: number;  // days to keep AMS sensor history
   // Date/time format settings
   // Date/time format settings
   date_format: 'system' | 'us' | 'eu' | 'iso';
   date_format: 'system' | 'us' | 'eu' | 'iso';
   time_format: 'system' | '12h' | '24h';
   time_format: 'system' | '12h' | '24h';
@@ -1893,4 +1894,28 @@ export const api = {
     }),
     }),
   deleteAPIKey: (id: number) =>
   deleteAPIKey: (id: number) =>
     request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }),
     request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }),
+
+  // AMS History
+  getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
+    request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),
 };
 };
+
+// AMS History types
+export interface AMSHistoryPoint {
+  recorded_at: string;
+  humidity: number | null;
+  humidity_raw: number | null;
+  temperature: number | null;
+}
+
+export interface AMSHistoryResponse {
+  printer_id: number;
+  ams_id: number;
+  data: AMSHistoryPoint[];
+  min_humidity: number | null;
+  max_humidity: number | null;
+  avg_humidity: number | null;
+  min_temperature: number | null;
+  max_temperature: number | null;
+  avg_temperature: number | null;
+}

+ 371 - 0
frontend/src/components/AMSHistoryModal.tsx

@@ -0,0 +1,371 @@
+import { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { X, Droplets, Thermometer, TrendingUp, TrendingDown, Minus } from 'lucide-react';
+import {
+  LineChart,
+  Line,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  Legend,
+  ReferenceLine,
+} from 'recharts';
+import { api, type AMSHistoryResponse } from '../api/client';
+import { useTranslation } from 'react-i18next';
+import { useTheme } from '../contexts/ThemeContext';
+
+interface AMSHistoryModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  printerId: number;
+  printerName: string;
+  amsId: number;
+  amsLabel: string;
+  initialMode?: 'humidity' | 'temperature';
+  thresholds?: {
+    humidityGood: number;
+    humidityFair: number;
+    tempGood: number;
+    tempFair: number;
+  };
+}
+
+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 },
+];
+
+export function AMSHistoryModal({
+  isOpen,
+  onClose,
+  printerId,
+  printerName,
+  amsId,
+  amsLabel,
+  initialMode = 'humidity',
+  thresholds,
+}: AMSHistoryModalProps) {
+  const { t } = useTranslation();
+  const { theme } = useTheme();
+  const [timeRange, setTimeRange] = useState<TimeRange>('24h');
+  const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
+  const isDark = theme === 'dark';
+
+  // Close on Escape key
+  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<AMSHistoryResponse>({
+    queryKey: ['ams-history', printerId, amsId, hours],
+    queryFn: () => api.getAMSHistory(printerId, amsId, hours),
+    enabled: isOpen,
+    refetchInterval: 60000, // Refresh every minute
+  });
+
+  if (!isOpen) return null;
+
+  // Format data for chart
+  const chartData = data?.data.map(point => ({
+    time: new Date(point.recorded_at).getTime(),
+    humidity: point.humidity,
+    temperature: point.temperature,
+    timeLabel: new Date(point.recorded_at).toLocaleTimeString([], {
+      hour: '2-digit',
+      minute: '2-digit',
+      ...(hours > 24 ? { day: 'numeric', month: 'short' } : {}),
+    }),
+  })) || [];
+
+  // Get thresholds
+  const humidityGood = thresholds?.humidityGood || 40;
+  const humidityFair = thresholds?.humidityFair || 60;
+  const tempGood = thresholds?.tempGood || 30;
+  const tempFair = thresholds?.tempFair || 35;
+
+  // Current values (last data point)
+  const lastPoint = chartData[chartData.length - 1];
+  const currentHumidity = lastPoint?.humidity;
+  const currentTemp = lastPoint?.temperature;
+
+  // Trend calculation (compare first and last 20% of data)
+  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 humidityTrend = getTrend(chartData.map(d => d.humidity));
+  const tempTrend = getTrend(chartData.map(d => d.temperature));
+
+  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" />;
+  };
+
+  // Get status color for current value
+  const getHumidityColor = (value: number | undefined | null) => {
+    if (value == null) return '#9ca3af';
+    if (value <= humidityGood) return '#22a352';
+    if (value <= humidityFair) return '#d4a017';
+    return '#c62828';
+  };
+
+  const getTempColor = (value: number | undefined | null) => {
+    if (value == null) return '#9ca3af';
+    if (value <= tempGood) return '#22a352';
+    if (value <= tempFair) return '#d4a017';
+    return '#c62828';
+  };
+
+  // Theme-aware styles (using isDark since dark: prefix doesn't work in portals)
+  const modalBg = isDark ? '#2d2d2d' : '#ffffff';
+  const cardBg = isDark ? '#1d1d1d' : '#f3f4f6';
+  const borderColor = isDark ? '#3d3d3d' : '#e5e7eb';
+  const textPrimary = isDark ? '#ffffff' : '#111827';
+  const textSecondary = isDark ? '#9ca3af' : '#4b5563';
+
+  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()}
+      >
+        {/* Header */}
+        <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 }}>
+              {amsLabel} {t('common.history', '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 }}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
+          {/* Time Range & Mode Selector */}
+          <div className="flex items-center justify-between">
+            <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
+              <button
+                onClick={() => setMode('humidity')}
+                className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
+                  mode === 'humidity' ? 'bg-blue-600 text-white' : ''
+                }`}
+                style={mode !== 'humidity' ? { color: textSecondary } : undefined}
+              >
+                <Droplets className="w-4 h-4" />
+                {t('common.humidity', 'Humidity')}
+              </button>
+              <button
+                onClick={() => setMode('temperature')}
+                className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
+                  mode === 'temperature' ? 'bg-orange-600 text-white' : ''
+                }`}
+                style={mode !== 'temperature' ? { color: textSecondary } : undefined}
+              >
+                <Thermometer className="w-4 h-4" />
+                {t('common.temperature', 'Temperature')}
+              </button>
+            </div>
+
+            <div className="flex gap-1 rounded-lg p-1" 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>
+
+          {/* Stats Cards */}
+          <div className="grid grid-cols-4 gap-4">
+            {mode === 'humidity' ? (
+              <>
+                <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: getHumidityColor(currentHumidity) }}>
+                      {currentHumidity != null ? `${currentHumidity}%` : '—'}
+                    </p>
+                    <TrendIcon trend={humidityTrend} />
+                  </div>
+                </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 }}>
+                    {data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}
+                  </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 text-green-500">
+                    {data?.min_humidity != null ? `${data.min_humidity}%` : '—'}
+                  </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 text-red-500">
+                    {data?.max_humidity != null ? `${data.max_humidity}%` : '—'}
+                  </p>
+                </div>
+              </>
+            ) : (
+              <>
+                <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: getTempColor(currentTemp) }}>
+                      {currentTemp != null ? `${currentTemp}°C` : '—'}
+                    </p>
+                    <TrendIcon trend={tempTrend} />
+                  </div>
+                </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 }}>
+                    {data?.avg_temperature != null ? `${data.avg_temperature}°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 text-blue-500">
+                    {data?.min_temperature != null ? `${data.min_temperature}°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 text-red-500">
+                    {data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}
+                  </p>
+                </div>
+              </>
+            )}
+          </div>
+
+          {/* Chart */}
+          <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+            {isLoading ? (
+              <div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
+                {t('common.loading', 'Loading...')}
+              </div>
+            ) : error ? (
+              <div className="h-[300px] flex items-center justify-center text-red-500">
+                {t('common.error', 'Error loading data')}
+              </div>
+            ) : chartData.length === 0 ? (
+              <div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
+                {t('common.noData', 'No data available for this time range')}
+              </div>
+            ) : (
+              <ResponsiveContainer width="100%" height={300}>
+                <LineChart data={chartData}>
+                  <CartesianGrid strokeDasharray="3 3" stroke={isDark ? '#3d3d3d' : '#e5e7eb'} />
+                  <XAxis
+                    dataKey="time"
+                    type="number"
+                    domain={['dataMin', 'dataMax']}
+                    tickFormatter={(ts) => {
+                      const date = new Date(ts);
+                      if (hours > 24) {
+                        return date.toLocaleDateString([], { day: 'numeric', month: 'short' });
+                      }
+                      return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+                    }}
+                    stroke={isDark ? '#9ca3af' : '#6b7280'}
+                    tick={{ fontSize: 12 }}
+                  />
+                  <YAxis
+                    stroke={isDark ? '#9ca3af' : '#6b7280'}
+                    tick={{ fontSize: 12 }}
+                    domain={mode === 'humidity' ? [0, 100] : ['auto', 'auto']}
+                    tickFormatter={(value) => mode === 'humidity' ? `${value}%` : `${value}°C`}
+                  />
+                  <Tooltip
+                    contentStyle={{
+                      backgroundColor: isDark ? '#2d2d2d' : '#ffffff',
+                      border: `1px solid ${isDark ? '#3d3d3d' : '#e5e7eb'}`,
+                      borderRadius: '8px',
+                      color: isDark ? '#fff' : '#000',
+                    }}
+                    labelFormatter={(ts) => new Date(ts).toLocaleString()}
+                    formatter={(value: number) => [
+                      mode === 'humidity' ? `${value}%` : `${value}°C`,
+                      mode === 'humidity' ? 'Humidity' : 'Temperature'
+                    ]}
+                  />
+                  <Legend />
+
+                  {/* Threshold lines */}
+                  {mode === 'humidity' ? (
+                    <>
+                      <ReferenceLine y={humidityGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
+                      <ReferenceLine y={humidityFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
+                    </>
+                  ) : (
+                    <>
+                      <ReferenceLine y={tempGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
+                      <ReferenceLine y={tempFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
+                    </>
+                  )}
+
+                  <Line
+                    type="monotone"
+                    dataKey={mode}
+                    name={mode === 'humidity' ? 'Humidity' : 'Temperature'}
+                    stroke={mode === 'humidity' ? '#3b82f6' : '#f97316'}
+                    strokeWidth={2}
+                    dot={false}
+                    activeDot={{ r: 4 }}
+                  />
+                </LineChart>
+              </ResponsiveContainer>
+            )}
+          </div>
+
+          {/* Info */}
+          <div className="text-xs text-center" style={{ color: textSecondary }}>
+            {t('amsHistory.recordingInfo', 'Data is recorded every 5 minutes while the printer is connected')}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 52 - 6
frontend/src/pages/PrintersPage.tsx

@@ -38,6 +38,7 @@ import { FileManagerModal } from '../components/FileManagerModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal } from '../components/HMSErrorModal';
 import { HMSErrorModal } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
+import { AMSHistoryModal } from '../components/AMSHistoryModal';
 
 
 // Nozzle side indicators (Bambu Lab style - square badge with L/R)
 // Nozzle side indicators (Bambu Lab style - square badge with L/R)
 function NozzleBadge({ side }: { side: 'L' | 'R' }) {
 function NozzleBadge({ side }: { side: 'L' | 'R' }) {
@@ -203,9 +204,10 @@ interface HumidityIndicatorProps {
   humidity: number | string;
   humidity: number | string;
   goodThreshold?: number;  // <= this is green
   goodThreshold?: number;  // <= this is green
   fairThreshold?: number;  // <= this is orange, > is red
   fairThreshold?: number;  // <= this is orange, > is red
+  onClick?: () => void;
 }
 }
 
 
-function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: HumidityIndicatorProps) {
+function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick }: HumidityIndicatorProps) {
   const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
   const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
   const good = typeof goodThreshold === 'number' ? goodThreshold : 40;
   const good = typeof goodThreshold === 'number' ? goodThreshold : 40;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 60;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 60;
@@ -242,10 +244,15 @@ function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }:
   }
   }
 
 
   return (
   return (
-    <div className="flex items-center justify-end gap-1" title={`Humidity: ${humidityValue}% - ${statusText}`}>
+    <button
+      type="button"
+      onClick={onClick}
+      className={`flex items-center justify-end gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
+      title={`Humidity: ${humidityValue}% - ${statusText}${onClick ? ' (click for history)' : ''}`}
+    >
       <DropComponent className="w-3 h-4" />
       <DropComponent className="w-3 h-4" />
       <span className="text-xs font-medium tabular-nums w-8 text-right" style={{ color: textColor }}>{humidityValue}%</span>
       <span className="text-xs font-medium tabular-nums w-8 text-right" style={{ color: textColor }}>{humidityValue}%</span>
-    </div>
+    </button>
   );
   );
 }
 }
 
 
@@ -254,32 +261,42 @@ interface TemperatureIndicatorProps {
   temp: number;
   temp: number;
   goodThreshold?: number;  // <= this is blue
   goodThreshold?: number;  // <= this is blue
   fairThreshold?: number;  // <= this is orange, > is red
   fairThreshold?: number;  // <= this is orange, > is red
+  onClick?: () => void;
 }
 }
 
 
-function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35 }: TemperatureIndicatorProps) {
+function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick }: TemperatureIndicatorProps) {
   // Ensure thresholds are numbers
   // Ensure thresholds are numbers
   const good = typeof goodThreshold === 'number' ? goodThreshold : 28;
   const good = typeof goodThreshold === 'number' ? goodThreshold : 28;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 35;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 35;
 
 
   let textColor: string;
   let textColor: string;
+  let statusText: string;
   let ThermoComponent: React.FC<{ className?: string }>;
   let ThermoComponent: React.FC<{ className?: string }>;
 
 
   if (temp <= good) {
   if (temp <= good) {
     textColor = '#22a352'; // Green - good (same as humidity)
     textColor = '#22a352'; // Green - good (same as humidity)
+    statusText = 'Good';
     ThermoComponent = ThermometerEmpty;
     ThermoComponent = ThermometerEmpty;
   } else if (temp <= fair) {
   } else if (temp <= fair) {
     textColor = '#d4a017'; // Gold - fair (same as humidity)
     textColor = '#d4a017'; // Gold - fair (same as humidity)
+    statusText = 'Fair';
     ThermoComponent = ThermometerHalf;
     ThermoComponent = ThermometerHalf;
   } else {
   } else {
     textColor = '#c62828'; // Red - bad (same as humidity)
     textColor = '#c62828'; // Red - bad (same as humidity)
+    statusText = 'Bad';
     ThermoComponent = ThermometerFull;
     ThermoComponent = ThermometerFull;
   }
   }
 
 
   return (
   return (
-    <span className="flex items-center gap-1" title="Temperature">
+    <button
+      type="button"
+      onClick={onClick}
+      className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
+      title={`Temperature: ${temp}°C - ${statusText}${onClick ? ' (click for history)' : ''}`}
+    >
       <ThermoComponent className="w-3 h-4" />
       <ThermoComponent className="w-3 h-4" />
       <span className="tabular-nums w-12 text-right" style={{ color: textColor }}>{temp}°C</span>
       <span className="tabular-nums w-12 text-right" style={{ color: textColor }}>{temp}°C</span>
-    </span>
+    </button>
   );
   );
 }
 }
 
 
@@ -507,6 +524,11 @@ function PrinterCard({
   const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
   const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
   const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [showHMSModal, setShowHMSModal] = useState(false);
   const [showHMSModal, setShowHMSModal] = useState(false);
+  const [amsHistoryModal, setAmsHistoryModal] = useState<{
+    amsId: number;
+    amsLabel: string;
+    mode: 'humidity' | 'temperature';
+  } | null>(null);
 
 
   const { data: status } = useQuery({
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printer.id],
     queryKey: ['printerStatus', printer.id],
@@ -1048,6 +1070,11 @@ function PrinterCard({
                                 humidity={ams.humidity}
                                 humidity={ams.humidity}
                                 goodThreshold={amsThresholds?.humidityGood}
                                 goodThreshold={amsThresholds?.humidityGood}
                                 fairThreshold={amsThresholds?.humidityFair}
                                 fairThreshold={amsThresholds?.humidityFair}
+                                onClick={() => setAmsHistoryModal({
+                                  amsId: ams.id,
+                                  amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                  mode: 'humidity',
+                                })}
                               />
                               />
                             )}
                             )}
                             {ams.temp != null && (
                             {ams.temp != null && (
@@ -1055,6 +1082,11 @@ function PrinterCard({
                                 temp={ams.temp}
                                 temp={ams.temp}
                                 goodThreshold={amsThresholds?.tempGood}
                                 goodThreshold={amsThresholds?.tempGood}
                                 fairThreshold={amsThresholds?.tempFair}
                                 fairThreshold={amsThresholds?.tempFair}
+                                onClick={() => setAmsHistoryModal({
+                                  amsId: ams.id,
+                                  amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                  mode: 'temperature',
+                                })}
                               />
                               />
                             )}
                             )}
                           </div>
                           </div>
@@ -1271,6 +1303,20 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
+      {/* AMS History Modal */}
+      {amsHistoryModal && (
+        <AMSHistoryModal
+          isOpen={!!amsHistoryModal}
+          onClose={() => setAmsHistoryModal(null)}
+          printerId={printer.id}
+          printerName={printer.name}
+          amsId={amsHistoryModal.amsId}
+          amsLabel={amsHistoryModal.amsLabel}
+          initialMode={amsHistoryModal.mode}
+          thresholds={amsThresholds}
+        />
+      )}
+
       {/* Edit Printer Modal */}
       {/* Edit Printer Modal */}
       {showEditModal && (
       {showEditModal && (
         <EditPrinterModal
         <EditPrinterModal

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

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
@@ -314,6 +314,7 @@ export function SettingsPage() {
       settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
       settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
+      settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       settings.date_format !== localSettings.date_format ||
       settings.date_format !== localSettings.date_format ||
       settings.time_format !== localSettings.time_format ||
       settings.time_format !== localSettings.time_format ||
       settings.default_printer_id !== localSettings.default_printer_id;
       settings.default_printer_id !== localSettings.default_printer_id;
@@ -788,6 +789,33 @@ export function SettingsPage() {
                   Above fair threshold shows as red (hot)
                   Above fair threshold shows as red (hot)
                 </p>
                 </p>
               </div>
               </div>
+
+              {/* History Retention */}
+              <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center gap-2 text-white">
+                  <Database className="w-4 h-4 text-purple-400" />
+                  <span className="font-medium">History Retention</span>
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    Keep sensor history for
+                  </label>
+                  <div className="flex items-center gap-2">
+                    <input
+                      type="number"
+                      min="1"
+                      max="365"
+                      value={localSettings.ams_history_retention_days ?? 30}
+                      onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
+                      className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    />
+                    <span className="text-bambu-gray">days</span>
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray">
+                  Older humidity and temperature data will be automatically deleted
+                </p>
+              </div>
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Bwdh7UG9.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Bz7SiJBw.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CRQNt4-E.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C2iKZTlc.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Bwdh7UG9.css">
+    <script type="module" crossorigin src="/assets/index-CRQNt4-E.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bz7SiJBw.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff