From 4aa08d0ef4fb9b241549a06d0daa78c22ebdc983 Mon Sep 17 00:00:00 2001 From: Joel Male Date: Sun, 15 Feb 2026 16:04:57 +1000 Subject: [PATCH] feat: full collector with macOS support, metrics, and one-click installer - Collect CPU, memory, swap, disk, disk I/O, network (per-second rates), load average, system info, uptime, and top processes via gopsutil - Install script supports both Linux (systemd) and macOS (launchd) - Auto-detect OS and architecture for binary download - Accept TOKEN env var for non-interactive installs - Config at /etc/pulsewise-collector/config with simple KEY=VALUE format - Debug mode logs full response body - Hostname auto-detected if not set in config --- build.sh | 4 + config.example | 13 ++ go.mod | 15 ++ go.sum | 24 ++ install.sh | 333 ++++++++++++++++++++++++++++ main.go | 586 +++++++++++++++++++++++++++++++++++++++++++++++++ payload.json | 104 +++++++++ 7 files changed, 1079 insertions(+) create mode 100755 build.sh create mode 100644 config.example create mode 100644 go.mod create mode 100644 go.sum create mode 100755 install.sh create mode 100644 main.go create mode 100644 payload.json diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..55ee069 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +go build -o pulsewise-collector . +echo "Built: pulsewise-collector" diff --git a/config.example b/config.example new file mode 100644 index 0000000..35894b6 --- /dev/null +++ b/config.example @@ -0,0 +1,13 @@ +# Pulsewise Collector Configuration +# Copy this file to /etc/pulsewise-collector/config and fill in your values + +TOKEN=your-token-here + +# Optional — auto-detected from system hostname if omitted +# HOSTNAME=my-server + +# Optional — defaults to https://pulsewise.app/api/collect +# URL=http://localhost:8000/api/collect + +# Optional — logs full response body from the server +# DEBUG=true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a63921a --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module pulsewise-collector + +go 1.24.3 + +require ( + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bba3242 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..86e4796 --- /dev/null +++ b/install.sh @@ -0,0 +1,333 @@ +#!/bin/bash + +set -e + +# ───────────────────────────────────────────────────────────── +# Pulsewise Collector — One-click installer +# Usage: curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=your-token sudo -E bash +# ───────────────────────────────────────────────────────────── + +# Colors +BOLD='\033[1m' +DIM='\033[2m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +# Detect OS and architecture +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case "$OS" in + linux) OS_SLUG="linux" ;; + darwin) OS_SLUG="darwin" ;; + *) + echo -e "\033[0;31mUnsupported OS: $OS\033[0m" + exit 1 + ;; +esac + +case "$ARCH" in + x86_64|amd64) ARCH_SLUG="amd64" ;; + aarch64|arm64) ARCH_SLUG="arm64" ;; + *) + echo -e "\033[0;31mUnsupported architecture: $ARCH\033[0m" + exit 1 + ;; +esac + +# Configuration +BINARY_URL="https://pulsewise.app/collector/download/${OS_SLUG}-${ARCH_SLUG}" +BINARY_PATH="/usr/local/bin/pulsewise-collector" +CONFIG_DIR="/etc/pulsewise-collector" +CONFIG_FILE="${CONFIG_DIR}/config" +SERVICE_NAME="pulsewise-collector" +LAUNCHD_LABEL="app.pulsewise.collector" +LAUNCHD_PLIST="/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist" +SYSTEMD_FILE="/etc/systemd/system/${SERVICE_NAME}.service" + +# ───────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────── + +print_banner() { + echo "" + echo -e "${CYAN}${BOLD}" + echo " ┌─────────────────────────────────────┐" + echo " │ Pulsewise Collector Setup │" + echo " │ │" + echo " │ System monitoring made simple. │" + echo " └─────────────────────────────────────┘" + echo -e "${NC}" + echo "" +} + +step() { + echo -e " ${CYAN}${BOLD}[$1/4]${NC} ${BOLD}$2${NC}" +} + +info() { + echo -e " $1" +} + +success() { + echo -e " ${GREEN}$1${NC}" +} + +warn() { + echo -e " ${YELLOW}$1${NC}" +} + +fail() { + echo -e "\n ${RED}${BOLD}Error:${NC} $1\n" + exit 1 +} + +prompt() { + local var_name="$1" + local prompt_text="$2" + local default_val="$3" + local secret="$4" + local value="" + + if [ -n "$default_val" ]; then + prompt_text="${prompt_text} ${DIM}[${default_val}]${NC}" + fi + + echo "" + if [ "$secret" = "true" ]; then + echo -ne " ${BOLD}${prompt_text}:${NC} " + read -rs value + echo "" + else + echo -ne " ${BOLD}${prompt_text}:${NC} " + read -r value + fi + + if [ -z "$value" ] && [ -n "$default_val" ]; then + value="$default_val" + fi + + eval "$var_name=\"$value\"" +} + +stop_existing_service() { + if [ "$OS_SLUG" = "linux" ]; then + if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + info "Stopping existing service..." + systemctl stop "$SERVICE_NAME" + fi + elif [ "$OS_SLUG" = "darwin" ]; then + if launchctl list "$LAUNCHD_LABEL" &>/dev/null; then + info "Stopping existing service..." + launchctl bootout system "$LAUNCHD_PLIST" 2>/dev/null || true + fi + fi +} + +register_service() { + if [ "$OS_SLUG" = "linux" ]; then + if ! command -v systemctl &> /dev/null; then + fail "systemd is required but not found on this system." + fi + + cat > "$SYSTEMD_FILE" < "$LAUNCHD_PLIST" < + + + + Label + ${LAUNCHD_LABEL} + ProgramArguments + + ${BINARY_PATH} + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/pulsewise-collector.log + StandardErrorPath + /var/log/pulsewise-collector.log + + +EOF + success "launchd service registered" + fi +} + +start_service() { + if [ "$OS_SLUG" = "linux" ]; then + systemctl enable --quiet "$SERVICE_NAME" + systemctl start "$SERVICE_NAME" + + sleep 1 + if systemctl is-active --quiet "$SERVICE_NAME"; then + success "Service is running" + else + warn "Service may not have started. Check: journalctl -u $SERVICE_NAME -n 20" + fi + + elif [ "$OS_SLUG" = "darwin" ]; then + launchctl bootstrap system "$LAUNCHD_PLIST" + + sleep 1 + if launchctl list "$LAUNCHD_LABEL" &>/dev/null; then + success "Service is running" + else + warn "Service may not have started. Check: /var/log/pulsewise-collector.log" + fi + fi +} + +print_summary() { + echo "" + echo -e " ${GREEN}${BOLD}Setup complete.${NC}" + echo "" + echo -e " ${DIM}Hostname:${NC} $COLLECTOR_HOSTNAME" + echo -e " ${DIM}Config:${NC} $CONFIG_FILE" + echo -e " ${DIM}Binary:${NC} $BINARY_PATH" + echo "" + + if [ "$OS_SLUG" = "linux" ]; then + echo -e " ${DIM}Useful commands:${NC}" + echo -e " journalctl -u $SERVICE_NAME -f ${DIM}# follow logs${NC}" + echo -e " systemctl status $SERVICE_NAME ${DIM}# check status${NC}" + echo -e " systemctl restart $SERVICE_NAME ${DIM}# restart${NC}" + elif [ "$OS_SLUG" = "darwin" ]; then + echo -e " ${DIM}Useful commands:${NC}" + echo -e " tail -f /var/log/pulsewise-collector.log ${DIM}# follow logs${NC}" + echo -e " sudo launchctl kickstart -k system/${LAUNCHD_LABEL} ${DIM}# restart${NC}" + echo -e " sudo launchctl bootout system ${LAUNCHD_PLIST} ${DIM}# stop${NC}" + fi + + echo "" +} + +# ───────────────────────────────────────────────────────────── +# Pre-flight checks +# ───────────────────────────────────────────────────────────── + +print_banner + +# Must be root +if [ "$EUID" -ne 0 ]; then + fail "Please run as root:\n\n ${DIM}curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=your-token sudo -E bash${NC}" +fi + +# Check for existing installation +if [ -f "$BINARY_PATH" ] || [ -f "$CONFIG_FILE" ]; then + warn "An existing Pulsewise installation was detected." + echo "" + echo -ne " ${BOLD}Reinstall and overwrite? (y/N):${NC} " + read -r -n 1 REPLY + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo -e "\n Installation cancelled.\n" + exit 0 + fi + echo "" + stop_existing_service +fi + +# ───────────────────────────────────────────────────────────── +# Onboarding +# ───────────────────────────────────────────────────────────── + +echo -e " ${DIM}Let's get your collector set up. This takes about 30 seconds.${NC}" + +# Token — use env var if provided, otherwise prompt +if [ -z "$TOKEN" ]; then + prompt TOKEN "Pulsewise token" "" "true" + if [ -z "$TOKEN" ]; then + fail "A token is required. Find yours at https://pulsewise.app/settings/tokens" + fi +else + success "Token provided via environment" +fi + +# Hostname — use env var if provided, otherwise prompt with system default +if [ -z "$COLLECTOR_HOSTNAME" ]; then + DEFAULT_HOSTNAME=$(hostname -s 2>/dev/null || hostname) + prompt COLLECTOR_HOSTNAME "Hostname" "$DEFAULT_HOSTNAME" + if [ -z "$COLLECTOR_HOSTNAME" ]; then + COLLECTOR_HOSTNAME="$DEFAULT_HOSTNAME" + fi +else + success "Hostname: $COLLECTOR_HOSTNAME" +fi + +# ───────────────────────────────────────────────────────────── +# Installation +# ───────────────────────────────────────────────────────────── + +echo "" + +# Step 1: Download binary +step 1 "Downloading collector..." +TEMP_BINARY=$(mktemp) +if command -v curl &> /dev/null; then + HTTP_CODE=$(curl -fsSL -o "$TEMP_BINARY" -w "%{http_code}" "$BINARY_URL" 2>/dev/null) || true +elif command -v wget &> /dev/null; then + wget -q -O "$TEMP_BINARY" "$BINARY_URL" 2>/dev/null && HTTP_CODE="200" || HTTP_CODE="000" +else + fail "Neither curl nor wget is available. Install one and try again." +fi + +if [ ! -s "$TEMP_BINARY" ] || [ "$HTTP_CODE" != "200" ]; then + rm -f "$TEMP_BINARY" + fail "Download failed (HTTP $HTTP_CODE). Check your network and try again." +fi + +chmod +x "$TEMP_BINARY" +mkdir -p "$(dirname "$BINARY_PATH")" +mv "$TEMP_BINARY" "$BINARY_PATH" +success "Installed to $BINARY_PATH" + +# Step 2: Write configuration +step 2 "Writing configuration..." +mkdir -p "$CONFIG_DIR" +cat > "$CONFIG_FILE" < 0 { + metrics.System.CPUModel = cpuInfo[0].ModelName + } + + // Load average + loadAvg, err := load.Avg() + if err != nil { + log.Printf("Warning: Failed to get load average: %v", err) + } else { + metrics.LoadAvg = LoadMetrics{ + Load1: loadAvg.Load1, + Load5: loadAvg.Load5, + Load15: loadAvg.Load15, + } + } + + // CPU + cpuPercent, err := cpu.Percent(time.Second, false) + if err != nil { + return nil, fmt.Errorf("failed to get CPU percent: %w", err) + } + if len(cpuPercent) > 0 { + metrics.CPU.Percent = cpuPercent[0] + } + + cpuPercentPerCore, err := cpu.Percent(time.Second, true) + if err != nil { + return nil, fmt.Errorf("failed to get per-core CPU percent: %w", err) + } + metrics.CPU.PerCore = cpuPercentPerCore + + cpuCount, err := cpu.Counts(true) + if err != nil { + return nil, fmt.Errorf("failed to get CPU count: %w", err) + } + metrics.CPU.Count = cpuCount + + // Memory + memInfo, err := mem.VirtualMemory() + if err != nil { + return nil, fmt.Errorf("failed to get memory info: %w", err) + } + metrics.Memory = MemoryMetrics{ + Total: memInfo.Total, + Available: memInfo.Available, + Used: memInfo.Used, + UsedPercent: memInfo.UsedPercent, + } + + // Swap + swapInfo, err := mem.SwapMemory() + if err != nil { + log.Printf("Warning: Failed to get swap info: %v", err) + } else { + metrics.Swap = SwapMetrics{ + Total: swapInfo.Total, + Used: swapInfo.Used, + Free: swapInfo.Free, + UsedPercent: swapInfo.UsedPercent, + } + } + + // Disk usage per mount point + partitions, err := disk.Partitions(false) + if err != nil { + return nil, fmt.Errorf("failed to get disk partitions: %w", err) + } + for _, p := range partitions { + usage, err := disk.Usage(p.Mountpoint) + if err != nil { + continue + } + if usage.Total == 0 { + continue + } + metrics.Disks = append(metrics.Disks, DiskMetrics{ + Mountpoint: p.Mountpoint, + Device: p.Device, + Fstype: p.Fstype, + Total: usage.Total, + Free: usage.Free, + Used: usage.Used, + UsedPercent: usage.UsedPercent, + }) + } + + // Disk I/O (aggregate across all devices) + ioCounters, err := disk.IOCounters() + if err != nil { + log.Printf("Warning: Failed to get disk I/O: %v", err) + } else { + var totalRead, totalWrite, totalReadCount, totalWriteCount, totalIops uint64 + for _, io := range ioCounters { + totalRead += io.ReadBytes + totalWrite += io.WriteBytes + totalReadCount += io.ReadCount + totalWriteCount += io.WriteCount + totalIops += io.IopsInProgress + } + metrics.DiskIO = DiskIOMetrics{ + ReadBytes: totalRead, + WriteBytes: totalWrite, + ReadCount: totalReadCount, + WriteCount: totalWriteCount, + IopsInProgress: totalIops, + } + } + + // Network (per-second rates) + netStats, err := net.IOCounters(false) + if err != nil { + log.Printf("Warning: Failed to get network stats: %v", err) + } else if len(netStats) > 0 { + var totalSent, totalRecv, totalPacketsSent, totalPacketsRecv uint64 + for _, stat := range netStats { + totalSent += stat.BytesSent + totalRecv += stat.BytesRecv + totalPacketsSent += stat.PacketsSent + totalPacketsRecv += stat.PacketsRecv + } + + now := time.Now() + current := &networkSnapshot{ + BytesSent: totalSent, + BytesRecv: totalRecv, + PacketsSent: totalPacketsSent, + PacketsRecv: totalPacketsRecv, + Time: now, + } + + if prevNet != nil && + totalSent >= prevNet.BytesSent && + totalRecv >= prevNet.BytesRecv { + elapsed := now.Sub(prevNet.Time).Seconds() + if elapsed > 0 { + metrics.Network = &NetworkMetrics{ + BytesSentPerSec: float64(totalSent-prevNet.BytesSent) / elapsed, + BytesRecvPerSec: float64(totalRecv-prevNet.BytesRecv) / elapsed, + PacketsSentPerSec: float64(totalPacketsSent-prevNet.PacketsSent) / elapsed, + PacketsRecvPerSec: float64(totalPacketsRecv-prevNet.PacketsRecv) / elapsed, + } + } + } + + prevNet = current + } + + // Top processes + processes, err := collectTopProcesses() + if err != nil { + log.Printf("Warning: Failed to collect process info: %v", err) + metrics.Processes = []ProcessInfo{} + } else { + metrics.Processes = processes + } + + return metrics, nil +} + +func collectTopProcesses() ([]ProcessInfo, error) { + pids, err := process.Pids() + if err != nil { + return nil, fmt.Errorf("failed to get process list: %w", err) + } + + type procData struct { + proc *process.Process + pid int32 + name string + cmdline string + memPercent float32 + memInfo *process.MemoryInfoStat + cpuTime float64 + } + + var processes []procData + + for _, pid := range pids { + proc, err := process.NewProcess(pid) + if err != nil { + continue + } + + _, err = proc.CPUPercent() + if err != nil { + continue + } + + name, err := proc.Name() + if err != nil { + name = "unknown" + } + + cmdline, err := proc.Cmdline() + if err != nil { + cmdline = name + } + if len(cmdline) > 200 { + cmdline = cmdline[:200] + "..." + } + + memPercent, err := proc.MemoryPercent() + if err != nil { + continue + } + + memInfo, err := proc.MemoryInfo() + if err != nil { + continue + } + + cpuTimes, err := proc.Times() + if err != nil { + continue + } + cpuTime := cpuTimes.User + cpuTimes.System + + processes = append(processes, procData{ + proc: proc, + pid: pid, + name: name, + cmdline: cmdline, + memPercent: memPercent, + memInfo: memInfo, + cpuTime: cpuTime, + }) + } + + time.Sleep(time.Millisecond * 200) + + var processList []ProcessInfo + for _, p := range processes { + cpuPercent, err := p.proc.CPUPercent() + if err != nil { + continue + } + + processList = append(processList, ProcessInfo{ + PID: p.pid, + Name: p.name, + Command: p.cmdline, + CPUPercent: cpuPercent, + MemPercent: p.memPercent, + CPUTime: p.cpuTime, + MemoryBytes: p.memInfo.RSS, + }) + } + + // Sort by CPU usage (descending) and take top N + for i := 0; i < len(processList) && i < topProcessesCount; i++ { + maxIdx := i + for j := i + 1; j < len(processList); j++ { + if processList[j].CPUPercent > processList[maxIdx].CPUPercent { + maxIdx = j + } + } + if maxIdx != i { + processList[i], processList[maxIdx] = processList[maxIdx], processList[i] + } + } + + if len(processList) > topProcessesCount { + return processList[:topProcessesCount], nil + } + + return processList, nil +} + +func sendMetrics(config *Config, metrics *Metrics) error { + jsonData, err := json.Marshal(metrics) + if err != nil { + return fmt.Errorf("failed to marshal metrics: %w", err) + } + + req, err := http.NewRequest("POST", config.URL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.Token) + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if config.Debug { + log.Printf("[DEBUG] Response %d: %s", resp.StatusCode, string(body)) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status code: %d — %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/payload.json b/payload.json new file mode 100644 index 0000000..0adf7d2 --- /dev/null +++ b/payload.json @@ -0,0 +1,104 @@ +{ + "timestamp": "2026-02-14T14:09:08.879637+10:00", + "token": "pw_tok_example_abc123", + "hostname": "web-1", + "uptime": 92582, + "system": { + "os": "linux", + "platform": "ubuntu", + "platform_version": "22.04", + "kernel_version": "6.1.0-18-amd64", + "kernel_arch": "x86_64", + "virtualization": "kvm", + "cpu_model": "Intel(R) Xeon(R) E-2288G CPU @ 3.70GHz" + }, + "load_avg": { + "load1": 2.45, + "load5": 1.87, + "load15": 1.32 + }, + "cpu": { + "percent": 34.5, + "per_core": [ + 42.1, + 28.3, + 35.7, + 31.9 + ], + "core_count": 4 + }, + "memory": { + "total": 8589934592, + "available": 3221225472, + "used": 5368709120, + "used_percent": 62.5 + }, + "swap": { + "total": 2147483648, + "used": 524288000, + "free": 1623195648, + "used_percent": 24.41 + }, + "disks": [ + { + "mountpoint": "/", + "device": "/dev/sda1", + "fstype": "ext4", + "total": 107374182400, + "free": 64424509440, + "used": 42949672960, + "used_percent": 40.0 + }, + { + "mountpoint": "/var", + "device": "/dev/sda2", + "fstype": "ext4", + "total": 53687091200, + "free": 21474836480, + "used": 32212254720, + "used_percent": 60.0 + } + ], + "disk_io": { + "read_bytes": 391543873536, + "write_bytes": 428321370112, + "read_count": 22743663, + "write_count": 19895365, + "iops_in_progress": 0 + }, + "network": { + "bytes_sent_per_sec": 31200.5, + "bytes_recv_per_sec": 52400.8, + "packets_sent_per_sec": 95.3, + "packets_recv_per_sec": 140.7 + }, + "processes": [ + { + "pid": 1234, + "name": "nginx", + "command": "nginx: worker process", + "cpu_percent": 12.5, + "mem_percent": 3.2, + "cpu_time": 4567.89, + "memory_bytes": 67108864 + }, + { + "pid": 5678, + "name": "postgres", + "command": "/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/15/main", + "cpu_percent": 8.1, + "mem_percent": 6.7, + "cpu_time": 12345.67, + "memory_bytes": 268435456 + }, + { + "pid": 9012, + "name": "php-fpm", + "command": "php-fpm: pool www", + "cpu_percent": 5.3, + "mem_percent": 4.1, + "cpu_time": 890.12, + "memory_bytes": 134217728 + } + ] +}