Răsfoiți Sursa

Add in-app software update with fun waiting dialog

Replace SSH instructions with a one-click "Update Now" button that fires
`dw update` as a detached subprocess and shows an animated dialog with
rotating sand-themed messages while polling for the service to come back.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris 4 luni în urmă
părinte
comite
cd674316a5

+ 196 - 0
frontend/src/components/UpdateDialog.tsx

@@ -0,0 +1,196 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { apiClient } from '@/lib/apiClient'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+
+interface UpdateDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentVersion: string
+  latestVersion: string
+}
+
+type UpdateState = 'confirming' | 'updating' | 'error'
+
+const FUN_MESSAGES = [
+  'Shifting the sands...',
+  'Aligning the stars...',
+  'Polishing the steel ball...',
+  'Recalculating the spirals...',
+  'Tuning the motors...',
+  'Almost there...',
+]
+
+export function UpdateDialog({ open, onOpenChange, currentVersion, latestVersion }: UpdateDialogProps) {
+  const [state, setState] = useState<UpdateState>('confirming')
+  const [errorMessage, setErrorMessage] = useState('')
+  const [messageIndex, setMessageIndex] = useState(0)
+  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
+  const messageRef = useRef<ReturnType<typeof setInterval> | null>(null)
+
+  const cleanup = useCallback(() => {
+    if (pollRef.current) {
+      clearInterval(pollRef.current)
+      pollRef.current = null
+    }
+    if (messageRef.current) {
+      clearInterval(messageRef.current)
+      messageRef.current = null
+    }
+  }, [])
+
+  // Reset state when dialog closes
+  useEffect(() => {
+    if (!open) {
+      cleanup()
+      setState('confirming')
+      setErrorMessage('')
+      setMessageIndex(0)
+    }
+  }, [open, cleanup])
+
+  // Cleanup on unmount
+  useEffect(() => cleanup, [cleanup])
+
+  // Rotate fun messages every 4 seconds while updating
+  useEffect(() => {
+    if (state !== 'updating') return
+    messageRef.current = setInterval(() => {
+      setMessageIndex(i => (i + 1) % FUN_MESSAGES.length)
+    }, 4000)
+    return () => {
+      if (messageRef.current) clearInterval(messageRef.current)
+    }
+  }, [state])
+
+  // Poll backend every 3 seconds while updating
+  useEffect(() => {
+    if (state !== 'updating') return
+
+    // Wait a few seconds before starting to poll (give the service time to go down)
+    const startDelay = setTimeout(() => {
+      pollRef.current = setInterval(async () => {
+        try {
+          await apiClient.request('/api/version')
+          // Backend is back — reload the page
+          cleanup()
+          window.location.reload()
+        } catch {
+          // Still down, keep polling
+        }
+      }, 3000)
+    }, 5000)
+
+    return () => {
+      clearTimeout(startDelay)
+      if (pollRef.current) clearInterval(pollRef.current)
+    }
+  }, [state, cleanup])
+
+  const handleUpdate = async () => {
+    setState('updating')
+    setMessageIndex(0)
+    try {
+      const res = await apiClient.request<{ success: boolean; message: string }>('/api/update', {
+        method: 'POST',
+      })
+      if (!res.success) {
+        setState('error')
+        setErrorMessage(res.message || 'Update failed')
+      }
+    } catch (err) {
+      setState('error')
+      setErrorMessage(err instanceof Error ? err.message : 'Failed to start update')
+    }
+  }
+
+  const isUpdating = state === 'updating'
+
+  return (
+    <Dialog open={open} onOpenChange={isUpdating ? undefined : onOpenChange}>
+      <DialogContent
+        onPointerDownOutside={isUpdating ? (e) => e.preventDefault() : undefined}
+        onEscapeKeyDown={isUpdating ? (e) => e.preventDefault() : undefined}
+        className={isUpdating ? '[&>button:last-child]:hidden' : ''}
+      >
+        {state === 'confirming' && (
+          <>
+            <DialogHeader>
+              <DialogTitle>Update Software</DialogTitle>
+              <DialogDescription>
+                Update from v{currentVersion} to v{latestVersion}
+              </DialogDescription>
+            </DialogHeader>
+            <p className="text-sm text-muted-foreground">
+              The system will download the latest version and restart automatically.
+              This usually takes 1-2 minutes.
+            </p>
+            <DialogFooter>
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Cancel
+              </Button>
+              <Button onClick={handleUpdate}>
+                Update Now
+              </Button>
+            </DialogFooter>
+          </>
+        )}
+
+        {state === 'updating' && (
+          <div className="flex flex-col items-center py-8 gap-6">
+            {/* Animated spinner */}
+            <div className="relative w-16 h-16">
+              <div className="absolute inset-0 rounded-full border-4 border-muted" />
+              <div className="absolute inset-0 rounded-full border-4 border-t-primary animate-spin" />
+              <div className="absolute inset-[6px] rounded-full border-4 border-muted" />
+              <div
+                className="absolute inset-[6px] rounded-full border-4 border-t-primary/60"
+                style={{ animation: 'spin 1.5s linear infinite reverse' }}
+              />
+            </div>
+
+            <div className="text-center space-y-2">
+              <p className="text-lg font-medium animate-pulse">
+                {FUN_MESSAGES[messageIndex]}
+              </p>
+              <p className="text-sm text-muted-foreground">
+                This usually takes 1-2 minutes.
+                <br />
+                The page will reload automatically.
+              </p>
+            </div>
+          </div>
+        )}
+
+        {state === 'error' && (
+          <>
+            <DialogHeader>
+              <DialogTitle>Update Failed</DialogTitle>
+              <DialogDescription>
+                Something went wrong while starting the update.
+              </DialogDescription>
+            </DialogHeader>
+            <div className="rounded-lg bg-destructive/10 p-3">
+              <p className="text-sm text-destructive">{errorMessage}</p>
+            </div>
+            <DialogFooter>
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Close
+              </Button>
+              <Button onClick={handleUpdate}>
+                Retry
+              </Button>
+            </DialogFooter>
+          </>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 13 - 6
frontend/src/pages/SettingsPage.tsx

@@ -26,6 +26,7 @@ import {
 } from '@/components/ui/select'
 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 import { SearchableSelect } from '@/components/ui/searchable-select'
+import { UpdateDialog } from '@/components/UpdateDialog'
 
 // Types
 
@@ -185,6 +186,7 @@ export function SettingsPage() {
     latest: string
     update_available: boolean
   } | null>(null)
+  const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
 
   // Helper to scroll to element with header offset
   const scrollToSection = (sectionId: string) => {
@@ -2504,13 +2506,18 @@ export function SettingsPage() {
             </div>
 
             {versionInfo?.update_available && (
-              <Alert className="flex items-start">
-                <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
-                <AlertDescription>
-                  To update, SSH into your Raspberry Pi and run <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">dw update</code>
-                </AlertDescription>
-              </Alert>
+              <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
+                <span className="material-icons text-base mr-2">system_update</span>
+                Update Now
+              </Button>
             )}
+
+            <UpdateDialog
+              open={updateDialogOpen}
+              onOpenChange={setUpdateDialogOpen}
+              currentVersion={versionInfo?.current || ''}
+              latestVersion={versionInfo?.latest || ''}
+            />
           </AccordionContent>
         </AccordionItem>
       </Accordion>

+ 19 - 14
main.py

@@ -3946,22 +3946,27 @@ async def get_version_info(force_refresh: bool = False):
 
 @app.post("/api/update")
 async def trigger_update():
-    """Trigger software update by pulling latest code and restarting the service."""
+    """Trigger software update by running `dw update` as a detached process.
+
+    The `dw` CLI handles pulling code and restarting the service.
+    We fire-and-forget so the response returns immediately before the
+    service goes down for restart.
+    """
+    import shutil
     try:
         logger.info("Update triggered via API")
-        success, error_message, error_log = update_manager.update_software()
-
-        if success:
-            return JSONResponse(content={
-                "success": True,
-                "message": "Update started. Containers are being recreated with the latest images. The page will reload shortly."
-            })
-        else:
-            return JSONResponse(content={
-                "success": False,
-                "message": error_message or "Update failed",
-                "errors": error_log
-            })
+        dw_path = shutil.which('dw') or '/usr/local/bin/dw'
+        subprocess.Popen(
+            [dw_path, 'update'],
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+            start_new_session=True,
+            cwd=os.path.dirname(os.path.abspath(__file__))
+        )
+        return JSONResponse(content={
+            "success": True,
+            "message": "Update started"
+        })
     except Exception as e:
         logger.error(f"Error triggering update: {e}")
         return JSONResponse(

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
static/dist/assets/index-B78VxGpo.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
static/dist/assets/index-Dbujgl2S.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
static/dist/assets/index-UpWUtbXD.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
static/dist/assets/index-pZ3J7MoU.js


+ 2 - 2
static/dist/index.html

@@ -58,8 +58,8 @@
           .catch(function() {});
       })();
     </script>
-    <script type="module" crossorigin src="/assets/index-pZ3J7MoU.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B78VxGpo.css">
+    <script type="module" crossorigin src="/assets/index-Dbujgl2S.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-UpWUtbXD.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>
     <div id="root"></div>

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
static/dist/sw.js


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff