Browse Source

Added anonymous stats

maziggy 5 months ago
parent
commit
7048bafff3

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

@@ -63,7 +63,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
     for setting in db_settings:
         if setting.key in settings_dict:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
+            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates", "telemetry_enabled"]:
                 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)

+ 4 - 0
backend/app/main.py

@@ -71,6 +71,7 @@ from backend.app.services.tasmota import tasmota_service
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client, close_spoolman_client
 from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client, close_spoolman_client
 from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
 from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
+from backend.app.services.telemetry import start_telemetry_loop
 
 
 
 
 # Track active prints: {(printer_id, filename): archive_id}
 # Track active prints: {(printer_id, filename): archive_id}
@@ -1187,6 +1188,9 @@ async def lifespan(app: FastAPI):
     # Start AMS history recording
     # Start AMS history recording
     start_ams_history_recording()
     start_ams_history_recording()
 
 
+    # Start anonymous telemetry (opt-out via settings)
+    asyncio.create_task(start_telemetry_loop(async_session))
+
     yield
     yield
 
 
     # Shutdown
     # Shutdown

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

@@ -37,6 +37,9 @@ class AppSettings(BaseModel):
     # Default printer for operations
     # Default printer for operations
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
 
 
+    # Telemetry
+    telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy")
+
 
 
 class AppSettingsUpdate(BaseModel):
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
     """Schema for updating settings (all fields optional)."""
@@ -61,3 +64,4 @@ class AppSettingsUpdate(BaseModel):
     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
+    telemetry_enabled: bool | None = None

+ 125 - 0
backend/app/services/telemetry.py

@@ -0,0 +1,125 @@
+"""Anonymous telemetry service for BamBuddy."""
+
+import asyncio
+import logging
+import uuid
+from datetime import datetime, timedelta
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION
+from backend.app.models.settings import Settings
+
+logger = logging.getLogger(__name__)
+
+# Default telemetry server URL (can be overridden via settings)
+DEFAULT_TELEMETRY_URL = "https://telemetry.bambuddy.cool"
+
+# How often to send heartbeats (once per day)
+HEARTBEAT_INTERVAL = timedelta(hours=24)
+
+_last_heartbeat: datetime | None = None
+
+
+async def get_or_create_installation_id(db: AsyncSession) -> str:
+    """Get existing installation ID or create a new one."""
+    result = await db.execute(
+        select(Settings).where(Settings.key == "installation_id")
+    )
+    setting = result.scalar_one_or_none()
+
+    if setting:
+        return setting.value
+
+    # Generate new UUID
+    installation_id = str(uuid.uuid4())
+
+    # Save to database
+    new_setting = Settings(key="installation_id", value=installation_id)
+    db.add(new_setting)
+    await db.commit()
+
+    logger.info(f"Generated new installation ID: {installation_id[:8]}...")
+    return installation_id
+
+
+async def is_telemetry_enabled(db: AsyncSession) -> bool:
+    """Check if telemetry is enabled (opt-out model)."""
+    result = await db.execute(
+        select(Settings).where(Settings.key == "telemetry_enabled")
+    )
+    setting = result.scalar_one_or_none()
+
+    # Default to enabled (opt-out model)
+    if not setting:
+        return True
+
+    return setting.value.lower() == "true"
+
+
+async def get_telemetry_url(db: AsyncSession) -> str:
+    """Get telemetry server URL from settings."""
+    result = await db.execute(
+        select(Settings).where(Settings.key == "telemetry_url")
+    )
+    setting = result.scalar_one_or_none()
+
+    return setting.value if setting else DEFAULT_TELEMETRY_URL
+
+
+async def send_heartbeat(db: AsyncSession) -> bool:
+    """Send anonymous heartbeat to telemetry server."""
+    global _last_heartbeat
+
+    try:
+        # Check if telemetry is enabled
+        if not await is_telemetry_enabled(db):
+            logger.debug("Telemetry disabled, skipping heartbeat")
+            return False
+
+        # Rate limit: only send once per day
+        if _last_heartbeat and datetime.now() - _last_heartbeat < HEARTBEAT_INTERVAL:
+            logger.debug("Heartbeat already sent recently, skipping")
+            return True
+
+        installation_id = await get_or_create_installation_id(db)
+        telemetry_url = await get_telemetry_url(db)
+
+        async with httpx.AsyncClient(timeout=10.0) as client:
+            response = await client.post(
+                f"{telemetry_url}/heartbeat",
+                json={
+                    "installation_id": installation_id,
+                    "version": APP_VERSION,
+                },
+            )
+            response.raise_for_status()
+
+        _last_heartbeat = datetime.now()
+        logger.info(f"Telemetry heartbeat sent to {telemetry_url}")
+        return True
+
+    except httpx.HTTPError as e:
+        logger.debug(f"Telemetry heartbeat failed (network): {e}")
+        return False
+    except Exception as e:
+        logger.debug(f"Telemetry heartbeat failed: {e}")
+        return False
+
+
+async def start_telemetry_loop(get_session):
+    """Background task to send periodic heartbeats."""
+    # Wait a bit before first heartbeat to let app initialize
+    await asyncio.sleep(30)
+
+    while True:
+        try:
+            async with get_session() as db:
+                await send_heartbeat(db)
+        except Exception as e:
+            logger.debug(f"Telemetry loop error: {e}")
+
+        # Check daily
+        await asyncio.sleep(HEARTBEAT_INTERVAL.total_seconds())

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

