Heric Camargo pushed to branch main at Root / CLI / Mirror Monitor

Commits:

8 changed files:

Changes:

  • .gitignore
    ... ... @@ -5,6 +5,7 @@
    5 5
     # binaries
    
    6 6
     rsyncuptime-server
    
    7 7
     rsyncuptime-tui
    
    8
    +bin/
    
    8 9
     
    
    9 10
     # Binaries for programs and plugins
    
    10 11
     *.exe
    
    ... ... @@ -31,7 +32,10 @@ go.work.sum
    31 32
     
    
    32 33
     # env file
    
    33 34
     .env
    
    35
    +.envrc
    
    34 36
     
    
    35 37
     # Editor/IDE
    
    36 38
     # .idea/
    
    37 39
     # .vscode/
    
    40
    +// Ignore Mage generated main file
    
    41
    +mage_output_file.go

  • cmd/api/main.go
    ... ... @@ -263,64 +263,72 @@ func main() {
    263 263
     	mux := http.NewServeMux()
    
    264 264
     
    
    265 265
     	// Handler for the root endpoint, listing available modules.
    
    266
    -	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    
    267
    -		if r.URL.Path != "/" {
    
    268
    -			writeJSONError(w, http.StatusNotFound, "Endpoint not found. See / for available modules.", r.URL.Path)
    
    266
    +	// GET /modules - list all monitored modules
    
    267
    +	mux.HandleFunc("/modules", func(w http.ResponseWriter, r *http.Request) {
    
    268
    +		if r.Method != http.MethodGet {
    
    269
    +			writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed", r.URL.Path)
    
    269 270
     			return
    
    270 271
     		}
    
    271
    -
    
    272 272
     		w.Header().Set("Content-Type", "application/json")
    
    273
    -		endpoints := make(map[string]string)
    
    274
    -		for _, module := range discoveredModules {
    
    275
    -			endpoints[module] = fmt.Sprintf("/status/%s", module)
    
    276
    -		}
    
    277
    -
    
    278
    -		// Tenta obter a lista de diretórios do rsync
    
    279
    -		var rsyncDirs []string
    
    280
    -		out, err := execCommand("rsync", rsyncURL).CombinedOutput()
    
    281
    -		if err == nil {
    
    282
    -			scanner := bufio.NewScanner(strings.NewReader(string(out)))
    
    283
    -			for scanner.Scan() {
    
    284
    -				line := strings.TrimSpace(scanner.Text())
    
    285
    -				if line == "" {
    
    286
    -					continue
    
    287
    -				}
    
    288
    -				// Pega o nome do diretório (primeira palavra)
    
    289
    -				parts := strings.Fields(line)
    
    290
    -				if len(parts) > 0 {
    
    291
    -					rsyncDirs = append(rsyncDirs, parts[0])
    
    292
    -				}
    
    293
    -			}
    
    294
    -		}
    
    295
    -
    
    296 273
     		json.NewEncoder(w).Encode(map[string]interface{}{
    
    297
    -			"path":               "/",
    
    298
    -			"success":            true,
    
    299
    -			"message":            "Monitoring all discovered modules. See endpoints below.",
    
    300
    -			"monitored_modules":  endpoints,
    
    274
    +			"modules":            discoveredModules,
    
    301 275
     			"polling_interval_s": pollingInterval.Seconds(),
    
    302
    -			"rsync_directories":  rsyncDirs,
    
    303 276
     		})
    
    304 277
     	})
    
    305 278
     
    
    306
    -	// A single handler for all /status/ requests that validates input.
    
    307
    -	mux.HandleFunc("/status/", func(w http.ResponseWriter, r *http.Request) {
    
    308
    -		module := strings.TrimPrefix(r.URL.Path, "/status/")
    
    279
    +	// Router for module-specific endpoints: GET /modules/{module} and /modules/{module}/history
    
    280
    +	mux.HandleFunc("/modules/", func(w http.ResponseWriter, r *http.Request) {
    
    281
    +		path := strings.TrimPrefix(r.URL.Path, "/modules/")
    
    282
    +		parts := strings.Split(path, "/")
    
    283
    +		module := parts[0]
    
    309 284
     		if module == "" {
    
    310
    -			writeJSONError(w, http.StatusBadRequest, "Module name cannot be empty. Path should be /status/<module-name>.", r.URL.Path)
    
    285
    +			writeJSONError(w, http.StatusBadRequest, "Module name cannot be empty. Path should be /modules/{module}", r.URL.Path)
    
    311 286
     			return
    
    312 287
     		}
    
    313 288
     		if !isValidModulePath(module) {
    
    314
    -			writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Nome de módulo inválido: '%s'. Permitidos apenas letras, números, hífen, underline e ponto. Exemplo válido: debian-archive. Consulte a documentação.", module), r.URL.Path)
    
    289
    +			writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Invalid module name: '%s'", module), r.URL.Path)
    
    315 290
     			return
    
    316 291
     		}
    
    317
    -
    
    318 292
     		checker, found := checkers[module]
    
    319 293
     		if !found {
    
    320
    -			writeJSONError(w, http.StatusNotFound, fmt.Sprintf("Module '%s' is not monitored.", module), r.URL.Path)
    
    294
    +			writeJSONError(w, http.StatusNotFound, fmt.Sprintf("Module '%s' not found", module), r.URL.Path)
    
    295
    +			return
    
    296
    +		}
    
    297
    +		// GET /modules/{module} -> latest status
    
    298
    +		if len(parts) == 1 {
    
    299
    +			if r.Method != http.MethodGet {
    
    300
    +				writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed", r.URL.Path)
    
    301
    +				return
    
    302
    +			}
    
    303
    +			checker.mu.RLock()
    
    304
    +			defer checker.mu.RUnlock()
    
    305
    +			if len(checker.results) == 0 {
    
    306
    +				writeJSONError(w, http.StatusNoContent, "No status available", r.URL.Path)
    
    307
    +				return
    
    308
    +			}
    
    309
    +			latest := checker.results[len(checker.results)-1]
    
    310
    +			w.Header().Set("Content-Type", "application/json")
    
    311
    +			w.WriteHeader(latest.HTTPStatus)
    
    312
    +			json.NewEncoder(w).Encode(latest)
    
    313
    +			return
    
    314
    +		}
    
    315
    +		// GET /modules/{module}/history -> full history
    
    316
    +		if len(parts) == 2 && parts[1] == "history" {
    
    317
    +			if r.Method != http.MethodGet {
    
    318
    +				writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed", r.URL.Path)
    
    319
    +				return
    
    320
    +			}
    
    321
    +			checker.ServeHTTP(w, r)
    
    321 322
     			return
    
    322 323
     		}
    
    323
    -		checker.ServeHTTP(w, r)
    
    324
    +		writeJSONError(w, http.StatusNotFound, "Endpoint not found", r.URL.Path)
    
    325
    +	})
    
    326
    +
    
    327
    +	// A single handler for all /status/ requests that validates input.
    
    328
    +	// legacy /status/ redirect to new endpoints
    
    329
    +	mux.HandleFunc("/status/", func(w http.ResponseWriter, r *http.Request) {
    
    330
    +		http.Redirect(w, r, strings.Replace(r.URL.Path, "/status/", "/modules/", 1), http.StatusMovedPermanently)
    
    331
    +		return
    
    324 332
     	})
    
    325 333
     
    
    326 334
     	log.Printf("Starting monitoring server on :%s using rsync URL '%s'", serverPort, rsyncURL)
    

  • cmd/tui/main.go
    1 1
     package main
    
    2 2
     
    
    3 3
     import (
    
    4
    -	"encoding/json"
    
    5
    -	"fmt"
    
    6
    -	"io"
    
    7
    -	"log"
    
    8
    -	"net/http"
    
    9
    -	"os"
    
    10
    -	"sort"
    
    11
    -	"strings"
    
    12
    -	"sync"
    
    13
    -	"time"
    
    4
    +   "encoding/json"
    
    5
    +   "fmt"
    
    6
    +   "io"
    
    7
    +   "log"
    
    8
    +   "net/http"
    
    9
    +   "os"
    
    10
    +   "sort"
    
    11
    +   "strings"
    
    12
    +   "sync"
    
    13
    +   "time"
    
    14 14
     
    
    15 15
     	tea "github.com/charmbracelet/bubbletea"
    
    16 16
     	"github.com/charmbracelet/lipgloss"
    
    17 17
     )
    
    18 18
     
    
    19 19
     // Configuration
    
    20
    -const (
    
    21
    -	apiBaseURL      = "http://localhost:8080"
    
    22
    -	refreshInterval = 1 * time.Minute
    
    20
    +
    
    21
    +var (
    
    22
    +	apiBaseURL      = getAPIBaseURL()
    
    23
    +	refreshInterval = getRefreshInterval()
    
    23 24
     )
    
    24 25
     
    
    26
    +// getRefreshInterval returns the refresh interval from the environment or defaults to 1 minute.
    
    27
    +func getRefreshInterval() time.Duration {
    
    28
    +	if val := os.Getenv("REFRESH_INTERVAL_SECONDS"); val != "" {
    
    29
    +		if sec, err := time.ParseDuration(val + "s"); err == nil && sec > 0 {
    
    30
    +			return sec
    
    31
    +		}
    
    32
    +		log.Printf("WARN: Invalid REFRESH_INTERVAL_SECONDS value '%s'. Using default.", val)
    
    33
    +	}
    
    34
    +	return 1 * time.Minute
    
    35
    +}
    
    36
    +
    
    37
    +// getAPIBaseURL returns the API base URL from the environment or defaults to localhost.
    
    38
    +func getAPIBaseURL() string {
    
    39
    +	if url := os.Getenv("API_BASE_URL"); url != "" {
    
    40
    +		return url
    
    41
    +	}
    
    42
    +	return "http://localhost:8080"
    
    43
    +}
    
    44
    +
    
    25 45
     // historyBarWidth agora é dinâmico, depende do tamanho do terminal
    
    26 46
     
    
    27 47
     var (
    
    ... ... @@ -69,15 +89,15 @@ func initialModel() model {
    69 89
     // --- Bubble Tea Commands ---
    
    70 90
     func fetchStatuses() tea.Cmd {
    
    71 91
     	return func() tea.Msg {
    
    72
    -		resp, err := http.Get(apiBaseURL + "/")
    
    92
    +	   resp, err := http.Get(apiBaseURL + "/modules")
    
    73 93
     		if err != nil {
    
    74 94
     			return errMsg{err}
    
    75 95
     		}
    
    76 96
     		defer resp.Body.Close()
    
    77 97
     
    
    78
    -		var discoveryResponse struct {
    
    79
    -			Modules map[string]string `json:"monitored_modules"`
    
    80
    -		}
    
    98
    +	   var discoveryResponse struct {
    
    99
    +		   Modules []string `json:"modules"`
    
    100
    +	   }
    
    81 101
     		if err := json.NewDecoder(resp.Body).Decode(&discoveryResponse); err != nil {
    
    82 102
     			return errMsg{err}
    
    83 103
     		}
    
    ... ... @@ -86,7 +106,7 @@ func fetchStatuses() tea.Cmd {
    86 106
     		var mu sync.Mutex
    
    87 107
     		var wg sync.WaitGroup
    
    88 108
     
    
    89
    -		for name := range discoveryResponse.Modules {
    
    109
    +	   for _, name := range discoveryResponse.Modules {
    
    90 110
     			wg.Add(1)
    
    91 111
     			go func(moduleName string) {
    
    92 112
     				defer wg.Done()
    
    ... ... @@ -107,7 +127,8 @@ func fetchStatuses() tea.Cmd {
    107 127
     }
    
    108 128
     
    
    109 129
     func fetchModuleHistory(name string) ([]CheckResult, error) {
    
    110
    -	resp, err := http.Get(fmt.Sprintf("%s/status/%s", apiBaseURL, name))
    
    130
    +   // fetch full history for module via new RESTful endpoint
    
    131
    +   resp, err := http.Get(fmt.Sprintf("%s/modules/%s/history", apiBaseURL, name))
    
    111 132
     	if err != nil {
    
    112 133
     		return nil, err
    
    113 134
     	}
    
    ... ... @@ -299,9 +320,9 @@ func renderHistoryBar(history []CheckResult, width int) string {
    299 320
     	if totalChecks <= width {
    
    300 321
     		for _, check := range history {
    
    301 322
     			if check.IsUp {
    
    302
    -				b.WriteString(statusUpStyle.Render(""))
    
    323
    +				b.WriteString(statusUpStyle.Render(""))
    
    303 324
     			} else {
    
    304
    -				b.WriteString(statusDownStyle.Render(""))
    
    325
    +				b.WriteString(statusDownStyle.Render(""))
    
    305 326
     			}
    
    306 327
     		}
    
    307 328
     		b.WriteString(strings.Repeat(" ", width-totalChecks)) // Pad with space
    
    ... ... @@ -333,9 +354,9 @@ func renderHistoryBar(history []CheckResult, width int) string {
    333 354
     		}
    
    334 355
     
    
    335 356
     		if isUp {
    
    336
    -			b.WriteString(statusUpStyle.Render(""))
    
    357
    +			b.WriteString(statusUpStyle.Render(""))
    
    337 358
     		} else {
    
    338
    -			b.WriteString(statusDownStyle.Render(""))
    
    359
    +			b.WriteString(statusDownStyle.Render(""))
    
    339 360
     		}
    
    340 361
     	}
    
    341 362
     	return b.String()
    

  • go.mod
    ... ... @@ -13,6 +13,7 @@ require (
    13 13
     	github.com/charmbracelet/x/term v0.2.1 // indirect
    
    14 14
     	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
    
    15 15
     	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
    
    16
    +	github.com/magefile/mage v1.15.0 // indirect
    
    16 17
     	github.com/mattn/go-isatty v0.0.20 // indirect
    
    17 18
     	github.com/mattn/go-localereader v0.0.1 // indirect
    
    18 19
     	github.com/mattn/go-runewidth v0.0.16 // indirect
    

  • go.sum
    ... ... @@ -18,6 +18,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
    18 18
     github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
    
    19 19
     github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
    
    20 20
     github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
    
    21
    +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
    
    22
    +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
    
    21 23
     github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
    
    22 24
     github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
    
    23 25
     github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
    

  • mage.go
    1
    +
    
    2
    +package main
    
    3
    +
    
    4
    +import "github.com/magefile/mage/mage"
    
    5
    +
    
    6
    +func main() {
    
    7
    +	mage.Main()
    
    8
    +}

  • magefile.go
    1
    +//go:build mage
    
    2
    +// +build mage
    
    3
    +
    
    4
    +package main
    
    5
    +
    
    6
    +import (
    
    7
    +	"fmt"
    
    8
    +	"os"
    
    9
    +	"os/exec"
    
    10
    +)
    
    11
    +
    
    12
    +// Build builds both the API and TUI binaries.
    
    13
    +func Build() error {
    
    14
    +	fmt.Println("Building API...")
    
    15
    +	apiCmd := exec.Command("go", "build", "-o", "bin/api", "./cmd/api")
    
    16
    +	apiCmd.Stdout = os.Stdout
    
    17
    +	apiCmd.Stderr = os.Stderr
    
    18
    +	if err := apiCmd.Run(); err != nil {
    
    19
    +		return fmt.Errorf("failed to build API: %w", err)
    
    20
    +	}
    
    21
    +
    
    22
    +	fmt.Println("Building TUI...")
    
    23
    +	tuiCmd := exec.Command("go", "build", "-o", "bin/tui", "./cmd/tui")
    
    24
    +	tuiCmd.Stdout = os.Stdout
    
    25
    +	tuiCmd.Stderr = os.Stderr
    
    26
    +	if err := tuiCmd.Run(); err != nil {
    
    27
    +		return fmt.Errorf("failed to build TUI: %w", err)
    
    28
    +	}
    
    29
    +
    
    30
    +	fmt.Println("Build complete.")
    
    31
    +	return nil
    
    32
    +}

  • shell.nix
    ... ... @@ -7,6 +7,7 @@ pkgs.mkShell {
    7 7
         pkgs.go
    
    8 8
         pkgs.rsync
    
    9 9
         pkgs.jq
    
    10
    +    pkgs.mage
    
    10 11
       ];
    
    11 12
     
    
    12 13
       shellHook = ''