Improve update integration diagnostics

This commit is contained in:
rcourtman 2025-11-12 22:27:05 +00:00
parent 88d0aeebdf
commit bb55144637
8 changed files with 95 additions and 17 deletions

View file

@ -140,7 +140,7 @@ function GlobalUpdateProgressWatcher() {
} else if (!inProgress && hasAutoOpened()) { } else if (!inProgress && hasAutoOpened()) {
setHasAutoOpened(false); setHasAutoOpened(false);
} }
} catch (error) { } catch (_error) {
// Silently ignore polling errors // Silently ignore polling errors
} }
}; };

View file

@ -2061,7 +2061,7 @@ const Settings: Component<SettingsProps> = (props) => {
showError('Invalid backup file format. Expected encrypted data in "data" field.'); showError('Invalid backup file format. Expected encrypted data in "data" field.');
return; return;
} }
} catch (parseError) { } catch (_parseError) {
// Not JSON - treat entire contents as raw base64 from CLI export // Not JSON - treat entire contents as raw base64 from CLI export
encryptedData = fileContent.trim(); encryptedData = fileContent.trim();
} }

View file

@ -19,7 +19,7 @@ const getInitialViewMode = (): MetricsViewMode => {
if (stored === 'sparklines' || stored === 'bars') { if (stored === 'sparklines' || stored === 'bars') {
return stored; return stored;
} }
} catch (err) { } catch (_err) {
// Ignore localStorage errors // Ignore localStorage errors
} }

View file

