소스 검색

Add settings enhancements and Docker update functionality

- Add preferred port selection with auto-connect disable option
- Display gear ratio and X/Y steps per mm in machine settings
- Add MQTT test connection button to Home Assistant section
- Change "WLED" to "LED" in Still Sands section
- Implement Docker-based software update for two-container setup
- Update button now always enabled for testing
- Fix sensor offset input to allow clearing value
- Add searchable select component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 3 주 전
부모
커밋
04b15470f7
5개의 변경된 파일443개의 추가작업 그리고 119개의 파일을 삭제
  1. 128 0
      frontend/src/components/ui/searchable-select.tsx
  2. 229 64
      frontend/src/pages/SettingsPage.tsx
  3. 13 10
      main.py
  4. 14 16
      modules/core/pattern_manager.py
  5. 59 29
      modules/update/update_manager.py

+ 128 - 0
frontend/src/components/ui/searchable-select.tsx

@@ -0,0 +1,128 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+
+interface SearchableSelectOption {
+  value: string
+  label: string
+}
+
+interface SearchableSelectProps {
+  value?: string
+  onValueChange: (value: string) => void
+  options: SearchableSelectOption[]
+  placeholder?: string
+  searchPlaceholder?: string
+  emptyMessage?: string
+  className?: string
+  disabled?: boolean
+}
+
+export function SearchableSelect({
+  value,
+  onValueChange,
+  options,
+  placeholder = 'Select...',
+  searchPlaceholder = 'Search...',
+  emptyMessage = 'No results found',
+  className,
+  disabled,
+}: SearchableSelectProps) {
+  const [open, setOpen] = React.useState(false)
+  const [search, setSearch] = React.useState('')
+
+  // Find the selected option's label
+  const selectedOption = options.find((opt) => opt.value === value)
+
+  // Filter options based on search
+  const filteredOptions = React.useMemo(() => {
+    if (!search) return options
+    const searchLower = search.toLowerCase()
+    return options.filter(
+      (opt) =>
+        opt.label.toLowerCase().includes(searchLower) ||
+        opt.value.toLowerCase().includes(searchLower)
+    )
+  }, [options, search])
+
+  const handleSelect = (selectedValue: string) => {
+    onValueChange(selectedValue)
+    setOpen(false)
+    setSearch('')
+  }
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          disabled={disabled}
+          className={cn(
+            'w-full justify-between font-normal',
+            !value && 'text-muted-foreground',
+            className
+          )}
+        >
+          <span className="truncate">
+            {selectedOption?.label || placeholder}
+          </span>
+          <span className="material-icons text-base ml-2 shrink-0 opacity-50">
+            unfold_more
+          </span>
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
+        <div className="flex flex-col">
+          {/* Search input */}
+          <div className="p-2 border-b">
+            <Input
+              placeholder={searchPlaceholder}
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+              className="h-8"
+              autoFocus
+            />
+          </div>
+          {/* Options list */}
+          <div className="max-h-[200px] overflow-y-auto">
+            {filteredOptions.length === 0 ? (
+              <div className="py-6 text-center text-sm text-muted-foreground">
+                {emptyMessage}
+              </div>
+            ) : (
+              filteredOptions.map((option) => (
+                <button
+                  key={option.value}
+                  type="button"
+                  className={cn(
+                    'w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground flex items-center gap-2',
+                    value === option.value && 'bg-accent'
+                  )}
+                  onClick={() => handleSelect(option.value)}
+                >
+                  <span
+                    className={cn(
+                      'material-icons text-base',
+                      value === option.value ? 'opacity-100' : 'opacity-0'
+                    )}
+                  >
+                    check
+                  </span>
+                  <span className="truncate">{option.label}</span>
+                </button>
+              ))
+            )}
+          </div>
+        </div>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 229 - 64
frontend/src/pages/SettingsPage.tsx

@@ -22,6 +22,7 @@ import {
   SelectValue,
 } from '@/components/ui/select'
 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 
 // Types
 
@@ -32,6 +33,10 @@ interface Settings {
   // Machine settings
   table_type_override?: string
   detected_table_type?: string
+  effective_table_type?: string
+  gear_ratio?: number
+  x_steps_per_mm?: number
+  y_steps_per_mm?: number
   available_table_types?: { value: string; label: string }[]
   // Homing settings
   homing_mode?: number
@@ -176,6 +181,11 @@ export function SettingsPage() {
     switch (section) {
       case 'connection':
         await fetchPorts()
+        // Also load settings for preferred port
+        if (!loadedSections.has('_settings')) {
+          setLoadedSections((prev) => new Set(prev).add('_settings'))
+          await fetchSettings()
+        }
         break
       case 'application':
       case 'mqtt':
@@ -228,6 +238,30 @@ export function SettingsPage() {
     }
   }
 
+  const handleTriggerUpdate = async () => {
+    if (!confirm('This will update the software and restart the containers. The page will reload automatically. Continue?')) {
+      return
+    }
+    setIsLoading('update')
+    try {
+      const response = await fetch('/api/update', { method: 'POST' })
+      const data = await response.json()
+      if (data.success) {
+        toast.success('Update started! The page will reload in a few seconds...')
+        // Wait a bit for containers to restart, then reload
+        setTimeout(() => {
+          window.location.reload()
+        }, 10000)
+      } else {
+        toast.error(data.message || 'Update failed')
+      }
+    } catch (error) {
+      toast.error('Failed to trigger update')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
   // Handle accordion open/close and trigger data loading
   const handleAccordionChange = (values: string[]) => {
     // Find newly opened section
@@ -299,6 +333,10 @@ export function SettingsPage() {
         // Machine settings
         table_type_override: data.machine?.table_type_override,
         detected_table_type: data.machine?.detected_table_type,
+        effective_table_type: data.machine?.effective_table_type,
+        gear_ratio: data.machine?.gear_ratio,
+        x_steps_per_mm: data.machine?.x_steps_per_mm,
+        y_steps_per_mm: data.machine?.y_steps_per_mm,
         available_table_types: data.machine?.available_table_types,
         // Homing settings
         homing_mode: data.homing?.mode,
@@ -419,6 +457,30 @@ export function SettingsPage() {
     }
   }
 
+  const handleSavePreferredPort = async () => {
+    setIsLoading('preferredPort')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          connection: { preferred_port: settings.preferred_port || null },
+        }),
+      })
+      if (response.ok) {
+        toast.success(
+          settings.preferred_port
+            ? `Auto-connect set to ${settings.preferred_port}`
+            : 'Auto-connect disabled'
+        )
+      }
+    } catch (error) {
+      toast.error('Failed to save preferred port')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
   const handleSaveAppName = async () => {
     setIsLoading('appName')
     try {
@@ -565,6 +627,36 @@ export function SettingsPage() {
     }
   }
 
+  const handleTestMqttConnection = async () => {
+    if (!mqttConfig.broker) {
+      toast.error('Please enter a broker address')
+      return
+    }
+    setIsLoading('mqttTest')
+    try {
+      const response = await fetch('/api/mqtt-test', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          broker: mqttConfig.broker,
+          port: mqttConfig.port || 1883,
+          username: mqttConfig.username || '',
+          password: mqttConfig.password || '',
+        }),
+      })
+      const data = await response.json()
+      if (data.success) {
+        toast.success('MQTT connection successful!')
+      } else {
+        toast.error(data.error || 'Connection failed')
+      }
+    } catch (error) {
+      toast.error('Failed to test MQTT connection')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
   const handleSaveMachineSettings = async () => {
     setIsLoading('machine')
     try {
@@ -620,7 +712,8 @@ export function SettingsPage() {
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({
           patterns: {
-            clear_pattern_speed: settings.clear_pattern_speed || null,
+            // Send 0 to indicate "reset to default" - backend interprets 0 or negative as None
+            clear_pattern_speed: settings.clear_pattern_speed ?? 0,
             custom_clear_from_in: settings.custom_clear_from_in || null,
             custom_clear_from_out: settings.custom_clear_from_out || null,
           },
@@ -799,6 +892,48 @@ export function SettingsPage() {
                 Select a port and click 'Connect' to establish a connection.
               </p>
             </div>
+
+            <Separator />
+
+            {/* Preferred Port for Auto-Connect */}
+            <div className="space-y-3">
+              <Label>Preferred Port (Auto-Connect)</Label>
+              <div className="flex gap-3">
+                <Select
+                  value={settings.preferred_port || '__none__'}
+                  onValueChange={(value) =>
+                    setSettings({ ...settings, preferred_port: value === '__none__' ? undefined : value })
+                  }
+                >
+                  <SelectTrigger className="flex-1">
+                    <SelectValue placeholder="Select preferred port..." />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {ports.map((port) => (
+                      <SelectItem key={port} value={port}>
+                        {port}
+                      </SelectItem>
+                    ))}
+                    <SelectItem value="__none__">None (Disable auto-connect)</SelectItem>
+                  </SelectContent>
+                </Select>
+                <Button
+                  onClick={handleSavePreferredPort}
+                  disabled={isLoading === 'preferredPort'}
+                  className="gap-2"
+                >
+                  {isLoading === 'preferredPort' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                When set, the system will automatically connect to this port on startup. Set to "None" to disable auto-connect.
+              </p>
+            </div>
           </AccordionContent>
         </AccordionItem>
 
@@ -818,15 +953,24 @@ export function SettingsPage() {
             </div>
           </AccordionTrigger>
           <AccordionContent className="pt-4 pb-6 space-y-6">
-            {/* Detected Table Type */}
-            <div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
-              <span className="material-icons-outlined text-muted-foreground">info</span>
-              <span className="text-sm">
-                Detected:{' '}
-                <span className="font-medium">
-                  {settings.detected_table_type || 'Unknown'}
-                </span>
-              </span>
+            {/* Hardware Parameters */}
+            <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
+              <div className="p-3 bg-muted/50 rounded-lg">
+                <p className="text-xs text-muted-foreground">Detected Type</p>
+                <p className="font-medium text-sm">{settings.detected_table_type || 'Unknown'}</p>
+              </div>
+              <div className="p-3 bg-muted/50 rounded-lg">
+                <p className="text-xs text-muted-foreground">Gear Ratio</p>
+                <p className="font-medium text-sm">{settings.gear_ratio ?? '—'}</p>
+              </div>
+              <div className="p-3 bg-muted/50 rounded-lg">
+                <p className="text-xs text-muted-foreground">X Steps/mm</p>
+                <p className="font-medium text-sm">{settings.x_steps_per_mm ?? '—'}</p>
+              </div>
+              <div className="p-3 bg-muted/50 rounded-lg">
+                <p className="text-xs text-muted-foreground">Y Steps/mm</p>
+                <p className="font-medium text-sm">{settings.y_steps_per_mm ?? '—'}</p>
+              </div>
             </div>
 
             {/* Table Type Override */}
@@ -939,9 +1083,12 @@ export function SettingsPage() {
                   min="0"
                   max="360"
                   step="0.1"
-                  value={settings.angular_offset || 0}
+                  value={settings.angular_offset ?? ''}
                   onChange={(e) =>
-                    setSettings({ ...settings, angular_offset: parseFloat(e.target.value) || 0 })
+                    setSettings({
+                      ...settings,
+                      angular_offset: e.target.value === '' ? undefined : parseFloat(e.target.value),
+                    })
                   }
                   placeholder="0.0"
                 />
@@ -988,7 +1135,7 @@ export function SettingsPage() {
                     }
                   />
                   <p className="text-xs text-muted-foreground">
-                    Homing occurs after the clear pattern completes, before the next pattern.
+                    Homing occurs after each main pattern completes (clear patterns don't count).
                   </p>
                 </div>
               )}
@@ -1192,24 +1339,19 @@ export function SettingsPage() {
               <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                 <div className="space-y-2">
                   <Label htmlFor="clear-from-in">Clear From Center Pattern</Label>
-                  <Select
+                  <SearchableSelect
                     value={settings.custom_clear_from_in || '__default__'}
                     onValueChange={(value) =>
                       setSettings({ ...settings, custom_clear_from_in: value === '__default__' ? undefined : value })
                     }
-                  >
-                    <SelectTrigger>
-                      <SelectValue placeholder="Default (built-in)" />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectItem value="__default__">Default (built-in)</SelectItem>
-                      {patternFiles.map((file) => (
-                        <SelectItem key={file} value={file}>
-                          {file}
-                        </SelectItem>
-                      ))}
-                    </SelectContent>
-                  </Select>
+                    options={[
+                      { value: '__default__', label: 'Default (built-in)' },
+                      ...patternFiles.map((file) => ({ value: file, label: file })),
+                    ]}
+                    placeholder="Default (built-in)"
+                    searchPlaceholder="Search patterns..."
+                    emptyMessage="No patterns found"
+                  />
                   <p className="text-xs text-muted-foreground">
                     Pattern used when clearing from center outward.
                   </p>
@@ -1217,24 +1359,19 @@ export function SettingsPage() {
 
                 <div className="space-y-2">
                   <Label htmlFor="clear-from-out">Clear From Perimeter Pattern</Label>
-                  <Select
+                  <SearchableSelect
                     value={settings.custom_clear_from_out || '__default__'}
                     onValueChange={(value) =>
                       setSettings({ ...settings, custom_clear_from_out: value === '__default__' ? undefined : value })
                     }
-                  >
-                    <SelectTrigger>
-                      <SelectValue placeholder="Default (built-in)" />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectItem value="__default__">Default (built-in)</SelectItem>
-                      {patternFiles.map((file) => (
-                        <SelectItem key={file} value={file}>
-                          {file}
-                        </SelectItem>
-                      ))}
-                    </SelectContent>
-                  </Select>
+                    options={[
+                      { value: '__default__', label: 'Default (built-in)' },
+                      ...patternFiles.map((file) => ({ value: file, label: file })),
+                    ]}
+                    placeholder="Default (built-in)"
+                    searchPlaceholder="Search patterns..."
+                    emptyMessage="No patterns found"
+                  />
                   <p className="text-xs text-muted-foreground">
                     Pattern used when clearing from perimeter inward.
                   </p>
@@ -1523,18 +1660,35 @@ export function SettingsPage() {
               </div>
             )}
 
-            <Button
-              onClick={handleSaveMqttConfig}
-              disabled={isLoading === 'mqtt'}
-              className="gap-2"
-            >
-              {isLoading === 'mqtt' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
+            <div className="flex gap-3">
+              <Button
+                onClick={handleSaveMqttConfig}
+                disabled={isLoading === 'mqtt'}
+                className="gap-2"
+              >
+                {isLoading === 'mqtt' ? (
+                  <span className="material-icons-outlined animate-spin">sync</span>
+                ) : (
+                  <span className="material-icons-outlined">save</span>
+                )}
+                Save MQTT Configuration
+              </Button>
+              {mqttConfig.enabled && mqttConfig.broker && (
+                <Button
+                  variant="outline"
+                  onClick={handleTestMqttConnection}
+                  disabled={isLoading === 'mqttTest'}
+                  className="gap-2"
+                >
+                  {isLoading === 'mqttTest' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">wifi_tethering</span>
+                  )}
+                  Test Connection
+                </Button>
               )}
-              Save MQTT Configuration
-            </Button>
+            </div>
           </AccordionContent>
         </AccordionItem>
 
@@ -1574,7 +1728,7 @@ export function SettingsPage() {
                 <div className="space-y-2">
                   <Label>Startup Playlist</Label>
                   <Select
-                    value={autoPlaySettings.playlist}
+                    value={autoPlaySettings.playlist || undefined}
                     onValueChange={(value) =>
                       setAutoPlaySettings({ ...autoPlaySettings, playlist: value })
                     }
@@ -1583,11 +1737,17 @@ export function SettingsPage() {
                       <SelectValue placeholder="Select a playlist..." />
                     </SelectTrigger>
                     <SelectContent>
-                      {playlists.map((playlist) => (
-                        <SelectItem key={playlist} value={playlist}>
-                          {playlist}
-                        </SelectItem>
-                      ))}
+                      {playlists.length === 0 ? (
+                        <div className="py-6 text-center text-sm text-muted-foreground">
+                          No playlists found
+                        </div>
+                      ) : (
+                        playlists.map((playlist) => (
+                          <SelectItem key={playlist} value={playlist}>
+                            {playlist}
+                          </SelectItem>
+                        ))
+                      )}
                     </SelectContent>
                   </Select>
                   <p className="text-xs text-muted-foreground">
@@ -1755,9 +1915,9 @@ export function SettingsPage() {
                         lightbulb
                       </span>
                       <div>
-                        <p className="text-sm font-medium">Control WLED Lights</p>
+                        <p className="text-sm font-medium">Control LED Lights</p>
                         <p className="text-xs text-muted-foreground">
-                          Turn off WLED lights during still periods
+                          Turn off LED lights during still periods
                         </p>
                       </div>
                     </div>
@@ -1964,12 +2124,17 @@ export function SettingsPage() {
                 </p>
               </div>
               <Button
-                variant={versionInfo?.update_available ? 'default' : 'secondary'}
+                variant={versionInfo?.update_available ? 'default' : 'outline'}
                 size="sm"
-                disabled={!versionInfo?.update_available}
+                disabled={isLoading === 'update'}
+                onClick={handleTriggerUpdate}
               >
-                <span className="material-icons-outlined text-base mr-1">download</span>
-                Update
+                {isLoading === 'update' ? (
+                  <span className="material-icons-outlined text-base mr-1 animate-spin">sync</span>
+                ) : (
+                  <span className="material-icons-outlined text-base mr-1">download</span>
+                )}
+                {isLoading === 'update' ? 'Updating...' : 'Update'}
               </Button>
             </div>
           </AccordionContent>

+ 13 - 10
main.py

@@ -651,6 +651,9 @@ async def get_all_settings():
             "detected_table_type": state.table_type,
             "table_type_override": state.table_type_override,
             "effective_table_type": state.table_type_override or state.table_type,
+            "gear_ratio": state.gear_ratio,
+            "x_steps_per_mm": state.x_steps_per_mm,
+            "y_steps_per_mm": state.y_steps_per_mm,
             "available_table_types": [
                 {"value": "dune_weaver_mini", "label": "Dune Weaver Mini"},
                 {"value": "dune_weaver_mini_pro", "label": "Dune Weaver Mini Pro"},
@@ -2836,26 +2839,26 @@ async def get_version_info(force_refresh: bool = False):
 
 @app.post("/api/update")
 async def trigger_update():
-    """Trigger software update (placeholder for future implementation)"""
+    """Trigger software update by pulling latest Docker images and recreating containers."""
     try:
-        # For now, just return the GitHub release URL
-        version_info = await version_manager.get_version_info()
-        if version_info.get("latest_release"):
+        logger.info("Update triggered via API")
+        success, error_message, error_log = update_manager.update_software()
+
+        if success:
             return JSONResponse(content={
-                "success": False,
-                "message": "Automatic updates not implemented yet",
-                "manual_update_url": version_info["latest_release"].get("html_url"),
-                "instructions": "Please visit the GitHub release page to download and install the update manually"
+                "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": "No updates available"
+                "message": error_message or "Update failed",
+                "errors": error_log
             })
     except Exception as e:
         logger.error(f"Error triggering update: {e}")
         return JSONResponse(
-            content={"success": False, "message": "Failed to check for updates"},
+            content={"success": False, "message": f"Failed to trigger update: {str(e)}"},
             status_code=500
         )
 

+ 14 - 16
modules/core/pattern_manager.py

@@ -967,11 +967,20 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
 
                 current_is_clear = is_clear_pattern(file_path)
 
-                # Check if we need to auto-home before this clear pattern
-                # Auto-home happens after pause, before the clear pattern runs
-                if current_is_clear and state.auto_home_enabled:
-                    # Check if we've reached the pattern threshold
-                    if state.patterns_since_last_home >= state.auto_home_after_patterns:
+                # Update state for main patterns only
+                logger.info(f"Running pattern {file_path}")
+
+                # Execute the pattern
+                await run_theta_rho_file(file_path, is_playlist=True)
+
+                # Increment pattern counter and check auto-home for non-clear patterns
+                if not current_is_clear:
+                    state.patterns_since_last_home += 1
+                    logger.debug(f"Patterns since last home: {state.patterns_since_last_home}")
+
+                    # Check if we need to auto-home after this pattern
+                    # Auto-home triggers after X main patterns, regardless of clear pattern setting
+                    if state.auto_home_enabled and state.patterns_since_last_home >= state.auto_home_after_patterns:
                         logger.info(f"Auto-homing triggered after {state.patterns_since_last_home} patterns")
                         try:
                             # Perform homing using connection_manager
@@ -984,17 +993,6 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                         except Exception as e:
                             logger.error(f"Error during auto-homing: {e}")
 
-                # Update state for main patterns only
-                logger.info(f"Running pattern {file_path}")
-
-                # Execute the pattern
-                await run_theta_rho_file(file_path, is_playlist=True)
-
-                # Increment pattern counter only for non-clear patterns
-                if not current_is_clear:
-                    state.patterns_since_last_home += 1
-                    logger.debug(f"Patterns since last home: {state.patterns_since_last_home}")
-
                 # Check for scheduled pause after pattern completes (when "finish pattern first" is enabled)
                 if state.scheduled_pause_finish_pattern and is_in_scheduled_pause_period() and not state.stop_requested:
                     logger.info("Pattern completed. Entering Still Sands period (finish pattern first mode)...")

+ 59 - 29
modules/update/update_manager.py

@@ -50,46 +50,76 @@ def check_git_updates():
         }
 
 def update_software():
-    """Update the software to the latest version."""
+    """Update the software to the latest version using Docker."""
     error_log = []
     logger.info("Starting software update process")
 
-    def run_command(command, error_message):
+    def run_command(command, error_message, capture_output=False):
         try:
             logger.debug(f"Running command: {' '.join(command)}")
-            subprocess.run(command, check=True)
+            result = subprocess.run(command, check=True, capture_output=capture_output, text=True)
+            return result.stdout if capture_output else None
         except subprocess.CalledProcessError as e:
             logger.error(f"{error_message}: {e}")
             error_log.append(error_message)
+            return None
 
+    # Pull new Docker images for both frontend and backend
+    logger.info("Pulling latest Docker images...")
+    run_command(
+        ["docker", "pull", "ghcr.io/tuanchris/dune-weaver:main"],
+        "Failed to pull backend Docker image"
+    )
+    run_command(
+        ["docker", "pull", "ghcr.io/tuanchris/dune-weaver-frontend:main"],
+        "Failed to pull frontend Docker image"
+    )
+
+    # Recreate containers with new images using docker-compose
+    # Try docker-compose first, then docker compose (v2)
+    logger.info("Recreating containers with new images...")
+    compose_success = False
+
+    # Try docker-compose (v1)
     try:
-        subprocess.run(["git", "fetch", "--tags"], check=True)
-        latest_remote_tag = subprocess.check_output(
-            ["git", "describe", "--tags", "--abbrev=0", "origin/main"]
-        ).strip().decode()
-        logger.info(f"Latest remote tag: {latest_remote_tag}")
-    except subprocess.CalledProcessError as e:
-        error_msg = f"Failed to fetch tags or get latest remote tag: {e}"
-        logger.error(error_msg)
-        error_log.append(error_msg)
-        return False, error_msg, error_log
+        subprocess.run(
+            ["docker-compose", "up", "-d", "--force-recreate"],
+            check=True,
+            cwd="/app"
+        )
+        compose_success = True
+        logger.info("Containers recreated successfully with docker-compose")
+    except (subprocess.CalledProcessError, FileNotFoundError):
+        logger.debug("docker-compose not available, trying docker compose")
 
-    run_command(["git", "checkout", latest_remote_tag, '--force'], f"Failed to checkout version {latest_remote_tag}")
+    # Try docker compose (v2) if v1 failed
+    if not compose_success:
+        try:
+            subprocess.run(
+                ["docker", "compose", "up", "-d", "--force-recreate"],
+                check=True,
+                cwd="/app"
+            )
+            compose_success = True
+            logger.info("Containers recreated successfully with docker compose")
+        except (subprocess.CalledProcessError, FileNotFoundError):
+            logger.debug("docker compose not available, falling back to individual restarts")
 
-    # Pull new image and restart container using direct docker commands
-    # Note: docker restart reuses the existing container, so code changes (via volume mount)
-    # are picked up immediately. For image changes (new dependencies), manual recreation is needed.
-    run_command(["docker", "pull", "ghcr.io/tuanchris/dune-weaver:main"], "Failed to pull Docker image")
-    run_command(["docker", "restart", "dune-weaver"], "Failed to restart Docker container")
+    # Fallback: restart individual containers
+    if not compose_success:
+        logger.info("Falling back to individual container restarts...")
+        run_command(
+            ["docker", "restart", "dune-weaver-frontend"],
+            "Failed to restart frontend container"
+        )
+        run_command(
+            ["docker", "restart", "dune-weaver-backend"],
+            "Failed to restart backend container"
+        )
 
-    update_status = check_git_updates()
+    if error_log:
+        logger.error(f"Software update completed with errors: {error_log}")
+        return False, "Update completed with errors", error_log
 
-    if (
-        update_status["updates_available"] is False
-        and update_status["latest_local_tag"] == update_status["latest_remote_tag"]
-    ):
-        logger.info("Software update completed successfully")
-        return True, None, None
-    else:
-        logger.error("Software update incomplete")
-        return False, "Update incomplete", error_log
+    logger.info("Software update completed successfully")
+    return True, None, None