Added update module

This commit is contained in:
Martin Ziegler
2025-12-01 07:34:25 +01:00
parent 8bc263ad02
commit 8571236bc0
12 changed files with 935 additions and 431 deletions
+1 -1
View File
@@ -46,7 +46,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"]:
if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
settings_dict[setting.key] = setting.value.lower() == "true"
elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
settings_dict[setting.key] = float(setting.value)
+276
View File
@@ -0,0 +1,276 @@
"""Update checking and management routes."""
import asyncio
import logging
import subprocess
import sys
from pathlib import Path
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
from backend.app.core.database import get_db
from backend.app.api.routes.settings import get_setting
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/updates", tags=["updates"])
# Global state for update progress
_update_status = {
"status": "idle", # idle, checking, downloading, installing, complete, error
"progress": 0,
"message": "",
"error": None,
}
def parse_version(version: str) -> tuple[int, ...]:
"""Parse version string into tuple for comparison."""
# Remove 'v' prefix if present
version = version.lstrip("v")
# Split and convert to integers
parts = []
for part in version.split("."):
try:
parts.append(int(part))
except ValueError:
# Handle pre-release versions like "1.0.0-beta"
num = "".join(c for c in part if c.isdigit())
parts.append(int(num) if num else 0)
return tuple(parts)
def is_newer_version(latest: str, current: str) -> bool:
"""Check if latest version is newer than current."""
try:
return parse_version(latest) > parse_version(current)
except Exception:
return False
@router.get("/version")
async def get_version():
"""Get current application version."""
return {
"version": APP_VERSION,
"repo": GITHUB_REPO,
}
@router.get("/check")
async def check_for_updates(db: AsyncSession = Depends(get_db)):
"""Check GitHub for available updates."""
global _update_status
_update_status = {
"status": "checking",
"progress": 0,
"message": "Checking for updates...",
"error": None,
}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
headers={"Accept": "application/vnd.github.v3+json"},
timeout=10.0,
)
if response.status_code == 404:
# No releases yet
_update_status = {
"status": "idle",
"progress": 100,
"message": "No releases found",
"error": None,
}
return {
"update_available": False,
"current_version": APP_VERSION,
"latest_version": None,
"message": "No releases found",
}
response.raise_for_status()
release_data = response.json()
latest_version = release_data.get("tag_name", "").lstrip("v")
release_name = release_data.get("name", latest_version)
release_notes = release_data.get("body", "")
release_url = release_data.get("html_url", "")
published_at = release_data.get("published_at", "")
update_available = is_newer_version(latest_version, APP_VERSION)
_update_status = {
"status": "idle",
"progress": 100,
"message": "Update available" if update_available else "Up to date",
"error": None,
}
return {
"update_available": update_available,
"current_version": APP_VERSION,
"latest_version": latest_version,
"release_name": release_name,
"release_notes": release_notes,
"release_url": release_url,
"published_at": published_at,
}
except httpx.HTTPError as e:
logger.error(f"Failed to check for updates: {e}")
_update_status = {
"status": "error",
"progress": 0,
"message": "Failed to check for updates",
"error": str(e),
}
return {
"update_available": False,
"current_version": APP_VERSION,
"latest_version": None,
"error": str(e),
}
async def _perform_update():
"""Perform the actual update using git pull."""
global _update_status
try:
_update_status = {
"status": "downloading",
"progress": 20,
"message": "Pulling latest changes...",
"error": None,
}
# Run git pull in the project directory
base_dir = settings.base_dir
process = await asyncio.create_subprocess_exec(
"git", "pull", "--rebase",
cwd=str(base_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Git pull failed"
logger.error(f"Git pull failed: {error_msg}")
_update_status = {
"status": "error",
"progress": 0,
"message": "Failed to pull updates",
"error": error_msg,
}
return
_update_status = {
"status": "installing",
"progress": 50,
"message": "Installing dependencies...",
"error": None,
}
# Install Python dependencies
process = await asyncio.create_subprocess_exec(
sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "-q",
cwd=str(base_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
_update_status = {
"status": "installing",
"progress": 70,
"message": "Building frontend...",
"error": None,
}
# Build frontend
frontend_dir = base_dir / "frontend"
if frontend_dir.exists():
# npm install
process = await asyncio.create_subprocess_exec(
"npm", "install",
cwd=str(frontend_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate()
# npm run build
process = await asyncio.create_subprocess_exec(
"npm", "run", "build",
cwd=str(frontend_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
_update_status = {
"status": "complete",
"progress": 100,
"message": "Update complete! Please restart the application.",
"error": None,
}
logger.info("Update completed successfully")
except Exception as e:
logger.error(f"Update failed: {e}")
_update_status = {
"status": "error",
"progress": 0,
"message": "Update failed",
"error": str(e),
}
@router.post("/apply")
async def apply_update(background_tasks: BackgroundTasks):
"""Apply available update (git pull + rebuild)."""
global _update_status
if _update_status["status"] in ["downloading", "installing"]:
return {
"success": False,
"message": "Update already in progress",
"status": _update_status,
}
# Start update in background
background_tasks.add_task(_perform_update)
_update_status = {
"status": "downloading",
"progress": 10,
"message": "Starting update...",
"error": None,
}
return {
"success": True,
"message": "Update started",
"status": _update_status,
}
@router.get("/status")
async def get_update_status():
"""Get current update status."""
return _update_status
+4
View File
@@ -1,6 +1,10 @@
from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.1.4"
GITHUB_REPO = "maziggy/bambusy"
class Settings(BaseSettings):
app_name: str = "BambuTrack"
+4 -3
View File
@@ -9,7 +9,7 @@ from logging.handlers import RotatingFileHandler
from fastapi import FastAPI
# Import settings first for logging configuration
from backend.app.core.config import settings as app_settings
from backend.app.core.config import settings as app_settings, APP_VERSION
# Configure logging based on settings
# DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
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, spoolman
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates
from backend.app.api.routes import settings as settings_routes
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
@@ -963,7 +963,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title=app_settings.app_name,
description="Archive and manage Bambu Lab 3MF files",
version="0.1.2",
version=APP_VERSION,
lifespan=lifespan,
)
@@ -978,6 +978,7 @@ app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(notifications.router, prefix=app_settings.api_prefix)
app.include_router(spoolman.router, prefix=app_settings.api_prefix)
app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
+4
View File
@@ -17,6 +17,9 @@ class AppSettings(BaseModel):
spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
spoolman_sync_mode: str = Field(default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press")
# Updates
check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
class AppSettingsUpdate(BaseModel):
"""Schema for updating settings (all fields optional)."""
@@ -31,3 +34,4 @@ class AppSettingsUpdate(BaseModel):
spoolman_enabled: bool | None = None
spoolman_url: str | None = None
spoolman_sync_mode: str | None = None
check_updates: bool | None = None
+35
View File
@@ -153,6 +153,7 @@ export interface AppSettings {
currency: string;
energy_cost_per_kwh: number;
energy_tracking_mode: 'print' | 'total';
check_updates: boolean;
}
export type AppSettingsUpdate = Partial<AppSettings>;
@@ -492,6 +493,31 @@ export interface SpoolmanSyncResult {
errors: string[];
}
// Update types
export interface VersionInfo {
version: string;
repo: string;
}
export interface UpdateCheckResult {
update_available: boolean;
current_version: string;
latest_version: string | null;
release_name?: string;
release_notes?: string;
release_url?: string;
published_at?: string;
error?: string;
message?: string;
}
export interface UpdateStatus {
status: 'idle' | 'checking' | 'downloading' | 'installing' | 'complete' | 'error';
progress: number;
message: string;
error: string | null;
}
// API functions
export const api = {
// Printers
@@ -906,4 +932,13 @@ export const api = {
request<{ spools: unknown[] }>('/spoolman/spools'),
getSpoolmanFilaments: () =>
request<{ filaments: unknown[] }>('/spoolman/filaments'),
// Updates
getVersion: () => request<VersionInfo>('/updates/version'),
checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
applyUpdate: () =>
request<{ success: boolean; message: string; status: UpdateStatus }>('/updates/apply', {
method: 'POST',
}),
getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
};
+46 -2
View File
@@ -1,8 +1,10 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, type LucideIcon } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
interface NavItem {
id: string;
@@ -76,6 +78,27 @@ export function Layout() {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const hasRedirected = useRef(false);
// Check for updates
const { data: versionInfo } = useQuery({
queryKey: ['version'],
queryFn: api.getVersion,
staleTime: Infinity,
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const { data: updateCheck } = useQuery({
queryKey: ['updateCheck'],
queryFn: api.checkForUpdates,
enabled: settings?.check_updates !== false,
staleTime: 60 * 60 * 1000, // 1 hour
refetchInterval: 60 * 60 * 1000, // Check every hour
});
// Redirect to default view on initial load
useEffect(() => {
if (!hasRedirected.current && location.pathname === '/') {
@@ -239,7 +262,19 @@ export function Layout() {
<div className="p-2 border-t border-bambu-dark-tertiary">
{sidebarExpanded ? (
<div className="flex items-center justify-between px-2">
<span className="text-sm text-bambu-gray">v0.1.4</span>
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
{updateCheck?.update_available && (
<button
onClick={() => navigate('/settings')}
className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
title={`Update available: v${updateCheck.latest_version}`}
>
<ArrowUpCircle className="w-4 h-4" />
<span>Update</span>
</button>
)}
</div>
<div className="flex items-center gap-1">
<a
href="https://github.com/maziggy/bambusy"
@@ -268,6 +303,15 @@ export function Layout() {
</div>
) : (
<div className="flex flex-col items-center gap-1">
{updateCheck?.update_available && (
<button
onClick={() => navigate('/settings')}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
title={`Update available: v${updateCheck.latest_version}`}
>
<ArrowUpCircle className="w-5 h-5" />
</button>
)}
<a
href="https://github.com/maziggy/bambusy"
target="_blank"
+157 -17
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell } from 'lucide-react';
import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink } from 'lucide-react';
import { api } from '../api/client';
import type { AppSettings, SmartPlug, NotificationProvider } from '../api/client';
import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -53,6 +53,37 @@ export function SettingsPage() {
queryFn: api.checkFfmpeg,
});
const { data: versionInfo } = useQuery({
queryKey: ['version'],
queryFn: api.getVersion,
});
const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
queryKey: ['updateCheck'],
queryFn: api.checkForUpdates,
staleTime: 5 * 60 * 1000,
});
const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({
queryKey: ['updateStatus'],
queryFn: api.getUpdateStatus,
refetchInterval: (query) => {
const status = query.state.data as UpdateStatus | undefined;
// Poll while update is in progress
if (status?.status === 'downloading' || status?.status === 'installing') {
return 1000;
}
return false;
},
});
const applyUpdateMutation = useMutation({
mutationFn: api.applyUpdate,
onSuccess: () => {
refetchUpdateStatus();
},
});
// Sync local state when settings load
useEffect(() => {
if (settings && !localSettings) {
@@ -70,7 +101,8 @@ export function SettingsPage() {
settings.default_filament_cost !== localSettings.default_filament_cost ||
settings.currency !== localSettings.currency ||
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
settings.energy_tracking_mode !== localSettings.energy_tracking_mode;
settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
settings.check_updates !== localSettings.check_updates;
setHasChanges(changed);
}
}, [settings, localSettings]);
@@ -326,29 +358,137 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
{/* Second Column - Spoolman & Updates */}
<div className="space-y-6 flex-1 max-w-md">
<SpoolmanSettings />
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">About</h2>
<h2 className="text-lg font-semibold text-white">Updates</h2>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-white">Bambusy v0.1.2</p>
<p className="text-bambu-gray">
Archive and manage your Bambu Lab 3MF files
</p>
<p className="text-bambu-gray">
Connect to printers via LAN mode (developer mode required)
</p>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white">Check for updates</p>
<p className="text-sm text-bambu-gray">
Automatically check for new versions on startup
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.check_updates}
onChange={(e) => updateSetting('check_updates', 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">
<div>
<p className="text-white">Current version</p>
<p className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => refetchUpdateCheck()}
disabled={isCheckingUpdate}
>
{isCheckingUpdate ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Check now
</Button>
</div>
{updateCheck?.update_available ? (
<div className="mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
<div className="flex items-start justify-between">
<div>
<p className="text-bambu-green font-medium">
Update available: v{updateCheck.latest_version}
</p>
{updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
<p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
)}
{updateCheck.release_notes && (
<p className="text-sm text-bambu-gray mt-2 whitespace-pre-line line-clamp-3">
{updateCheck.release_notes}
</p>
)}
</div>
{updateCheck.release_url && (
<a
href={updateCheck.release_url}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-gray hover:text-white transition-colors"
title="View release on GitHub"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
{updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
<div className="mt-3">
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{updateStatus.message}</span>
</div>
<div className="mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2">
<div
className="bg-bambu-green h-2 rounded-full transition-all duration-300"
style={{ width: `${updateStatus.progress}%` }}
/>
</div>
</div>
) : updateStatus?.status === 'complete' ? (
<div className="mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green">
{updateStatus.message}
</div>
) : updateStatus?.status === 'error' ? (
<div className="mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400">
{updateStatus.error || updateStatus.message}
</div>
) : (
<Button
className="mt-3"
onClick={() => applyUpdateMutation.mutate()}
disabled={applyUpdateMutation.isPending}
>
{applyUpdateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
Install Update
</Button>
)}
</div>
) : updateCheck?.error ? (
<div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400">
Failed to check for updates: {updateCheck.error}
</div>
) : updateCheck && !updateCheck.update_available ? (
<p className="mt-2 text-sm text-bambu-gray">
You're running the latest version
</p>
) : null}
</div>
</CardContent>
</Card>
</div>
{/* Middle Column - Smart Plugs */}
<div className="w-96 flex-shrink-0">
{/* Third Column - Smart Plugs */}
<div className="w-80 flex-shrink-0">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -400,8 +540,8 @@ export function SettingsPage() {
</Card>
</div>
{/* Right Column - Notifications */}
<div className="w-96 flex-shrink-0">
{/* Fourth Column - Notifications */}
<div className="w-80 flex-shrink-0">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
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
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-zNwYBAHC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CfyzT_2B.css">
<script type="module" crossorigin src="/assets/index-DFEXODPu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CLNqfrMK.css">
</head>
<body>
<div id="root"></div>