├── deployment └── reverse-proxy-compose │ ├── caddy │ └── Caddyfile │ └── caddy.compose.yml ├── docs ├── assets │ └── systemd-edit-service-file.jpeg ├── systemd │ └── capture.service ├── RELEASE.md ├── TESTING.md └── README.md ├── schemathesis.toml ├── internal ├── server │ ├── handler │ │ ├── health.go │ │ ├── types.go │ │ └── metrics.go │ ├── net.go │ ├── middleware │ │ └── auth.go │ └── server.go ├── system │ ├── cpu_darwin.go │ ├── host_windows.go │ ├── host_linux.go │ ├── host_darwin.go │ ├── cpu_windows.go │ └── cpu_linux.go ├── config │ └── config.go └── metric │ ├── memory.go │ ├── net.go │ ├── host.go │ ├── metric_math.go │ ├── cpu.go │ ├── smart_metrics.go │ ├── metric.go │ ├── disk.go │ ├── docker.go │ └── docker_test.go ├── .air.toml ├── .golangci.yml ├── SECURITY.md ├── test ├── integration │ ├── host_test.go │ └── cpu_test.go ├── helpers.go ├── arch_test.go ├── benchmark │ └── disk_test.go └── time_test.go ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.md │ └── bug-report.md └── workflows │ ├── check.yml │ ├── contract.yml │ ├── release.yml │ ├── go.yml │ └── codeql.yml ├── .gitignore ├── Dockerfile ├── Justfile ├── .goreleaser.yml ├── cmd └── capture │ └── main.go ├── CONTRIBUTING.md ├── go.mod ├── README.md ├── CHANGELOG.md ├── openapi.yml ├── go.sum └── LICENSE /deployment/reverse-proxy-compose/caddy/Caddyfile: -------------------------------------------------------------------------------- 1 | replacewithyourdomain.com { 2 | reverse_proxy capture:59232 3 | } -------------------------------------------------------------------------------- /docs/assets/systemd-edit-service-file.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluewave-labs/capture/HEAD/docs/assets/systemd-edit-service-file.jpeg -------------------------------------------------------------------------------- /schemathesis.toml: -------------------------------------------------------------------------------- 1 | continue-on-failure = true 2 | checks.unsupported_method.enabled = false 3 | 4 | [headers] 5 | Authorization = "Bearer ${API_SECRET}" 6 | -------------------------------------------------------------------------------- /internal/server/handler/health.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | func Health(c *gin.Context) { 8 | c.JSON(200, "OK") 9 | } 10 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "tmp" 3 | 4 | [build] 5 | bin = "./tmp/capture" 6 | cmd = "go build -o ./tmp/capture ./cmd/capture/" 7 | include_dir = ["cmd", "internal", "pkg"] 8 | include_ext = ["go"] 9 | exclude_dir = ["docs", "vendor", "test"] 10 | stop_on_error = false 11 | rerun_delay = 0 12 | 13 | [misc] 14 | # Delete tmp directory on exit 15 | clean_on_exit = true 16 | -------------------------------------------------------------------------------- /internal/server/handler/types.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import "github.com/bluewave-labs/capture/internal/metric" 4 | 5 | type APIResponse struct { 6 | Data metric.Metric `json:"data"` 7 | Capture CaptureMeta `json:"capture"` 8 | Errors []metric.CustomErr `json:"errors"` 9 | } 10 | 11 | type CaptureMeta struct { 12 | Version string `json:"version"` 13 | Mode string `json:"mode"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/system/cpu_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package system 5 | 6 | import ( 7 | "errors" 8 | ) 9 | 10 | var ( 11 | ErrCPUDetailsNotImplemented = errors.New("CPU details not implemented on darwin") 12 | ) 13 | 14 | func CPUTemperature() ([]float32, error) { 15 | return nil, ErrCPUDetailsNotImplemented 16 | } 17 | func CPUCurrentFrequency() (int, error) { 18 | return 0, ErrCPUDetailsNotImplemented 19 | } 20 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 1m 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - asciicheck 8 | - bodyclose 9 | - dogsled 10 | - errcheck 11 | - errorlint 12 | - exhaustive 13 | - gocyclo 14 | - goimports 15 | - goprintffuncname 16 | - gosec 17 | - gosimple 18 | - govet 19 | - ineffassign 20 | - misspell 21 | - nakedret 22 | - prealloc 23 | - revive 24 | - staticcheck 25 | - stylecheck 26 | - unconvert 27 | - unused 28 | - whitespace 29 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To report a potential security vulnerability, please use the [GitHub private vulnerability reporting feature](https://github.com/bluewave-labs/capture/security/advisories/new). 6 | 7 | For us to respond to your report most effectively, please include: 8 | 9 | - Steps to reproduce or a proof-of-concept. 10 | - Relevant information, such as the versions of `capture` and any dependencies used. 11 | 12 | Thank you for helping us maintain a secure and reliable project! 13 | -------------------------------------------------------------------------------- /test/integration/host_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bluewave-labs/capture/internal/system" 7 | ) 8 | 9 | // TestPrettyName tests the functionality of retrieving the pretty name of the system. 10 | // It checks if the pretty name can be fetched without errors and logs the result. 11 | func TestPrettyName(t *testing.T) { 12 | prettyName, err := system.GetPrettyName() 13 | if err != nil { 14 | t.Fatalf("Failed to get pretty name: %v", err) 15 | } 16 | t.Logf("Pretty Name: %s", prettyName) 17 | } 18 | -------------------------------------------------------------------------------- /internal/system/host_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package system 5 | 6 | import ( 7 | "golang.org/x/sys/windows/registry" 8 | ) 9 | 10 | func GetPrettyName() (string, error) { 11 | key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) 12 | if err != nil { 13 | return "", err 14 | } 15 | defer key.Close() 16 | 17 | productName, _, err := key.GetStringValue("ProductName") 18 | if err != nil { 19 | return "", err 20 | } 21 | 22 | return productName, nil 23 | } 24 | -------------------------------------------------------------------------------- /test/helpers.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | // SkipIfCI skips the current test if running in a CI environment and the given condition is true. 9 | // It checks if the CI environment variable is set to "true" and if the provided condition pointer 10 | // is not nil and points to true. If both conditions are met, the test is skipped with the provided message. 11 | func SkipIfCI(t *testing.T, condition *bool, message string) { 12 | if os.Getenv("CI") == "true" && condition != nil && *condition { 13 | t.Skip(message) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/server/net.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "net" 4 | 5 | // GetLocalIP retrieves the local IP address of the machine. 6 | // It returns the first non-loopback IPv4 address found. 7 | // If no valid address is found, it returns "" as a placeholder. 8 | // This function is used to display the local IP address in the log message. 9 | func GetLocalIP() string { 10 | addrs, err := net.InterfaceAddrs() 11 | if err != nil { 12 | return "" 13 | } 14 | for _, addr := range addrs { 15 | if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 16 | if ipnet.IP.To4() != nil { 17 | return ipnet.IP.String() 18 | } 19 | } 20 | } 21 | return "" 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE]: ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I wish Capture could monitor [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context, specifications, or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - develop 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | persist-credentials: false 21 | - uses: actions/setup-go@v5 22 | with: 23 | go-version-file: 'go.mod' 24 | 25 | - name: Run linter 26 | run: | 27 | go vet ./... 28 | go run honnef.co/go/tools/cmd/staticcheck@latest -checks=all,-ST1000,-U1000 ./... 29 | go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run 30 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "log" 4 | 5 | type Config struct { 6 | Port string 7 | APISecret string 8 | } 9 | 10 | var defaultPort = "59232" 11 | 12 | func NewConfig(port string, apiSecret string) *Config { 13 | // Set default port if not provided 14 | if port == "" { 15 | port = defaultPort 16 | } 17 | 18 | // Print error message if API_SECRET is not provided 19 | if apiSecret == "" { 20 | log.Fatalln("API_SECRET environment variable is required for security purposes. Please set it before starting the server.") 21 | } 22 | 23 | return &Config{ 24 | Port: port, 25 | APISecret: apiSecret, 26 | } 27 | } 28 | 29 | func Default() *Config { 30 | return &Config{ 31 | Port: defaultPort, 32 | APISecret: "", 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/systemd/capture.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Capture Service 3 | Documentation=https://docs.checkmate.so/users-guide/server-monitoring-agent 4 | After=network.target 5 | 6 | [Service] 7 | ## TODO: Replace with your binary path 8 | ExecStart=YOUR_PATH/capture 9 | WorkingDirectory=YOUR_PATH 10 | Restart=always 11 | RestartSec=3 12 | Type=simple 13 | ProtectSystem=strict 14 | ProtectHome=true 15 | NoNewPrivileges=true 16 | RestrictNamespaces=yes 17 | ProtectKernelTunables=yes 18 | ProtectKernelLogs=yes 19 | ProtectControlGroups=yes 20 | 21 | ## TODO: REPLACE WITH YOUR SECRET 22 | Environment="API_SECRET=your_secret_here" 23 | Environment="GIN_MODE=release" 24 | 25 | ## TODO: Replace with your user and group names 26 | User=user_name 27 | Group=group_name 28 | 29 | [Install] 30 | WantedBy=multi-user.target 31 | -------------------------------------------------------------------------------- /deployment/reverse-proxy-compose/caddy.compose.yml: -------------------------------------------------------------------------------- 1 | networks: 2 | capture-network: 3 | driver: bridge 4 | 5 | services: 6 | caddy: 7 | image: caddy:2.10-alpine 8 | container_name: caddy 9 | restart: unless-stopped 10 | ports: 11 | - "80:80" 12 | - "443:443" 13 | volumes: 14 | - ./caddy/Caddyfile:/etc/caddy/Caddyfile 15 | - caddy_data:/data 16 | networks: 17 | - capture-network 18 | 19 | capture: 20 | image: ghcr.io/bluewave-labs/capture:latest 21 | restart: unless-stopped 22 | expose: 23 | - "59232" 24 | environment: 25 | - API_SECRET=REPLACE_WITH_YOUR_SECRET 26 | - GIN_MODE=release 27 | volumes: 28 | - /etc/os-release:/etc/os-release:ro 29 | networks: 30 | - capture-network 31 | 32 | volumes: 33 | caddy_data: 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | dist 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # env file 26 | .env 27 | 28 | # Editor directories 29 | .idea 30 | 31 | # air-verse/air tmp directory 32 | tmp 33 | 34 | # schemathesis directories, used for API Contract testing 35 | .hypothesis/ 36 | schemathesis-report/ 37 | .schemathesis-report/ 38 | -------------------------------------------------------------------------------- /internal/server/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func AuthRequired(secret string) gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | authHeader := c.GetHeader("Authorization") 12 | splittedHeader := strings.Split(authHeader, " ") 13 | 14 | if len(splittedHeader) != 2 || splittedHeader[0] != "Bearer" { 15 | c.JSON(401, gin.H{ 16 | "error": "Unable to parse 'Authorization' header", 17 | }) 18 | c.Abort() 19 | return 20 | } 21 | 22 | token := splittedHeader[1] 23 | if token == "" { 24 | c.JSON(401, gin.H{"error": "Authorization token required"}) 25 | c.Abort() 26 | return 27 | } else if token != secret { 28 | c.JSON(403, gin.H{"error": "Invalid token provided"}) 29 | c.Abort() 30 | return 31 | } 32 | 33 | c.Next() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/metric/memory.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "github.com/shirou/gopsutil/v4/mem" 5 | ) 6 | 7 | func CollectMemoryMetrics() (*MemoryData, []CustomErr) { 8 | var memErrors []CustomErr 9 | defaultMemoryData := &MemoryData{ 10 | TotalBytes: 0, 11 | AvailableBytes: 0, 12 | UsedBytes: 0, 13 | UsagePercent: RoundFloatPtr(0, 4), 14 | } 15 | vMem, vMemErr := mem.VirtualMemory() 16 | 17 | if vMemErr != nil { 18 | memErrors = append(memErrors, CustomErr{ 19 | Metric: []string{"memory.total_bytes", "memory.available_bytes", "memory.used_bytes", "memory.usage_percent"}, 20 | Error: vMemErr.Error(), 21 | }) 22 | return defaultMemoryData, memErrors 23 | } 24 | 25 | return &MemoryData{ 26 | TotalBytes: vMem.Total, 27 | AvailableBytes: vMem.Available, 28 | UsedBytes: vMem.Used, 29 | UsagePercent: RoundFloatPtr(vMem.UsedPercent/100, 4), 30 | }, memErrors 31 | } 32 | -------------------------------------------------------------------------------- /internal/system/host_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package system 5 | 6 | import ( 7 | "bufio" 8 | "errors" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | func GetPrettyName() (string, error) { 14 | const osReleasePath = "/etc/os-release" 15 | 16 | file, err := os.Open(osReleasePath) 17 | if err != nil { 18 | return "", errors.New("unable to open /etc/os-release: " + err.Error()) 19 | } 20 | defer file.Close() 21 | 22 | scanner := bufio.NewScanner(file) 23 | for scanner.Scan() { 24 | line := scanner.Text() 25 | if strings.HasPrefix(line, "PRETTY_NAME=") { 26 | pretty := strings.TrimPrefix(line, "PRETTY_NAME=") 27 | return strings.Trim(pretty, `"`), nil 28 | } 29 | } 30 | 31 | if err := scanner.Err(); err != nil { 32 | return "", errors.New("error reading /etc/os-release: " + err.Error()) 33 | } 34 | 35 | return "", errors.New("PRETTY_NAME field not found in /etc/os-release") 36 | } 37 | -------------------------------------------------------------------------------- /internal/metric/net.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "github.com/shirou/gopsutil/v4/net" 5 | ) 6 | 7 | func GetNetInformation() (MetricsSlice, []CustomErr) { 8 | var netData MetricsSlice 9 | var netErrors []CustomErr 10 | netIOCounters, err := net.IOCounters(true) 11 | if err != nil { 12 | netErrors = append(netErrors, CustomErr{ 13 | Metric: []string{"net"}, 14 | Error: err.Error(), 15 | }) 16 | return nil, netErrors 17 | } 18 | 19 | for _, v := range netIOCounters { 20 | netData = append(netData, NetData{ 21 | Name: v.Name, 22 | BytesSent: v.BytesSent, 23 | BytesRecv: v.BytesRecv, 24 | PacketsSent: v.PacketsSent, 25 | PacketsRecv: v.PacketsRecv, 26 | ErrIn: v.Errin, 27 | ErrOut: v.Errout, 28 | DropIn: v.Dropin, 29 | DropOut: v.Dropout, 30 | FIFOIn: v.Fifoin, 31 | FIFOOut: v.Fifoout, 32 | }) 33 | } 34 | 35 | return netData, nil 36 | } 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use golang:1.24.7-alpine as the base image. 2 | FROM golang:1.24.7-alpine AS builder 3 | COPY . /app 4 | # Change directory and build the binary. Build command is also used to download the dependencies. 5 | RUN cd /app && go build -o capture ./cmd/capture 6 | 7 | # Use chainguard/static:latest-glibc as the base image. 8 | # This image is a minimal static image, which is suitable for running Go applications. 9 | FROM chainguard/static:latest-glibc 10 | COPY --from=builder /app/capture /usr/bin/ 11 | 12 | # Set the default GIN_MODE to release, so that the application runs in production mode. However, this can be overridden by setting the GIN_MODE environment variable. 13 | # https://docs.docker.com/reference/dockerfile/#env 14 | ENV GIN_MODE=release 15 | 16 | # https://docs.docker.com/reference/dockerfile/#stopsignal 17 | STOPSIGNAL SIGINT 18 | 19 | # https://docs.docker.com/reference/dockerfile/#expose 20 | EXPOSE 59232 21 | CMD ["/usr/bin/capture"] 22 | -------------------------------------------------------------------------------- /internal/system/host_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package system 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | func GetPrettyName() (string, error) { 14 | cmd := exec.Command("sw_vers") 15 | var out bytes.Buffer 16 | cmd.Stdout = &out 17 | 18 | err := cmd.Run() 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | output := out.String() 24 | 25 | var productName, productVersion string 26 | 27 | lines := strings.Split(output, "\n") 28 | for _, line := range lines { 29 | if trimmed, found := strings.CutPrefix(line, "ProductName:"); found { 30 | productName = strings.TrimSpace(trimmed) 31 | } else if trimmed, found := strings.CutPrefix(line, "ProductVersion:"); found { 32 | productVersion = strings.TrimSpace(trimmed) 33 | } 34 | } 35 | 36 | if productName != "" && productVersion != "" { 37 | return fmt.Sprintf("%s %s", productName, productVersion), nil 38 | } 39 | 40 | return "", fmt.Errorf("could not determine macOS version") 41 | } 42 | -------------------------------------------------------------------------------- /test/arch_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mstrYoda/go-arctest/pkg/arctest" 7 | ) 8 | 9 | // TestArchitecture tests the Capture's architecture rules. 10 | // 11 | // Rules: 12 | // - cmd should not depend on internal/handler 13 | func TestArchitecture(t *testing.T) { 14 | arch, err := arctest.New("../") 15 | if err != nil { 16 | t.Fatalf("Failed to create architecture: %v", err) 17 | } 18 | 19 | err = arch.ParsePackages() 20 | if err != nil { 21 | t.Fatalf("Failed to parse packages: %v", err) 22 | } 23 | 24 | // Architecture Rule: cmd should not depend on internal/handler 25 | cmdDoesNotDependOnHandler, err := arch.DoesNotDependOn("cmd.*$", "internal/handler.*$") 26 | if err != nil { 27 | t.Fatalf("Failed to create dependency rule: %v", err) 28 | } 29 | 30 | // Validate dependencies 31 | valid, violations := arch.ValidateDependenciesWithRules([]*arctest.DependencyRule{ 32 | cmdDoesNotDependOnHandler, 33 | }) 34 | if !valid { 35 | for _, violation := range violations { 36 | t.Errorf("Dependency violation: %s", violation) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/metric/host.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "github.com/bluewave-labs/capture/internal/system" 5 | "github.com/shirou/gopsutil/v4/host" 6 | ) 7 | 8 | func GetHostInformation() (*HostData, []CustomErr) { 9 | var hostErrors []CustomErr 10 | defaultHostData := HostData{ 11 | Os: "unknown", 12 | Platform: "unknown", 13 | KernelVersion: "unknown", 14 | PrettyName: "unknown", 15 | } 16 | info, infoErr := host.Info() 17 | 18 | if infoErr != nil { 19 | hostErrors = append(hostErrors, CustomErr{ 20 | Metric: []string{"host.os", "host.platform", "host.kernel_version"}, 21 | Error: infoErr.Error(), 22 | }) 23 | return &defaultHostData, hostErrors 24 | } 25 | 26 | prettyName, prettyErr := system.GetPrettyName() 27 | if prettyErr != nil { 28 | hostErrors = append(hostErrors, CustomErr{ 29 | Metric: []string{"host.pretty_name"}, 30 | Error: prettyErr.Error(), 31 | }) 32 | prettyName = "unknown" 33 | } 34 | 35 | return &HostData{ 36 | Os: info.OS, 37 | Platform: info.Platform, 38 | KernelVersion: info.KernelVersion, 39 | PrettyName: prettyName, 40 | }, hostErrors 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/contract.yml: -------------------------------------------------------------------------------- 1 | name: Contract 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - develop 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | contract: 15 | runs-on: ubuntu-22.04 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | persist-credentials: false 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version-file: 'go.mod' 25 | 26 | - name: Build and Run Server 27 | env: 28 | API_SECRET: ${{ secrets.API_SECRET }} 29 | GIN_MODE: release 30 | run: | 31 | go build -o capture ./cmd/capture 32 | ./capture & 33 | sleep 5 34 | 35 | - name: Run Schemathesis Tests 36 | uses: schemathesis/action@1f15936316e0742005bf69657b5909ac68f04cb3 # v2.1.0 37 | with: 38 | schema: './openapi.yml' 39 | base-url: 'http://localhost:59232' 40 | version: 'latest' 41 | config-file: 'schemathesis.toml' 42 | env: 43 | API_SECRET: ${{ secrets.API_SECRET }} 44 | -------------------------------------------------------------------------------- /test/benchmark/disk_test.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "testing" 7 | ) 8 | 9 | // generateDevices simulates N unique device names. 10 | func generateDevices(n int) []string { 11 | devices := make([]string, n) 12 | for i := range n { 13 | devices[i] = fmt.Sprintf("/dev/sd%d", i) 14 | } 15 | return devices 16 | } 17 | 18 | // Benchmark for slices.Contains approach 19 | func BenchmarkCheckedDevicesWithSlices(b *testing.B) { 20 | b.ReportAllocs() 21 | 22 | devices := generateDevices(10000) // 10k devices 23 | var checked []string 24 | 25 | b.ResetTimer() 26 | for i := range b.N { 27 | target := devices[i%len(devices)] 28 | if !slices.Contains(checked, target) { 29 | checked = append(checked, target) 30 | } 31 | } 32 | } 33 | 34 | // Benchmark for map[string]struct{} approach 35 | func BenchmarkCheckedDevicesWithMap(b *testing.B) { 36 | b.ReportAllocs() 37 | 38 | devices := generateDevices(10000) 39 | checked := make(map[string]struct{}) 40 | 41 | b.ResetTimer() 42 | for i := range b.N { 43 | target := devices[i%len(devices)] 44 | if _, ok := checked[target]; !ok { 45 | checked[target] = struct{}{} 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | # It will be triggered when a new tag starting with 'v' is pushed 7 | # v1.0.0, v1.0.1, ... 8 | - 'v*' 9 | 10 | permissions: 11 | contents: write 12 | packages: write 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-22.04 17 | steps: 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - 24 | name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version-file: 'go.mod' 28 | - 29 | name: Install Syft 30 | run: | 31 | curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin 32 | - 33 | name: Run GoReleaser 34 | uses: goreleaser/goreleaser-action@v6 35 | with: 36 | distribution: goreleaser 37 | version: '~> v2' 38 | args: release --clean 39 | env: 40 | # Pass the GitHub token to GoReleaser 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | # Pass the registry and image name to GoReleaser 43 | KO_DOCKER_REPO: ghcr.io/bluewave-labs/capture 44 | -------------------------------------------------------------------------------- /test/time_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bluewave-labs/capture/internal/metric" 7 | ) 8 | 9 | // TestGetUnixTimestamp tests the metric.GetUnixTimestamp function with various timestamp formats. 10 | // It verifies that the function correctly converts RFC3339 timestamps to Unix timestamps 11 | // and handles edge cases like invalid input and zero timestamps. 12 | func TestGetUnixTimestamp(t *testing.T) { 13 | tests := []struct { 14 | input string 15 | expected int64 16 | }{ 17 | {"2023-01-01T00:00:00Z", 1672531200}, // Valid RFC3339 timestamp with seconds precision 18 | {"2025-06-13T20:00:49.097168933Z", 1749844849}, // Valid RFC3339 timestamp with nanosecond precision 19 | {"invalid-timestamp", 0}, // Invalid timestamp format should return 0 20 | {"0001-01-01T00:00:00Z", 0}, // Zero time value should return 0 21 | } 22 | 23 | // Run each test case as a subtest for better isolation and reporting 24 | for _, test := range tests { 25 | t.Run(test.input, func(t *testing.T) { 26 | result := metric.GetUnixTimestamp(test.input) 27 | if result != test.expected { 28 | t.Errorf("expected %d, got %d", test.expected, result) 29 | } 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG]: ' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Run the command `...` 16 | 2. Go to '...' 17 | 3. Click on '....' 18 | 4. See error 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **System information** 24 | Please provide the following information to help us debug the issue: 25 | 26 | 32 | 33 | - Operating System (with version): 34 | - CPU Architecture: 35 | - Capture Version: 36 | - Installation Method (binary, container, etc.): 37 | - Output of `uname -a`: 38 | 39 | ```shell 40 | 41 | ``` 42 | 43 | - Output of `lsblk`: 44 | 45 | ```shell 46 | 47 | ``` 48 | 49 | - Output of `df -Th`: 50 | 51 | ```shell 52 | 53 | ``` 54 | 55 | - Root Filesystem Type: 56 | - Virtualization (bare metal/VM/container): 57 | 58 | **Logs** 59 | Please provide: 60 | 61 | - Capture metrics responses (if applicable): 62 | 63 | ```shell 64 | 65 | ``` 66 | 67 | - Capture logs (if applicable): 68 | 69 | ```shell 70 | 71 | ``` 72 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: go 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - develop 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [ubuntu-latest, ubuntu-24.04-arm, macos-15, windows-2025] 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | persist-credentials: false 29 | - uses: actions/setup-go@v5 30 | with: 31 | go-version-file: 'go.mod' 32 | - name: Run all tests 33 | run: go test -v -timeout 30s ./test/... 34 | 35 | build: 36 | runs-on: ${{ matrix.os }} 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | os: [ubuntu-latest, ubuntu-24.04-arm, macos-15, windows-2025] 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | with: 45 | fetch-depth: 0 46 | persist-credentials: false 47 | - uses: actions/setup-go@v5 48 | with: 49 | go-version-file: 'go.mod' 50 | - name: Build capture binary 51 | run: go build -o dist/capture ./cmd/capture/ 52 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | APP_NAME := "capture" 2 | 3 | format: 4 | @gofmt -w ./ 5 | 6 | format-check: 7 | @gofmt -l ./ 8 | 9 | unit-test: 10 | @go test \ 11 | -v \ 12 | -timeout 30s \ 13 | ./test/ 14 | 15 | integration-test: 16 | @go test \ 17 | -v \ 18 | -timeout 30s \ 19 | ./test/integration/... 20 | 21 | test: 22 | @go test \ 23 | -v \ 24 | -timeout 30s \ 25 | ./test/... 26 | 27 | lint: 28 | go vet ./... 29 | go run honnef.co/go/tools/cmd/staticcheck@latest -checks=all,-ST1000,-U1000 ./... 30 | go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run 31 | 32 | build: 33 | @go build -o dist/capture ./cmd/capture/ 34 | 35 | build-all: build-linux build-macos build-windows 36 | 37 | build-linux: 38 | @GOOS=linux GOARCH=amd64 go build -o dist/linux/{{APP_NAME}}-linux-amd64 ./cmd/capture 39 | @GOOS=linux GOARCH=arm64 go build -o dist/linux/{{APP_NAME}}-linux-arm64 ./cmd/capture 40 | 41 | build-macos: 42 | @GOOS=darwin GOARCH=amd64 go build -o dist/darwin/{{APP_NAME}}-darwin-amd64 ./cmd/capture 43 | @GOOS=darwin GOARCH=arm64 go build -o dist/darwin/{{APP_NAME}}-darwin-arm64 ./cmd/capture 44 | 45 | build-windows: 46 | @GOOS=windows GOARCH=amd64 go build -o dist/windows/{{APP_NAME}}-windows-amd64.exe ./cmd/capture 47 | @GOOS=windows GOARCH=arm64 go build -o dist/windows/{{APP_NAME}}-windows-arm64.exe ./cmd/capture 48 | 49 | openapi-contract-test: 50 | @uvx schemathesis \ 51 | --config-file ./schemathesis.toml \ 52 | run ./openapi.yml \ 53 | --url http://127.0.0.1:59232 \ 54 | --report har \ 55 | --report-dir .schemathesis-report 56 | -------------------------------------------------------------------------------- /internal/metric/metric_math.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/binary" 6 | "math" 7 | "math/big" 8 | ) 9 | 10 | // RoundFloatPtr rounds a float to a given precision and returns a pointer to the result. 11 | func RoundFloatPtr(val float64, precision uint) *float64 { 12 | r := RoundFloat(val, precision) 13 | return &r 14 | } 15 | 16 | // RoundFloat rounds a float to a given precision and returns the result. 17 | func RoundFloat(val float64, precision uint) float64 { 18 | ratio := math.Pow(10, float64(precision)) 19 | prc := math.Round(val*ratio) / ratio 20 | return prc 21 | } 22 | 23 | // RandomIntPtr generates a random integer up to a maximum value and returns a pointer to it. 24 | func RandomIntPtr(maximum int64) *int { 25 | n, err := rand.Int(rand.Reader, big.NewInt(maximum)) 26 | if err != nil { 27 | panic(err) // handle error appropriately in production 28 | } 29 | result := int(n.Int64()) 30 | return &result 31 | } 32 | 33 | // RandomUInt64Ptr generates a random uint64 and returns a pointer to it. 34 | func RandomUInt64Ptr() *uint64 { 35 | var b [8]byte 36 | _, err := rand.Read(b[:]) 37 | if err != nil { 38 | panic(err) // handle error appropriately in production 39 | } 40 | result := binary.BigEndian.Uint64(b[:]) 41 | return &result 42 | } 43 | 44 | // RandomFloatPtr generates a random float64 and returns a pointer to it. 45 | func RandomFloatPtr() *float64 { 46 | var b [8]byte 47 | _, err := rand.Read(b[:]) 48 | if err != nil { 49 | panic(err) // handle error appropriately in production 50 | } 51 | randomUint64 := binary.BigEndian.Uint64(b[:]) 52 | result := float64(randomUint64) / float64(math.MaxUint64) 53 | return &result 54 | } 55 | -------------------------------------------------------------------------------- /internal/system/cpu_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package system 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | 10 | "github.com/yusufpapurcu/wmi" 11 | ) 12 | 13 | var ( 14 | ErrCPUDetailsNotImplemented = errors.New("CPU details not implemented on windows") 15 | ) 16 | 17 | // CPUTemperature returns CPU temperatures in Celsius for all thermal zones. 18 | // Note: May not work on all systems due to WMI access restrictions. 19 | func CPUTemperature() ([]float32, error) { 20 | var temps []struct { 21 | CurrentTemperature uint32 22 | } 23 | 24 | err := wmi.Query("SELECT CurrentTemperature FROM MSAcpi_ThermalZoneTemperature", &temps) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to query thermal zone temperature: %w", err) 27 | } 28 | 29 | if len(temps) == 0 { 30 | return nil, fmt.Errorf("no thermal zone data available") 31 | } 32 | 33 | temperatures := make([]float32, 0, len(temps)) 34 | for _, temp := range temps { 35 | // Convert from tenths of Kelvin to Celsius 36 | tempC := float32(temp.CurrentTemperature-2732) / 10.0 37 | temperatures = append(temperatures, tempC) 38 | } 39 | 40 | return temperatures, nil 41 | } 42 | 43 | // CPUCurrentFrequency returns the current CPU frequency in MHz. 44 | func CPUCurrentFrequency() (int, error) { 45 | var processors []struct { 46 | CurrentClockSpeed uint32 47 | } 48 | 49 | err := wmi.Query("SELECT CurrentClockSpeed FROM Win32_Processor", &processors) 50 | if err != nil { 51 | return 0, fmt.Errorf("failed to query processor frequency: %w", err) 52 | } 53 | 54 | if len(processors) == 0 { 55 | return 0, fmt.Errorf("no processor data available") 56 | } 57 | 58 | // Return the first processor's current clock speed in MHz 59 | return int(processors[0].CurrentClockSpeed), nil 60 | } 61 | -------------------------------------------------------------------------------- /test/integration/cpu_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "errors" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/bluewave-labs/capture/internal/system" 9 | "github.com/bluewave-labs/capture/test" 10 | ) 11 | 12 | // TestCPUTemperature tests the functionality of retrieving the CPU temperature. 13 | // It checks if the temperature can be fetched without errors and logs the result. 14 | func TestCPUTemperature(t *testing.T) { 15 | platformToSkipOnCI := runtime.GOOS == "linux" || runtime.GOOS == "windows" // GitHub ubuntu and windows runners may not have permission to access CPU frequency 16 | test.SkipIfCI(t, &platformToSkipOnCI, "Skipping CPU temperature test in CI environment due to potential permission and virtualization issues") 17 | 18 | temperature, err := system.CPUTemperature() 19 | if err != nil { 20 | if errors.Is(err, system.ErrCPUDetailsNotImplemented) { 21 | t.Skip("CPU temperature retrieval is not implemented on this platform") 22 | } 23 | 24 | t.Fatalf("Failed to get CPU temperature: %v", err) 25 | } 26 | t.Logf("CPU Temperature: %v", temperature) 27 | } 28 | 29 | // TestCPUCurrentFrequency tests the functionality of retrieving the CPU's current frequency. 30 | // It checks if the frequency can be fetched without errors and logs the result. 31 | func TestCPUCurrentFrequency(t *testing.T) { 32 | platformToSkipOnCI := runtime.GOOS == "linux" // GitHub ubuntu runners may not have permission to access CPU frequency 33 | test.SkipIfCI(t, &platformToSkipOnCI, "Skipping CPU current frequency test in CI environment due to potential permission and virtualization issues") 34 | 35 | frequency, err := system.CPUCurrentFrequency() 36 | if err != nil { 37 | if errors.Is(err, system.ErrCPUDetailsNotImplemented) { 38 | t.Skip("CPU current frequency retrieval is not implemented on this platform") 39 | } 40 | 41 | t.Fatalf("Failed to get CPU current frequency: %v", err) 42 | } 43 | t.Logf("CPU Current Frequency: %v MHz", frequency) 44 | } 45 | -------------------------------------------------------------------------------- /docs/RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process for Capture 2 | 3 | This document outlines the steps to create a new release for the Capture. 4 | 5 | You must follow these steps to ensure a smooth release process. 6 | 7 | - Tag Pattern: `v{0-9}.{0-9}.{0-9}` 8 | - Example: `v1.3.0` 9 | 10 | 1. Create a new tag for the release version. 11 | 12 | ```shell 13 | git tag 14 | ``` 15 | 16 | 2. Push the tag to the remote repository. 17 | 18 | ```shell 19 | git push origin 20 | ``` 21 | 22 | 3. [Create a PR](https://github.com/bluewave-labs/capture/compare/main...develop) for merging `develop` into `main`. Merge it with 'Merge Commit' option, do not squash or rebase. 23 | 24 | 4. Update the `CHANGELOG.md` file with the new version details. 25 | 26 | 5. Commit the changes to the `CHANGELOG.md` file. 27 | 28 | ```shell 29 | git switch -c docs/changelog- 30 | git add CHANGELOG.md 31 | git commit -m "docs(changelog): Update CHANGELOG for version " 32 | git push origin docs/changelog- 33 | ``` 34 | 35 | 6. Create a PR for the `docs/changelog-` branch to merge into `develop`. 36 | 37 | 7. Close the milestone for the current version. 38 | 39 | ## Troubleshooting 40 | 41 | If you encounter issues during the release process, consider following these steps: 42 | 43 | 1. Check the GitHub Actions logs for any errors or warnings. 44 | 2. Ensure that all required environment variables are set correctly. 45 | 3. Verify that the version tag follows the correct format (e.g., `v1.0.0`). 46 | 4. Try to re-run the release workflow if it fails due to transient issues. 47 | 5. If the release process fails, you can revert the changes by deleting the tag and branch created during the release. 48 | 49 | ```shell 50 | git tag -d 51 | git push --delete origin 52 | ``` 53 | 54 | ## Conclusion 55 | 56 | Following these steps will help you create a new release for Capture successfully. Ensure that you have the necessary permissions to push tags and branches to the repository. If you have any questions or need assistance, feel free to reach out to the maintainers. 57 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: capture 3 | 4 | builds: 5 | - id: capture 6 | main: ./cmd/capture 7 | ldflags: &common_ldflags 8 | - -s -w 9 | - -extldflags "-static" 10 | - -X "main.Version={{.Version}}" 11 | - -X "main.Commit={{.Commit}}" 12 | - -X "main.CommitDate={{.CommitDate}}" 13 | - -X "main.CompiledAt={{.Date}}" 14 | - -X "main.GitTag={{.Tag}}" 15 | env: 16 | - CGO_ENABLED=0 17 | goos: 18 | - linux 19 | - darwin 20 | - windows 21 | goarch: 22 | # Support multiple cpu architectures 23 | - amd64 24 | - arm64 25 | - arm 26 | goarm: 27 | - 6 28 | - 7 29 | ignore: 30 | # Ignore the arm(arm32) architecture Windows 31 | # Still supporting the arm64 architecture for Windows + Raspberry Pi combo and other development environments like arm64 laptops. 32 | - goos: windows 33 | goarch: arm 34 | 35 | kos: 36 | - id: capture 37 | platforms: 38 | - linux/amd64 39 | - linux/arm64 40 | tags: 41 | - latest 42 | - '{{.Tag}}' 43 | main: ./cmd/capture 44 | flags: 45 | - -trimpath 46 | ldflags: *common_ldflags 47 | creation_time: "{{.CommitTimestamp}}" 48 | env: 49 | - CGO_ENABLED=0 50 | bare: true 51 | 52 | archives: 53 | - formats: tar.gz 54 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 55 | format_overrides: 56 | - goos: windows 57 | formats: zip 58 | 59 | sboms: 60 | - id: capture 61 | cmd: syft 62 | args: ["scan", "$artifact", "--output", "spdx-json=$document"] 63 | artifacts: archive 64 | documents: 65 | - "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}.sbom.json" 66 | 67 | checksum: 68 | # Generate checksum files to confirm the integrity of the files. 69 | # `sha256sum ` 70 | name_template: 'checksums.txt' 71 | algorithm: sha256 72 | 73 | changelog: 74 | sort: asc 75 | filters: 76 | exclude: 77 | - '^docs:' 78 | - '^test:' 79 | 80 | release: 81 | draft: true 82 | -------------------------------------------------------------------------------- /cmd/capture/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/bluewave-labs/capture/internal/config" 11 | "github.com/bluewave-labs/capture/internal/server" 12 | "github.com/bluewave-labs/capture/internal/server/handler" 13 | ) 14 | 15 | // Application configuration variable that holds the settings. 16 | // 17 | // - Port: Server port, default is 59232 18 | // 19 | // - APISecret: Secret key for API access, default is a blank string. 20 | var appConfig *config.Config 21 | 22 | // Build information variables populated at build time. 23 | // These variables are typically set using ldflags during the build process 24 | // to provide runtime access to build metadata. 25 | var ( 26 | // Version represents the Capture version (default: "develop"). 27 | Version = "develop" 28 | // Commit contains the Git commit hash of the build. 29 | Commit = "unknown" 30 | // CommitDate holds the date of the commit. 31 | CommitDate = "unknown" 32 | // CompiledAt stores the build compilation date. 33 | CompiledAt = "unknown" 34 | // GitTag contains the Git tag associated with the build, if any. 35 | GitTag = "unknown" 36 | ) 37 | 38 | func main() { 39 | showVersion := flag.Bool("version", false, "Display the version of the capture") 40 | flag.Parse() 41 | 42 | // Check if the version flag is provided and show build information 43 | if *showVersion { 44 | fmt.Println("Capture Build Information") 45 | fmt.Println("-------------------------") 46 | fmt.Printf("Version : %s\n", Version) 47 | fmt.Printf("Commit Hash : %s\n", Commit) 48 | fmt.Printf("Commit Date : %s\n", CommitDate) 49 | fmt.Printf("Compiled At : %s\n", CompiledAt) 50 | fmt.Printf("Git Tag : %s\n", GitTag) 51 | os.Exit(0) 52 | } 53 | 54 | appConfig = config.NewConfig( 55 | os.Getenv("PORT"), 56 | os.Getenv("API_SECRET"), 57 | ) 58 | 59 | srv := server.NewServer(appConfig, nil, &handler.CaptureMeta{ 60 | Version: Version, 61 | }) 62 | log.Println("WARNING: Remember to add http://" + server.GetLocalIP() + ":" + appConfig.Port + "/api/v1/metrics to your Checkmate Infrastructure Dashboard. Without this endpoint, system metrics will not be displayed.") 63 | 64 | srv.Serve() 65 | 66 | srv.GracefulShutdown(5 * time.Second) 67 | } 68 | -------------------------------------------------------------------------------- /internal/server/handler/metrics.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/bluewave-labs/capture/internal/metric" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | type MetricsHandler struct { 9 | metadata *CaptureMeta 10 | } 11 | 12 | func NewMetricsHandler(metadata *CaptureMeta) *MetricsHandler { 13 | if metadata == nil { 14 | metadata = &CaptureMeta{Version: "unknown", Mode: "unknown"} 15 | } 16 | return &MetricsHandler{ 17 | metadata: metadata, 18 | } 19 | } 20 | 21 | func (h *MetricsHandler) handleResponse(c *gin.Context, metrics metric.Metric, errs []metric.CustomErr) { 22 | statusCode := 200 23 | if len(errs) > 0 { 24 | statusCode = 207 25 | } 26 | c.JSON(statusCode, APIResponse{ 27 | Data: metrics, 28 | Errors: errs, 29 | Capture: *h.metadata, 30 | }) 31 | } 32 | 33 | func (h *MetricsHandler) Metrics(c *gin.Context) { 34 | metrics, metricsErrs := metric.GetAllSystemMetrics() 35 | h.handleResponse(c, metrics, metricsErrs) 36 | } 37 | 38 | func (h *MetricsHandler) MetricsCPU(c *gin.Context) { 39 | cpuMetrics, metricsErrs := metric.CollectCPUMetrics() 40 | h.handleResponse(c, cpuMetrics, metricsErrs) 41 | } 42 | 43 | func (h *MetricsHandler) MetricsMemory(c *gin.Context) { 44 | memoryMetrics, metricsErrs := metric.CollectMemoryMetrics() 45 | h.handleResponse(c, memoryMetrics, metricsErrs) 46 | } 47 | 48 | func (h *MetricsHandler) MetricsDisk(c *gin.Context) { 49 | diskMetrics, metricsErrs := metric.CollectDiskMetrics() 50 | h.handleResponse(c, diskMetrics, metricsErrs) 51 | } 52 | 53 | func (h *MetricsHandler) MetricsHost(c *gin.Context) { 54 | hostMetrics, metricsErrs := metric.GetHostInformation() 55 | h.handleResponse(c, hostMetrics, metricsErrs) 56 | } 57 | 58 | func (h *MetricsHandler) SmartMetrics(c *gin.Context) { 59 | smartMetrics, smartErrs := metric.GetSmartMetrics() 60 | h.handleResponse(c, smartMetrics, smartErrs) 61 | } 62 | 63 | func (h *MetricsHandler) MetricsNet(c *gin.Context) { 64 | netMetrics, netErrs := metric.GetNetInformation() 65 | h.handleResponse(c, netMetrics, netErrs) 66 | } 67 | 68 | func (h *MetricsHandler) MetricsDocker(c *gin.Context) { 69 | all := c.Query("all") == "true" 70 | 71 | // Get Docker metrics, passing the "all" flag from the context 72 | // This will include all containers if "all" is true, otherwise only running containers 73 | dockerMetrics, dockerErrs := metric.GetDockerMetrics(all) 74 | h.handleResponse(c, dockerMetrics, dockerErrs) 75 | } 76 | -------------------------------------------------------------------------------- /internal/metric/cpu.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bluewave-labs/capture/internal/system" 7 | "github.com/shirou/gopsutil/v4/cpu" 8 | ) 9 | 10 | func CollectCPUMetrics() (*CPUData, []CustomErr) { 11 | // Collect CPU Core Counts 12 | cpuPhysicalCoreCount, cpuPhysicalErr := cpu.Counts(false) 13 | cpuLogicalCoreCount, cpuLogicalErr := cpu.Counts(true) 14 | 15 | var cpuErrors []CustomErr 16 | if cpuPhysicalErr != nil { 17 | cpuErrors = append(cpuErrors, CustomErr{ 18 | Metric: []string{"cpu.physical_core"}, 19 | Error: cpuPhysicalErr.Error(), 20 | }) 21 | cpuPhysicalCoreCount = 0 22 | } 23 | 24 | if cpuLogicalErr != nil { 25 | cpuErrors = append(cpuErrors, CustomErr{ 26 | Metric: []string{"cpu.logical_core"}, 27 | Error: cpuLogicalErr.Error(), 28 | }) 29 | cpuLogicalCoreCount = 0 30 | } 31 | 32 | // Collect CPU Information (Frequency, Model, etc) 33 | cpuInformation, cpuInfoErr := cpu.Info() 34 | var cpuFrequency float64 35 | if cpuInfoErr != nil { 36 | cpuErrors = append(cpuErrors, CustomErr{ 37 | Metric: []string{"cpu.frequency"}, 38 | Error: cpuInfoErr.Error(), 39 | }) 40 | cpuFrequency = 0 41 | } else { 42 | cpuFrequency = cpuInformation[0].Mhz 43 | } 44 | 45 | // Collect CPU Usage 46 | var cpuUsagePercent float64 47 | 48 | // Percent calculates the percentage of cpu used either per CPU or combined. 49 | // If an interval of 0 is given it will compare the current cpu times against the last call 50 | // Returns one value per cpu, or a single value if percpu is set to false. 51 | cpuPercents, cpuPercentsErr := cpu.Percent(time.Second, false) 52 | if cpuPercentsErr != nil { 53 | cpuErrors = append(cpuErrors, CustomErr{ 54 | Metric: []string{"cpu.usage_percent"}, 55 | Error: cpuPercentsErr.Error(), 56 | }) 57 | cpuUsagePercent = 0 58 | } else { 59 | cpuUsagePercent = cpuPercents[0] / 100.0 60 | } 61 | 62 | // Collect CPU Temperature from system-specific implementation 63 | cpuTemp, cpuTempErr := system.CPUTemperature() 64 | 65 | if cpuTempErr != nil { 66 | cpuErrors = append(cpuErrors, CustomErr{ 67 | Metric: []string{"cpu.temperature"}, 68 | Error: cpuTempErr.Error(), 69 | }) 70 | cpuTemp = nil 71 | } 72 | 73 | // Collect Current CPU Frequency from system-specific implementation 74 | cpuCurrentFrequency, cpuCurFreqErr := system.CPUCurrentFrequency() 75 | if cpuCurFreqErr != nil { 76 | cpuErrors = append(cpuErrors, CustomErr{ 77 | Metric: []string{"cpu.current_frequency"}, 78 | Error: cpuCurFreqErr.Error(), 79 | }) 80 | cpuCurrentFrequency = 0 81 | } 82 | 83 | return &CPUData{ 84 | PhysicalCore: cpuPhysicalCoreCount, 85 | LogicalCore: cpuLogicalCoreCount, 86 | Frequency: cpuFrequency, 87 | CurrentFrequency: cpuCurrentFrequency, 88 | Temperature: cpuTemp, 89 | FreePercent: *RoundFloatPtr(1-cpuUsagePercent, 4), 90 | UsagePercent: *RoundFloatPtr(cpuUsagePercent, 4), 91 | }, cpuErrors 92 | } 93 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Capture 2 | 3 | Thank you for your interest in contributing to Capture! Whether you're fixing bugs, adding features, or improving documentation, your efforts are appreciated. 4 | 5 | ## Getting Started 6 | 7 | - **Star the Repository**: If you like the project, give it a star to show your support. 8 | - **Join the Community**: Connect with us on [Discord](https://discord.com/invite/NAb6H3UTjK) for discussions and support. 9 | - **Explore Issues**: Check out [open issues](https://github.com/bluewave-labs/capture/issues) and look for those labeled `good first issue` if you're new. 10 | 11 | ## Reporting Bugs 12 | 13 | 1. **Search Existing Issues**: Before reporting, see if the issue already exists. 14 | 2. **Open a New Issue**: If not found, create a new issue with: 15 | - A clear title and description. 16 | - Steps to reproduce the problem. 17 | - Expected vs. actual behavior. 18 | - Relevant logs or screenshots. 19 | 20 | ## Suggesting Features 21 | 22 | 1. **Check for Similar Requests**: Look through existing issues to avoid duplicates. 23 | 2. **Create a Feature Request**: If unique, open a new issue detailing: 24 | - The problem you're addressing. 25 | - Proposed solution or feature. 26 | - Any alternatives considered. 27 | 28 | ## Architecture Overview 29 | 30 | Read a detailed structure of Capture if you would like to deep dive into the architecture. 31 | 32 | - [Architecture Overview](docs/README.md#high-level-overview) 33 | - [Capture DeepWiki Page](https://deepwiki.com/bluewave-labs/capture) 34 | 35 | ## Development Setup 36 | 37 | 1. **Clone the Repository**: 38 | 39 | ```bash 40 | git clone https://github.com/bluewave-labs/capture.git 41 | cd capture 42 | ``` 43 | 44 | 2. **Install Dependencies**: 45 | 46 | ```bash 47 | go mod tidy 48 | ``` 49 | 50 | 3. **Run the Application**: 51 | 52 | ```bash 53 | go run cmd/capture/main.go 54 | ``` 55 | 56 | ## Coding Guidelines 57 | 58 | - **Code Style**: Follow Go conventions and project-specific guidelines. 59 | - **Linting**: Run linters to catch issues early. 60 | - **Documentation**: Update or add documentation as needed. 61 | - **Tests**: Write tests for new features and bug fixes. 62 | 63 | ## Submitting Changes 64 | 65 | 1. **Create a Branch**: Use a descriptive name for your branch. 66 | 67 | ```bash 68 | git switch -c feature/your-feature-name 69 | ``` 70 | 71 | 2. **Make Your Changes**: Ensure code is clean and well-documented. 72 | 3. **Test Your Changes**: Verify that everything works as expected. 73 | 4. **Commit and Push**: 74 | 75 | Please follow [conventional commit messages](https://www.conventionalcommits.org/en/v1.0.0/). 76 | 77 | ```bash 78 | git add . 79 | git commit -m "feat: add new feature" 80 | git push origin feature/your-feature-name 81 | ``` 82 | 83 | 5. **Open a Pull Request**: Go to the repository and open a PR against the develop branch. 84 | 85 | Your contributions make Capture better for everyone. We appreciate your time and effort! 86 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/bluewave-labs/capture/internal/config" 13 | "github.com/bluewave-labs/capture/internal/server/handler" 14 | "github.com/bluewave-labs/capture/internal/server/middleware" 15 | "github.com/gin-gonic/gin" 16 | ) 17 | 18 | type Server struct { 19 | *http.Server 20 | MetaData *handler.CaptureMeta // Metadata can be used to store additional information about the server 21 | } 22 | 23 | // Serve function starts the HTTP server and listens for incoming requests concurrently. 24 | // It uses a goroutine to handle the server's ListenAndServe method, allowing the main thread to continue executing. 25 | func (s *Server) Serve() { 26 | go func() { 27 | if err := s.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 28 | log.Fatalf("server error: %v", err) 29 | } 30 | }() 31 | log.Printf("server started on %s", s.Server.Addr) 32 | } 33 | 34 | // GracefulShutdown gracefully shuts down the server with a timeout. 35 | func (s *Server) GracefulShutdown(timeout time.Duration) { 36 | quit := make(chan os.Signal, 1) 37 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 38 | 39 | sig := <-quit 40 | log.Printf("signal received: %v", sig) 41 | 42 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 43 | defer cancel() 44 | 45 | log.Println("shutting down server...") 46 | if err := s.Server.Shutdown(ctx); err != nil { 47 | log.Printf("server shutdown error: %v", err) 48 | } else { 49 | log.Println("server shutdown gracefully") 50 | } 51 | } 52 | 53 | func InitializeHandler(config *config.Config, metadata *handler.CaptureMeta) http.Handler { 54 | // Initialize the Gin with default middlewares 55 | r := gin.Default() 56 | metadata.Mode = gin.Mode() 57 | if gin.Mode() == gin.ReleaseMode { 58 | println("running in Release Mode") 59 | } else { 60 | println("running in Debug Mode") 61 | } 62 | // Health Check 63 | r.GET("/health", handler.Health) 64 | 65 | apiV1 := r.Group("/api/v1") 66 | apiV1.Use(middleware.AuthRequired(config.APISecret)) 67 | 68 | // Create metrics handler 69 | metricsHandler := handler.NewMetricsHandler(metadata) 70 | 71 | // Metrics 72 | apiV1.GET("/metrics", metricsHandler.Metrics) 73 | apiV1.GET("/metrics/cpu", metricsHandler.MetricsCPU) 74 | apiV1.GET("/metrics/memory", metricsHandler.MetricsMemory) 75 | apiV1.GET("/metrics/disk", metricsHandler.MetricsDisk) 76 | apiV1.GET("/metrics/host", metricsHandler.MetricsHost) 77 | apiV1.GET("/metrics/smart", metricsHandler.SmartMetrics) 78 | apiV1.GET("/metrics/net", metricsHandler.MetricsNet) 79 | apiV1.GET("/metrics/docker", metricsHandler.MetricsDocker) 80 | 81 | return r.Handler() 82 | } 83 | 84 | func NewServer(config *config.Config, handler http.Handler, metadata *handler.CaptureMeta) *Server { 85 | if handler == nil { 86 | handler = InitializeHandler(config, metadata) 87 | } 88 | return &Server{ 89 | Server: &http.Server{ 90 | Addr: "0.0.0.0:" + config.Port, 91 | Handler: handler, 92 | ReadHeaderTimeout: 5 * time.Second, 93 | }, 94 | MetaData: metadata, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /docs/TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing Process for Capture 2 | 3 | This document provides an overview of testing in Capture, covering architecture, unit, integration, and OpenAPI contract testing. 4 | 5 | ## Architecture Testing 6 | 7 | Architecture testing ensures that the system's architecture meets the specified requirements and is robust against potential issues. This includes verifying the design patterns, module interactions, and overall system structure. 8 | 9 | You can see the `test/arch_test.go` file for the architecture tests. It's powered by the [go-arctest](https://github.com/mstrYoda/go-arctest) package, which provides tools for testing the architecture of Go applications. 10 | 11 | We don't have a dedicated command for architecture tests; `just unit-test` runs all unit tests in the codebase, including the architecture tests. 12 | 13 | ### Rules 14 | 15 | 1. `cmd` must not depend on `handlers`. 16 | 17 | ## Unit Testing 18 | 19 | Unit tests are not os/arch specific, they can be run on any platform where the codebase is runnable. 20 | 21 | You can search for `test/*_test.go` files in the `test` directory to find unit tests. 22 | 23 | `just unit-test` command runs all unit tests in the codebase. 24 | 25 | ## Integration Testing 26 | 27 | Integration tests are designed to test the interactions between different components of the system. They ensure that the components work together as expected and can handle real-world scenarios. 28 | 29 | You can see the `test/integration/*_test.go` files for integration tests. 30 | 31 | `just integration-test` command runs all integration tests in the codebase. 32 | 33 | ## OpenAPI Contract Testing 34 | 35 | OpenAPI contract testing ensures that the API adheres to the defined OpenAPI specifications. This is crucial for maintaining consistency and reliability in API interactions. 36 | 37 | You can see the `schemathesis.toml` file for OpenAPI contract tests. 38 | 39 | `just openapi-contract-test` command runs OpenAPI contract tests using [Schemathesis](https://schemathesis.readthedocs.io/). 40 | 41 | Prerequisites: 42 | 43 | - Ensure the API server is running and reachable. 44 | - Export API_SECRET environment variable with the API secret key before running the tests. 45 | 46 | ```bash 47 | export API_SECRET=your_api_secret_key 48 | just openapi-contract-test 49 | ``` 50 | 51 | or 52 | 53 | ```bash 54 | API_SECRET=your_api_secret_key just openapi-contract-test 55 | ``` 56 | 57 | ## Benchmarking 58 | 59 | Benchmarking is an essential part of testing to ensure that the system performs well under various conditions. It helps identify performance bottlenecks and areas for optimization. 60 | 61 | You can see the `test/benchmark/*_test.go` files for benchmarking tests. 62 | 63 | We don't have a dedicated command for benchmarking tests; you can run them manually using the `go test` command: 64 | 65 | ```bash 66 | go test -benchmem -run='^$' \ 67 | -bench . \ 68 | -count 10 \ 69 | ./test/benchmark | tee my_benchmark_result.txt 70 | ``` 71 | 72 | You can also profile the tests using the `-cpuprofile` and `-memprofile` flags: 73 | 74 | ```bash 75 | go test -benchmem -run='^$' \ 76 | -bench . \ 77 | -cpuprofile=cpu.prof \ 78 | -memprofile=mem.prof \ 79 | ./test/benchmark 80 | ``` 81 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bluewave-labs/capture 2 | 3 | go 1.24.7 4 | 5 | require ( 6 | github.com/docker/docker v28.1.1+incompatible 7 | github.com/gin-gonic/gin v1.10.0 8 | github.com/mstrYoda/go-arctest v0.0.0-20250422073853-ff9fe79f31d7 9 | github.com/shirou/gopsutil/v4 v4.24.9 10 | github.com/yusufpapurcu/wmi v1.2.4 11 | golang.org/x/sys v0.33.0 12 | ) 13 | 14 | require ( 15 | github.com/Microsoft/go-winio v0.4.14 // indirect 16 | github.com/bytedance/sonic v1.12.3 // indirect 17 | github.com/bytedance/sonic/loader v0.2.0 // indirect 18 | github.com/cloudwego/base64x v0.1.4 // indirect 19 | github.com/cloudwego/iasm v0.2.0 // indirect 20 | github.com/containerd/log v0.1.0 // indirect 21 | github.com/distribution/reference v0.6.0 // indirect 22 | github.com/docker/go-connections v0.5.0 // indirect 23 | github.com/docker/go-units v0.5.0 // indirect 24 | github.com/ebitengine/purego v0.8.0 // indirect 25 | github.com/felixge/httpsnoop v1.0.4 // indirect 26 | github.com/gabriel-vasile/mimetype v1.4.6 // indirect 27 | github.com/gin-contrib/sse v0.1.0 // indirect 28 | github.com/go-logr/logr v1.4.2 // indirect 29 | github.com/go-logr/stdr v1.2.2 // indirect 30 | github.com/go-ole/go-ole v1.2.6 // indirect 31 | github.com/go-playground/locales v0.14.1 // indirect 32 | github.com/go-playground/universal-translator v0.18.1 // indirect 33 | github.com/go-playground/validator/v10 v10.22.1 // indirect 34 | github.com/goccy/go-json v0.10.3 // indirect 35 | github.com/gogo/protobuf v1.3.2 // indirect 36 | github.com/json-iterator/go v1.1.12 // indirect 37 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 38 | github.com/leodido/go-urn v1.4.0 // indirect 39 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 40 | github.com/mattn/go-isatty v0.0.20 // indirect 41 | github.com/moby/docker-image-spec v1.3.1 // indirect 42 | github.com/moby/sys/atomicwriter v0.1.0 // indirect 43 | github.com/moby/term v0.5.2 // indirect 44 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 45 | github.com/modern-go/reflect2 v1.0.2 // indirect 46 | github.com/morikuni/aec v1.0.0 // indirect 47 | github.com/opencontainers/go-digest v1.0.0 // indirect 48 | github.com/opencontainers/image-spec v1.1.1 // indirect 49 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 50 | github.com/pkg/errors v0.9.1 // indirect 51 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 52 | github.com/tklauser/go-sysconf v0.3.12 // indirect 53 | github.com/tklauser/numcpus v0.6.1 // indirect 54 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 55 | github.com/ugorji/go/codec v1.2.12 // indirect 56 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 57 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 58 | go.opentelemetry.io/otel v1.36.0 // indirect 59 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect 60 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 61 | go.opentelemetry.io/otel/trace v1.36.0 // indirect 62 | golang.org/x/arch v0.11.0 // indirect 63 | golang.org/x/crypto v0.38.0 // indirect 64 | golang.org/x/net v0.40.0 // indirect 65 | golang.org/x/text v0.25.0 // indirect 66 | golang.org/x/time v0.11.0 // indirect 67 | google.golang.org/protobuf v1.36.6 // indirect 68 | gopkg.in/yaml.v3 v3.0.1 // indirect 69 | gotest.tools/v3 v3.5.2 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /internal/system/cpu_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package system 5 | 6 | import ( 7 | "errors" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | ErrCPUDetailsNotImplemented = errors.New("CPU details not implemented on linux") 16 | ) 17 | 18 | // readTempFile reads a temperature file and returns the temperature in Celsius. 19 | func readTempFile(path string) (float32, error) { 20 | data, err := os.ReadFile(path) 21 | if err != nil { 22 | return 0, err 23 | } 24 | 25 | temp, err := strconv.Atoi(strings.TrimSpace(string(data))) 26 | if err != nil { 27 | return 0, err 28 | } 29 | 30 | return float32(temp) / 1000, nil 31 | } 32 | 33 | // readCPUFreqFile reads a CPU frequency file and returns the frequency in kHz. 34 | func readCPUFreqFile(path string) (int, error) { 35 | data, err := os.ReadFile(path) 36 | if err != nil { 37 | return 0, err 38 | } 39 | 40 | freq, err := strconv.Atoi(strings.TrimSpace(string(data))) 41 | if err != nil { 42 | return 0, err 43 | } 44 | 45 | return freq, nil 46 | } 47 | 48 | // isValidCPUTempSensor determines if a temperature sensor should be considered for CPU temperature reading 49 | func isValidCPUTempSensor(path string) bool { 50 | if !strings.Contains(path, "hwmon") { 51 | // For non-hwmon paths (like thermal_zone), assume valid 52 | return true 53 | } 54 | 55 | labelPath := strings.Replace(path, "_input", "_label", 1) 56 | label, err := os.ReadFile(labelPath) 57 | if err != nil { 58 | // No label file exists, assume it could be a CPU temperature sensor 59 | return true 60 | } 61 | 62 | labelStr := strings.ToLower(strings.TrimSpace(string(label))) 63 | return strings.Contains(labelStr, "core") || strings.Contains(labelStr, "tctl") 64 | } 65 | 66 | // addTemperatureIfValid reads temperature from path and adds it to temps slice if successful 67 | func addTemperatureIfValid(path string, temps *[]float32) { 68 | if temp, err := readTempFile(path); err == nil { 69 | *temps = append(*temps, temp) 70 | } 71 | } 72 | 73 | // CPUTemperature returns the temperature of CPU cores in Celsius. 74 | func CPUTemperature() ([]float32, error) { 75 | // Look in all these folders for core temp 76 | corePaths := []string{ 77 | "/sys/devices/platform/coretemp.0/hwmon/hwmon*/temp*_input", // hwmon 78 | "/sys/class/hwmon/hwmon*/temp*_input", // hwmon 79 | // "/sys/class/thermal/thermal_zone0/temp", // thermal_zone. it's the same as /sys/class/hwmon/hwmon0/temp1_input 80 | } 81 | 82 | var temps []float32 83 | 84 | for _, pathPattern := range corePaths { 85 | // Find paths for inputs that may contain core temp 86 | matches, err := filepath.Glob(pathPattern) 87 | if err != nil { // Keep looking for matches if we get an error 88 | continue 89 | } 90 | 91 | // Loop over temp_input paths 92 | for _, path := range matches { 93 | if isValidCPUTempSensor(path) { 94 | addTemperatureIfValid(path, &temps) 95 | } 96 | } 97 | } 98 | 99 | if len(temps) == 0 { 100 | return nil, errors.New("unable to read CPU temperature") 101 | } 102 | return temps, nil 103 | } 104 | 105 | // CPUCurrentFrequency returns the current CPU frequency in MHz. 106 | func CPUCurrentFrequency() (int, error) { 107 | frequency, cpuFrequencyError := readCPUFreqFile("/sys/devices/system/cpu/cpufreq/policy0/scaling_cur_freq") 108 | 109 | if cpuFrequencyError != nil { 110 | return 0, cpuFrequencyError 111 | } 112 | 113 | // Convert kHz to MHz 114 | return frequency / 1000, nil 115 | } 116 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Capture Documentation 2 | 3 | ## Table of Contents 4 | 5 | - [Capture Documentation](#capture-documentation) 6 | - [Table of Contents](#table-of-contents) 7 | - [High Level Overview](#high-level-overview) 8 | - [Systemd service](#systemd-service) 9 | - [How to edit?](#how-to-edit) 10 | - [Setup](#setup) 11 | 12 | ## High Level Overview 13 | 14 | ```mermaid 15 | sequenceDiagram 16 | participant CHK as Checkmate Backend 17 | participant CAP as Capture API Server 18 | participant CAP_METRICS as Capture API Metric Handler 19 | 20 | participant HOST as Host Machine 21 | loop Every N seconds 22 | CHK->>CAP: GET Metrics 23 | CAP->>CAP_METRICS: Capture Metrics from Host 24 | # CPU 25 | CAP_METRICS->>HOST: CPU 26 | HOST-->>CAP_METRICS: CPU Metrics 27 | 28 | # Memory 29 | CAP_METRICS->>HOST: Memory 30 | HOST-->>CAP_METRICS: Memory Metrics 31 | 32 | # Disk 33 | CAP_METRICS->>HOST: Disk 34 | HOST-->>CAP_METRICS: Disk Metrics 35 | 36 | # Host Info 37 | CAP_METRICS->>HOST: Host Info 38 | HOST-->>CAP_METRICS: Host Information 39 | 40 | CAP_METRICS->>CAP: Captured Metrics 41 | alt Success(HTTP 200) 42 | CAP-->>CHK: Metrics Response 43 | else Partial Success(HTTP 207) 44 | CAP-->>CHK: Metrics Response with Errors 45 | else System Error(Unexpected) 46 | CAP-->>CHK: Error Response 47 | end 48 | end 49 | ``` 50 | 51 | ## Systemd service 52 | 53 | How can I run the Capture with systemd? 54 | 55 | We provide you an example service file for systemd. You can find it in the `/docs/systemd/capture.service` file. Please don't forget to change the path, user, group and secret key in the service file. 56 | 57 | ### How to edit? 58 | 59 | 1. Open the `capture.service` file with your favorite text editor. 60 | 61 | ```shell 62 | nano systemd/capture.service 63 | ``` 64 | 65 | or 66 | 67 | ```shell 68 | vim systemd/capture.service 69 | ``` 70 | 71 | 2. Change the path, user, group and secret key in the service file. 72 | 73 | ![capture.service](./assets/systemd-edit-service-file.jpeg) 74 | 75 |