@@ -438,6 +438,8 @@ export interface AppSettings {
   time_format: 'system' | '12h' | '24h';
   time_format: 'system' | '12h' | '24h';
   // Default printer
   // Default printer
   default_printer_id: number | null;
   default_printer_id: number | null;
+  // Telemetry
+  telemetry_enabled: boolean;
 }
 }
 
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 15 - 0
frontend/src/i18n/locales/de.ts

@@ -243,6 +243,21 @@ export default {
     latestVersion: 'Neueste Version',
     latestVersion: 'Neueste Version',
     upToDate: 'Sie sind auf dem neuesten Stand',
     upToDate: 'Sie sind auf dem neuesten Stand',
     updateAvailable: 'Update verfügbar',
     updateAvailable: 'Update verfügbar',
+    telemetry: 'Anonyme Telemetrie',
+    telemetryDescription: 'Helfen Sie BamBuddy zu verbessern, indem Sie anonyme Nutzungsdaten senden',
+    telemetryLearnMore: 'Mehr erfahren',
+    telemetryInfoTitle: 'Welche Daten werden gesammelt?',
+    telemetryInfoIntro: 'BamBuddy sammelt minimale anonyme Daten, um zu verstehen, wie viele Personen die App nutzen und welche Versionen verwendet werden. Dies hilft bei der Priorisierung von Fehlerbehebungen und neuen Funktionen.',
+    telemetryInfoCollected: 'Was wir sammeln:',
+    telemetryInfoItem1: 'Eine zufällige Installations-ID (nicht mit Ihnen oder Ihrer Hardware verknüpft)',
+    telemetryInfoItem2: 'Die App-Version, die Sie verwenden',
+    telemetryInfoItem3: 'Ein Zeitstempel (um tägliche/wöchentliche aktive Nutzer zu zählen)',
+    telemetryInfoNotCollected: 'Was wir NICHT sammeln:',
+    telemetryInfoNotItem1: 'IP-Adressen oder Standortdaten',
+    telemetryInfoNotItem2: 'Druckernamen, Seriennummern oder Druckerdaten',
+    telemetryInfoNotItem3: 'Druckverlauf, Dateinamen oder persönliche Inhalte',
+    telemetryInfoNotItem4: 'Informationen, die Sie identifizieren könnten',
+    telemetryInfoFooter: 'Sie können die Telemetrie jederzeit deaktivieren. Die Installations-ID wird zufällig generiert und kann nicht zu Ihnen zurückverfolgt werden.',
     // Notifications
     // Notifications
     notificationLanguage: 'Benachrichtigungssprache',
     notificationLanguage: 'Benachrichtigungssprache',
     notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
     notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',

+ 15 - 0
frontend/src/i18n/locales/en.ts

@@ -243,6 +243,21 @@ export default {
     latestVersion: 'Latest Version',
     latestVersion: 'Latest Version',
     upToDate: 'You are up to date',
     upToDate: 'You are up to date',
     updateAvailable: 'Update available',
     updateAvailable: 'Update available',
+    telemetry: 'Anonymous telemetry',
+    telemetryDescription: 'Help improve BamBuddy by sending anonymous usage data',
+    telemetryLearnMore: 'Learn more',
+    telemetryInfoTitle: 'What data is collected?',
+    telemetryInfoIntro: 'BamBuddy collects minimal anonymous data to help understand how many people use the app and which versions are in use. This helps prioritize bug fixes and new features.',
+    telemetryInfoCollected: 'What we collect:',
+    telemetryInfoItem1: 'A random installation ID (not linked to you or your hardware)',
+    telemetryInfoItem2: 'The app version you\'re running',
+    telemetryInfoItem3: 'A timestamp (to count daily/weekly active users)',
+    telemetryInfoNotCollected: 'What we do NOT collect:',
+    telemetryInfoNotItem1: 'IP addresses or location data',
+    telemetryInfoNotItem2: 'Printer names, serial numbers, or any printer data',
+    telemetryInfoNotItem3: 'Print history, filenames, or any personal content',
+    telemetryInfoNotItem4: 'Any information that could identify you',
+    telemetryInfoFooter: 'You can disable telemetry at any time. The installation ID is randomly generated and cannot be traced back to you.',
     // Notifications
     // Notifications
     notificationLanguage: 'Notification Language',
     notificationLanguage: 'Notification Language',
     notificationLanguageDescription: 'Language for push notifications',
     notificationLanguageDescription: 'Language for push notifications',

+ 108 - 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, Database } 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, Info, X, Shield } 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';
@@ -50,6 +50,7 @@ export function SettingsPage() {
   const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
   const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
   const [showBackupModal, setShowBackupModal] = useState(false);
   const [showBackupModal, setShowBackupModal] = useState(false);
   const [showRestoreModal, setShowRestoreModal] = useState(false);
   const [showRestoreModal, setShowRestoreModal] = useState(false);
+  const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
 
 
   const handleDefaultViewChange = (path: string) => {
   const handleDefaultViewChange = (path: string) => {
     setDefaultViewState(path);
     setDefaultViewState(path);
@@ -310,6 +311,7 @@ export function SettingsPage() {
       settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
       settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
       settings.check_updates !== localSettings.check_updates ||
       settings.check_updates !== localSettings.check_updates ||
       settings.notification_language !== localSettings.notification_language ||
       settings.notification_language !== localSettings.notification_language ||
+      settings.telemetry_enabled !== localSettings.telemetry_enabled ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
       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 ||
@@ -848,6 +850,32 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
                 </label>
               </div>
               </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <div className="flex items-center gap-2">
+                    <p className="text-white">{t('settings.telemetry')}</p>
+                    <button
+                      onClick={() => setShowTelemetryInfo(true)}
+                      className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-bambu-dark rounded-full text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+                    >
+                      <Info className="w-3 h-3" />
+                      {t('settings.telemetryLearnMore')}
+                    </button>
+                  </div>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.telemetryDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.telemetry_enabled}
+                    onChange={(e) => updateSetting('telemetry_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
 
 
               <div className="border-t border-bambu-dark-tertiary pt-4">
               <div className="border-t border-bambu-dark-tertiary pt-4">
                 <div className="flex items-center justify-between mb-2">
                 <div className="flex items-center justify-between mb-2">
@@ -1831,6 +1859,85 @@ export function SettingsPage() {
           }}
           }}
         />
         />
       )}
       )}