@ -188,6 +188,13 @@ func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return hijacker.Hijack() return hijacker.Hijack()
} }
// Flush implements http.Flusher when the underlying writer supports it.
func (rw *responseWriter) Flush() {
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
// NewAPIError creates a new API error // NewAPIError creates a new API error
func NewAPIError(statusCode int, code, message string) error { func NewAPIError(statusCode int, code, message string) error {
return &APIError{ return &APIError{

View file

@ -15,6 +15,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -58,7 +59,11 @@ type UpdateInfo struct {
Warning string `json:"warning,omitempty"` Warning string `json:"warning,omitempty"`
} }
var errGitHubRateLimited = errors.New("GitHub API rate limit exceeded") var (
errGitHubRateLimited = errors.New("GitHub API rate limit exceeded")
stageDelayOnce sync.Once
stageDelayValue time.Duration
)
// Manager handles update operations // Manager handles update operations
type Manager struct { type Manager struct {
@ -1137,6 +1142,10 @@ func (m *Manager) updateStatus(status string, progress int, message string, err
if m.sseBroadcast != nil { if m.sseBroadcast != nil {
m.sseBroadcast.Broadcast(statusCopy) m.sseBroadcast.Broadcast(statusCopy)
} }
if delay := statusDelayForStage(status); delay > 0 {
time.Sleep(delay)
}
} }
// sseHeartbeatLoop sends periodic heartbeats to SSE clients // sseHeartbeatLoop sends periodic heartbeats to SSE clients
@ -1168,6 +1177,37 @@ func sanitizeError(err error) string {
return errMsg return errMsg
} }
func statusDelayForStage(status string) time.Duration {
delay := configuredStageDelay()
if delay == 0 {
return 0
}
switch status {
case "downloading", "verifying", "extracting", "backing-up", "applying":
return delay
default:
return 0
}
}
func configuredStageDelay() time.Duration {
stageDelayOnce.Do(func() {
value := strings.TrimSpace(os.Getenv("PULSE_UPDATE_STAGE_DELAY_MS"))
if value == "" {
return
}
ms, err := strconv.Atoi(value)
if err != nil || ms <= 0 {
log.Warn().Str("value", value).Msg("Invalid PULSE_UPDATE_STAGE_DELAY_MS, ignoring")
return
}
stageDelayValue = time.Duration(ms) * time.Millisecond
})
return stageDelayValue
}
// cleanupOldTempDirs removes old pulse-update-* temp directories from previous runs // cleanupOldTempDirs removes old pulse-update-* temp directories from previous runs
func (m *Manager) cleanupOldTempDirs() { func (m *Manager) cleanupOldTempDirs() {
// Check multiple locations where temp dirs might exist // Check multiple locations where temp dirs might exist

View file

@ -181,7 +181,7 @@ func waitForCompletion(t *testing.T, client *http.Client, baseURL string, timeou
if time.Now().After(deadline) { if time.Now().After(deadline) {
t.Fatalf("update did not complete within %s (last status: %+v)", timeout, status) t.Fatalf("update did not complete within %s (last status: %+v)", timeout, status)
} }
time.Sleep(500 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
} }

View file

@ -41,6 +41,7 @@ services:
# Mock mode for faster testing # Mock mode for faster testing
- PULSE_MOCK_MODE=true - PULSE_MOCK_MODE=true
- PULSE_ALLOW_DOCKER_UPDATES=true - PULSE_ALLOW_DOCKER_UPDATES=true
- PULSE_UPDATE_STAGE_DELAY_MS=250
# Pre-configure authentication to bypass first-run setup # Pre-configure authentication to bypass first-run setup
- PULSE_AUTH_USER=admin - PULSE_AUTH_USER=admin
- PULSE_AUTH_PASS=admin - PULSE_AUTH_PASS=admin

View file

@ -57,6 +57,15 @@ func getenvDefault(key, fallback string) string {
return fallback return fallback
} }
func isChecksumFilename(name string) bool {
switch strings.ToLower(name) {
case "checksums.txt", "sha256sums", "sha256sums.txt":
return true
default:
return false
}
}
func (rl *rateLimiter) cleanup() { func (rl *rateLimiter) cleanup() {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@ -122,6 +131,7 @@ func main() {
// In-memory storage for tarballs and checksums // In-memory storage for tarballs and checksums
tarballs := make(map[string][]byte) tarballs := make(map[string][]byte)
checksums := make(map[string]string) checksums := make(map[string]string)
checksumEntries := make(map[string][]string)
latestTag := getenvDefault("MOCK_LATEST_VERSION", "v99.0.0") latestTag := getenvDefault("MOCK_LATEST_VERSION", "v99.0.0")
prevTag := getenvDefault("MOCK_PREVIOUS_VERSION", "v98.5.0") prevTag := getenvDefault("MOCK_PREVIOUS_VERSION", "v98.5.0")
@ -169,6 +179,9 @@ func main() {
} }
checksums[filename] = checksum checksums[filename] = checksum
entry := fmt.Sprintf("%s %s", checksum, filename)
checksumEntries[version] = append(checksumEntries[version], entry)
checksumEntries["v"+version] = append(checksumEntries["v"+version], entry)
// Add download URLs to release // Add download URLs to release
rel.Assets = []struct { rel.Assets = []struct {
@ -242,27 +255,44 @@ func main() {
version := parts[0] version := parts[0]
file := parts[1] file := parts[1]
if file == "checksums.txt" { if isChecksumFilename(file) {
// Generate checksums.txt // Generate checksums.txt
var buf bytes.Buffer var buf bytes.Buffer
for fname, chksum := range checksums { entries, ok := checksumEntries[version]
if strings.Contains(fname, version) { if !ok {
buf.WriteString(fmt.Sprintf("%s %s\n", chksum, fname)) trimmed := strings.TrimPrefix(version, "v")
entries = checksumEntries[trimmed]
}
if len(entries) == 0 {
w.WriteHeader(http.StatusNotFound)
log.Printf("No checksums found for version %s", version)
return
}
for _, line := range entries {
buf.WriteString(line)
if !strings.HasSuffix(line, "\n") {
buf.WriteByte('\n')
} }
} }
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.Write(buf.Bytes()) w.Write(buf.Bytes())
log.Printf("Served checksums for version %s", version) log.Printf("Served checksums for version %s (requested %s)", version, file)
return return
} }
// Serve tarball // Serve tarball (strictly match requested filename first)
filename := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", version) tarball, ok := tarballs[file]
tarball, ok := tarballs[filename]
if !ok { if !ok {
w.WriteHeader(http.StatusNotFound) // Fallback to canonical filename derived from version (without leading v)
log.Printf("Tarball not found: %s", filename) trimmedVersion := strings.TrimPrefix(version, "v")
return canonical := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", trimmedVersion)
tarball, ok = tarballs[canonical]
if !ok {
w.WriteHeader(http.StatusNotFound)
log.Printf("Tarball not found: %s (canonical %s)", file, canonical)
return
}
file = canonical
} }
// Mark as stale if requested // Mark as stale if requested
@ -274,7 +304,7 @@ func main() {
w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Length", strconv.Itoa(len(tarball))) w.Header().Set("Content-Length", strconv.Itoa(len(tarball)))
w.Write(tarball) w.Write(tarball)
log.Printf("Served tarball: %s", filename) log.Printf("Served tarball: %s (version %s)", file, version)
}) })
// Health check // Health check