76 | Editing the systemd capture.service file 77 |

78 | 79 | - **ExecStart**: The path to the Capture executable file. 80 | - **Working Directory**: The path to the directory where the Capture is installed. 81 | - **User**: The user who will run the Capture. 82 | - **Group**: The group of the user who will run the Capture. 83 | 84 | ### Setup 85 | 86 | 1. Copy the `capture.service` file to `/etc/systemd/system/` directory. 87 | 88 | ```shell 89 | cp systemd/capture.service /etc/systemd/system/ 90 | ``` 91 | 92 | 2. Reload systemd with `systemctl daemon-reload`. 93 | 94 | ```shell 95 | systemctl daemon-reload 96 | ``` 97 | 98 | 3. Start the service with `systemctl start capture`. 99 | 100 | ```shell 101 | systemctl start capture 102 | ``` 103 | 104 | 4. Enable the service to start on boot with `systemctl enable capture`. 105 | 106 | ```shell 107 | systemctl enable capture 108 | ``` 109 | 110 | 5. Check the status of the service with `systemctl status capture`. 111 | 112 | ```shell 113 | systemctl status capture 114 | ``` 115 | 116 | That's it! You have successfully set up the Capture with systemd. You can now start, stop, restart, enable, disable and check the status of the service with the `systemctl` command. 117 | 118 | If you find any issues, please let us know by creating an issue on our [GitHub repository](https://github.com/bluewave-labs/capture/issues). 119 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | concurrency: 14 | group: codeql-${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | on: 18 | push: 19 | branches: [ "develop" ] 20 | pull_request: 21 | branches: [ "develop" ] 22 | schedule: 23 | - cron: '39 5 * * 6' 24 | 25 | jobs: 26 | analyze: 27 | name: Analyze (${{ matrix.language }}) 28 | # Runner size impacts CodeQL analysis time. To learn more, please see: 29 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 30 | # - https://gh.io/supported-runners-and-hardware-resources 31 | # - https://gh.io/using-larger-runners (GitHub.com only) 32 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 33 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 34 | permissions: 35 | # required for all workflows 36 | security-events: write 37 | 38 | # required to fetch internal or private CodeQL packs 39 | packages: read 40 | 41 | # only required for workflows in private repositories 42 | actions: read 43 | contents: read 44 | 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | include: 49 | - language: actions 50 | build-mode: none 51 | - language: go 52 | build-mode: autobuild 53 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 54 | # Use `c-cpp` to analyze code written in C, C++ or both 55 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 56 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 57 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 58 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 59 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 60 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 61 | steps: 62 | - name: Checkout repository 63 | uses: actions/checkout@v4 64 | with: 65 | fetch-depth: 0 66 | persist-credentials: false 67 | 68 | # Add any setup steps before running the `github/codeql-action/init` action. 69 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 70 | # or others). This is typically only required for manual builds. 71 | # - name: Setup runtime (example) 72 | # uses: actions/setup-example@v1 73 | 74 | # Initializes the CodeQL tools for scanning. 75 | - name: Initialize CodeQL 76 | uses: github/codeql-action/init@v3 77 | with: 78 | languages: ${{ matrix.language }} 79 | build-mode: ${{ matrix.build-mode }} 80 | # If you wish to specify custom queries, you can do so here or in a config file. 81 | # By default, queries listed here will override any specified in a config file. 82 | # Prefix the list here with "+" to use these queries and those in the config file. 83 | 84 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 85 | # queries: security-extended,security-and-quality 86 | 87 | # If the analyze step fails for one of the languages you are analyzing with 88 | # "We were unable to automatically build your code", modify the matrix above 89 | # to set the build mode to "manual" for that language. Then modify this step 90 | # to build your code. 91 | # ℹ️ Command-line programs to run using the OS shell. 92 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 93 | - if: matrix.build-mode == 'manual' 94 | shell: bash 95 | run: | 96 | echo 'If you are using a "manual" build mode for one or more of the' \ 97 | 'languages you are analyzing, replace this with the commands to build' \ 98 | 'your code, for example:' 99 | echo ' make bootstrap' 100 | echo ' make release' 101 | exit 1 102 | 103 | - name: Perform CodeQL Analysis 104 | uses: github/codeql-action/analyze@v3 105 | with: 106 | category: "/language:${{matrix.language}}" 107 | -------------------------------------------------------------------------------- /internal/metric/smart_metrics.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | // Check if smartctl is installed 12 | func checkSmartctlInstalled() error { 13 | _, err := exec.LookPath("smartctl") 14 | if err != nil { 15 | return fmt.Errorf("smartctl is not installed") 16 | } 17 | return nil 18 | } 19 | 20 | // scanDevices returns a list of disks using `smartctl --scan` 21 | func scanDevices() ([]string, error) { 22 | out, err := exec.Command("smartctl", "--scan").Output() 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to scan devices: %w", err) 25 | } 26 | 27 | var devices []string 28 | lines := strings.Split(string(out), "\n") 29 | for _, line := range lines { 30 | fields := strings.Fields(line) 31 | if len(fields) > 0 { 32 | devices = append(devices, fields[0]) 33 | } 34 | } 35 | return devices, nil 36 | } 37 | 38 | func isErrorKey(key string) bool { 39 | loweredKey := strings.ToLower(key) 40 | return strings.Contains(loweredKey, "failed") || strings.Contains(loweredKey, "error") 41 | } 42 | 43 | func setField(key, value string, data *SmartData) { 44 | switch strings.ToLower(key) { 45 | case "available spare": 46 | data.AvailableSpare = value 47 | case "available spare threshold": 48 | data.AvailableSpareThreshold = value 49 | case "controller busy time": 50 | data.ControllerBusyTime = value 51 | case "critical warning": 52 | data.CriticalWarning = value 53 | case "data units read": 54 | data.DataUnitsRead = value 55 | case "data units written": 56 | data.DataUnitsWritten = value 57 | case "host read commands": 58 | data.HostReadCommands = value 59 | case "host write commands": 60 | data.HostWriteCommands = value 61 | case "percentage used": 62 | data.PercentageUsed = value 63 | case "power cycles": 64 | data.PowerCycles = value 65 | case "power on hours": 66 | data.PowerOnHours = value 67 | case "smart overall-health self-assessment test result": 68 | data.SmartOverallHealthResult = value 69 | case "temperature": 70 | data.Temperature = value 71 | case "unsafe shutdowns": 72 | data.UnsafeShutdowns = value 73 | } 74 | } 75 | 76 | // parseSmartctlOutput parses the output from smartctl command 77 | func parseSmartctlOutput(output string) (*SmartData, []CustomErr) { 78 | // Define the start marker to locate the section of interest 79 | startMarker := "=== START OF SMART DATA SECTION ===" 80 | startIdx := strings.Index(output, startMarker) 81 | if startIdx == -1 { 82 | return &SmartData{}, nil 83 | } 84 | 85 | // Extract the section starting from the marker 86 | section := output[startIdx:] 87 | endIdx := strings.Index(section, "===") 88 | if endIdx > len(startMarker) { 89 | section = section[:endIdx] 90 | } 91 | 92 | data := SmartData{} 93 | var errors []CustomErr 94 | 95 | // Split the section into lines 96 | lines := strings.Split(section, "\n") 97 | 98 | for _, line := range lines { 99 | // Skip empty lines or lines starting with "===" 100 | if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "===") { 101 | continue 102 | } 103 | 104 | // Split each line into a key-value pair 105 | parts := strings.SplitN(line, ":", 2) 106 | if len(parts) != 2 { 107 | continue 108 | } 109 | 110 | key := strings.TrimSpace(parts[0]) 111 | value := strings.TrimSpace(parts[1]) 112 | 113 | // Clean up value by removing extra spaces and brackets 114 | value = regexp.MustCompile(`\s+`).ReplaceAllString(value, " ") 115 | value = strings.Trim(value, "[]") 116 | 117 | // Set the field in the SmartData struct 118 | setField(key, value, &data) 119 | 120 | // If the key contains "error" or "failed", add it to the errors 121 | if isErrorKey(key) { 122 | errors = append(errors, CustomErr{ 123 | Metric: []string{key}, 124 | Error: fmt.Sprintf("Unable to retrieve the '%s'", key), 125 | }) 126 | } 127 | } 128 | 129 | return &data, errors 130 | } 131 | 132 | func getMetrics(device string) (*SmartData, []CustomErr) { 133 | cmd := exec.Command("smartctl", "-d", "nvme", "--xall", "--nocheck", "standby", device) 134 | out, err := cmd.CombinedOutput() 135 | 136 | // If there's an exit error with exit code 4, we ignore the error 137 | var exitErr *exec.ExitError 138 | if errors.As(err, &exitErr) { 139 | switch exitErr.ExitCode() { 140 | case 4: 141 | err = nil 142 | case 2: 143 | // Exit code 2 indicates permission denied 144 | if strings.HasSuffix(string(out), "failed: Permission denied\n") { 145 | return &SmartData{}, []CustomErr{{ 146 | Metric: []string{"smartctl"}, 147 | Error: "smartctl failed: permission denied (try running as root)", 148 | }} 149 | } 150 | } 151 | } 152 | 153 | // If there's an error executing the command, return empty SmartData and the error 154 | if err != nil { 155 | return &SmartData{}, []CustomErr{{ 156 | Metric: []string{"smartctl"}, 157 | Error: fmt.Sprintf("smartctl failed: %v", err), 158 | }} 159 | } 160 | 161 | return parseSmartctlOutput(string(out)) 162 | } 163 | 164 | // GetSmartMetrics retrieves the SMART metrics from all available devices. 165 | func GetSmartMetrics() (SmartData, []CustomErr) { 166 | var metrics SmartData 167 | var smartCtlrErrs []CustomErr 168 | 169 | // Check if smartctl is installed 170 | if err := checkSmartctlInstalled(); err != nil { 171 | smartCtlrErrs = append(smartCtlrErrs, CustomErr{ 172 | Metric: []string{"smartctl"}, 173 | Error: err.Error(), 174 | }) 175 | return metrics, smartCtlrErrs 176 | } 177 | // Scan for devices 178 | devices, devicesErr := scanDevices() 179 | if devicesErr != nil { 180 | // Return the error if the scan fails 181 | smartCtlrErrs = append(smartCtlrErrs, CustomErr{ 182 | Metric: []string{"smart"}, 183 | Error: devicesErr.Error(), 184 | }) 185 | return metrics, smartCtlrErrs 186 | } 187 | 188 | // Iterate over devices and collect metrics 189 | for _, device := range devices { 190 | metric, metricErr := getMetrics(device) 191 | 192 | if metric != nil { 193 | metrics = *metric 194 | } 195 | 196 | if len(metricErr) > 0 { 197 | smartCtlrErrs = append(smartCtlrErrs, metricErr...) 198 | } 199 | } 200 | 201 | return metrics, smartCtlrErrs 202 | } 203 | -------------------------------------------------------------------------------- /internal/metric/metric.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | type MetricsSlice []Metric 4 | 5 | func (m MetricsSlice) isMetric() {} 6 | 7 | type Metric interface { 8 | isMetric() 9 | } 10 | 11 | type SmartData struct { 12 | AvailableSpare string `json:"available_spare"` 13 | AvailableSpareThreshold string `json:"available_spare_threshold"` 14 | ControllerBusyTime string `json:"controller_busy_time"` 15 | CriticalWarning string `json:"critical_warning"` 16 | DataUnitsRead string `json:"data_units_read"` 17 | DataUnitsWritten string `json:"data_units_written"` 18 | HostReadCommands string `json:"host_read_commands"` 19 | HostWriteCommands string `json:"host_write_commands"` 20 | PercentageUsed string `json:"percentage_used"` 21 | PowerCycles string `json:"power_cycles"` 22 | PowerOnHours string `json:"power_on_hours"` 23 | SmartOverallHealthResult string `json:"smart_overall_health_self_assessment_test_result"` 24 | Temperature string `json:"temperature"` 25 | UnsafeShutdowns string `json:"unsafe_shutdowns"` 26 | } 27 | 28 | func (s SmartData) isMetric() {} 29 | 30 | type AllMetrics struct { 31 | CPU CPUData `json:"cpu"` 32 | Memory MemoryData `json:"memory"` 33 | Disk MetricsSlice `json:"disk"` 34 | Host HostData `json:"host"` 35 | Net MetricsSlice `json:"net"` 36 | } 37 | 38 | func (a AllMetrics) isMetric() {} 39 | 40 | type CustomErr struct { 41 | Metric []string `json:"metric"` 42 | Error string `json:"err"` 43 | } 44 | 45 | type CPUData struct { 46 | PhysicalCore int `json:"physical_core"` // Physical cores 47 | LogicalCore int `json:"logical_core"` // Logical cores aka Threads 48 | Frequency float64 `json:"frequency"` // Frequency in mHz 49 | CurrentFrequency int `json:"current_frequency"` // Current Frequency in mHz 50 | Temperature []float32 `json:"temperature"` // Temperature in Celsius (nil if not available) 51 | FreePercent float64 `json:"free_percent"` // Free percentage //* 1 - (Total - Idle / Total) 52 | UsagePercent float64 `json:"usage_percent"` // Usage percentage //* Total - Idle / Total 53 | } 54 | 55 | func (c CPUData) isMetric() {} 56 | 57 | type MemoryData struct { 58 | TotalBytes uint64 `json:"total_bytes"` // Total space in bytes 59 | AvailableBytes uint64 `json:"available_bytes"` // Available space in bytes 60 | UsedBytes uint64 `json:"used_bytes"` // Used space in bytes //* Total - Free - Buffers - Cached 61 | UsagePercent *float64 `json:"usage_percent"` // Usage Percent //* (Used / Total) * 100.0 62 | } 63 | 64 | func (m MemoryData) isMetric() {} 65 | 66 | type DiskData struct { 67 | Device string `json:"device"` // Device 68 | TotalBytes *uint64 `json:"total_bytes"` // Total space of device in bytes 69 | FreeBytes *uint64 `json:"free_bytes"` // Free space of device in bytes 70 | UsedBytes *uint64 `json:"used_bytes"` // Used space of device in bytes 71 | UsagePercent *float64 `json:"usage_percent"` // Usage percent of device 72 | TotalInodes *uint64 `json:"total_inodes"` // Total space of device in inodes 73 | FreeInodes *uint64 `json:"free_inodes"` // Free space of device in inodes 74 | UsedInodes *uint64 `json:"used_inodes"` // Used space of device in inodes 75 | InodesUsagePercent *float64 `json:"inodes_usage_percent"` // Usage percent of device in inodes 76 | ReadBytes *uint64 `json:"read_bytes"` // Amount of data read from the disk in bytes 77 | WriteBytes *uint64 `json:"write_bytes"` // Amount of data written to the disk in bytes 78 | ReadTime *uint64 `json:"read_time"` // Cumulative time spent performing read operations 79 | WriteTime *uint64 `json:"write_time"` // Cumulative time spent performing write operations 80 | } 81 | 82 | func (d DiskData) isMetric() {} 83 | 84 | type HostData struct { 85 | Os string `json:"os"` // Operating System 86 | Platform string `json:"platform"` // Platform Name 87 | KernelVersion string `json:"kernel_version"` // Kernel Version 88 | PrettyName string `json:"pretty_name"` // Pretty OS Name from /etc/os-release 89 | } 90 | 91 | func (h HostData) isMetric() {} 92 | 93 | type NetData struct { 94 | Name string `json:"name"` // Network Interface Name 95 | BytesSent uint64 `json:"bytes_sent"` // Bytes sent 96 | BytesRecv uint64 `json:"bytes_recv"` // Bytes received 97 | PacketsSent uint64 `json:"packets_sent"` // Packets sent 98 | PacketsRecv uint64 `json:"packets_recv"` // Packets received 99 | ErrIn uint64 `json:"err_in"` // Incoming packets with errors 100 | ErrOut uint64 `json:"err_out"` // Outgoing packets with errors 101 | DropIn uint64 `json:"drop_in"` // Incoming packets that were dropped 102 | DropOut uint64 `json:"drop_out"` // Outgoing packets that were dropped 103 | FIFOIn uint64 `json:"fifo_in"` // Incoming packets dropped due to full buffer 104 | FIFOOut uint64 `json:"fifo_out"` // Outgoing packets dropped due to full buffer 105 | } 106 | 107 | func (n NetData) isMetric() {} 108 | 109 | func GetAllSystemMetrics() (AllMetrics, []CustomErr) { 110 | cpu, cpuErr := CollectCPUMetrics() 111 | memory, memErr := CollectMemoryMetrics() 112 | disk, diskErr := CollectDiskMetrics() 113 | host, hostErr := GetHostInformation() 114 | net, netErr := GetNetInformation() 115 | 116 | var errors []CustomErr 117 | 118 | if cpuErr != nil { 119 | errors = append(errors, cpuErr...) 120 | } 121 | 122 | if memErr != nil { 123 | errors = append(errors, memErr...) 124 | } 125 | 126 | if diskErr != nil { 127 | errors = append(errors, diskErr...) 128 | } 129 | 130 | if hostErr != nil { 131 | errors = append(errors, hostErr...) 132 | } 133 | 134 | if netErr != nil { 135 | errors = append(errors, netErr...) 136 | } 137 | 138 | return AllMetrics{ 139 | CPU: *cpu, 140 | Memory: *memory, 141 | Disk: disk, 142 | Host: *host, 143 | Net: net, 144 | }, errors 145 | } 146 | -------------------------------------------------------------------------------- /internal/metric/disk.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | "strings" 7 | 8 | "github.com/shirou/gopsutil/v4/disk" 9 | ) 10 | 11 | // isLoopbackDevice checks if the partition is a loopback device. 12 | func isLoopbackDevice(p disk.PartitionStat) bool { 13 | return strings.Contains(p.Device, "/dev/loop") 14 | } 15 | 16 | // isZFSFilesystem checks if the partition type is ZFS. 17 | func isZFSFilesystem(p disk.PartitionStat) bool { 18 | return p.Fstype == "zfs" 19 | } 20 | 21 | // isDevPrefixed checks if the device path starts with /dev. 22 | func isDevPrefixed(p disk.PartitionStat) bool { 23 | return strings.HasPrefix(p.Device, "/dev") 24 | } 25 | 26 | // isWindowsDrive checks if the device is a Windows drive (C:, D:, etc.). 27 | func isWindowsDrive(p disk.PartitionStat) bool { 28 | device := strings.TrimSpace(p.Device) 29 | if len(device) >= 2 { 30 | return device[1] == ':' && ((device[0] >= 'A' && device[0] <= 'Z') || (device[0] >= 'a' && device[0] <= 'z')) 31 | } 32 | return false 33 | } 34 | 35 | // isSpecialPartition checks if the partition is a special system partition 36 | // (Recovery, EFI, System Reserved, etc.). 37 | func isSpecialPartition(p disk.PartitionStat) bool { 38 | deviceUpper := strings.ToUpper(p.Device) 39 | 40 | specialPatterns := []string{ 41 | "RECOVERY", 42 | "SYSTEM RESERVED", 43 | "EFI", 44 | } 45 | 46 | for _, pattern := range specialPatterns { 47 | if strings.Contains(deviceUpper, pattern) { 48 | return true 49 | } 50 | } 51 | 52 | return false 53 | } 54 | 55 | // shouldIncludePartition determines if a partition should be included in metrics 56 | // collection based on the disk metric flow rules. 57 | func shouldIncludePartition(partition disk.PartitionStat) bool { 58 | // Always include ZFS filesystems 59 | if isZFSFilesystem(partition) { 60 | return true 61 | } 62 | 63 | // Skip loopback devices 64 | if isLoopbackDevice(partition) { 65 | return false 66 | } 67 | 68 | // Skip special system partitions 69 | if isSpecialPartition(partition) { 70 | return false 71 | } 72 | 73 | // For Unix systems, require /dev prefix 74 | if runtime.GOOS != "windows" { 75 | if !isDevPrefixed(partition) { 76 | return false 77 | } 78 | } else { 79 | // For Windows, include drives that look like C:, D:, etc. 80 | if !isWindowsDrive(partition) { 81 | return false 82 | } 83 | } 84 | 85 | return true 86 | } 87 | 88 | // collectPartitionMetrics gathers all required metrics for a single partition. 89 | func collectPartitionMetrics(partition disk.PartitionStat) (*DiskData, CustomErr) { 90 | // Collect IO statistics 91 | ioStats, ioErr := collectIOStats(partition.Device) 92 | if ioErr != nil { 93 | return nil, *ioErr 94 | } 95 | 96 | // Collect usage statistics 97 | usageStats, usageErr := collectUsageStats(partition.Mountpoint) 98 | if usageErr != nil { 99 | return nil, *usageErr 100 | } 101 | 102 | // Combine all metrics into a DiskData structure 103 | return &DiskData{ 104 | Device: partition.Device, 105 | TotalBytes: &usageStats.Total, 106 | UsedBytes: &usageStats.Used, 107 | FreeBytes: &usageStats.Free, 108 | UsagePercent: RoundFloatPtr(usageStats.UsedPercent/100, 4), 109 | 110 | TotalInodes: &usageStats.InodesTotal, 111 | FreeInodes: &usageStats.InodesFree, 112 | UsedInodes: &usageStats.InodesUsed, 113 | InodesUsagePercent: RoundFloatPtr(usageStats.InodesUsedPercent/100, 4), 114 | 115 | ReadBytes: &ioStats.ReadBytes, 116 | WriteBytes: &ioStats.WriteBytes, 117 | ReadTime: &ioStats.ReadTime, 118 | WriteTime: &ioStats.WriteTime, 119 | }, CustomErr{} 120 | } 121 | 122 | // collectIOStats gathers IO-related metrics for a device. 123 | // Supports LVM/device-mapper by resolving /dev/mapper/* -> /dev/dm-* 124 | // and trying multiple key candidates against the map returned by disk.IOCounters(). 125 | func collectIOStats(device string) (*disk.IOCountersStat, *CustomErr) { 126 | // Get all counters once and look up by key 127 | all, err := disk.IOCounters() 128 | if err != nil { 129 | return nil, &CustomErr{ 130 | Metric: []string{"disk.read_bytes", "disk.write_bytes", "disk.read_time", "disk.write_time"}, 131 | Error: err.Error() + " " + device, 132 | } 133 | } 134 | 135 | candidates := buildDeviceKeyCandidates(device) 136 | 137 | // 1) Direct map key match 138 | for _, k := range candidates { 139 | if stat, ok := all[k]; ok { 140 | return &stat, nil 141 | } 142 | } 143 | 144 | // 2) Fallback: match by stat.Name field 145 | for _, stat := range all { 146 | for _, k := range candidates { 147 | if stat.Name == k { 148 | s := stat 149 | return &s, nil 150 | } 151 | } 152 | } 153 | 154 | return nil, &CustomErr{ 155 | Metric: []string{"disk.read_bytes", "disk.write_bytes", "disk.read_time", "disk.write_time"}, 156 | Error: "device stats not found: " + device + " (tried: " + strings.Join(candidates, ", ") + ")", 157 | } 158 | } 159 | 160 | // buildDeviceKeyCandidates returns possible keys for the disk.IOCounters() map. 161 | // Handles paths like /dev/sda, /dev/nvme0n1, /dev/mapper/vg-lv -> dm-0, etc. 162 | func buildDeviceKeyCandidates(device string) []string { 163 | if runtime.GOOS == "windows" { 164 | // On Windows, gopsutil uses names like "C:", so keep as-is. 165 | d := strings.TrimSpace(device) 166 | return []string{d} 167 | } 168 | 169 | var out []string 170 | d := strings.TrimSpace(device) 171 | 172 | // Strip /dev/ 173 | out = append(out, strings.TrimPrefix(d, "/dev/")) 174 | // Basename (e.g., /dev/mapper/vg-lv -> vg-lv) 175 | out = append(out, filepath.Base(d)) 176 | 177 | // Resolve symlinks: /dev/mapper/vg-lv -> /dev/dm-0 -> dm-0 178 | if resolved, err := filepath.EvalSymlinks(d); err == nil && resolved != "" { 179 | out = append(out, strings.TrimPrefix(resolved, "/dev/")) 180 | out = append(out, filepath.Base(resolved)) 181 | } 182 | 183 | // Deduplicate 184 | seen := map[string]struct{}{} 185 | uniq := make([]string, 0, len(out)) 186 | for _, k := range out { 187 | if k == "" { 188 | continue 189 | } 190 | if _, ok := seen[k]; !ok { 191 | seen[k] = struct{}{} 192 | uniq = append(uniq, k) 193 | } 194 | } 195 | return uniq 196 | } 197 | 198 | // collectUsageStats collects usage-related metrics for a mountpoint. 199 | func collectUsageStats(mountpoint string) (*disk.UsageStat, *CustomErr) { 200 | diskUsage, diskUsageErr := disk.Usage(mountpoint) 201 | if diskUsageErr != nil { 202 | return nil, &CustomErr{ 203 | Metric: []string{"disk.usage_percent", "disk.total_bytes", "disk.free_bytes", "disk.used_bytes", 204 | "disk.total_inodes", "disk.free_inodes", "disk.used_inodes", "disk.inodes_usage_percent"}, 205 | Error: diskUsageErr.Error() + " " + mountpoint, 206 | } 207 | } 208 | 209 | return diskUsage, nil 210 | } 211 | 212 | // CollectDiskMetrics collects disk metrics following the disk metric flow specification. 213 | // Lists all partitions on the system using disk.Partitions(all=true). 214 | // Checks each partition for filtering conditions. 215 | // For each valid partition, gathers the specified metrics. 216 | func CollectDiskMetrics() (MetricsSlice, []CustomErr) { 217 | defaultDiskData := []*DiskData{ 218 | { 219 | Device: "unknown", 220 | TotalBytes: nil, 221 | FreeBytes: nil, 222 | UsedBytes: nil, 223 | ReadBytes: nil, 224 | WriteBytes: nil, 225 | ReadTime: nil, 226 | WriteTime: nil, 227 | UsagePercent: nil, 228 | TotalInodes: nil, 229 | FreeInodes: nil, 230 | UsedInodes: nil, 231 | InodesUsagePercent: nil, 232 | }, 233 | } 234 | 235 | var diskErrors []CustomErr 236 | var metricsSlice MetricsSlice 237 | var checkedDevices = make(map[string]struct{}) // Track already processed devices 238 | 239 | // List all partitions on the system 240 | partitions, partErr := disk.Partitions(true) 241 | if partErr != nil { 242 | diskErrors = append(diskErrors, CustomErr{ 243 | Metric: []string{"disk.partitions"}, 244 | Error: partErr.Error(), 245 | }) 246 | return MetricsSlice{defaultDiskData[0]}, diskErrors 247 | } 248 | 249 | // Iterate through partitions and apply filters 250 | for _, partition := range partitions { 251 | // Skip duplicates 252 | if _, ok := checkedDevices[partition.Device]; ok { 253 | continue 254 | } 255 | 256 | // Apply filtering logic 257 | if !shouldIncludePartition(partition) { 258 | continue 259 | } 260 | 261 | // Gather metrics for valid partitions 262 | diskMetrics, err := collectPartitionMetrics(partition) 263 | if err.Error != "" { 264 | diskErrors = append(diskErrors, err) 265 | continue 266 | } 267 | 268 | checkedDevices[partition.Device] = struct{}{} // Mark as checked 269 | metricsSlice = append(metricsSlice, diskMetrics) 270 | } 271 | 272 | if len(diskErrors) == 0 { 273 | return metricsSlice, nil 274 | } 275 | 276 | if len(metricsSlice) == 0 { 277 | return MetricsSlice{defaultDiskData[0]}, diskErrors 278 | } 279 | 280 | return metricsSlice, diskErrors 281 | } 282 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![github-license](https://img.shields.io/github/license/bluewave-labs/capture) 2 | ![github-repo-size](https://img.shields.io/github/repo-size/bluewave-labs/capture) 3 | ![github-commit-activity](https://img.shields.io/github/commit-activity/w/bluewave-labs/capture) 4 | ![github-last-commit-data](https://img.shields.io/github/last-commit/bluewave-labs/capture) 5 | ![github-languages](https://img.shields.io/github/languages/top/bluewave-labs/capture) 6 | ![github-issues-and-prs](https://img.shields.io/github/issues-pr/bluewave-labs/capture) 7 | ![github-issues](https://img.shields.io/github/issues/bluewave-labs/capture) 8 | [![go-reference](https://pkg.go.dev/badge/github.com/bluewave-labs/capture.svg)](https://pkg.go.dev/github.com/bluewave-labs/capture) 9 | [![github-actions-check](https://github.com/bluewave-labs/capture/actions/workflows/check.yml/badge.svg)](https://github.com/bluewave-labs/capture/actions/workflows/check.yml) 10 | [![github-actions-go](https://github.com/bluewave-labs/capture/actions/workflows/go.yml/badge.svg)](https://github.com/bluewave-labs/capture/actions/workflows/go.yml) 11 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/capture) 12 | 13 |