+
+      {/* Telemetry Info Modal */}
+      {showTelemetryInfo && (
+        <div
+          className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+          onClick={() => setShowTelemetryInfo(false)}
+        >
+          <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+            <CardHeader className="flex flex-row items-center justify-between">
+              <div className="flex items-center gap-2">
+                <Shield className="w-5 h-5 text-bambu-green" />
+                <h2 className="text-lg font-semibold text-white">{t('settings.telemetryInfoTitle')}</h2>
+              </div>
+              <button
+                onClick={() => setShowTelemetryInfo(false)}
+                className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-bambu-gray text-sm">
+                {t('settings.telemetryInfoIntro')}
+              </p>
+
+              <div className="space-y-3">
+                <h3 className="text-white font-medium">{t('settings.telemetryInfoCollected')}</h3>
+                <ul className="space-y-2 text-sm">
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoItem1')}</span>
+                  </li>
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoItem2')}</span>
+                  </li>
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoItem3')}</span>
+                  </li>
+                </ul>
+              </div>
+
+              <div className="space-y-3">
+                <h3 className="text-white font-medium">{t('settings.telemetryInfoNotCollected')}</h3>
+                <ul className="space-y-2 text-sm">
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoNotItem1')}</span>
+                  </li>
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoNotItem2')}</span>
+                  </li>
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoNotItem3')}</span>
+                  </li>
+                  <li className="flex items-start gap-2 text-bambu-gray">
+                    <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
+                    <span>{t('settings.telemetryInfoNotItem4')}</span>
+                  </li>
+                </ul>
+              </div>
+
+              <p className="text-bambu-gray text-xs border-t border-bambu-dark-tertiary pt-4">
+                {t('settings.telemetryInfoFooter')}
+              </p>
+
+              <Button
+                onClick={() => setShowTelemetryInfo(false)}
+                className="w-full"
+              >
+                {t('common.close')}
+              </Button>
+            </CardContent>
+          </Card>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }

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


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


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


+ 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-BEiBb2xd.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B1Oj5EC9.css">
+    <script type="module" crossorigin src="/assets/index-C3VnznMS.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C5I5zyf3.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