Heric Camargo pushed to branch main at Root / CLI / Mirror Monitor
Commits:
-
3797536c
by Heric Camargo at 2025-08-01T14:49:28-03:00
-
96b47d68
by Heric Camargo at 2025-08-01T14:50:12-03:00
8 changed files:
Changes:
... | ... | @@ -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 |
... | ... | @@ -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)
|
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()
|
... | ... | @@ -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
|
... | ... | @@ -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=
|
1 | + |
|
2 | +package main
|
|
3 | + |
|
4 | +import "github.com/magefile/mage/mage"
|
|
5 | + |
|
6 | +func main() {
|
|
7 | + mage.Main()
|
|
8 | +} |
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 | +} |
... | ... | @@ -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 = ''
|