Added anonymous stats

This commit is contained in:
maziggy
2025-12-11 13:35:59 +01:00
parent 77a66ea867
commit 7048bafff3
12 changed files with 664 additions and 392 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
for setting in db_settings:
if setting.key in settings_dict:
# 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"
elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
settings_dict[setting.key] = float(setting.value)
+4
View File
@@ -71,6 +71,7 @@ from backend.app.services.tasmota import tasmota_service
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.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}
@@ -1187,6 +1188,9 @@ async def lifespan(app: FastAPI):
# Start AMS history recording
start_ams_history_recording()
# Start anonymous telemetry (opt-out via settings)
asyncio.create_task(start_telemetry_loop(async_session))
yield
# Shutdown
+4
View File
@@ -37,6 +37,9 @@ class AppSettings(BaseModel):
# Default printer for operations
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):
"""Schema for updating settings (all fields optional)."""
@@ -61,3 +64,4 @@ class AppSettingsUpdate(BaseModel):
date_format: str | None = None
time_format: str | None = None
default_printer_id: int | None = None
telemetry_enabled: bool | None = None
+125
View File
@@ -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
View File
@@ -438,6 +438,8 @@ export interface AppSettings {
time_format: 'system' | '12h' | '24h';
// Default printer
default_printer_id: number | null;
// Telemetry
telemetry_enabled: boolean;
}
export type AppSettingsUpdate = Partial<AppSettings>;
+15
View File
@@ -243,6 +243,21 @@ export default {
latestVersion: 'Neueste Version',
upToDate: 'Sie sind auf dem neuesten Stand',
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
notificationLanguage: 'Benachrichtigungssprache',
notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
+15
View File
@@ -243,6 +243,21 @@ export default {
latestVersion: 'Latest Version',
upToDate: 'You are up to date',
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
notificationLanguage: 'Notification Language',
notificationLanguageDescription: 'Language for push notifications',
+108 -1
View File
@@ -1,5 +1,5 @@
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 { api } 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 [showBackupModal, setShowBackupModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
const handleDefaultViewChange = (path: string) => {
setDefaultViewState(path);
@@ -310,6 +311,7 @@ export function SettingsPage() {
settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
settings.check_updates !== localSettings.check_updates ||
settings.notification_language !== localSettings.notification_language ||
settings.telemetry_enabled !== localSettings.telemetry_enabled ||
settings.ams_humidity_good !== localSettings.ams_humidity_good ||
settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
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>
</label>
</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="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>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<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>
<body>
<div id="root"></div>