Capture

14 | 15 |

An open source hardware monitoring agent

16 | 17 | Capture is a hardware monitoring agent that collects hardware information from the host machine and exposes it through a RESTful API. The agent is designed to be lightweight and easy to use. 18 | 19 | - [Features](#features) 20 | - [Quick Start (Docker)](#quick-start-docker) 21 | - [Quick Start (Docker Compose)](#quick-start-docker-compose) 22 | - [Configuration](#configuration) 23 | - [Endpoints](#endpoints) 24 | - [API Documentation](#api-documentation) 25 | - [Installation Options](#installation-options) 26 | - [Docker (Recommended)](#docker-recommended) 27 | - [System Installation](#system-installation) 28 | - [Reverse Proxy and SSL](#reverse-proxy-and-ssl) 29 | - [Caddy](#caddy) 30 | - [Contributing](#contributing) 31 | - [Star History](#star-history) 32 | - [License](#license) 33 | 34 | ## Features 35 | 36 | - CPU Monitoring 37 | - Temperature 38 | - Load 39 | - Frequency 40 | - Usage 41 | - Memory Monitoring 42 | - Disk Monitoring 43 | - Usage 44 | - Inode Usage 45 | - Read/Write Bytes 46 | - S.M.A.R.T. Monitoring (Self-Monitoring, Analysis and Reporting Technology) 47 | - Network Monitoring 48 | - Docker Container Monitoring 49 | - GPU Monitoring (coming soon) 50 | 51 | ## Quick Start (Docker) 52 | 53 | ```shell 54 | docker run -d \ 55 | -v /etc/os-release:/etc/os-release:ro \ 56 | -p 59232:59232 \ 57 | -e API_SECRET=your-secret-key \ 58 | ghcr.io/bluewave-labs/capture:latest 59 | ``` 60 | 61 | ## Quick Start (Docker Compose) 62 | 63 | ```yaml 64 | services: 65 | # Capture service 66 | capture: 67 | image: ghcr.io/bluewave-labs/capture:latest 68 | container_name: capture 69 | restart: unless-stopped 70 | ports: 71 | - "59232:59232" 72 | environment: 73 | - API_SECRET=REPLACE_WITH_YOUR_SECRET # Required authentication key. Do not forget to replace this with your actual secret key. 74 | - GIN_MODE=release 75 | volumes: 76 | - /etc/os-release:/etc/os-release:ro 77 | ``` 78 | 79 | ## Configuration 80 | 81 | | Variable | Description | Default | Required | 82 | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------- | 83 | | `API_SECRET` | Authentication key ([Must match the secret you enter on Checkmate](https://docs.checkmate.so/users-guide/infrastructure-monitor#step-2-configure-general-settings)) | - | Yes | 84 | | `PORT` | Server port number | 59232 | No | 85 | | `GIN_MODE` | Gin(web framework) mode. Debug is for development | release | No | 86 | 87 | Example configurations: 88 | 89 | ```shell 90 | # Minimal 91 | API_SECRET=your-secret-key ./capture 92 | 93 | # Complete 94 | API_SECRET=your-secret-key PORT=59232 GIN_MODE=release ./capture 95 | ``` 96 | 97 | ## Endpoints 98 | 99 | - **Base URL**: `http://:` (default port `59232`) 100 | - **Authentication**: Every `/api/v1/**` route requires `Authorization: Bearer $API_SECRET`. `/health` stays public so you can use it for liveness checks. 101 | 102 | | Method | Path | Auth | Description | Notes | 103 | | ------ | ------------------------ | ---- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------- | 104 | | `GET` | `/health` | ❌ | Liveness probe that returns `"OK"`. | Useful for container orchestrators. | 105 | | `GET` | `/api/v1/metrics` | ✅ | Returns the complete capture payload with CPU, memory, disk, host, SMART, network, and Docker data. | Aggregates every collector. | 106 | | `GET` | `/api/v1/metrics/cpu` | ✅ | CPU temps, load, and utilization. | | 107 | | `GET` | `/api/v1/metrics/memory` | ✅ | Memory totals and usage metrics. | | 108 | | `GET` | `/api/v1/metrics/disk` | ✅ | Disk capacity, inode usage, and IO stats. | | 109 | | `GET` | `/api/v1/metrics/host` | ✅ | Host metadata (OS, uptime, kernel, etc.). | | 110 | | `GET` | `/api/v1/metrics/smart` | ✅ | S.M.A.R.T. drive health information. | | 111 | | `GET` | `/api/v1/metrics/net` | ✅ | Interface-level network throughput. | | 112 | | `GET` | `/api/v1/metrics/docker` | ✅ | Docker container metrics. | Use `?all=true` to include stopped containers. | 113 | 114 | All responses share the same envelope: 115 | 116 | ```jsonc 117 | { 118 | "data": { 119 | // collector-specific payload 120 | }, 121 | "capture": { 122 | "version": "1.0.0", 123 | "mode": "release" 124 | }, 125 | "errors": [ 126 | // optional array of error messages if any collectors failed, can be null 127 | ], 128 | } 129 | ``` 130 | 131 | Collectors can partially fail; when that happens the API responds with HTTP `207 Multi-Status` and fills `errors` with detailed reasons so you can alert without dropping other metric data. 132 | 133 | ## API Documentation 134 | 135 | Our API is documented in accordance with the OpenAPI spec. 136 | 137 | You can find the OpenAPI specifications [in openapi.yml](https://github.com/bluewave-labs/capture/blob/develop/openapi.yml) 138 | 139 | ## Installation Options 140 | 141 | ### Docker (Recommended) 142 | 143 | Pull and run the official image: 144 | 145 | ```shell 146 | docker run -d \ 147 | -v /etc/os-release:/etc/os-release:ro \ 148 | -p 59232:59232 \ 149 | -e API_SECRET=your-secret-key \ 150 | ghcr.io/bluewave-labs/capture:latest 151 | ``` 152 | 153 | Or build locally: 154 | 155 | ```shell 156 | docker buildx build -t capture . 157 | docker run -d -v /etc/os-release:/etc/os-release:ro -p 59232:59232 -e API_SECRET=your-secret-key capture 158 | ``` 159 | 160 | Docker options explained: 161 | 162 | - `-v /etc/os-release:/etc/os-release:ro`: Platform detection 163 | - `-p 59232:59232`: Port mapping 164 | - `-e API_SECRET`: Required authentication key 165 | - `-d`: Detached mode 166 | 167 | ## System Installation 168 | 169 | Choose one of these methods: 170 | 171 | 1. **Pre-built Binaries**: Download from [GitHub Releases](https://github.com/bluewave-labs/capture/releases) 172 | 173 | 2. **Go Package**: 174 | 175 | ```shell 176 | go install github.com/bluewave-labs/capture/cmd/capture@latest 177 | ``` 178 | 179 | 3. **Build from Source**: 180 | 181 | ```shell 182 | git clone git@github.com:bluewave-labs/capture 183 | cd capture 184 | just build # or: go build -o dist/capture ./cmd/capture/ 185 | ``` 186 | 187 | ## Reverse Proxy and SSL 188 | 189 | You can use a reverse proxy in front of the Capture service to handle HTTPS requests and SSL termination. 190 | 191 | ### Caddy 192 | 193 | ```lua 194 | ├deployment/reverse-proxy-compose/ 195 | ├── caddy/ 196 | │ └── Caddyfile 197 | └── caddy.compose.yml 198 | ``` 199 | 200 | 1. Go to the `deployment/reverse-proxy-compose` directory 201 | 202 | ```shell 203 | cd deployment/reverse-proxy-compose 204 | ``` 205 | 206 | 2. Replace `replacewithyourdomain.com` with your actual domain in [deployment/reverse-proxy-compose/caddy/Caddyfile](./deployment/reverse-proxy-compose/caddy/Caddyfile) 207 | 3. Set `API_SECRET` environment variable for the Capture service in [deployment/reverse-proxy-compose/caddy.compose.yml](./deployment/reverse-proxy-compose/caddy.compose.yml). 208 | 4. Ensure your domain’s DNS A/AAAA records point to this server’s IP. 209 | 5. Open inbound TCP ports 80 and 443 on your firewall/security group. 210 | 211 | Start the Caddy reverse proxy 212 | 213 | ```shell 214 | docker compose -f caddy.compose.yml up -d 215 | ``` 216 | 217 | ## Contributing 218 | 219 | We welcome contributions! If you would like to contribute, please read the [CONTRIBUTING.md](./CONTRIBUTING.md) file for more information. 220 | 221 | 222 | Contributors Graph 223 | 224 | 225 | ## Star History 226 | 227 | [![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/capture&type=Date)](https://www.star-history.com/#bluewave-labs/capture&Date) 228 | 229 | ## License 230 | 231 | Capture is licensed under AGPLv3. You can find the license [here](./LICENSE) 232 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for Capture 2 | 3 | > This CHANGELOG.md file tracks all released versions and their changes. All releases are automatically published via GitHub Actions and GoReleaser with cryptographic checksums for security verification. 4 | 5 | Date-Format: YYYY-MM-DD 6 | 7 | ## 1.3.1 (2025-09-21) 8 | 9 | This is a patch release that addresses a bug in the CPU temperature validation logic on some Linux systems and adding more build metadata to `capture --version` output. 10 | 11 | - [1e14393](https://github.com/bluewave-labs/capture/commit/1e14393a8139b50503de7a90b07dd9de819cacdb) Update linting tools to include go vet and staticcheck (#118) 12 | - [ac0f516](https://github.com/bluewave-labs/capture/commit/ac0f516dbb25c808ccbb317d47fc597864c26155) feat: Add more build metadata like commit hash, commit date, build date and git tag (#121) 13 | - [515fa5c](https://github.com/bluewave-labs/capture/commit/515fa5cada6e5180a79b35af072fdb1af59c1d23) fix(linux-cpu): cover CPU temperature sensors without label files (#125) 14 | 15 | [Full Changelog](https://github.com/bluewave-labs/capture/compare/v1.3.0...v1.3.1) 16 | 17 | Contributors: @mertssmnoglu 18 | 19 | ## 1.3.0 (2025-08-06) 20 | 21 | The most wanted Capture release is here. 22 | Capture supports **🔥 Windows (amd64 & arm64)**, **macOS**, **Linux**, and even **ARMv6/v7** devices like Raspberry Pi. 23 | 24 | You can access new pre-built binaries from the [release page](https://github.com/bluewave-labs/capture/releases/tag/v1.3.0). 25 | 26 | **Improved Clarity & Security** 27 | Curious about what's inside Capture? Each platform build now includes a **Software Bill of Materials (SBOM)**, giving you full transparency into dependencies and components. 28 | 29 | - [af2e7a3](https://github.com/bluewave-labs/capture/commit/af2e7a329e6cf74d700a8d10c70cdc5189598ebf) CI Improvements for Check/Test/Build in each platform (#98) 30 | - [9f3dd0d](https://github.com/bluewave-labs/capture/commit/9f3dd0d6b9793d26a1200e4dff43e08873b97bfd) build: Add support for SBOM generation (#110) 31 | - [f658dbb](https://github.com/bluewave-labs/capture/commit/f658dbb945fbef0e59a9fa03689bc35046f5771f) build: Enable Windows amd64 and arm64 builds (#109) 32 | - [e33faa9](https://github.com/bluewave-labs/capture/commit/e33faa9e6f7cb167db38a30637b25f423a5021bd) build: Ignore arm(arm32) architecture on Windows (#111) 33 | - [961b984](https://github.com/bluewave-labs/capture/commit/961b9848e2cb35c99c8202ac5ffaf69e19cf6ce1) cd(release): Install Syft for enhanced dependency scanning (#113) 34 | - [013b3aa](https://github.com/bluewave-labs/capture/commit/013b3aa3abe8e6f096d79ffc322d531fd32127d2) chore: Add support for armv6 and armv7 architecture in GoReleaser (#76) 35 | - [a84f6ed](https://github.com/bluewave-labs/capture/commit/a84f6edaef782743c6a866b8ca3e763c697e58d1) feat(ci): Add OpenAPI contract testing workflow (#102) 36 | - [6b9ac0d](https://github.com/bluewave-labs/capture/commit/6b9ac0dfb5a3820534dd096bf4db2ea2b0ce7215) feat(system): Migrate sysfs module to system for Multi Platform Support (#97) 37 | - [0110495](https://github.com/bluewave-labs/capture/commit/01104959f648453be1affadf0cd684c468e2fcb1) feat: Add unit and integration test commands to Justfile (#101) 38 | - [a95f94c](https://github.com/bluewave-labs/capture/commit/a95f94c61072659f0784017cad434cac3622a9bc) feat: Implement GetPrettyName for macOS, Linux, and Windows (#96) 39 | - [88fad86](https://github.com/bluewave-labs/capture/commit/88fad86171529d615864aeb29833e0f2f582c177) refactor(disk): Improve device filtering compatibility on Windows (#108) 40 | 41 | [Full Changelog](https://github.com/bluewave-labs/capture/compare/v1.2.0...v1.3.0) 42 | 43 | Contributors: @mertssmnoglu 44 | 45 | ## 1.2.0 (2025-06-24) 46 | 47 | This release adds support for monitoring network activity and Docker containers. It also includes enhanced API responses with metadata and introduces user-friendly host names (e.g., "Ubuntu 24.04.2 LTS") for improved readability. 48 | 49 | - [2de06c3](https://github.com/bluewave-labs/capture/commit/2de06c3cf9acca167d05c6fde52c9c0177dbd6ee) Capture metadata in API Responses (#82) 50 | - [7b98c15](https://github.com/bluewave-labs/capture/commit/7b98c15dfe2ee3feff8f55ba227e44f34f2da686) Issue and pr templates (#86) 51 | - [d7c9c74](https://github.com/bluewave-labs/capture/commit/d7c9c747767fcdb644a3e4d2dc6d0cc6ba9eb9e6) User friendly instructions in README for Quick Start (#93) 52 | - [8161d57](https://github.com/bluewave-labs/capture/commit/8161d57102ee576ab462159acd135a422599048e) build(deps): bump golang.org/x/net from 0.30.0 to 0.38.0 (#84) 53 | - [d7d5824](https://github.com/bluewave-labs/capture/commit/d7d5824c2d53990077ba642777a78c0ed4f5cc10) chore: Add bug report issue template to improve issue tracking (#69) 54 | - [546e533](https://github.com/bluewave-labs/capture/commit/546e533e58342ee8051a0a59a7c9b966a8453cc5) chore: Enhance Dockerfile with additional comments and structure (#88) 55 | - [41283a5](https://github.com/bluewave-labs/capture/commit/41283a5299a3f688b8d354dedb8a275092ccb042) ci: Add codeql.yml (#70) 56 | - [019c1ca](https://github.com/bluewave-labs/capture/commit/019c1ca41fc93f9821f6a297fda84e26efc64d7f) ci: Make go workflow read-only (#74) 57 | - [442bf24](https://github.com/bluewave-labs/capture/commit/442bf24ba9ea6da7a9b24abdbb2763a352707055) docs(security): Update vulnerability reporting guideline (#71) 58 | - [c7ba448](https://github.com/bluewave-labs/capture/commit/c7ba4486c90b577d27afac13d37b9de0036b3b71) feat(api): Update OpenAPI specification to version 1.1.0 (#83) 59 | - [e2580a9](https://github.com/bluewave-labs/capture/commit/e2580a9fc131224382255b1d2053ef7323d163a9) feat(metric): Docker container monitoring (#78) 60 | - [e5ee49d](https://github.com/bluewave-labs/capture/commit/e5ee49d4a5ffdaa2eb82017e65fd7729ab403879) feat(metrics): Add network metrics collection (#67) 61 | - [f0f8fee](https://github.com/bluewave-labs/capture/commit/f0f8fee5fe32d32790b4793e4dd430086f66e0d8) feat: host.prettyname added (#90) 62 | - [592cc72](https://github.com/bluewave-labs/capture/commit/592cc722f8f4c48f1315687eb86007f50814c67a) fix: Correct JSON key for SmartOverallHealthResult in metrics (#87) 63 | - [92de4a2](https://github.com/bluewave-labs/capture/commit/92de4a2aa2582d06ad316b1646450248b3a51d53) fix: Move health check route to the correct position in the handler initialization (#79) 64 | 65 | [Full Changelog](https://github.com/bluewave-labs/capture/compare/v1.1.0...v1.2.0) 66 | 67 | Contributors: @mertssmnoglu 68 | 69 | ## 1.1.0 (2025-05-12) 70 | 71 | The new Capture release enhances system performance monitoring with features like S.M.A.R.T metrics, disk current read/write stats, iNode usage and a ZFS filtering fix for Debian/Ubuntu systems. 72 | 73 | You can access new MacOS pre-built binaries from the [releases page](https://github.com/bluewave-labs/capture/releases). 74 | 75 | --- 76 | 77 | Featured Changes 78 | 79 | - [472e7be95987a33dc50d573654dcb1c2f3bee1ab](https://github.com/bluewave-labs/capture/commit/472e7be95987a33dc50d573654dcb1c2f3bee1ab) Feat: Current Read/Write Data (#54) @Br0wnHammer 80 | - [aadedfb99e8afbc5aa34dd3941ba90cc6ce12bcb](https://github.com/bluewave-labs/capture/commit/aadedfb99e8afbc5aa34dd3941ba90cc6ce12bcb) Fix 51 smartctlr metrics od there serve (#53) @Owaiseimdad 81 | - [ef5b2367ae8f10a3f0acb55dbd3211e652ae902e](https://github.com/bluewave-labs/capture/commit/ef5b2367ae8f10a3f0acb55dbd3211e652ae902e) Fix: #46 Inode Usage metrics (#56) @noodlewhale 82 | - [9429bdcae6b8f33e52ef1aa3783098bfe2d311b1](https://github.com/bluewave-labs/capture/commit/9429bdcae6b8f33e52ef1aa3783098bfe2d311b1) feat(logging): Warn users to remember adding endpoint to Checkmate Infrastructure Dashboard (#59) @mertssmnoglu 83 | - [994e4b3188b949604dcadb17ba34941fda75288f](https://github.com/bluewave-labs/capture/commit/994e4b3188b949604dcadb17ba34941fda75288f) fix(disk): Enhance partition filtering logic to include ZFS filesystems #55 (#64) @mertssmnoglu 84 | 85 | [Full Changelog](https://github.com/bluewave-labs/capture/compare/v1.0.1...994e4b3188b949604dcadb17ba34941fda75288f) 86 | 87 | Contributors: @mertssmnoglu, @Br0wnHammer, @Owaiseimdad, @noodlewhale 88 | 89 | ## 1.0.1 (2025-02-06) 90 | 91 | This release focuses on feature improvements and extending system metrics coverage to enhance functionality and reliability. 92 | 93 | > Requires Checkmate >= 2.0 94 | > 95 | > If your version is higher than 2.0, you don't need to upgrade Checkmate. 96 | 97 | - [14aff9e0b3deb8771d0aaa9eee61c4c5e023705e](https://github.com/bluewave-labs/capture/commit/14aff9e0b3deb8771d0aaa9eee61c4c5e023705e) feat(main): Add version flag to display application version (#45) 98 | - [93122c59b45c13d1a6914aa30bca2671e1a0336c](https://github.com/bluewave-labs/capture/commit/93122c59b45c13d1a6914aa30bca2671e1a0336c) fix(metric): collect all disk partitions instead of only physical ones (#44) 99 | 100 | Contributors: @mertssmnoglu 101 | 102 | ## 1.0.0 (2024-12-31) 103 | 104 | First release of the Capture project. 105 | 106 | - [aace2934eb80cbdb8903e76c1c2b57fdfe179454](https://github.com/bluewave-labs/capture/commit/aace2934eb80cbdb8903e76c1c2b57fdfe179454) Merge pull request #36 from bluewave-labs/chore/openapi-specs 107 | - [e984e733f70243bbb05d8231fd9be9f5eea6cdce](https://github.com/bluewave-labs/capture/commit/e984e733f70243bbb05d8231fd9be9f5eea6cdce) Merge pull request #37 from bluewave-labs/ci/lint 108 | - [861c26c340f12bd2b531698d965da59508677a1a](https://github.com/bluewave-labs/capture/commit/861c26c340f12bd2b531698d965da59508677a1a) Merge pull request #38 from bluewave-labs/readme-update 109 | - [a3623ef7c7ae7b3039dd9290bf9aacd7c42d5424](https://github.com/bluewave-labs/capture/commit/a3623ef7c7ae7b3039dd9290bf9aacd7c42d5424) chore(openapi): add openapi 3.0.0 specs for the API 110 | - [8abb8581c45307fac49bc96e2127a4eb4a82ba05](https://github.com/bluewave-labs/capture/commit/8abb8581c45307fac49bc96e2127a4eb4a82ba05) chore(openapi): add security schema and improve example response 111 | - [eb6695df4fd77e8c2e525565b5cc6e25123dcf60](https://github.com/bluewave-labs/capture/commit/eb6695df4fd77e8c2e525565b5cc6e25123dcf60) chore(openapi): remove unimplemented websocket path 112 | - [ba2ab5f6a0184967d88b7fcf5176ff4e561de1fa](https://github.com/bluewave-labs/capture/commit/ba2ab5f6a0184967d88b7fcf5176ff4e561de1fa) chore: Remove unimplemented 'ReadSpeedBytes' and 'WriteSpeedBytes' fields from the DiskData struct 113 | - [4e474b7aff7546c161dab26c4851eb53bb4ff7b9](https://github.com/bluewave-labs/capture/commit/4e474b7aff7546c161dab26c4851eb53bb4ff7b9) ci: Change 'ubuntu-latest' runners to 'ubuntu-22.04' (#40) 114 | - [b00cd259b6fd56e43d1b00360835063527ff3817](https://github.com/bluewave-labs/capture/commit/b00cd259b6fd56e43d1b00360835063527ff3817) ci: add lint.yml 115 | - [7a2fbd97f996b91a50ffed58130fca779453e561](https://github.com/bluewave-labs/capture/commit/7a2fbd97f996b91a50ffed58130fca779453e561) ci: change job name to lint 116 | - [33f51d1a7129fadfadff3dc3bc081abb39cee980](https://github.com/bluewave-labs/capture/commit/33f51d1a7129fadfadff3dc3bc081abb39cee980) docs(README): Clarify how to install and use the Capture 117 | - [0707d258d106452ffc6a033d2699cfd9aa047e89](https://github.com/bluewave-labs/capture/commit/0707d258d106452ffc6a033d2699cfd9aa047e89) docs(README): Describe how to install with 'go install' and update Environment Variables list 118 | - [bce26b2f194e43b43e955ebf5dd966db5b808530](https://github.com/bluewave-labs/capture/commit/bce26b2f194e43b43e955ebf5dd966db5b808530) fix(lint): Solve all linter warnings and errors 119 | - [acddb720cc9256992f8704f518bfe5e826b6cddd](https://github.com/bluewave-labs/capture/commit/acddb720cc9256992f8704f518bfe5e826b6cddd) fix: remove websocket handler 120 | - [2f8f3f9c18cee756a867a88e5df4279c7124948c](https://github.com/bluewave-labs/capture/commit/2f8f3f9c18cee756a867a88e5df4279c7124948c) refactor(server): improve logging and handle shutdown signals with ease (#39) 121 | 122 | Contributors: @mertssmnoglu 123 | -------------------------------------------------------------------------------- /internal/metric/docker.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/docker/docker/api/types/container" 9 | "github.com/docker/docker/client" 10 | ) 11 | 12 | type ContainerMetrics struct { 13 | ContainerID string `json:"container_id"` 14 | ContainerName string `json:"container_name"` 15 | Status string `json:"status"` // "created", "running", "paused", "restarting", "removing", "exited", "dead" 16 | Health *ContainerHealthStatus `json:"health"` 17 | Running bool `json:"running"` 18 | BaseImage string `json:"base_image"` 19 | ExposedPorts []Port `json:"exposed_ports"` 20 | StartedAt int64 `json:"started_at"` // Unix timestamp 21 | FinishedAt int64 `json:"finished_at"` // Unix timestamp 22 | Stats *ContainerStats `json:"stats"` 23 | } 24 | 25 | type ContainerStats struct { 26 | CPUPercent float64 `json:"cpu_percent"` 27 | MemoryUsage uint64 `json:"memory_usage"` 28 | MemoryLimit uint64 `json:"memory_limit"` 29 | MemoryPercent float64 `json:"memory_percent"` 30 | NetworkRx uint64 `json:"network_rx_bytes"` 31 | NetworkTx uint64 `json:"network_tx_bytes"` 32 | BlockRead uint64 `json:"block_read_bytes"` 33 | BlockWrite uint64 `json:"block_write_bytes"` 34 | PIDs uint64 `json:"pids"` 35 | } 36 | 37 | func (c ContainerMetrics) isMetric() {} 38 | 39 | type Port struct { 40 | Port string `json:"port"` 41 | Protocol string `json:"protocol"` 42 | } 43 | 44 | type ContainerHealthStatus struct { 45 | Healthy bool `json:"healthy"` 46 | Source ContainerHealthSource `json:"source"` // "container_health_check", "state_based_health_check" 47 | Message string `json:"message"` // Additional message if needed 48 | } 49 | 50 | type ContainerHealthSource string 51 | 52 | const ( 53 | SourceContainerHealthCheck ContainerHealthSource = "container_health_check" 54 | SourceStateBasedHealthCheck ContainerHealthSource = "state_based_health_check" 55 | ) 56 | 57 | func GetDockerMetrics(all bool) (MetricsSlice, []CustomErr) { 58 | var metrics = make(MetricsSlice, 0) 59 | var containerErrors []CustomErr 60 | 61 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 62 | defer cancel() 63 | 64 | cli, err := initializeDockerClient() 65 | if err != nil { 66 | containerErrors = append(containerErrors, CustomErr{ 67 | Metric: []string{"docker.client"}, 68 | Error: err.Error(), 69 | }) 70 | return nil, containerErrors 71 | } 72 | defer cli.Close() 73 | 74 | containers, err := listContainers(ctx, cli, all) 75 | if err != nil { 76 | return nil, append(containerErrors, CustomErr{ 77 | Metric: []string{"docker.container.list"}, 78 | Error: err.Error(), 79 | }) 80 | } 81 | 82 | for _, container := range containers { 83 | metric, customErr := processContainer(ctx, cli, container) 84 | if customErr.Error != "" { 85 | containerErrors = append(containerErrors, customErr) 86 | continue 87 | } 88 | metrics = append(metrics, metric) 89 | } 90 | 91 | if len(containerErrors) > 0 { 92 | return metrics, containerErrors 93 | } 94 | 95 | return metrics, nil 96 | } 97 | 98 | // initializeDockerClient creates a new Docker client with environment configuration. 99 | func initializeDockerClient() (*client.Client, error) { 100 | // Initialize the Docker client 101 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 102 | if err != nil { 103 | return nil, err 104 | } 105 | return cli, nil 106 | } 107 | 108 | // listContainers retrieves the list of containers from Docker. 109 | func listContainers(ctx context.Context, cli *client.Client, all bool) ([]container.Summary, error) { 110 | // List all containers 111 | containers, err := cli.ContainerList(ctx, container.ListOptions{ 112 | All: all, 113 | }) 114 | if err != nil { 115 | return nil, err 116 | } 117 | return containers, nil 118 | } 119 | 120 | // processContainer processes a single container and returns its metrics. 121 | func processContainer(ctx context.Context, cli *client.Client, container container.Summary) (ContainerMetrics, CustomErr) { 122 | containerInspectResponse, err := inspectContainer(ctx, cli, container.ID) 123 | if err != nil { 124 | return ContainerMetrics{}, CustomErr{ 125 | Metric: []string{"docker.container.inspect"}, 126 | Error: err.Error(), 127 | } 128 | } 129 | 130 | portList := extractExposedPorts(containerInspectResponse) 131 | 132 | containerStats, customErr := getContainerStats(ctx, cli, container.ID) 133 | if customErr.Error != "" { 134 | return ContainerMetrics{}, customErr 135 | } 136 | 137 | cpuPercent := calculateCPUPercent(containerStats) 138 | memUsage, memLimit, memPercent := calculateMemoryMetrics(containerStats) 139 | rx, tx := calculateNetworkMetrics(containerStats) 140 | blockRead, blockWrite := calculateBlockIOMetrics(containerStats) 141 | pids := containerStats.PidsStats.Current 142 | 143 | return ContainerMetrics{ 144 | ContainerID: container.ID, 145 | ContainerName: getContainerName(container.Names), 146 | Status: containerInspectResponse.State.Status, // Can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead" 147 | Running: containerInspectResponse.State.Running, 148 | BaseImage: container.Image, 149 | ExposedPorts: portList, 150 | StartedAt: GetUnixTimestamp(containerInspectResponse.State.StartedAt), 151 | FinishedAt: GetUnixTimestamp(containerInspectResponse.State.FinishedAt), 152 | Health: healthCheck(containerInspectResponse), 153 | Stats: &ContainerStats{ 154 | CPUPercent: cpuPercent, 155 | MemoryUsage: memUsage, 156 | MemoryLimit: memLimit, 157 | MemoryPercent: memPercent, 158 | NetworkRx: rx, 159 | NetworkTx: tx, 160 | BlockRead: blockRead, 161 | BlockWrite: blockWrite, 162 | PIDs: pids, 163 | }, 164 | }, CustomErr{} 165 | } 166 | 167 | // inspectContainer inspects a container and returns its detailed information. 168 | func inspectContainer(ctx context.Context, cli *client.Client, containerID string) (container.InspectResponse, error) { 169 | // Inspect each container 170 | containerInspectResponse, err := cli.ContainerInspect(ctx, containerID) 171 | if err != nil { 172 | return container.InspectResponse{}, err 173 | } 174 | return containerInspectResponse, nil 175 | } 176 | 177 | // extractExposedPorts extracts the exposed ports from a container inspection response. 178 | func extractExposedPorts(containerInspectResponse container.InspectResponse) []Port { 179 | portList := make([]Port, 0) 180 | if containerInspectResponse.Config == nil { 181 | return portList 182 | } 183 | for port := range containerInspectResponse.Config.ExposedPorts { 184 | portList = append(portList, Port{ 185 | Port: port.Port(), 186 | Protocol: port.Proto(), 187 | }) 188 | } 189 | return portList 190 | } 191 | 192 | // dockerStatsResponse represents the raw statistics response from Docker API. 193 | type dockerStatsResponse struct { 194 | CPUStats struct { 195 | CPUUsage struct { 196 | TotalUsage uint64 `json:"total_usage"` 197 | PercpuUsage []uint64 `json:"percpu_usage"` 198 | } `json:"cpu_usage"` 199 | SystemUsage uint64 `json:"system_cpu_usage"` 200 | OnlineCPUs uint32 `json:"online_cpus"` 201 | } `json:"cpu_stats"` 202 | PreCPUStats struct { 203 | CPUUsage struct { 204 | TotalUsage uint64 `json:"total_usage"` 205 | } `json:"cpu_usage"` 206 | SystemUsage uint64 `json:"system_cpu_usage"` 207 | } `json:"precpu_stats"` 208 | MemoryStats struct { 209 | Usage uint64 `json:"usage"` 210 | Limit uint64 `json:"limit"` 211 | Stats struct { 212 | InactiveFile uint64 `json:"inactive_file"` 213 | } `json:"stats"` 214 | } `json:"memory_stats"` 215 | Networks map[string]struct { 216 | RxBytes uint64 `json:"rx_bytes"` 217 | TxBytes uint64 `json:"tx_bytes"` 218 | } `json:"networks"` 219 | BlkioStats struct { 220 | IoServiceBytesRecursive []struct { 221 | Op string `json:"op"` 222 | Value uint64 `json:"value"` 223 | } `json:"io_service_bytes_recursive"` 224 | } `json:"blkio_stats"` 225 | PidsStats struct { 226 | Current uint64 `json:"current"` 227 | } `json:"pids_stats"` 228 | } 229 | 230 | // getContainerStats retrieves and decodes container statistics. 231 | func getContainerStats(ctx context.Context, cli *client.Client, containerID string) (dockerStatsResponse, CustomErr) { 232 | // Get container stats 233 | stats, err := cli.ContainerStats(ctx, containerID, false) 234 | if err != nil { 235 | return dockerStatsResponse{}, CustomErr{ 236 | Metric: []string{"docker.container.stats"}, 237 | Error: err.Error(), 238 | } 239 | } 240 | 241 | defer stats.Body.Close() 242 | 243 | var s dockerStatsResponse 244 | dec := json.NewDecoder(stats.Body) 245 | if err := dec.Decode(&s); err != nil { 246 | return dockerStatsResponse{}, CustomErr{ 247 | Metric: []string{"docker.container.stats.decode"}, 248 | Error: err.Error(), 249 | } 250 | } 251 | 252 | return s, CustomErr{} 253 | } 254 | 255 | // calculateCPUPercent calculates the CPU usage percentage from Docker stats. 256 | func calculateCPUPercent(s dockerStatsResponse) float64 { 257 | // Calculate CPU percent (use Docker's common calculation) 258 | cpuPercent := 0.0 259 | cpuDelta := float64(s.CPUStats.CPUUsage.TotalUsage) - float64(s.PreCPUStats.CPUUsage.TotalUsage) 260 | systemDelta := float64(s.CPUStats.SystemUsage) - float64(s.PreCPUStats.SystemUsage) 261 | onlineCPUs := float64(s.CPUStats.OnlineCPUs) 262 | if onlineCPUs == 0 && len(s.CPUStats.CPUUsage.PercpuUsage) > 0 { 263 | onlineCPUs = float64(len(s.CPUStats.CPUUsage.PercpuUsage)) 264 | } 265 | if systemDelta > 0.0 && cpuDelta > 0.0 && onlineCPUs > 0 { 266 | cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100.0 267 | } 268 | return cpuPercent 269 | } 270 | 271 | // calculateMemoryMetrics calculates memory usage, limit, and percentage from Docker stats. 272 | // Uses the same calculation as Docker CLI: usage - inactive_file (cache that can be reclaimed) 273 | func calculateMemoryMetrics(s dockerStatsResponse) (uint64, uint64, float64) { 274 | // Memory usage and percent 275 | // Docker CLI subtracts inactive_file (page cache) from usage to get "real" memory usage 276 | memUsage := s.MemoryStats.Usage 277 | if s.MemoryStats.Stats.InactiveFile > 0 && s.MemoryStats.Stats.InactiveFile < memUsage { 278 | memUsage -= s.MemoryStats.Stats.InactiveFile 279 | } 280 | memLimit := s.MemoryStats.Limit 281 | memPercent := 0.0 282 | if memLimit > 0 { 283 | memPercent = float64(memUsage) / float64(memLimit) * 100.0 284 | } 285 | return memUsage, memLimit, memPercent 286 | } 287 | 288 | // calculateNetworkMetrics calculates total network bytes received and transmitted. 289 | func calculateNetworkMetrics(s dockerStatsResponse) (uint64, uint64) { 290 | // Network bytes (sum across interfaces) 291 | var rx uint64 292 | var tx uint64 293 | if s.Networks != nil { 294 | for _, v := range s.Networks { 295 | rx += v.RxBytes 296 | tx += v.TxBytes 297 | } 298 | } 299 | return rx, tx 300 | } 301 | 302 | // calculateBlockIOMetrics calculates total block I/O read and write bytes. 303 | func calculateBlockIOMetrics(s dockerStatsResponse) (uint64, uint64) { 304 | // Block I/O bytes 305 | var blockRead uint64 306 | var blockWrite uint64 307 | for _, stat := range s.BlkioStats.IoServiceBytesRecursive { 308 | // Docker uses lowercase operation names: "read" and "write" 309 | switch stat.Op { 310 | case "read", "Read": 311 | blockRead += stat.Value 312 | case "write", "Write": 313 | blockWrite += stat.Value 314 | } 315 | } 316 | return blockRead, blockWrite 317 | } 318 | 319 | func stateBasedHealthCheck(inspectResponse container.InspectResponse) bool { 320 | if inspectResponse.State == nil { 321 | // If the state is nil, we cannot determine health 322 | return false 323 | } 324 | // Check for explicit failure conditions first 325 | if inspectResponse.State.OOMKilled || inspectResponse.State.Dead || inspectResponse.State.ExitCode != 0 { 326 | return false 327 | } 328 | 329 | // Only consider healthy if running and status is "running" 330 | return inspectResponse.State != nil && inspectResponse.State.Running && inspectResponse.State.Status == "running" 331 | } 332 | 333 | // healthCheck returns the health status of a container based on its inspection response. 334 | // If there is health check information available, it returns the health status. 335 | // If not, it runs a state based health check based on the container's state. 336 | // If the container is running and healthy, it returns a healthy status. 337 | // If the container is not running or has failed, it returns an unhealthy status. 338 | // If the container is starting, it returns 'healthy' status. 339 | func healthCheck(inspectResponse container.InspectResponse) *ContainerHealthStatus { 340 | if inspectResponse.State.Health != nil { 341 | // If the container has a health check, return its status 342 | return &ContainerHealthStatus{ 343 | // If the health check is healthy or starting, consider it healthy 344 | Healthy: inspectResponse.State.Health.Status == "healthy" || inspectResponse.State.Health.Status == "starting", 345 | Source: SourceContainerHealthCheck, 346 | Message: "Based on container health check", 347 | } 348 | } 349 | 350 | // If no health check is defined, run state based health check 351 | return &ContainerHealthStatus{ 352 | Healthy: stateBasedHealthCheck(inspectResponse), 353 | Source: SourceStateBasedHealthCheck, 354 | Message: "Based on container state", 355 | } 356 | } 357 | 358 | // getContainerName extracts the container name from the list of names. 359 | func getContainerName(names []string) string { 360 | if len(names) == 0 || len(names[0]) == 0 { 361 | // If there are no names or the first name is empty, return an empty string 362 | return "" 363 | } 364 | 365 | if names[0][0] == '/' { 366 | return names[0][1:] // Remove the leading '/' from the container name 367 | } 368 | 369 | return names[0] 370 | } 371 | 372 | // GetUnixTimestamp converts a timestamp string in RFC3339 format to a Unix timestamp. 373 | // If the timestamp is invalid or represents the zero value, it returns 0. 374 | // The function handles both seconds and nanoseconds precision. 375 | func GetUnixTimestamp(timestamp string) int64 { 376 | // Convert the timestamp string to a Unix timestamp 377 | t, err := time.Parse(time.RFC3339Nano, timestamp) 378 | if err != nil || timestamp == "0001-01-01T00:00:00Z" { 379 | return 0 // Return 0 if parsing fails or if the timestamp is the zero value 380 | } 381 | return t.Unix() 382 | } 383 | -------------------------------------------------------------------------------- /internal/metric/docker_test.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // Mock data based on docker stats output: 8 | // CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS 9 | // 09e88f793869982fdb86e0ac183a4487a8bcf763179c9bbf2f8d6e25492f23bc optimistic_jang 0.00% 4.488MiB / 15GiB 0.03% 8.03kB / 126B 3.58MB / 3.14MB 7 10 | 11 | func TestCalculateCPUPercent(t *testing.T) { 12 | t.Run("zero CPU usage", func(t *testing.T) { 13 | stats := dockerStatsResponse{} 14 | stats.CPUStats.CPUUsage.TotalUsage = 1000000 15 | stats.CPUStats.CPUUsage.PercpuUsage = []uint64{250000, 250000, 250000, 250000} 16 | stats.CPUStats.SystemUsage = 10000000000 17 | stats.CPUStats.OnlineCPUs = 4 18 | stats.PreCPUStats.CPUUsage.TotalUsage = 1000000 19 | stats.PreCPUStats.SystemUsage = 10000000000 20 | 21 | result := calculateCPUPercent(stats) 22 | if result != 0.0 { 23 | t.Errorf("calculateCPUPercent() = %v, expected 0.0", result) 24 | } 25 | }) 26 | 27 | t.Run("normal CPU usage", func(t *testing.T) { 28 | stats := dockerStatsResponse{} 29 | stats.CPUStats.CPUUsage.TotalUsage = 5000000 30 | stats.CPUStats.CPUUsage.PercpuUsage = []uint64{1250000, 1250000, 1250000, 1250000} 31 | stats.CPUStats.SystemUsage = 10100000000 32 | stats.CPUStats.OnlineCPUs = 4 33 | stats.PreCPUStats.CPUUsage.TotalUsage = 1000000 34 | stats.PreCPUStats.SystemUsage = 10000000000 35 | 36 | result := calculateCPUPercent(stats) 37 | expected := 16.0 // (4000000 / 100000000) * 4 * 100 = 16% 38 | // Allow small tolerance for floating point precision 39 | if result < expected-0.1 || result > expected+0.1 { 40 | t.Errorf("calculateCPUPercent() = %v, expected around %v", result, expected) 41 | } 42 | }) 43 | 44 | t.Run("infer CPU count from PercpuUsage", func(t *testing.T) { 45 | stats := dockerStatsResponse{} 46 | stats.CPUStats.CPUUsage.TotalUsage = 5000000 47 | stats.CPUStats.CPUUsage.PercpuUsage = []uint64{1250000, 1250000, 1250000, 1250000} 48 | stats.CPUStats.SystemUsage = 10100000000 49 | stats.CPUStats.OnlineCPUs = 0 // Not set 50 | stats.PreCPUStats.CPUUsage.TotalUsage = 1000000 51 | stats.PreCPUStats.SystemUsage = 10000000000 52 | 53 | result := calculateCPUPercent(stats) 54 | expected := 16.0 // (4000000 / 100000000) * 4 * 100 = 16% 55 | // Allow small tolerance for floating point precision 56 | if result < expected-0.1 || result > expected+0.1 { 57 | t.Errorf("calculateCPUPercent() = %v, expected around %v", result, expected) 58 | } 59 | }) 60 | } 61 | 62 | func TestCalculateMemoryMetrics(t *testing.T) { 63 | t.Run("optimistic_jang container - 4.488MiB / 15GiB (0.03%)", func(t *testing.T) { 64 | stats := dockerStatsResponse{} 65 | stats.MemoryStats.Usage = 5238784 // Raw usage: 4.996 MiB 66 | stats.MemoryStats.Stats.InactiveFile = 532480 // Inactive file cache: 0.508 MiB 67 | stats.MemoryStats.Limit = 16101339136 // 15 GiB in bytes 68 | 69 | usage, limit, percentage := calculateMemoryMetrics(stats) 70 | // Docker CLI calculation: usage - inactive_file = 5238784 - 532480 = 4706304 bytes (4.488 MiB) 71 | expectedUsage := uint64(4706304) 72 | if usage != expectedUsage { 73 | t.Errorf("usage = %v, expected %v", usage, expectedUsage) 74 | } 75 | if limit != 16101339136 { 76 | t.Errorf("limit = %v, expected 16101339136", limit) 77 | } 78 | expectedPercentage := 0.0292 79 | // Allow small tolerance for floating point precision 80 | if percentage < expectedPercentage-0.0001 || percentage > expectedPercentage+0.0001 { 81 | t.Errorf("percentage = %v, expected around %v", percentage, expectedPercentage) 82 | } 83 | }) 84 | 85 | t.Run("memory without inactive_file (no cache)", func(t *testing.T) { 86 | stats := dockerStatsResponse{} 87 | stats.MemoryStats.Usage = 1048576 88 | stats.MemoryStats.Stats.InactiveFile = 0 // No cache 89 | stats.MemoryStats.Limit = 2097152 90 | 91 | usage, limit, percentage := calculateMemoryMetrics(stats) 92 | // When inactive_file is 0, usage should remain unchanged 93 | if usage != 1048576 { 94 | t.Errorf("usage = %v, expected 1048576", usage) 95 | } 96 | if limit != 2097152 { 97 | t.Errorf("limit = %v, expected 2097152", limit) 98 | } 99 | expectedPercentage := 50.0 100 | if percentage != expectedPercentage { 101 | t.Errorf("percentage = %v, expected %v", percentage, expectedPercentage) 102 | } 103 | }) 104 | 105 | t.Run("zero memory limit", func(t *testing.T) { 106 | stats := dockerStatsResponse{} 107 | stats.MemoryStats.Usage = 1048576 108 | stats.MemoryStats.Limit = 0 109 | 110 | usage, limit, percentage := calculateMemoryMetrics(stats) 111 | if usage != 1048576 { 112 | t.Errorf("usage = %v, expected 1048576", usage) 113 | } 114 | if limit != 0 { 115 | t.Errorf("limit = %v, expected 0", limit) 116 | } 117 | if percentage != 0.0 { 118 | t.Errorf("percentage = %v, expected 0.0", percentage) 119 | } 120 | }) 121 | } 122 | 123 | func TestCalculateNetworkMetrics(t *testing.T) { 124 | t.Run("optimistic_jang container - 8.03kB / 126B", func(t *testing.T) { 125 | stats := dockerStatsResponse{} 126 | stats.Networks = make(map[string]struct { 127 | RxBytes uint64 `json:"rx_bytes"` 128 | TxBytes uint64 `json:"tx_bytes"` 129 | }) 130 | stats.Networks["eth0"] = struct { 131 | RxBytes uint64 `json:"rx_bytes"` 132 | TxBytes uint64 `json:"tx_bytes"` 133 | }{ 134 | RxBytes: 8030, // 8.03 kB 135 | TxBytes: 126, 136 | } 137 | 138 | rx, tx := calculateNetworkMetrics(stats) 139 | if rx != 8030 { 140 | t.Errorf("rx = %v, expected 8030", rx) 141 | } 142 | if tx != 126 { 143 | t.Errorf("tx = %v, expected 126", tx) 144 | } 145 | }) 146 | 147 | t.Run("multiple network interfaces", func(t *testing.T) { 148 | stats := dockerStatsResponse{} 149 | stats.Networks = make(map[string]struct { 150 | RxBytes uint64 `json:"rx_bytes"` 151 | TxBytes uint64 `json:"tx_bytes"` 152 | }) 153 | stats.Networks["eth0"] = struct { 154 | RxBytes uint64 `json:"rx_bytes"` 155 | TxBytes uint64 `json:"tx_bytes"` 156 | }{ 157 | RxBytes: 5000, 158 | TxBytes: 100, 159 | } 160 | stats.Networks["eth1"] = struct { 161 | RxBytes uint64 `json:"rx_bytes"` 162 | TxBytes uint64 `json:"tx_bytes"` 163 | }{ 164 | RxBytes: 3030, 165 | TxBytes: 26, 166 | } 167 | 168 | rx, tx := calculateNetworkMetrics(stats) 169 | if rx != 8030 { 170 | t.Errorf("rx = %v, expected 8030", rx) 171 | } 172 | if tx != 126 { 173 | t.Errorf("tx = %v, expected 126", tx) 174 | } 175 | }) 176 | 177 | t.Run("nil networks", func(t *testing.T) { 178 | stats := dockerStatsResponse{} 179 | stats.Networks = nil 180 | 181 | rx, tx := calculateNetworkMetrics(stats) 182 | if rx != 0 { 183 | t.Errorf("rx = %v, expected 0", rx) 184 | } 185 | if tx != 0 { 186 | t.Errorf("tx = %v, expected 0", tx) 187 | } 188 | }) 189 | } 190 | 191 | func TestCalculateBlockIOMetrics(t *testing.T) { 192 | t.Run("optimistic_jang container - 3.58MB / 3.14MB", func(t *testing.T) { 193 | stats := dockerStatsResponse{} 194 | stats.BlkioStats.IoServiceBytesRecursive = []struct { 195 | Op string `json:"op"` 196 | Value uint64 `json:"value"` 197 | }{ 198 | {Op: "read", Value: 3580000}, // 3.58 MB 199 | {Op: "write", Value: 3140000}, // 3.14 MB 200 | } 201 | 202 | read, write := calculateBlockIOMetrics(stats) 203 | if read != 3580000 { 204 | t.Errorf("read = %v, expected 3580000", read) 205 | } 206 | if write != 3140000 { 207 | t.Errorf("write = %v, expected 3140000", write) 208 | } 209 | }) 210 | 211 | t.Run("uppercase operation names", func(t *testing.T) { 212 | stats := dockerStatsResponse{} 213 | stats.BlkioStats.IoServiceBytesRecursive = []struct { 214 | Op string `json:"op"` 215 | Value uint64 `json:"value"` 216 | }{ 217 | {Op: "Read", Value: 1000000}, 218 | {Op: "Write", Value: 500000}, 219 | } 220 | 221 | read, write := calculateBlockIOMetrics(stats) 222 | if read != 1000000 { 223 | t.Errorf("read = %v, expected 1000000", read) 224 | } 225 | if write != 500000 { 226 | t.Errorf("write = %v, expected 500000", write) 227 | } 228 | }) 229 | 230 | t.Run("multiple read and write operations", func(t *testing.T) { 231 | stats := dockerStatsResponse{} 232 | stats.BlkioStats.IoServiceBytesRecursive = []struct { 233 | Op string `json:"op"` 234 | Value uint64 `json:"value"` 235 | }{ 236 | {Op: "read", Value: 2000000}, 237 | {Op: "read", Value: 1580000}, 238 | {Op: "write", Value: 2000000}, 239 | {Op: "write", Value: 1140000}, 240 | } 241 | 242 | read, write := calculateBlockIOMetrics(stats) 243 | if read != 3580000 { 244 | t.Errorf("read = %v, expected 3580000", read) 245 | } 246 | if write != 3140000 { 247 | t.Errorf("write = %v, expected 3140000", write) 248 | } 249 | }) 250 | 251 | t.Run("empty blkio stats", func(t *testing.T) { 252 | stats := dockerStatsResponse{} 253 | stats.BlkioStats.IoServiceBytesRecursive = []struct { 254 | Op string `json:"op"` 255 | Value uint64 `json:"value"` 256 | }{} 257 | 258 | read, write := calculateBlockIOMetrics(stats) 259 | if read != 0 { 260 | t.Errorf("read = %v, expected 0", read) 261 | } 262 | if write != 0 { 263 | t.Errorf("write = %v, expected 0", write) 264 | } 265 | }) 266 | } 267 | 268 | func TestGetContainerName(t *testing.T) { 269 | t.Run("name with leading slash", func(t *testing.T) { 270 | result := getContainerName([]string{"/optimistic_jang"}) 271 | if result != "optimistic_jang" { 272 | t.Errorf("getContainerName() = %v, expected optimistic_jang", result) 273 | } 274 | }) 275 | 276 | t.Run("name without leading slash", func(t *testing.T) { 277 | result := getContainerName([]string{"optimistic_jang"}) 278 | if result != "optimistic_jang" { 279 | t.Errorf("getContainerName() = %v, expected optimistic_jang", result) 280 | } 281 | }) 282 | 283 | t.Run("empty names array", func(t *testing.T) { 284 | result := getContainerName([]string{}) 285 | if result != "" { 286 | t.Errorf("getContainerName() = %v, expected empty string", result) 287 | } 288 | }) 289 | 290 | t.Run("empty first name", func(t *testing.T) { 291 | result := getContainerName([]string{""}) 292 | if result != "" { 293 | t.Errorf("getContainerName() = %v, expected empty string", result) 294 | } 295 | }) 296 | 297 | t.Run("multiple names", func(t *testing.T) { 298 | result := getContainerName([]string{"/optimistic_jang", "/another_name"}) 299 | if result != "optimistic_jang" { 300 | t.Errorf("getContainerName() = %v, expected optimistic_jang", result) 301 | } 302 | }) 303 | } 304 | 305 | func TestGetUnixTimestamp(t *testing.T) { 306 | t.Run("valid RFC3339Nano timestamp", func(t *testing.T) { 307 | result := GetUnixTimestamp("2023-11-28T10:30:45.123456789Z") 308 | // Check that it's a valid positive timestamp (not zero) 309 | if result <= 0 { 310 | t.Errorf("GetUnixTimestamp() = %v, expected positive timestamp", result) 311 | } 312 | // Verify it's in the expected range (Nov 2023) 313 | if result < 1700000000 || result > 1702000000 { 314 | t.Errorf("GetUnixTimestamp() = %v, expected timestamp around Nov 2023", result) 315 | } 316 | }) 317 | 318 | t.Run("valid RFC3339 timestamp without nano", func(t *testing.T) { 319 | result := GetUnixTimestamp("2023-11-28T10:30:45Z") 320 | // Check that it's a valid positive timestamp (not zero) 321 | if result <= 0 { 322 | t.Errorf("GetUnixTimestamp() = %v, expected positive timestamp", result) 323 | } 324 | // Verify it's in the expected range (Nov 2023) 325 | if result < 1700000000 || result > 1702000000 { 326 | t.Errorf("GetUnixTimestamp() = %v, expected timestamp around Nov 2023", result) 327 | } 328 | }) 329 | 330 | t.Run("zero value timestamp", func(t *testing.T) { 331 | result := GetUnixTimestamp("0001-01-01T00:00:00Z") 332 | if result != 0 { 333 | t.Errorf("GetUnixTimestamp() = %v, expected 0", result) 334 | } 335 | }) 336 | 337 | t.Run("invalid timestamp", func(t *testing.T) { 338 | result := GetUnixTimestamp("invalid") 339 | if result != 0 { 340 | t.Errorf("GetUnixTimestamp() = %v, expected 0", result) 341 | } 342 | }) 343 | 344 | t.Run("empty timestamp", func(t *testing.T) { 345 | result := GetUnixTimestamp("") 346 | if result != 0 { 347 | t.Errorf("GetUnixTimestamp() = %v, expected 0", result) 348 | } 349 | }) 350 | } 351 | 352 | // TestMockContainerStats tests the complete container stats structure 353 | // based on the mock docker stats output 354 | func TestMockContainerStats(t *testing.T) { 355 | // Mock data for: 09e88f793869982fdb86e0ac183a4487a8bcf763179c9bbf2f8d6e25492f23bc optimistic_jang 356 | mockStats := dockerStatsResponse{} 357 | 358 | // CPU Stats 359 | mockStats.CPUStats.CPUUsage.TotalUsage = 1000000 // Minimal usage for 0.00% 360 | mockStats.CPUStats.CPUUsage.PercpuUsage = []uint64{250000, 250000, 250000, 250000} 361 | mockStats.CPUStats.SystemUsage = 10000000000 362 | mockStats.CPUStats.OnlineCPUs = 4 363 | mockStats.PreCPUStats.CPUUsage.TotalUsage = 1000000 364 | mockStats.PreCPUStats.SystemUsage = 10000000000 365 | 366 | // Memory Stats 367 | mockStats.MemoryStats.Usage = 5238784 // Raw usage: 4.996 MiB 368 | mockStats.MemoryStats.Stats.InactiveFile = 532480 // Inactive file cache: 0.508 MiB 369 | mockStats.MemoryStats.Limit = 16101339136 // 15 GiB 370 | 371 | // Network Stats 372 | mockStats.Networks = make(map[string]struct { 373 | RxBytes uint64 `json:"rx_bytes"` 374 | TxBytes uint64 `json:"tx_bytes"` 375 | }) 376 | mockStats.Networks["eth0"] = struct { 377 | RxBytes uint64 `json:"rx_bytes"` 378 | TxBytes uint64 `json:"tx_bytes"` 379 | }{ 380 | RxBytes: 8030, // 8.03 kB 381 | TxBytes: 126, 382 | } 383 | 384 | // Block I/O Stats 385 | mockStats.BlkioStats.IoServiceBytesRecursive = []struct { 386 | Op string `json:"op"` 387 | Value uint64 `json:"value"` 388 | }{ 389 | {Op: "read", Value: 3580000}, // 3.58 MB 390 | {Op: "write", Value: 3140000}, // 3.14 MB 391 | } 392 | 393 | // PIDs Stats 394 | mockStats.PidsStats.Current = 7 395 | 396 | // Test CPU calculation 397 | cpuPercent := calculateCPUPercent(mockStats) 398 | if cpuPercent > 0.01 { // Should be close to 0% 399 | t.Errorf("CPU percent should be near 0%%, got %v", cpuPercent) 400 | } 401 | 402 | // Test memory calculation 403 | memUsage, memLimit, memPercent := calculateMemoryMetrics(mockStats) 404 | // Expected: 5238784 - 532480 = 4706304 bytes (4.488 MiB) 405 | expectedMemUsage := uint64(4706304) 406 | if memUsage != expectedMemUsage { 407 | t.Errorf("Memory usage = %v, expected %v (usage - inactive_file)", memUsage, expectedMemUsage) 408 | } 409 | if memLimit != 16101339136 { 410 | t.Errorf("Memory limit = %v, expected 16101339136", memLimit) 411 | } 412 | if memPercent < 0.029 || memPercent > 0.03 { 413 | t.Errorf("Memory percent = %v, expected around 0.03%%", memPercent) 414 | } 415 | 416 | // Test network calculation 417 | rx, tx := calculateNetworkMetrics(mockStats) 418 | if rx != 8030 { 419 | t.Errorf("Network RX = %v, expected 8030", rx) 420 | } 421 | if tx != 126 { 422 | t.Errorf("Network TX = %v, expected 126", tx) 423 | } 424 | 425 | // Test block I/O calculation 426 | blockRead, blockWrite := calculateBlockIOMetrics(mockStats) 427 | if blockRead != 3580000 { 428 | t.Errorf("Block read = %v, expected 3580000", blockRead) 429 | } 430 | if blockWrite != 3140000 { 431 | t.Errorf("Block write = %v, expected 3140000", blockWrite) 432 | } 433 | 434 | // Test PIDs 435 | if mockStats.PidsStats.Current != 7 { 436 | t.Errorf("PIDs = %v, expected 7", mockStats.PidsStats.Current) 437 | } 438 | 439 | t.Logf("Mock container stats validated successfully") 440 | t.Logf("Container ID: 09e88f793869982fdb86e0ac183a4487a8bcf763179c9bbf2f8d6e25492f23bc") 441 | t.Logf("Container Name: optimistic_jang") 442 | t.Logf("CPU: %.2f%%", cpuPercent) 443 | t.Logf("Memory: %.2fMiB / %.2fGiB (%.2f%%)", float64(memUsage)/1048576, float64(memLimit)/1073741824, memPercent) 444 | t.Logf("Network: %d / %d bytes", rx, tx) 445 | t.Logf("Block I/O: %d / %d bytes", blockRead, blockWrite) 446 | t.Logf("PIDs: %d", mockStats.PidsStats.Current) 447 | } 448 | -------------------------------------------------------------------------------- /openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Capture 4 | description: OpenAPI Specifications for the Capture's API 5 | version: 1.1.0 6 | license: 7 | name: AGPL-3.0 8 | url: https://github.com/bluewave-labs/capture/blob/develop/LICENSE 9 | servers: 10 | - url: http://localhost:{PORT} 11 | description: Local development server 12 | variables: 13 | PORT: 14 | default: '59232' 15 | description: Port number 16 | enum: 17 | - '59232' 18 | paths: 19 | /health: 20 | get: 21 | summary: Check if the server is healthy or not 22 | responses: 23 | '200': 24 | description: Server is healthy 25 | content: 26 | application/json: 27 | schema: 28 | type: string 29 | example: "OK" 30 | /api/v1/metrics: 31 | get: 32 | summary: Read server data 33 | responses: 34 | '200': 35 | description: OK 36 | content: 37 | application/json: 38 | schema: 39 | $ref: '#/components/schemas/AllMetricResponse' 40 | '207': 41 | description: Multi-Status | Some of the data is not available 42 | content: 43 | application/json: 44 | schema: 45 | $ref: '#/components/schemas/AllMetricResponse' 46 | security: 47 | - type: http 48 | scheme: bearer 49 | bearerFormat: JWT 50 | /api/v1/metrics/cpu: 51 | get: 52 | summary: Read CPU data 53 | responses: 54 | '200': 55 | description: OK 56 | content: 57 | application/json: 58 | schema: 59 | $ref: '#/components/schemas/CPUMetricResponse' 60 | '207': 61 | description: Multi-Status | Some of the data is not available 62 | content: 63 | application/json: 64 | schema: 65 | $ref: '#/components/schemas/CPUMetricResponse' 66 | security: 67 | - type: http 68 | scheme: bearer 69 | bearerFormat: JWT 70 | /api/v1/metrics/memory: 71 | get: 72 | summary: Read Memory data 73 | responses: 74 | '200': 75 | description: OK 76 | content: 77 | application/json: 78 | schema: 79 | $ref: '#/components/schemas/MemoryMetricResponse' 80 | '207': 81 | description: Multi-Status | Some of the data is not available 82 | content: 83 | application/json: 84 | schema: 85 | $ref: '#/components/schemas/MemoryMetricResponse' 86 | security: 87 | - type: http 88 | scheme: bearer 89 | bearerFormat: JWT 90 | /api/v1/metrics/disk: 91 | get: 92 | summary: Read Disk data 93 | responses: 94 | '200': 95 | description: OK 96 | content: 97 | application/json: 98 | schema: 99 | $ref: '#/components/schemas/DiskMetricResponse' 100 | '207': 101 | description: Multi-Status | Some of the data is not available 102 | content: 103 | application/json: 104 | schema: 105 | $ref: '#/components/schemas/DiskMetricResponse' 106 | security: 107 | - type: http 108 | scheme: bearer 109 | bearerFormat: JWT 110 | /api/v1/metrics/host: 111 | get: 112 | summary: Read Host data 113 | responses: 114 | '200': 115 | description: OK 116 | content: 117 | application/json: 118 | schema: 119 | $ref: '#/components/schemas/HostMetricResponse' 120 | '207': 121 | description: Multi-Status | Some of the data is not available 122 | content: 123 | application/json: 124 | schema: 125 | $ref: '#/components/schemas/HostMetricResponse' 126 | security: 127 | - type: http 128 | scheme: bearer 129 | bearerFormat: JWT 130 | /api/v1/metrics/smart: 131 | get: 132 | summary: Read S.M.A.R.T. data 133 | responses: 134 | '200': 135 | description: OK 136 | content: 137 | application/json: 138 | schema: 139 | $ref: '#/components/schemas/SmartMetricResponse' 140 | '207': 141 | description: Multi-Status | Some of the data is not available 142 | content: 143 | application/json: 144 | schema: 145 | $ref: '#/components/schemas/SmartMetricResponse' 146 | security: 147 | - type: http 148 | scheme: bearer 149 | bearerFormat: JWT 150 | /api/v1/metrics/net: 151 | get: 152 | summary: Read Network data 153 | responses: 154 | '200': 155 | description: OK 156 | content: 157 | application/json: 158 | schema: 159 | $ref: '#/components/schemas/NetworkMetricResponse' 160 | '207': 161 | description: Multi-Status | Some of the data is not available 162 | content: 163 | application/json: 164 | schema: 165 | $ref: '#/components/schemas/NetworkMetricResponse' 166 | security: 167 | - type: http 168 | scheme: bearer 169 | bearerFormat: JWT 170 | /api/v1/metrics/docker: 171 | get: 172 | summary: Read Docker container metrics 173 | responses: 174 | '200': 175 | description: OK 176 | content: 177 | application/json: 178 | schema: 179 | $ref: '#/components/schemas/DockerMetricResponse' 180 | '207': 181 | description: Multi-Status | Some of the data is not available 182 | content: 183 | application/json: 184 | schema: 185 | $ref: '#/components/schemas/DockerMetricResponse' 186 | '403': 187 | description: Forbidden - Invalid authentication token 188 | content: 189 | application/json: 190 | schema: 191 | type: object 192 | properties: 193 | error: 194 | type: string 195 | example: "Invalid token provided" 196 | security: 197 | - type: http 198 | scheme: bearer 199 | bearerFormat: JWT 200 | components: 201 | schemas: 202 | AllMetricResponse: 203 | type: object 204 | properties: 205 | data: 206 | type: object 207 | properties: 208 | cpu: 209 | $ref: '#/components/schemas/CPUData' 210 | memory: 211 | $ref: '#/components/schemas/MemoryData' 212 | disk: 213 | type: array 214 | items: 215 | $ref: '#/components/schemas/DiskData' 216 | host: 217 | $ref: '#/components/schemas/HostData' 218 | net: 219 | type: array 220 | items: 221 | $ref: '#/components/schemas/NetData' 222 | capture: 223 | $ref: '#/components/schemas/CaptureMetadata' 224 | errors: 225 | type: array 226 | nullable: true 227 | items: 228 | $ref: '#/components/schemas/MetricErrorObject' 229 | CPUMetricResponse: 230 | type: object 231 | properties: 232 | data: 233 | $ref: '#/components/schemas/CPUData' 234 | capture: 235 | $ref: '#/components/schemas/CaptureMetadata' 236 | errors: 237 | type: array 238 | nullable: true 239 | items: 240 | $ref: '#/components/schemas/MetricErrorObject' 241 | MemoryMetricResponse: 242 | type: object 243 | properties: 244 | data: 245 | $ref: '#/components/schemas/MemoryData' 246 | capture: 247 | $ref: '#/components/schemas/CaptureMetadata' 248 | errors: 249 | type: array 250 | nullable: true 251 | items: 252 | $ref: '#/components/schemas/MetricErrorObject' 253 | DiskMetricResponse: 254 | type: object 255 | properties: 256 | data: 257 | type: array 258 | items: 259 | $ref: '#/components/schemas/DiskData' 260 | capture: 261 | $ref: '#/components/schemas/CaptureMetadata' 262 | errors: 263 | type: array 264 | nullable: true 265 | items: 266 | $ref: '#/components/schemas/MetricErrorObject' 267 | HostMetricResponse: 268 | type: object 269 | properties: 270 | data: 271 | $ref: '#/components/schemas/HostData' 272 | capture: 273 | $ref: '#/components/schemas/CaptureMetadata' 274 | errors: 275 | type: array 276 | nullable: true 277 | items: 278 | $ref: '#/components/schemas/MetricErrorObject' 279 | CPUData: 280 | type: object 281 | properties: 282 | physical_core: 283 | type: integer 284 | example: 4 285 | logical_core: 286 | type: integer 287 | example: 8 288 | frequency: 289 | type: number 290 | example: 2500 291 | temperature: 292 | type: array 293 | nullable: true 294 | items: 295 | type: number 296 | example: [50] 297 | free_percent: 298 | type: number 299 | example: 0.5 300 | usage_percent: 301 | type: number 302 | example: 0.5 303 | MemoryData: 304 | type: object 305 | properties: 306 | total_bytes: 307 | type: integer 308 | example: 1351533568 309 | available_bytes: 310 | type: integer 311 | example: 351533568 312 | used_bytes: 313 | type: integer 314 | example: 1000000000 315 | usage_percent: 316 | type: number 317 | example: 0.2601 318 | DiskData: 319 | type: object 320 | properties: 321 | device: 322 | type: string 323 | example: "/dev/sda1" 324 | total_bytes: 325 | type: integer 326 | format: uint64 327 | nullable: true 328 | example: 1099511627776 329 | free_bytes: 330 | type: integer 331 | format: uint64 332 | nullable: true 333 | example: 549755813888 334 | used_bytes: 335 | type: integer 336 | format: uint64 337 | nullable: true 338 | example: 549755813888 339 | usage_percent: 340 | type: number 341 | nullable: true 342 | example: 50.0 343 | total_inodes: 344 | type: integer 345 | format: uint64 346 | nullable: true 347 | example: 1000000 348 | free_inodes: 349 | type: integer 350 | format: uint64 351 | nullable: true 352 | example: 500000 353 | used_inodes: 354 | type: integer 355 | format: uint64 356 | nullable: true 357 | example: 500000 358 | inodes_usage_percent: 359 | type: number 360 | nullable: true 361 | example: 50.0 362 | read_bytes: 363 | type: integer 364 | format: uint64 365 | nullable: true 366 | example: 1024000 367 | write_bytes: 368 | type: integer 369 | format: uint64 370 | nullable: true 371 | example: 512000 372 | read_time: 373 | type: integer 374 | format: uint64 375 | nullable: true 376 | example: 1000 377 | write_time: 378 | type: integer 379 | format: uint64 380 | nullable: true 381 | example: 500 382 | HostData: 383 | type: object 384 | properties: 385 | os: 386 | type: string 387 | example: "linux" 388 | platform: 389 | type: string 390 | example: "debian" 391 | kernel_version: 392 | type: string 393 | example: "5.4.0-42-generic" 394 | MetricErrorObject: 395 | type: object 396 | properties: 397 | metric: 398 | type: array 399 | items: 400 | type: string 401 | example: "cpu.temperature" 402 | err: 403 | type: string 404 | NetworkMetricResponse: 405 | type: object 406 | properties: 407 | data: 408 | type: array 409 | items: 410 | $ref: '#/components/schemas/NetData' 411 | capture: 412 | $ref: '#/components/schemas/CaptureMetadata' 413 | errors: 414 | type: array 415 | nullable: true 416 | items: 417 | $ref: '#/components/schemas/MetricErrorObject' 418 | SmartMetricResponse: 419 | type: object 420 | properties: 421 | data: 422 | $ref: '#/components/schemas/SmartData' 423 | capture: 424 | $ref: '#/components/schemas/CaptureMetadata' 425 | errors: 426 | type: array 427 | nullable: true 428 | items: 429 | $ref: '#/components/schemas/MetricErrorObject' 430 | CaptureMetadata: 431 | type: object 432 | properties: 433 | version: 434 | type: string 435 | mode: 436 | type: string 437 | NetData: 438 | type: object 439 | properties: 440 | name: 441 | type: string 442 | example: "eth0" 443 | bytes_sent: 444 | type: integer 445 | format: uint64 446 | example: 1024 447 | bytes_recv: 448 | type: integer 449 | format: uint64 450 | example: 2048 451 | packets_sent: 452 | type: integer 453 | format: uint64 454 | example: 100 455 | packets_recv: 456 | type: integer 457 | format: uint64 458 | example: 200 459 | err_in: 460 | type: integer 461 | format: uint64 462 | example: 0 463 | err_out: 464 | type: integer 465 | format: uint64 466 | example: 0 467 | drop_in: 468 | type: integer 469 | format: uint64 470 | example: 0 471 | drop_out: 472 | type: integer 473 | format: uint64 474 | example: 0 475 | fifo_in: 476 | type: integer 477 | format: uint64 478 | example: 0 479 | fifo_out: 480 | type: integer 481 | format: uint64 482 | example: 0 483 | SmartData: 484 | type: object 485 | properties: 486 | available_spare: 487 | type: string 488 | example: "100%" 489 | available_spare_threshold: 490 | type: string 491 | example: "10%" 492 | controller_busy_time: 493 | type: string 494 | example: "0" 495 | critical_warning: 496 | type: string 497 | example: "0" 498 | data_units_read: 499 | type: string 500 | example: "1234567" 501 | data_units_written: 502 | type: string 503 | example: "7654321" 504 | host_read_commands: 505 | type: string 506 | example: "100000" 507 | host_write_commands: 508 | type: string 509 | example: "50000" 510 | percentage_used: 511 | type: string 512 | example: "5" 513 | power_cycles: 514 | type: string 515 | example: "100" 516 | power_on_hours: 517 | type: string 518 | example: "1000" 519 | smart_overall_health_self_assessment_test_result: 520 | type: string 521 | example: "PASSED" 522 | temperature: 523 | type: string 524 | example: "35C" 525 | unsafe_shutdowns: 526 | type: string 527 | example: "0" 528 | DockerMetricResponse: 529 | type: object 530 | properties: 531 | data: 532 | type: array 533 | items: 534 | $ref: '#/components/schemas/ContainerData' 535 | capture: 536 | $ref: '#/components/schemas/CaptureMetadata' 537 | errors: 538 | type: array 539 | nullable: true 540 | items: 541 | $ref: '#/components/schemas/MetricErrorObject' 542 | ContainerData: 543 | type: object 544 | properties: 545 | container_id: 546 | type: string 547 | example: "09e88f793869982fdb86e0ac183a4487a8bcf763179c9bbf2f8d6e25492f23bc" 548 | container_name: 549 | type: string 550 | example: "optimistic_jang" 551 | status: 552 | type: string 553 | enum: ["created", "running", "paused", "restarting", "removing", "exited", "dead"] 554 | example: "running" 555 | health: 556 | $ref: '#/components/schemas/ContainerHealthStatus' 557 | running: 558 | type: boolean 559 | example: true 560 | base_image: 561 | type: string 562 | example: "nginx:latest" 563 | exposed_ports: 564 | type: array 565 | items: 566 | $ref: '#/components/schemas/PortData' 567 | started_at: 568 | type: integer 569 | format: int64 570 | example: 1701167445 571 | description: Unix timestamp 572 | finished_at: 573 | type: integer 574 | format: int64 575 | example: 0 576 | description: Unix timestamp 577 | stats: 578 | $ref: '#/components/schemas/ContainerStats' 579 | ContainerHealthStatus: 580 | type: object 581 | properties: 582 | healthy: 583 | type: boolean 584 | example: true 585 | source: 586 | type: string 587 | enum: ["container_health_check", "state_based_health_check"] 588 | example: "state_based_health_check" 589 | message: 590 | type: string 591 | example: "Based on container state" 592 | PortData: 593 | type: object 594 | properties: 595 | port: 596 | type: string 597 | example: "80" 598 | protocol: 599 | type: string 600 | example: "tcp" 601 | ContainerStats: 602 | type: object 603 | properties: 604 | cpu_percent: 605 | type: number 606 | example: 0.00 607 | memory_usage: 608 | type: integer 609 | format: uint64 610 | example: 4706304 611 | memory_limit: 612 | type: integer 613 | format: uint64 614 | example: 16101339136 615 | memory_percent: 616 | type: number 617 | example: 0.03 618 | network_rx_bytes: 619 | type: integer 620 | format: uint64 621 | example: 8030 622 | network_tx_bytes: 623 | type: integer 624 | format: uint64 625 | example: 126 626 | block_read_bytes: 627 | type: integer 628 | format: uint64 629 | example: 3580000 630 | block_write_bytes: 631 | type: integer 632 | format: uint64 633 | example: 3140000 634 | pids: 635 | type: integer 636 | format: uint64 637 | example: 7 638 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= 2 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 3 | github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= 4 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 5 | github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU= 6 | github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= 7 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 8 | github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= 9 | github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 10 | github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= 11 | github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 12 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 13 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 14 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 15 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 16 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 17 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 22 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 23 | github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= 24 | github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 25 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 26 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 27 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 28 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 29 | github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= 30 | github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 31 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 32 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 33 | github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= 34 | github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= 35 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 36 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 37 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 38 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 39 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 40 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 41 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 42 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 43 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 44 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 45 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 46 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 47 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 48 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 49 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 50 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 51 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 52 | github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= 53 | github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 54 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 55 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 56 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 57 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 58 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 59 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 60 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 61 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 62 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 63 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 64 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 65 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 66 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 67 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 68 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 69 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 70 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 71 | github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= 72 | github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 73 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 74 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 75 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 76 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 77 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 78 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 79 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 80 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 81 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 82 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 83 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 84 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 85 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 86 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 87 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= 88 | github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= 89 | github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= 90 | github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 91 | github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 92 | github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 93 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 94 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 95 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 96 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 97 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 98 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 99 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 100 | github.com/mstrYoda/go-arctest v0.0.0-20250422073853-ff9fe79f31d7 h1:MJx0hv/ODW4GvLOieVFcLZwV7IagDh6p5UKW+Ci+4yM= 101 | github.com/mstrYoda/go-arctest v0.0.0-20250422073853-ff9fe79f31d7/go.mod h1:DAMla66FTqzfpdvrk+io9mbcIEanDl90tZ+Wk3t4sBI= 102 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 103 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 104 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 105 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 106 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 107 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 108 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 109 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 110 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 111 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 112 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 113 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 114 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 115 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 116 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 117 | github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI= 118 | github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= 119 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 120 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 121 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 122 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 123 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 124 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 125 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 126 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 127 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 128 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 129 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 130 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 131 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 132 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 133 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 134 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 135 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 136 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 137 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 138 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 139 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 140 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 141 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 142 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 143 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 144 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 145 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 146 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 147 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 148 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 149 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 150 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 151 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 152 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= 153 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= 154 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= 155 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= 156 | go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 157 | go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 158 | go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= 159 | go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= 160 | go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= 161 | go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= 162 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 163 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 164 | go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 165 | go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 166 | golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= 167 | golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 168 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 169 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 170 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 171 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 172 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 173 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 174 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 175 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 176 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 177 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 178 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 179 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 180 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 181 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 182 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 183 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 184 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 185 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 186 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 187 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 188 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 189 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 190 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 191 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 192 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 193 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 194 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 195 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 196 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 197 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 198 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 199 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 200 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 201 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 202 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 203 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 204 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 205 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 206 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 207 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 208 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 209 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 210 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 211 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= 212 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= 213 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= 214 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 215 | google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= 216 | google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 217 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 218 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 219 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 220 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 221 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 222 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 223 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 224 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 225 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= 226 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 227 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 228 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 2024 BlueWave Labs 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------