diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index c358313..cdf7cdb 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -37,8 +37,13 @@ const ( DiskSpaceCriticalThreshold = 95.0 // CapabilitiesUpdateInterval is how often to update capabilities (including disk space) CapabilitiesUpdateInterval = 5 * time.Minute + // BandwidthTestInterval is how often to run bandwidth tests (hourly) + BandwidthTestInterval = 1 * time.Hour ) +// Global bandwidth manager - accessible for triggering manual tests +var bandwidthManager *envcheck.BandwidthManager + func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { cfg, err := config.LoadDefault(*configFile) @@ -152,7 +157,15 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu dockerHost = dh } } + + // Initialize bandwidth manager with the Gitea server address + bandwidthManager = envcheck.NewBandwidthManager(reg.Address, BandwidthTestInterval) + bandwidthManager.Start(ctx) + log.Infof("bandwidth manager started, testing against: %s", reg.Address) + capabilities := envcheck.DetectCapabilities(ctx, dockerHost) + // Include initial bandwidth result if available + capabilities.Bandwidth = bandwidthManager.GetLastResult() capabilitiesJson := capabilities.ToJSON() log.Infof("detected capabilities: %s", capabilitiesJson) @@ -225,7 +238,7 @@ func checkDiskSpaceWarnings(capabilities *envcheck.RunnerCapabilities) { } } -// periodicCapabilitiesUpdate periodically updates capabilities including disk space +// periodicCapabilitiesUpdate periodically updates capabilities including disk space and bandwidth func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNames []string, dockerHost string) { ticker := time.NewTicker(CapabilitiesUpdateInterval) defer ticker.Stop() @@ -234,10 +247,19 @@ func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNa select { case <-ctx.Done(): log.Debug("stopping periodic capabilities update") + if bandwidthManager != nil { + bandwidthManager.Stop() + } return case <-ticker.C: // Detect updated capabilities (disk space changes over time) capabilities := envcheck.DetectCapabilities(ctx, dockerHost) + + // Include latest bandwidth result + if bandwidthManager != nil { + capabilities.Bandwidth = bandwidthManager.GetLastResult() + } + capabilitiesJson := capabilities.ToJSON() // Check for disk space warnings @@ -248,9 +270,14 @@ func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNa if err != nil { log.WithError(err).Debug("failed to update capabilities") } else { - log.Debugf("capabilities updated: disk %.1f%% used, %.2f GB free", + bandwidthInfo := "" + if capabilities.Bandwidth != nil { + bandwidthInfo = fmt.Sprintf(", bandwidth: %.1f Mbps", capabilities.Bandwidth.DownloadMbps) + } + log.Debugf("capabilities updated: disk %.1f%% used, %.2f GB free%s", capabilities.Disk.UsedPercent, - float64(capabilities.Disk.Free)/(1024*1024*1024)) + float64(capabilities.Disk.Free)/(1024*1024*1024), + bandwidthInfo) } } } diff --git a/internal/pkg/envcheck/bandwidth.go b/internal/pkg/envcheck/bandwidth.go new file mode 100644 index 0000000..0b8cf7e --- /dev/null +++ b/internal/pkg/envcheck/bandwidth.go @@ -0,0 +1,209 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package envcheck + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// BandwidthInfo holds network bandwidth test results +type BandwidthInfo struct { + DownloadMbps float64 `json:"download_mbps"` + UploadMbps float64 `json:"upload_mbps,omitempty"` + Latency float64 `json:"latency_ms,omitempty"` + TestedAt time.Time `json:"tested_at"` +} + +// BandwidthManager handles periodic bandwidth testing +type BandwidthManager struct { + serverURL string + lastResult *BandwidthInfo + mu sync.RWMutex + testInterval time.Duration + stopChan chan struct{} +} + +// NewBandwidthManager creates a new bandwidth manager +func NewBandwidthManager(serverURL string, testInterval time.Duration) *BandwidthManager { + return &BandwidthManager{ + serverURL: serverURL, + testInterval: testInterval, + stopChan: make(chan struct{}), + } +} + +// Start begins periodic bandwidth testing +func (bm *BandwidthManager) Start(ctx context.Context) { + // Run initial test + bm.RunTest(ctx) + + // Start periodic testing + go func() { + ticker := time.NewTicker(bm.testInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + bm.RunTest(ctx) + case <-bm.stopChan: + return + case <-ctx.Done(): + return + } + } + }() +} + +// Stop stops the periodic testing +func (bm *BandwidthManager) Stop() { + close(bm.stopChan) +} + +// RunTest runs a bandwidth test and stores the result +func (bm *BandwidthManager) RunTest(ctx context.Context) *BandwidthInfo { + result := TestBandwidth(ctx, bm.serverURL) + + bm.mu.Lock() + bm.lastResult = result + bm.mu.Unlock() + + return result +} + +// GetLastResult returns the most recent bandwidth test result +func (bm *BandwidthManager) GetLastResult() *BandwidthInfo { + bm.mu.RLock() + defer bm.mu.RUnlock() + return bm.lastResult +} + +// TestBandwidth tests network bandwidth to the Gitea server +func TestBandwidth(ctx context.Context, serverURL string) *BandwidthInfo { + if serverURL == "" { + return nil + } + + info := &BandwidthInfo{ + TestedAt: time.Now(), + } + + // Test latency first + info.Latency = testLatency(ctx, serverURL) + + // Test download speed + info.DownloadMbps = testDownloadSpeed(ctx, serverURL) + + return info +} + +func testLatency(ctx context.Context, serverURL string) float64 { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, "HEAD", serverURL, nil) + if err != nil { + return 0 + } + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + return 0 + } + resp.Body.Close() + + latency := time.Since(start).Seconds() * 1000 // Convert to ms + return float64(int(latency*100)) / 100 // Round to 2 decimals +} + +func testDownloadSpeed(ctx context.Context, serverURL string) float64 { + // Try multiple endpoints to accumulate ~1MB of data + endpoints := []string{ + "/assets/css/index.css", + "/assets/js/index.js", + "/assets/img/logo.svg", + "/assets/img/logo.png", + "/", + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + var totalBytes int64 + var totalDuration time.Duration + targetBytes := int64(1024 * 1024) // 1MB target + maxAttempts := 10 // Limit iterations + + for attempt := 0; attempt < maxAttempts && totalBytes < targetBytes; attempt++ { + for _, endpoint := range endpoints { + if totalBytes >= targetBytes { + break + } + + url := serverURL + endpoint + + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + req, err := http.NewRequestWithContext(reqCtx, "GET", url, nil) + if err != nil { + cancel() + continue + } + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + cancel() + continue + } + + n, _ := io.Copy(io.Discard, resp.Body) + resp.Body.Close() + cancel() + + duration := time.Since(start) + + if n > 0 { + totalBytes += n + totalDuration += duration + } + } + } + + if totalBytes == 0 || totalDuration == 0 { + return 0 + } + + // Calculate speed in Mbps + seconds := totalDuration.Seconds() + if seconds == 0 { + return 0 + } + + bytesPerSecond := float64(totalBytes) / seconds + mbps := (bytesPerSecond * 8) / (1024 * 1024) + + return float64(int(mbps*100)) / 100 +} + +// FormatBandwidth formats bandwidth for display +func FormatBandwidth(mbps float64) string { + if mbps == 0 { + return "Unknown" + } + if mbps >= 1000 { + return fmt.Sprintf("%.1f Gbps", mbps/1000) + } + return fmt.Sprintf("%.1f Mbps", mbps) +} diff --git a/internal/pkg/envcheck/capabilities.go b/internal/pkg/envcheck/capabilities.go index d3a578a..014ebdf 100644 --- a/internal/pkg/envcheck/capabilities.go +++ b/internal/pkg/envcheck/capabilities.go @@ -35,6 +35,7 @@ type RunnerCapabilities struct { Features *CapabilityFeatures `json:"features,omitempty"` Limitations []string `json:"limitations,omitempty"` Disk *DiskInfo `json:"disk,omitempty"` + Bandwidth *BandwidthInfo `json:"bandwidth,omitempty"` } // CapabilityFeatures represents feature support flags