mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 20:56:18 +02:00
Added anonymous stats
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user