mirror of
https://github.com/wearepixel/pulsewise-collector.git
synced 2026-11-14 22:12:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6677b58005 | ||
|
|
541c239b7e | ||
|
|
28a555c1de | ||
|
|
cc2b44b7cb | ||
|
|
3cb7bc7d60 | ||
|
|
4018ba5c4a | ||
|
|
9365e64cdb | ||
|
|
ef6fa1a07f | ||
|
|
b9b31414b0 | ||
|
|
ebdc5a2c48 |
@@ -1,532 +0,0 @@
|
|||||||
# Session Handover
|
|
||||||
|
|
||||||
**Session:** `ba459302-a174-4ab7-b546-989c611afd2e`
|
|
||||||
**Project:** `/Users/joel/Code/pulsewise-collector`
|
|
||||||
**Saved:** 2026-02-15 16:02:41
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Requests
|
|
||||||
|
|
||||||
1. We need to update this go package to: rename to pulsewise collector, an add a one click install script that is as simple as this: curl -fsSL https://openclaw.ai/install.sh | bash. It should then ask in a nice interactive flow some onboarding questions, like what is the pulsewise token to sync to, name the machine, etc
|
|
||||||
|
|
||||||
2. Now, the website is at ../pulsewise from this directory. Can you create a deploy script for me to compile the binaries, and move them to /public/collector/download in Laravel so I can deploy them? And the deploy script also copies the install script
|
|
||||||
|
|
||||||
3. Okay, now is linux-amd64 all we should support? Does that cover ubuntu, debian, etc?
|
|
||||||
|
|
||||||
4. yes
|
|
||||||
|
|
||||||
5. Can you also make this create a commit in the laravel directory? So updating the package or install script is always in its own commit?
|
|
||||||
|
|
||||||
6. Great, commit this please if you haven't already
|
|
||||||
|
|
||||||
7. Anyting else we could add to this collector to send more data to pulsewise that is worthy of tracking?
|
|
||||||
|
|
||||||
8. yes, add all the high value ones
|
|
||||||
|
|
||||||
9. Make sure you commit
|
|
||||||
|
|
||||||
10. How can I test what this json request looks like so I can have claude in the pulsewire file, know what the request is so we can implement it? Or can you give me a json dump example of what it sends?
|
|
||||||
|
|
||||||
11. Can you put the json dump in a payload.json in the root of this project?
|
|
||||||
|
|
||||||
12. How can I test this o a test server? How does the test url know to be used?
|
|
||||||
|
|
||||||
13. Can I test it by manually copying the binary to a linux server?
|
|
||||||
|
|
||||||
14. Ah yeah it working on macos hey?
|
|
||||||
|
|
||||||
15. Yep build and run the main.go for test mode
|
|
||||||
|
|
||||||
16. Can you let me run it here? And show me what I run with the token and test mode?
|
|
||||||
|
|
||||||
17. [Request interrupted by user]
|
|
||||||
|
|
||||||
18. No, build this for macos, and I will run it with TEST_MODE=true and TOKEN=
|
|
||||||
|
|
||||||
19. Does the install script install to a config file?
|
|
||||||
|
|
||||||
20. Can you add a build script so I can just run go build and it builds for my macos?
|
|
||||||
|
|
||||||
21. Can we add the test_mode into the config?
|
|
||||||
|
|
||||||
22. How is the token being sent?
|
|
||||||
|
|
||||||
23. For debug mode, can it console log the response?
|
|
||||||
|
|
||||||
24. Can we rename machine name to hostname?
|
|
||||||
|
|
||||||
25. [Request interrupted by user]
|
|
||||||
|
|
||||||
26. Can we rename machine name to hostname and if not manually set, it should get it automatically
|
|
||||||
|
|
||||||
27. Now the other validation it is failing is this: "cpu.core_count":["The cpu.core count field is required."]. Do we have a different field name, or not being sent?
|
|
||||||
|
|
||||||
28. What time range is the network data for?
|
|
||||||
|
|
||||||
29. Task: Update network metrics to send per-interval rates instead of cumulative totals
|
|
||||||
|
|
||||||
The Pulsewise API at POST /api/collect currently receives a network field as a flat object
|
|
||||||
with cumulative-since-boot counters:
|
|
||||||
|
|
||||||
{
|
|
||||||
"network": {
|
|
||||||
"bytes_recv": 30332568342,
|
|
||||||
"bytes_sent": 21713728388,
|
|
||||||
"packets_recv": 81513318,
|
|
||||||
"packets_sent": 75462736
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
These cumulative v...
|
|
||||||
|
|
||||||
30. Is there any more data we could be sending to make this more valuable?
|
|
||||||
|
|
||||||
31. Add the system object
|
|
||||||
|
|
||||||
32. Can we make this an even easier install? LIke somehow including the API token in the one line command so install doesn't even need to ask about it?
|
|
||||||
|
|
||||||
33. [Image: source: /Users/joel/Google Drive/joel@joelmale.com/Documents/Screenshots/CleanShot 2026-02-15 at 07.44.00@2x.png]
|
|
||||||
|
|
||||||
34. Can we also remove PULSEWISE_ before all of the config options? It's already our config so why do we need that?
|
|
||||||
|
|
||||||
35. No I don't want it to be prefixed.
|
|
||||||
|
|
||||||
36. commit your work that you've done
|
|
||||||
|
|
||||||
37. Do we need sudo to collect these metrics? I.e can we safely drop sudo from the one line install and everything still work?
|
|
||||||
|
|
||||||
38. Okay, we'll just open source this. What is a one line description for this for the github repository?
|
|
||||||
|
|
||||||
39. Can you make sure the README is up to date? I will be open sourcing this
|
|
||||||
|
|
||||||
40. Can we make the installer work for macos as well, incase they have macos servers?
|
|
||||||
|
|
||||||
## Key Actions Taken
|
|
||||||
|
|
||||||
### Bash (43 calls)
|
|
||||||
- `{'command': 'ls -la /Users/joel/Code/pulsewise-collector/', 'description': 'List project root'}`
|
|
||||||
- `{'command': 'go build -o /dev/null ./...', 'description': 'Check Go compilation'}`
|
|
||||||
- `{'command': 'ls -la /Users/joel/Code/pulsewise-collector/pingwire-collector /Users/joel/Code/pulsewise-collector/pingwire-collector-linux-amd64 2>/dev`
|
|
||||||
- `{'command': 'ls -la /Users/joel/Code/pulsewise/public/ 2>/dev/null', 'description': 'Check Laravel public directory'}`
|
|
||||||
- `{'command': 'chmod +x /Users/joel/Code/pulsewise-collector/deploy.sh', 'description': 'Make deploy script executable'}`
|
|
||||||
- ... and 38 more
|
|
||||||
|
|
||||||
### Read (43 calls)
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/main.go'}`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/README.md'}`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/go.mod'}`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/config.example'}`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/install.sh'}`
|
|
||||||
- ... and 38 more
|
|
||||||
|
|
||||||
### Edit (64 calls)
|
|
||||||
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/go.mod', 'old_string': 'module pingwire-collector', 'new_string': 'module pu`
|
|
||||||
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/main.go', 'old_string': '\tproductionURL = "https://openclaw.ai/api/coll`
|
|
||||||
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/install.sh', 'old_string': '# Configuration\nBINARY_URL="https://pulsewise.a`
|
|
||||||
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'old_string': '# Compile for Linux amd64\ninfo "[1/3] Compiling `
|
|
||||||
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'old_string': '# Summary\ninfo "[4/4] Done."\necho ""\necho -e "`
|
|
||||||
- ... and 59 more
|
|
||||||
|
|
||||||
### Write (13 calls)
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/main.go', 'content': 'package main\n\nimport (\n\t"bufio"\n\t"bytes"\n\t"encoding/json"\n\t"fmt"\n`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/install.sh', 'content': '#!/bin/bash\n\nset -e\n\n# ──────────────────────────────────────────────`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/config.example', 'content': '# Pulsewise Collector Configuration\n# Copy this file to /etc/pulsewi`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/README.md', 'content': '# Pulsewise Collector\n\nA lightweight Go agent that continuously collects`
|
|
||||||
- `{'file_path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'content': '#!/bin/bash\n\nset -e\n\nBOLD=\'\\033[1m\'\nDIM=\'\\033[2m\'\nGREEN=\'\\03`
|
|
||||||
- ... and 8 more
|
|
||||||
|
|
||||||
### Glob (1 calls)
|
|
||||||
- `{'pattern': '**/pingwire*', 'path': '/Users/joel/Code/pulsewise-collector'}`
|
|
||||||
|
|
||||||
### Grep (5 calls)
|
|
||||||
- `{'pattern': 'pingwire', 'path': '/Users/joel/Code/pulsewise-collector', '-i': True, 'output_mode': 'content'}`
|
|
||||||
- `{'pattern': 'PULSEWISE_TOKEN', 'path': '/Users/joel/Code/pulsewise-collector/install.sh', 'output_mode': 'content'}`
|
|
||||||
- `{'pattern': 'PULSEWISE_HOSTNAME', 'path': '/Users/joel/Code/pulsewise-collector/install.sh', 'output_mode': 'content'}`
|
|
||||||
- `{'pattern': 'COLLECTOR_HOSTNAME', 'path': '/Users/joel/Code/pulsewise-collector/install.sh', 'output_mode': 'content'}`
|
|
||||||
- `{'pattern': 'PULSEWISE_TOKEN', 'path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'output_mode': 'content'}`
|
|
||||||
|
|
||||||
### Task (1 calls)
|
|
||||||
- `{'description': 'Check gopsutil API availability', 'prompt': 'I need to verify what gopsutil v3 provides for these metrics. Search the go module cache`
|
|
||||||
|
|
||||||
## Assistant Responses (Summary)
|
|
||||||
|
|
||||||
- Let me start by exploring the current codebase to understand what we're working with.
|
|
||||||
|
|
||||||
- Now I have a clear picture. Let me implement this — rename everything from pingwire to pulsewise, add machine name support, and build a polished interactive install script.
|
|
||||||
|
|
||||||
A few assumptions I'll make:
|
|
||||||
- Production URL becomes `https://openclaw.ai/api/collect`
|
|
||||||
- Binary download from `https://opencl...
|
|
||||||
|
|
||||||
- Now let me rewrite `main.go` with all the renames and the new machine name support.
|
|
||||||
|
|
||||||
- Got it — updating to `pulsewise.app` for both the binary download URL and the production API endpoint. Let me update `main.go` and then write the install script and remaining files.
|
|
||||||
|
|
||||||
- Got it. Now let me write the install script and update the remaining files.
|
|
||||||
|
|
||||||
- Now let me update the config example and README.
|
|
||||||
|
|
||||||
- Let me verify the Go code compiles.
|
|
||||||
|
|
||||||
- Compiles clean (the warnings are from a third-party dependency, not our code). Let me also clean up the old binaries that shouldn't be in the repo.
|
|
||||||
|
|
||||||
- Good — the old binary files aren't tracked. Let me verify there are no leftover "pingwire" references in any tracked file.
|
|
||||||
|
|
||||||
- All clean. Here's a summary of everything that was done:
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
**Renamed** the entire project from "Pingwire" to "Pulsewise":
|
|
||||||
|
|
||||||
- **`go.mod`** — module name `pulsewise-collector`
|
|
||||||
- **`main.go`** — all constants, config keys, env vars, log messages, and types updated:
|
|
||||||
- Production URL: `http...
|
|
||||||
|
|
||||||
- Let me check the Laravel project structure first.
|
|
||||||
|
|
||||||
- Let me test it compiles and deploys correctly.
|
|
||||||
|
|
||||||
- Works. The binary (8.4M) and install script are now at:
|
|
||||||
|
|
||||||
- `../pulsewise/public/collector/download/linux-amd64`
|
|
||||||
- `../pulsewise/public/collector/install.sh`
|
|
||||||
|
|
||||||
Run `./deploy.sh` any time you want to rebuild and push updates to the Laravel public directory. After deploying the Laravel app, the install ...
|
|
||||||
|
|
||||||
- Yes, `linux-amd64` covers Ubuntu, Debian, CentOS, RHEL, Fedora, Arch, etc. — basically any Linux distro running on standard x86_64 Intel/AMD hardware. That's the vast majority of servers.
|
|
||||||
|
|
||||||
The one gap is **ARM servers** (like AWS Graviton, Oracle Ampere, Hetzner CAX, Raspberry Pi). Those need `linux...
|
|
||||||
|
|
||||||
- Now add `linux-arm64` to the deploy script.
|
|
||||||
|
|
||||||
- Both binaries compile and deploy. Here's what changed:
|
|
||||||
|
|
||||||
**`install.sh`** — auto-detects architecture at runtime using `uname -m`, maps it to the right binary (`linux-amd64` or `linux-arm64`), and fails with a clear message on unsupported architectures.
|
|
||||||
|
|
||||||
**`deploy.sh`** — now compiles both `linux/amd...
|
|
||||||
|
|
||||||
- Let me also update the step counters to reflect the new total.
|
|
||||||
|
|
||||||
- Let me verify the final script looks right.
|
|
||||||
|
|
||||||
- Done. Step 4 now stages `public/collector/` in the Laravel repo and commits it. If nothing changed (e.g. you run deploy twice without modifying Go code or the install script), it skips the commit cleanly instead of erroring.
|
|
||||||
|
|
||||||
- Committed as `b2c01bf`.
|
|
||||||
|
|
||||||
- Here's what I'd actually find useful beyond what you have, roughly ordered by value:
|
|
||||||
|
|
||||||
**High value — most teams want these:**
|
|
||||||
|
|
||||||
- **Load average** (1m, 5m, 15m) — more telling than CPU % for understanding if a machine is struggling. A box can show 40% CPU but have a load of 12.
|
|
||||||
- **Swap usage** — if ...
|
|
||||||
|
|
||||||
- Now let me check what gopsutil offers for load average, swap, disk I/O, and disk partitions.
|
|
||||||
|
|
||||||
- All five are available in gopsutil. Let me update `main.go`.
|
|
||||||
|
|
||||||
- Now update the Metrics struct and add the new types.
|
|
||||||
|
|
||||||
- Now update the `collectMetrics` function to collect all the new data.
|
|
||||||
|
|
||||||
- Compiles clean. Let me commit.
|
|
||||||
|
|
||||||
- Done. Here's what was added:
|
|
||||||
|
|
||||||
| Metric | Fields | Why it matters |
|
|
||||||
|--------|--------|---------------|
|
|
||||||
| **Load average** | `load1`, `load5`, `load15` | Shows system pressure over time — better than CPU % alone |
|
|
||||||
| **Swap** | `total`, `used`, `free`, `used_percent` | Any swap usage = memory pressure...
|
|
||||||
|
|
||||||
- Already committed — that was `00cfca2` above. Let me verify.
|
|
||||||
|
|
||||||
- All committed, working tree is clean.
|
|
||||||
|
|
||||||
- Easiest way — let me just collect real metrics from this machine and dump the JSON.
|
|
||||||
|
|
||||||
- The `//go:build ignore` tag excludes `main.go`. Let me just use a separate temp directory instead.
|
|
||||||
|
|
||||||
- There's the real JSON payload. That's what gets `POST`ed to `https://pulsewise.app/api/collect` with header `Authorization: Bearer <token>`.
|
|
||||||
|
|
||||||
One thing I notice — on macOS the disk partitions list includes a lot of system volumes (`/System/Volumes/VM`, `/System/Volumes/Preboot`, etc). On a Linux ser...
|
|
||||||
|
|
||||||
- Done. I cleaned up the values to look like a realistic Linux server (ext4 partitions, `/` and `/var` mounts, sensible load/memory numbers, 3 example processes) rather than the raw macOS dump.
|
|
||||||
|
|
||||||
- Looking at `main.go:105`, test mode is triggered by the `PULSEWISE_TEST_MODE` env var:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PULSEWISE_TEST_MODE=true ./pulsewise-collector
|
|
||||||
```
|
|
||||||
|
|
||||||
But the current test URL is `https://64215798ca4d6a42a266g1w48zayyyyyb.oast.pro` — that looks like an old OAST/Burp Collaborator URL that probably isn't...
|
|
||||||
|
|
||||||
- Yes, just cross-compile and copy it over:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build for Linux
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o pulsewise-collector
|
|
||||||
|
|
||||||
# Copy to your server
|
|
||||||
scp pulsewise-collector user@your-server:/tmp/
|
|
||||||
```
|
|
||||||
|
|
||||||
Then on the server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create the config
|
|
||||||
sudo mkdir -p /etc/pulsewise-collector
|
|
||||||
sudo tee /et...
|
|
||||||
|
|
||||||
- Yeah it works on macOS too — gopsutil supports it. You can just run it locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create local config
|
|
||||||
mkdir -p ~/.config/pulsewise-collector
|
|
||||||
cat > ~/.config/pulsewise-collector/config << 'EOF'
|
|
||||||
PULSEWISE_TOKEN=test-token
|
|
||||||
PULSEWISE_MACHINE_NAME=dev-laptop
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Run it
|
|
||||||
go run main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
The...
|
|
||||||
|
|
||||||
- It works. The collector starts up, collects all the metrics (CPU sampling takes a couple seconds), and tries to POST them. The `404` is expected — `pulsewise.app/api/collect` doesn't have a receiving endpoint yet. Once you build that in Laravel, it'll start going through.
|
|
||||||
|
|
||||||
- Sure. First let me add that URL override so test mode is actually useful.
|
|
||||||
|
|
||||||
- Let me add `PULSEWISE_URL` support to the config file, remove the old test mode/OAST URL, and rebuild.
|
|
||||||
|
|
||||||
- Now update the Config struct (remove TestMode), loadConfig, and loadConfigFromFile to return the URL too.
|
|
||||||
|
|
||||||
- Now update `loadConfigFromFile` to parse `PULSEWISE_URL` and return it.
|
|
||||||
|
|
||||||
- Built. Your config file is already at `~/.config/pulsewise-collector/config` from earlier. Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PULSEWISE_TEST_MODE=true ./pulsewise-collector
|
|
||||||
```
|
|
||||||
|
|
||||||
The token and machine name come from the config file. To change them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat > ~/.config/pulsewise-collector/config << 'EOF'
|
|
||||||
PULSEWISE_TO...
|
|
||||||
|
|
||||||
- Yes — the install script writes the token and machine name to `/etc/pulsewise-collector/config` at step 2 (`install.sh:200-207`). It creates:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Pulsewise Collector Configuration
|
|
||||||
# Docs: https://pulsewise.app/docs/collector
|
|
||||||
|
|
||||||
PULSEWISE_TOKEN=<whatever they entered>
|
|
||||||
PULSEWISE_MACHINE_NAME=<whateve...
|
|
||||||
|
|
||||||
- Done. Just run `./build.sh` then `./pulsewise-collector`.
|
|
||||||
|
|
||||||
- Now update the return and the caller to use the URL from config.
|
|
||||||
|
|
||||||
- Now update `loadConfig` to use the new signature and drop the env var/test mode logic.
|
|
||||||
|
|
||||||
- The file was reverted externally — the constant is still `productionURL`. Let me check.
|
|
||||||
|
|
||||||
- Builds clean. Now your config file controls everything. To test, just set `PULSEWISE_URL` in the config:
|
|
||||||
|
|
||||||
```
|
|
||||||
PULSEWISE_TOKEN=your-token
|
|
||||||
PULSEWISE_MACHINE_NAME=joels-mac
|
|
||||||
PULSEWISE_URL=https://pulsewise.test/api/collect
|
|
||||||
```
|
|
||||||
|
|
||||||
Omit `PULSEWISE_URL` and it defaults to `https://pulsewise.app/api/collect`....
|
|
||||||
|
|
||||||
- As a Bearer token in the `Authorization` header. From `main.go:399`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
req.Header.Set("Authorization", "Bearer "+config.Token)
|
|
||||||
```
|
|
||||||
|
|
||||||
So the request looks like:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/collect HTTP/1.1
|
|
||||||
Host: pulsewise.app
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer pw_tok_example_abc123
|
|
||||||
|
|
||||||
{ ...met...
|
|
||||||
|
|
||||||
- I'll add a `PULSEWISE_DEBUG` config option that logs the response status and body. Let me also check the Config struct and loadConfigFromFile.
|
|
||||||
|
|
||||||
- Now add parsing in `loadConfigFromFile`.
|
|
||||||
|
|
||||||
- I need to update the function signature and parsing to include debug, then update `loadConfig` and `sendMetrics`.
|
|
||||||
|
|
||||||
- Now update the function signature and return.
|
|
||||||
|
|
||||||
- Now update all the error returns and the final return.
|
|
||||||
|
|
||||||
- Now update `loadConfig` to receive the debug flag.
|
|
||||||
|
|
||||||
- Now update `sendMetrics` to log the response when debug is on.
|
|
||||||
|
|
||||||
- The named returns conflict with the local vars. Let me fix.
|
|
||||||
|
|
||||||
- Builds clean. Now add `PULSEWISE_DEBUG=true` to your config:
|
|
||||||
|
|
||||||
```
|
|
||||||
PULSEWISE_TOKEN=your-token
|
|
||||||
PULSEWISE_MACHINE_NAME=joels-mac
|
|
||||||
PULSEWISE_URL=https://pulsewise.test/api/collect
|
|
||||||
PULSEWISE_DEBUG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
With debug on, it'll log the full response body after each send:
|
|
||||||
|
|
||||||
```
|
|
||||||
[DEBUG] Response 200: {"statu...
|
|
||||||
|
|
||||||
- Renaming `MachineName` to `Hostname` everywhere, and auto-detecting from `os.Hostname()` if not set in config.
|
|
||||||
|
|
||||||
- Now rename the config key and make it optional with auto-detection.
|
|
||||||
|
|
||||||
- Now remove the error when hostname is empty and auto-detect instead.
|
|
||||||
|
|
||||||
- Now replace the hostname-missing error with auto-detection.
|
|
||||||
|
|
||||||
- Now update the log line and install script too.
|
|
||||||
|
|
||||||
- Now update the install script and config example.
|
|
||||||
|
|
||||||
- Now update the config file writing and the summary output.
|
|
||||||
|
|
||||||
- Now update the summary at the bottom.
|
|
||||||
|
|
||||||
- Now update the config example and payload.json.
|
|
||||||
|
|
||||||
- Done. Changes:
|
|
||||||
|
|
||||||
- **Renamed** `machine_name` to `hostname` everywhere (Go structs, JSON keys, config file, install script, payload example)
|
|
||||||
- **Auto-detects** from `os.Hostname()` if `PULSEWISE_HOSTNAME` is not set in config — it's now optional
|
|
||||||
- Updated `config.example` to document all optional fie...
|
|
||||||
|
|
||||||
- The JSON key is `count` (nested under `cpu`), so Laravel sees it as `cpu.count`. But the validation rule is checking for `cpu.core_count`.
|
|
||||||
|
|
||||||
Either rename the Go JSON tag to match, or update the Laravel validation. Since `core_count` is more descriptive, let me update the Go side.
|
|
||||||
|
|
||||||
- Now sends `cpu.core_count` instead of `cpu.count`.
|
|
||||||
|
|
||||||
- It's cumulative since boot — total bytes/packets since the system started. It's not a rate or a per-interval snapshot.
|
|
||||||
|
|
||||||
If you want to show useful dashboard data (like MB/s or packets/s), you'd calculate the delta between two consecutive readings on the Laravel side:
|
|
||||||
|
|
||||||
```
|
|
||||||
rate = (current.bytes_sent ...
|
|
||||||
|
|
||||||
- First, update the `NetworkMetrics` struct and make `Network` nullable in `Metrics`.
|
|
||||||
|
|
||||||
- Make `Network` a pointer so it serializes as `null` when nil.
|
|
||||||
|
|
||||||
- Now add the global state variable and update `main()` to pass it through, and rewrite the network collection in `collectMetrics`.
|
|
||||||
|
|
||||||
- Now replace the network collection block in `collectMetrics`.
|
|
||||||
|
|
||||||
- Now update the payload example.
|
|
||||||
|
|
||||||
- Done. Here's how it works:
|
|
||||||
|
|
||||||
- **First collection**: `network` is `null` (no previous reading to diff against)
|
|
||||||
- **Second collection onward**: computes per-second rates by diffing current vs previous counters, divided by elapsed seconds
|
|
||||||
- **Counter reset** (reboot): if current values are lower than p...
|
|
||||||
|
|
||||||
- Looking at what you already have, the biggest gap is **system info** — static metadata about the machine itself. Everything else you're collecting is time-series metrics, but you have no way to answer "what OS is this server running?" or "when was the last kernel update?" in your dashboard.
|
|
||||||
|
|
||||||
I'd add...
|
|
||||||
|
|
||||||
- Now add the collection logic. I'll put it right after the uptime block.
|
|
||||||
|
|
||||||
- Now update the payload example.
|
|
||||||
|
|
||||||
- Done. Every payload now includes a `system` object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
- Nice. Pass the token as an env var in the curl command. The install becomes truly one-line:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sSL https://pulsewise.app/install-collector.sh | PULSEWISE_TOKEN=pw_tok_xxx bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Let me update the install script to pick up `PULSEWISE_TOKEN` from the environment and only prompt if it's ...
|
|
||||||
|
|
||||||
- Now update the install URL in the script header and the error message to match what's in the screenshot (`pulsewise.app/install-collector.sh`).
|
|
||||||
|
|
||||||
- Now update the deploy script to copy to the right path for the new URL.
|
|
||||||
|
|
||||||
- Done. The install command shown in your dashboard can now be:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sSL https://pulsewise.app/install-collector.sh | PULSEWISE_TOKEN=pw_tok_2be54be4-45f6-4751-85cb-1d3a5bd5f501 sudo -E bash
|
|
||||||
```
|
|
||||||
|
|
||||||
- If `PULSEWISE_TOKEN` is set via env, the script skips the token prompt entirely
|
|
||||||
- If `PULSEWIS...
|
|
||||||
|
|
||||||
- Update the error message too.
|
|
||||||
|
|
||||||
- Now update the install script — the env var stays `PULSEWISE_TOKEN` (that's what users pass on the command line), but the config file it writes uses the short keys.
|
|
||||||
|
|
||||||
- Now update config.example.
|
|
||||||
|
|
||||||
- Update local dev config too.
|
|
||||||
|
|
||||||
- Done. Config file is now clean:
|
|
||||||
|
|
||||||
```
|
|
||||||
TOKEN=your-token-here
|
|
||||||
HOSTNAME=my-server
|
|
||||||
URL=http://localhost:8000/api/collect
|
|
||||||
DEBUG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
The `PULSEWISE_TOKEN` env var name is kept for the install one-liner (since that's a shell env var, not our config file — prefixing makes sense there).
|
|
||||||
|
|
||||||
- I used `COLLECTOR_HOSTNAME` for the env var because `HOSTNAME` is a built-in shell variable — using it as an env var in the curl pipe would silently get overwritten by the system hostname. Let me fix the config file output to still write `HOSTNAME=` though.
|
|
||||||
|
|
||||||
- Good — the config file at line 226 correctly writes `HOSTNAME=$COLLECTOR_HOSTNAME`. Now update the deploy script summary too.
|
|
||||||
|
|
||||||
- Done. The install one-liner is now:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=pw_tok_xxx sudo -E bash
|
|
||||||
```
|
|
||||||
|
|
||||||
No `PULSEWISE_` prefix anywhere — config file, env vars, or install script.
|
|
||||||
|
|
||||||
- Committed as `ae45d4f`. Excluded the binary (`pulsewise-collector`) and `dump.txt` from the commit.
|
|
||||||
|
|
||||||
- The **install script** needs root — it writes to `/usr/local/bin/`, `/etc/pulsewise-collector/`, and `/etc/systemd/system/`. No way around that.
|
|
||||||
|
|
||||||
The **running binary** mostly doesn't need root. CPU, memory, swap, load, uptime, disk usage, network I/O, system info — all readable by any user. The one...
|
|
||||||
|
|
||||||
- > Lightweight system metrics collector that reports CPU, memory, disk, network, and process data to Pulsewise.
|
|
||||||
|
|
||||||
- The README is quite outdated. Let me rewrite it to match the current state.
|
|
||||||
|
|
||||||
- Done. The README now matches the current state — unprefixed config keys, one-liner install with `TOKEN=`, all metrics documented in a table, link to `payload.json`, debug mode, cross-compiling, and the `deploy.sh` workflow.
|
|
||||||
|
|
||||||
- Now let me rewrite this to support both Linux (systemd) and macOS (launchd), and update the deploy script to compile macOS binaries too.
|
|
||||||
+8
-1
@@ -1,3 +1,10 @@
|
|||||||
dump*
|
dump*
|
||||||
deploy.sh
|
deploy.sh
|
||||||
pulsewise-collector
|
pulsewise-collector
|
||||||
|
|
||||||
|
# AI tools
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
.cursor/
|
||||||
|
.copilot/
|
||||||
|
.aider*
|
||||||
@@ -1,40 +1,55 @@
|
|||||||
# Pulsewise Collector
|
# Pulsewise Collector
|
||||||
|
|
||||||
Lightweight system metrics collector that reports CPU, memory, disk, network, and process data to [Pulsewise](https://pulsewise.app).
|
Lightweight system metrics collector that reports to [Pulsewise](https://pulsewise.app).
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=your-token sudo -E bash
|
curl -sSL https://pulsewise.app/collector/install.sh | TOKEN=<your-token> sudo -E bash
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. The script downloads the binary, writes the config, and starts a systemd service. Hostname is auto-detected.
|
The script downloads the binary, writes the config, registers the service, and starts it. Hostname is auto-detected from the system. If you omit the `TOKEN=` prefix, the script will prompt for it interactively.
|
||||||
|
|
||||||
If you prefer an interactive install (prompts for token and hostname):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sSL https://pulsewise.app/install-collector.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## What It Collects
|
## What It Collects
|
||||||
|
|
||||||
Every 30 seconds, the collector sends:
|
Every 60 seconds, the collector sends:
|
||||||
|
|
||||||
| Category | Metrics |
|
| Category | Metrics |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| **System** | OS, platform, platform version, kernel version, architecture, virtualization type, CPU model |
|
| **System** | OS, platform, platform version, kernel, architecture, virtualization type, CPU model |
|
||||||
| **Uptime** | Seconds since boot |
|
| **Uptime** | Seconds since boot |
|
||||||
| **Load average** | 1m, 5m, 15m |
|
| **Load average** | 1m, 5m, 15m |
|
||||||
| **CPU** | Overall %, per-core %, core count |
|
| **CPU** | Overall %, per-core %, iowait %, core count |
|
||||||
| **Memory** | Total, available, used, usage % |
|
| **Memory** | Total, available, used, usage % |
|
||||||
| **Swap** | Total, used, free, usage % |
|
| **Swap** | Total, used, free, usage % |
|
||||||
| **Disks** | Per mount point: device, fstype, total, free, used, usage % |
|
| **Disks** | Per physical mount: device, fstype, total, free, used, usage % (virtual/loop mounts excluded) |
|
||||||
| **Disk I/O** | Read/write bytes, read/write count, IOPS in progress |
|
| **Disk I/O** | Cumulative read/write bytes, read/write count, IOPS in progress |
|
||||||
| **Network** | Bytes and packets sent/received per second (rate, not cumulative) |
|
| **Network** | Bytes and packets sent/received per second (per-second rate, not cumulative) |
|
||||||
|
| **TCP** | Established, TIME_WAIT, CLOSE_WAIT, and LISTEN connection counts |
|
||||||
|
| **File descriptors** | Open fd count and system maximum |
|
||||||
| **Processes** | Top 10 by CPU: PID, name, command, CPU%, memory%, CPU time, RSS |
|
| **Processes** | Top 10 by CPU: PID, name, command, CPU%, memory%, CPU time, RSS |
|
||||||
|
|
||||||
|
Optional integrations (configured separately):
|
||||||
|
|
||||||
|
| Category | Metrics |
|
||||||
|
|----------|---------|
|
||||||
|
| **PHP-FPM** | Active/idle/total workers, max children reached, slow requests, listen queue, accepted connections |
|
||||||
|
| **Nginx** | Active connections, reading/writing/waiting, accepts/handled/requests |
|
||||||
|
| **Port checks** | TCP reachability per port with service name (ssh, mysql, redis, etc.) |
|
||||||
|
|
||||||
See [`payload.json`](payload.json) for a full example of the JSON sent to the API.
|
See [`payload.json`](payload.json) for a full example of the JSON sent to the API.
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pulsewise-collector version # show version, check for updates
|
||||||
|
sudo pulsewise-collector update # download and install latest version
|
||||||
|
sudo pulsewise-collector update-token <new-token> # replace the API token and restart
|
||||||
|
sudo pulsewise-collector status # show config, service state, last pulse
|
||||||
|
sudo pulsewise-collector dump # print full metrics payload as JSON
|
||||||
|
sudo pulsewise-collector uninstall # remove binary, config, and service
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Config lives at `/etc/pulsewise-collector/config`:
|
Config lives at `/etc/pulsewise-collector/config`:
|
||||||
@@ -50,12 +65,50 @@ ## Configuration
|
|||||||
| `TOKEN` | *(required)* | Your Pulsewise API token |
|
| `TOKEN` | *(required)* | Your Pulsewise API token |
|
||||||
| `HOSTNAME` | System hostname | Override the reported hostname |
|
| `HOSTNAME` | System hostname | Override the reported hostname |
|
||||||
| `URL` | `https://pulsewise.app/api/collect` | Override the API endpoint |
|
| `URL` | `https://pulsewise.app/api/collect` | Override the API endpoint |
|
||||||
| `DEBUG` | `false` | Log full response body from the server |
|
| `AUTO_UPDATE` | `true` | Set to `false` to disable automatic updates |
|
||||||
|
| `DEBUG` | `false` | Log full API response body |
|
||||||
|
|
||||||
|
### Optional integrations
|
||||||
|
|
||||||
|
| Key | Example | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `FPM_STATUS_URL` | `http://127.0.0.1/fpm-status` | PHP-FPM status page URL (`pm.status_path` must be set in your pool config) |
|
||||||
|
| `NGINX_STATUS_URL` | `http://127.0.0.1/nginx-status` | Nginx `stub_status` URL |
|
||||||
|
| `CHECK_PORTS` | `22,80,443,3306,6379` | Comma-separated ports to check TCP availability on |
|
||||||
|
|
||||||
|
See [`config.example`](config.example) for a commented reference.
|
||||||
|
|
||||||
Config file permissions should be `600` (root-only readable).
|
Config file permissions should be `600` (root-only readable).
|
||||||
|
|
||||||
For local development on macOS, the collector also checks `~/.config/pulsewise-collector/config`.
|
For local development on macOS, the collector also checks `~/.config/pulsewise-collector/config`.
|
||||||
|
|
||||||
|
## Managing the Service
|
||||||
|
|
||||||
|
**Linux (systemd)**
|
||||||
|
```bash
|
||||||
|
sudo systemctl status pulsewise-collector
|
||||||
|
sudo journalctl -u pulsewise-collector -f
|
||||||
|
sudo systemctl restart pulsewise-collector
|
||||||
|
sudo systemctl stop pulsewise-collector
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (launchd)**
|
||||||
|
```bash
|
||||||
|
tail -f /var/log/pulsewise-collector.log
|
||||||
|
sudo launchctl kickstart -k system/app.pulsewise.collector
|
||||||
|
sudo launchctl bootout system /Library/LaunchDaemons/app.pulsewise.collector.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
- Collects metrics every **60 seconds** using [gopsutil](https://github.com/shirou/gopsutil)
|
||||||
|
- CPU and process metrics are sampled concurrently over the same 1-second window to keep them aligned
|
||||||
|
- Virtual/pseudo filesystems (tmpfs, squashfs, loop devices, etc.) are excluded from disk metrics
|
||||||
|
- Network metrics are per-second rates computed from the delta between collections; the first collection omits network
|
||||||
|
- Sends JSON via `POST` to the configured URL with `Authorization: Bearer <token>`
|
||||||
|
- Checks for updates at startup (after 30s) and every 6 hours; updates are applied via service restart
|
||||||
|
- Runs as a systemd (Linux) or launchd (macOS) service with automatic restart on failure
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -79,27 +132,11 @@ # Build and run
|
|||||||
## Cross-Compiling
|
## Cross-Compiling
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GOOS=linux GOARCH=amd64 go build -o pulsewise-collector-linux-amd64
|
GOOS=linux GOARCH=amd64 go build -o pulsewise-collector-linux-amd64 .
|
||||||
GOOS=linux GOARCH=arm64 go build -o pulsewise-collector-linux-arm64
|
GOOS=linux GOARCH=arm64 go build -o pulsewise-collector-linux-arm64 .
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use `./deploy.sh` to compile both architectures and copy them into the Pulsewise Laravel app.
|
Or run `./deploy.sh` to bump the version, compile all targets, and publish a release.
|
||||||
|
|
||||||
## Managing the Service
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl status pulsewise-collector
|
|
||||||
sudo journalctl -u pulsewise-collector -f
|
|
||||||
sudo systemctl restart pulsewise-collector
|
|
||||||
sudo systemctl stop pulsewise-collector
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
- Collects metrics every 30 seconds using [gopsutil](https://github.com/shirou/gopsutil)
|
|
||||||
- Sends JSON via `POST` to the configured URL with `Authorization: Bearer <token>`
|
|
||||||
- Network metrics are computed as per-second rates (delta between intervals); the first collection sends `null` for network
|
|
||||||
- Runs as a systemd service with automatic restart on failure
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,16 @@ TOKEN=your-token-here
|
|||||||
|
|
||||||
# Optional — logs full response body from the server
|
# Optional — logs full response body from the server
|
||||||
# DEBUG=true
|
# DEBUG=true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Optional integrations
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# PHP-FPM status page URL (requires pm.status_path = /status in php-fpm pool config)
|
||||||
|
# FPM_STATUS_URL=http://127.0.0.1/fpm-status
|
||||||
|
|
||||||
|
# Nginx stub_status URL (requires stub_status module and location block)
|
||||||
|
# NGINX_STATUS_URL=http://127.0.0.1/nginx-status
|
||||||
|
|
||||||
|
# TCP port availability checks — comma-separated list of port numbers
|
||||||
|
# CHECK_PORTS=22,80,443,3306,6379
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v3/host"
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
"github.com/shirou/gopsutil/v3/load"
|
"github.com/shirou/gopsutil/v3/load"
|
||||||
"github.com/shirou/gopsutil/v3/mem"
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
"github.com/shirou/gopsutil/v3/net"
|
psnet "github.com/shirou/gopsutil/v3/net"
|
||||||
"github.com/shirou/gopsutil/v3/process"
|
"github.com/shirou/gopsutil/v3/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
defaultURL = "https://pulsewise.app/api/collect"
|
defaultURL = "https://pulsewise.app/api/collect"
|
||||||
versionCheckURL = "https://pulsewise.app/collector/release/latest"
|
versionCheckURL = "https://pulsewise.app/collector/release/latest"
|
||||||
collectionInterval = 30 * time.Second
|
collectionInterval = 60 * time.Second
|
||||||
updateCheckInterval = 6 * time.Hour
|
updateCheckInterval = 6 * time.Hour
|
||||||
configFilePath = "/etc/pulsewise-collector/config"
|
configFilePath = "/etc/pulsewise-collector/config"
|
||||||
stateFilePath = "/etc/pulsewise-collector/state"
|
stateFilePath = "/etc/pulsewise-collector/state"
|
||||||
@@ -59,12 +61,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Token string
|
Token string
|
||||||
Hostname string
|
Hostname string
|
||||||
URL string
|
URL string
|
||||||
Interval time.Duration
|
Interval time.Duration
|
||||||
Debug bool
|
Debug bool
|
||||||
AutoUpdate bool
|
AutoUpdate bool
|
||||||
|
FPMStatusURL string
|
||||||
|
NginxStatusURL string
|
||||||
|
CheckPorts []int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
@@ -85,6 +90,9 @@ type Metrics struct {
|
|||||||
Processes []ProcessInfo `json:"processes"`
|
Processes []ProcessInfo `json:"processes"`
|
||||||
TCP *TCPStats `json:"tcp,omitempty"`
|
TCP *TCPStats `json:"tcp,omitempty"`
|
||||||
FileDescriptors *FileDescriptors `json:"file_descriptors,omitempty"`
|
FileDescriptors *FileDescriptors `json:"file_descriptors,omitempty"`
|
||||||
|
FPM *FPMPoolStats `json:"fpm,omitempty"`
|
||||||
|
Ports []PortCheck `json:"ports,omitempty"`
|
||||||
|
Nginx *NginxStats `json:"nginx,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
@@ -123,6 +131,35 @@ type FileDescriptors struct {
|
|||||||
Max int `json:"max"`
|
Max int `json:"max"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FPMPoolStats struct {
|
||||||
|
Pool string `json:"pool"`
|
||||||
|
ActiveProcesses int `json:"active_processes"`
|
||||||
|
IdleProcesses int `json:"idle_processes"`
|
||||||
|
TotalProcesses int `json:"total_processes"`
|
||||||
|
MaxActiveProcesses int `json:"max_active_processes"`
|
||||||
|
MaxChildrenReached int `json:"max_children_reached"`
|
||||||
|
ListenQueue int `json:"listen_queue"`
|
||||||
|
MaxListenQueue int `json:"max_listen_queue"`
|
||||||
|
SlowRequests int `json:"slow_requests"`
|
||||||
|
AcceptedConn int `json:"accepted_conn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortCheck struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Open bool `json:"open"`
|
||||||
|
Service string `json:"service,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NginxStats struct {
|
||||||
|
ActiveConnections int `json:"active_connections"`
|
||||||
|
Reading int `json:"reading"`
|
||||||
|
Writing int `json:"writing"`
|
||||||
|
Waiting int `json:"waiting"`
|
||||||
|
Accepts int `json:"accepts"`
|
||||||
|
Handled int `json:"handled"`
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
type MemoryMetrics struct {
|
type MemoryMetrics struct {
|
||||||
Total uint64 `json:"total"`
|
Total uint64 `json:"total"`
|
||||||
Available uint64 `json:"available"`
|
Available uint64 `json:"available"`
|
||||||
@@ -204,6 +241,9 @@ func main() {
|
|||||||
case "dump":
|
case "dump":
|
||||||
runDumpCommand()
|
runDumpCommand()
|
||||||
return
|
return
|
||||||
|
case "update-token":
|
||||||
|
runUpdateTokenCommand()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +286,7 @@ func main() {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func runVersionCommand() {
|
func runVersionCommand() {
|
||||||
fmt.Printf("\n %spulsewise-collector%s\n", ansiBold, ansiReset)
|
fmt.Printf("\n %spulsewise-collector %s%sv%s%s\n\n", ansiBold, ansiReset, ansiCyan, version, ansiReset)
|
||||||
fmt.Printf(" %s%sv%s%s\n\n", ansiBold, ansiCyan, version, ansiReset)
|
|
||||||
|
|
||||||
fmt.Printf(" %sChecking for updates...%s ", ansiDim, ansiReset)
|
fmt.Printf(" %sChecking for updates...%s ", ansiDim, ansiReset)
|
||||||
latest, err := fetchLatestVersion()
|
latest, err := fetchLatestVersion()
|
||||||
@@ -270,8 +309,7 @@ func runUpdateCommand() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n %spulsewise-collector%s\n", ansiBold, ansiReset)
|
fmt.Printf("\n %spulsewise-collector %s%sv%s%s\n\n", ansiBold, ansiReset, ansiCyan, version, ansiReset)
|
||||||
fmt.Printf(" %s%sv%s%s\n\n", ansiBold, ansiCyan, version, ansiReset)
|
|
||||||
|
|
||||||
fmt.Printf(" %sChecking for updates...%s ", ansiDim, ansiReset)
|
fmt.Printf(" %sChecking for updates...%s ", ansiDim, ansiReset)
|
||||||
latest, err := fetchLatestVersion()
|
latest, err := fetchLatestVersion()
|
||||||
@@ -293,7 +331,7 @@ func runUpdateCommand() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%sDone.%s\n", ansiGreen, ansiReset)
|
fmt.Printf("%sDone.%s\n\n", ansiGreen, ansiReset)
|
||||||
fmt.Printf(" Successfully updated to %sv%s%s.\n\n", ansiCyan, latest, ansiReset)
|
fmt.Printf(" Successfully updated to %sv%s%s.\n\n", ansiCyan, latest, ansiReset)
|
||||||
|
|
||||||
restartService()
|
restartService()
|
||||||
@@ -360,6 +398,60 @@ func reexecWithSudo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runUpdateTokenCommand() {
|
||||||
|
if os.Getuid() != 0 {
|
||||||
|
reexecWithSudo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) < 3 || strings.TrimSpace(os.Args[2]) == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n %sUsage:%s pulsewise-collector update-token <new-token>\n\n", ansiBold, ansiReset)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
newToken := strings.TrimSpace(os.Args[2])
|
||||||
|
|
||||||
|
// Resolve config path (same logic as loadConfigFromFile)
|
||||||
|
configPath := configFilePath
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
if homeDir != "" {
|
||||||
|
local := filepath.Join(homeDir, ".config", "pulsewise-collector", "config")
|
||||||
|
if _, err := os.Stat(local); err == nil {
|
||||||
|
configPath = local
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing config, replace TOKEN line
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n %sError:%s could not read config: %v\n\n", ansiRed, ansiReset, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
tokenFound := false
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "TOKEN=") {
|
||||||
|
lines[i] = "TOKEN=" + newToken
|
||||||
|
tokenFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !tokenFound {
|
||||||
|
// Prepend if TOKEN line is somehow missing
|
||||||
|
lines = append([]string{"TOKEN=" + newToken}, lines...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(strings.Join(lines, "\n")), 0600); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n %sError:%s could not write config: %v\n\n", ansiRed, ansiReset, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n %sToken updated.%s\n", ansiGreen, ansiReset)
|
||||||
|
restartService()
|
||||||
|
}
|
||||||
|
|
||||||
func runStatusCommand() {
|
func runStatusCommand() {
|
||||||
if os.Getuid() != 0 {
|
if os.Getuid() != 0 {
|
||||||
reexecWithSudo()
|
reexecWithSudo()
|
||||||
@@ -371,24 +463,24 @@ func runStatusCommand() {
|
|||||||
fmt.Printf("\n%spulsewise-collector%s v%s\n\n", ansiBold, ansiReset, version)
|
fmt.Printf("\n%spulsewise-collector%s v%s\n\n", ansiBold, ansiReset, version)
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
token, hostname, url, _, autoUpdate, err := loadConfigFromFile()
|
cfg, err := loadConfigFromFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("%s Not configured — %v\n\n", ansiRed, ansiReset)
|
fmt.Printf("%s Not configured — %v\n\n", ansiRed, ansiReset)
|
||||||
} else {
|
} else {
|
||||||
maskedToken := token
|
maskedToken := cfg.Token
|
||||||
if len(token) >= 8 {
|
if len(cfg.Token) >= 8 {
|
||||||
maskedToken = token[:4] + "..." + token[len(token)-4:]
|
maskedToken = cfg.Token[:4] + "..." + cfg.Token[len(cfg.Token)-4:]
|
||||||
} else {
|
} else {
|
||||||
maskedToken = "****"
|
maskedToken = "****"
|
||||||
}
|
}
|
||||||
autoUpdateStr := ansiGreen + "enabled" + ansiReset
|
autoUpdateStr := ansiGreen + "enabled" + ansiReset
|
||||||
if !autoUpdate {
|
if !cfg.AutoUpdate {
|
||||||
autoUpdateStr = ansiDim + "disabled" + ansiReset
|
autoUpdateStr = ansiDim + "disabled" + ansiReset
|
||||||
}
|
}
|
||||||
fmt.Printf("%sCollector%s\n", ansiBold, ansiReset)
|
fmt.Printf("%sCollector%s\n", ansiBold, ansiReset)
|
||||||
fmt.Printf("%s %s\n", label("Hostname"), hostname)
|
fmt.Printf("%s %s\n", label("Hostname"), cfg.Hostname)
|
||||||
fmt.Printf("%s %s\n", label("Token"), maskedToken)
|
fmt.Printf("%s %s\n", label("Token"), maskedToken)
|
||||||
fmt.Printf("%s %s\n", label("API endpoint"), url)
|
fmt.Printf("%s %s\n", label("API endpoint"), cfg.URL)
|
||||||
fmt.Printf("%s %s\n", label("Interval"), collectionInterval)
|
fmt.Printf("%s %s\n", label("Interval"), collectionInterval)
|
||||||
fmt.Printf("%s %s\n", label("Auto-update"), autoUpdateStr)
|
fmt.Printf("%s %s\n", label("Auto-update"), autoUpdateStr)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -718,22 +810,14 @@ func execSelf() {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func loadConfig() *Config {
|
func loadConfig() *Config {
|
||||||
token, hostname, url, debug, autoUpdate, err := loadConfigFromFile()
|
cfg, err := loadConfigFromFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
return cfg
|
||||||
return &Config{
|
|
||||||
Token: token,
|
|
||||||
Hostname: hostname,
|
|
||||||
URL: url,
|
|
||||||
Interval: collectionInterval,
|
|
||||||
Debug: debug,
|
|
||||||
AutoUpdate: autoUpdate,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfigFromFile() (string, string, string, bool, bool, error) {
|
func loadConfigFromFile() (*Config, error) {
|
||||||
configPath := configFilePath
|
configPath := configFilePath
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
@@ -748,13 +832,15 @@ func loadConfigFromFile() (string, string, string, bool, bool, error) {
|
|||||||
|
|
||||||
file, err := os.Open(configPath)
|
file, err := os.Open(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", false, false, fmt.Errorf("config file not found at %s: %w", configPath, err)
|
return nil, fmt.Errorf("config file not found at %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var token, hostname, url string
|
cfg := &Config{
|
||||||
var debug bool
|
URL: defaultURL,
|
||||||
autoUpdate := true
|
Interval: collectionInterval,
|
||||||
|
AutoUpdate: true,
|
||||||
|
}
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -762,46 +848,48 @@ func loadConfigFromFile() (string, string, string, bool, bool, error) {
|
|||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "TOKEN=") {
|
key, val, ok := strings.Cut(line, "=")
|
||||||
token = strings.TrimPrefix(line, "TOKEN=")
|
if !ok {
|
||||||
token = strings.Trim(token, `"'`)
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "HOSTNAME=") {
|
val = strings.Trim(val, `"'`)
|
||||||
hostname = strings.TrimPrefix(line, "HOSTNAME=")
|
switch key {
|
||||||
hostname = strings.Trim(hostname, `"'`)
|
case "TOKEN":
|
||||||
}
|
cfg.Token = val
|
||||||
if strings.HasPrefix(line, "URL=") {
|
case "HOSTNAME":
|
||||||
url = strings.TrimPrefix(line, "URL=")
|
cfg.Hostname = val
|
||||||
url = strings.Trim(url, `"'`)
|
case "URL":
|
||||||
}
|
cfg.URL = val
|
||||||
if strings.HasPrefix(line, "DEBUG=") {
|
case "DEBUG":
|
||||||
val := strings.TrimPrefix(line, "DEBUG=")
|
cfg.Debug = val == "true" || val == "1"
|
||||||
val = strings.Trim(val, `"'`)
|
case "AUTO_UPDATE":
|
||||||
debug = val == "true" || val == "1"
|
cfg.AutoUpdate = val != "false" && val != "0"
|
||||||
}
|
case "FPM_STATUS_URL":
|
||||||
if strings.HasPrefix(line, "AUTO_UPDATE=") {
|
cfg.FPMStatusURL = val
|
||||||
val := strings.TrimPrefix(line, "AUTO_UPDATE=")
|
case "NGINX_STATUS_URL":
|
||||||
val = strings.Trim(val, `"'`)
|
cfg.NginxStatusURL = val
|
||||||
autoUpdate = val != "false" && val != "0"
|
case "CHECK_PORTS":
|
||||||
|
for _, p := range strings.Split(val, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if port, err := strconv.Atoi(p); err == nil && port > 0 && port < 65536 {
|
||||||
|
cfg.CheckPorts = append(cfg.CheckPorts, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return "", "", "", false, false, fmt.Errorf("error reading config file: %w", err)
|
return nil, fmt.Errorf("error reading config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if token == "" {
|
if cfg.Token == "" {
|
||||||
return "", "", "", false, false, fmt.Errorf("TOKEN not found in config file")
|
return nil, fmt.Errorf("TOKEN not found in config file")
|
||||||
}
|
}
|
||||||
if hostname == "" {
|
if cfg.Hostname == "" {
|
||||||
hostname, _ = os.Hostname()
|
cfg.Hostname, _ = os.Hostname()
|
||||||
}
|
}
|
||||||
|
|
||||||
if url == "" {
|
return cfg, nil
|
||||||
url = defaultURL
|
|
||||||
}
|
|
||||||
|
|
||||||
return token, hostname, url, debug, autoUpdate, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectAndSend(config *Config) {
|
func collectAndSend(config *Config) {
|
||||||
@@ -910,12 +998,34 @@ func collectMetrics(config *Config) (*Metrics, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CPU — single sample with percpu=true, then average for the total.
|
// CPU and process collection run concurrently so their measurement
|
||||||
// Calling cpu.Percent twice with separate 1s windows can produce
|
// windows overlap. Previously, processes were sampled 1+ second after
|
||||||
// wildly inconsistent totals vs per-core values.
|
// the CPU window ended, causing spikes to appear in system CPU but not
|
||||||
cpuPerCore, err := cpu.Percent(time.Second, true)
|
// in the process list (the offending process had already finished).
|
||||||
if err != nil {
|
var (
|
||||||
return nil, fmt.Errorf("failed to get CPU percent: %w", err)
|
cpuPerCore []float64
|
||||||
|
cpuErr error
|
||||||
|
processes []ProcessInfo
|
||||||
|
processErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
cpuPerCore, cpuErr = cpu.Percent(time.Second, true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
processes, processErr = collectTopProcesses()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if cpuErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get CPU percent: %w", cpuErr)
|
||||||
}
|
}
|
||||||
metrics.CPU.PerCore = cpuPerCore
|
metrics.CPU.PerCore = cpuPerCore
|
||||||
if len(cpuPerCore) > 0 {
|
if len(cpuPerCore) > 0 {
|
||||||
@@ -1016,7 +1126,7 @@ func collectMetrics(config *Config) (*Metrics, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Network (per-second rates)
|
// Network (per-second rates)
|
||||||
netStats, err := net.IOCounters(false)
|
netStats, err := psnet.IOCounters(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to get network stats: %v", err)
|
log.Printf("Warning: Failed to get network stats: %v", err)
|
||||||
} else if len(netStats) > 0 {
|
} else if len(netStats) > 0 {
|
||||||
@@ -1054,10 +1164,9 @@ func collectMetrics(config *Config) (*Metrics, error) {
|
|||||||
prevNet = current
|
prevNet = current
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top processes
|
// Top processes (collected concurrently with CPU above)
|
||||||
processes, err := collectTopProcesses()
|
if processErr != nil {
|
||||||
if err != nil {
|
log.Printf("Warning: Failed to collect process info: %v", processErr)
|
||||||
log.Printf("Warning: Failed to collect process info: %v", err)
|
|
||||||
metrics.Processes = []ProcessInfo{}
|
metrics.Processes = []ProcessInfo{}
|
||||||
} else {
|
} else {
|
||||||
metrics.Processes = processes
|
metrics.Processes = processes
|
||||||
@@ -1066,15 +1175,26 @@ func collectMetrics(config *Config) (*Metrics, error) {
|
|||||||
metrics.TCP = collectTCPStats()
|
metrics.TCP = collectTCPStats()
|
||||||
metrics.FileDescriptors = collectFileDescriptors()
|
metrics.FileDescriptors = collectFileDescriptors()
|
||||||
|
|
||||||
|
// Optional integrations (only collected when configured)
|
||||||
|
if config.FPMStatusURL != "" {
|
||||||
|
metrics.FPM = collectFPMStats(config.FPMStatusURL)
|
||||||
|
}
|
||||||
|
if config.NginxStatusURL != "" {
|
||||||
|
metrics.Nginx = collectNginxStats(config.NginxStatusURL)
|
||||||
|
}
|
||||||
|
if len(config.CheckPorts) > 0 {
|
||||||
|
metrics.Ports = collectPortChecks(config.CheckPorts)
|
||||||
|
}
|
||||||
|
|
||||||
return metrics, nil
|
return metrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectTCPStats() *TCPStats {
|
func collectTCPStats() *TCPStats {
|
||||||
conns, err := net.Connections("tcp")
|
conns, err := psnet.Connections("tcp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
conns6, _ := net.Connections("tcp6")
|
conns6, _ := psnet.Connections("tcp6")
|
||||||
conns = append(conns, conns6...)
|
conns = append(conns, conns6...)
|
||||||
|
|
||||||
stats := &TCPStats{}
|
stats := &TCPStats{}
|
||||||
@@ -1139,6 +1259,130 @@ func collectFileDescriptors() *FileDescriptors {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// knownPortServices maps common port numbers to service names.
|
||||||
|
var knownPortServices = map[int]string{
|
||||||
|
21: "ftp",
|
||||||
|
22: "ssh",
|
||||||
|
25: "smtp",
|
||||||
|
80: "http",
|
||||||
|
443: "https",
|
||||||
|
3306: "mysql",
|
||||||
|
5432: "postgres",
|
||||||
|
6379: "redis",
|
||||||
|
8080: "http-alt",
|
||||||
|
8443: "https-alt",
|
||||||
|
9200: "elasticsearch",
|
||||||
|
11211: "memcached",
|
||||||
|
27017: "mongodb",
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectFPMStats(statusURL string) *FPMPoolStats {
|
||||||
|
url := statusURL
|
||||||
|
if strings.Contains(url, "?") {
|
||||||
|
url += "&json"
|
||||||
|
} else {
|
||||||
|
url += "?json"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHP-FPM JSON keys contain spaces (e.g. "active processes")
|
||||||
|
var data struct {
|
||||||
|
Pool string `json:"pool"`
|
||||||
|
ActiveProcesses int `json:"active processes"`
|
||||||
|
IdleProcesses int `json:"idle processes"`
|
||||||
|
TotalProcesses int `json:"total processes"`
|
||||||
|
MaxActiveProcesses int `json:"max active processes"`
|
||||||
|
MaxChildrenReached int `json:"max children reached"`
|
||||||
|
ListenQueue int `json:"listen queue"`
|
||||||
|
MaxListenQueue int `json:"max listen queue"`
|
||||||
|
SlowRequests int `json:"slow requests"`
|
||||||
|
AcceptedConn int `json:"accepted conn"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FPMPoolStats{
|
||||||
|
Pool: data.Pool,
|
||||||
|
ActiveProcesses: data.ActiveProcesses,
|
||||||
|
IdleProcesses: data.IdleProcesses,
|
||||||
|
TotalProcesses: data.TotalProcesses,
|
||||||
|
MaxActiveProcesses: data.MaxActiveProcesses,
|
||||||
|
MaxChildrenReached: data.MaxChildrenReached,
|
||||||
|
ListenQueue: data.ListenQueue,
|
||||||
|
MaxListenQueue: data.MaxListenQueue,
|
||||||
|
SlowRequests: data.SlowRequests,
|
||||||
|
AcceptedConn: data.AcceptedConn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectNginxStats(statusURL string) *NginxStats {
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get(statusURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nginx stub_status format:
|
||||||
|
// Active connections: 291
|
||||||
|
// server accepts handled requests
|
||||||
|
// 16630948 16630948 31070465
|
||||||
|
// Reading: 6 Writing: 179 Waiting: 106
|
||||||
|
stats := &NginxStats{}
|
||||||
|
lines := strings.Split(string(body), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "Active connections:"):
|
||||||
|
fmt.Sscanf(strings.TrimPrefix(line, "Active connections:"), " %d", &stats.ActiveConnections)
|
||||||
|
case strings.HasPrefix(line, "Reading:"):
|
||||||
|
fmt.Sscanf(line, "Reading: %d Writing: %d Waiting: %d", &stats.Reading, &stats.Writing, &stats.Waiting)
|
||||||
|
case i > 0 && strings.TrimSpace(lines[i-1]) == "server accepts handled requests":
|
||||||
|
fmt.Sscanf(line, "%d %d %d", &stats.Accepts, &stats.Handled, &stats.Requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPortChecks(ports []int) []PortCheck {
|
||||||
|
results := make([]PortCheck, 0, len(ports))
|
||||||
|
for _, port := range ports {
|
||||||
|
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||||
|
open := err == nil
|
||||||
|
if open {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
results = append(results, PortCheck{
|
||||||
|
Port: port,
|
||||||
|
Open: open,
|
||||||
|
Service: knownPortServices[port],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
func collectTopProcesses() ([]ProcessInfo, error) {
|
func collectTopProcesses() ([]ProcessInfo, error) {
|
||||||
pids, err := process.Pids()
|
pids, err := process.Pids()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+29
-1
@@ -114,5 +114,33 @@
|
|||||||
"cpu_time": 890.12,
|
"cpu_time": 890.12,
|
||||||
"memory_bytes": 134217728
|
"memory_bytes": 134217728
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"fpm": {
|
||||||
|
"pool": "www",
|
||||||
|
"active_processes": 3,
|
||||||
|
"idle_processes": 7,
|
||||||
|
"total_processes": 10,
|
||||||
|
"max_active_processes": 8,
|
||||||
|
"max_children_reached": 0,
|
||||||
|
"listen_queue": 0,
|
||||||
|
"max_listen_queue": 2,
|
||||||
|
"slow_requests": 0,
|
||||||
|
"accepted_conn": 84291
|
||||||
|
},
|
||||||
|
"ports": [
|
||||||
|
{ "port": 22, "open": true, "service": "ssh" },
|
||||||
|
{ "port": 80, "open": true, "service": "http" },
|
||||||
|
{ "port": 443, "open": true, "service": "https" },
|
||||||
|
{ "port": 3306, "open": true, "service": "mysql" },
|
||||||
|
{ "port": 6379, "open": false, "service": "redis" }
|
||||||
|
],
|
||||||
|
"nginx": {
|
||||||
|
"active_connections": 42,
|
||||||
|
"reading": 1,
|
||||||
|
"writing": 8,
|
||||||
|
"waiting": 33,
|
||||||
|
"accepts": 1847293,
|
||||||
|
"handled": 1847293,
|
||||||
|
"requests": 3921847
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user