Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@ SQLITE_PATH=/data/pulse.db
RESEND_API_KEY=
SLACK_WEBHOOK_URL=
API_PORT=8080
# Permit monitors to target private/internal addresses (homelab). Off by default.
PULSE_ALLOW_PRIVATE_MONITORS=
# Comma-separated CIDRs of reverse proxies whose X-Forwarded-For may be trusted
# for the login rate limiter. Set when running behind a proxy (e.g. Caddy/Docker)
# so per-client throttling works instead of collapsing all visitors into one
# bucket. Leave empty to ignore proxy headers entirely.
PULSE_TRUSTED_PROXIES=
62 changes: 54 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,62 @@ on:

jobs:
go:
name: Go (vet, fmt, test)
name: Go (vet, fmt, test, coverage)
runs-on: ubuntu-latest
strategy:
matrix:
include:
# Thresholds exclude generated (sqlc) code. api and agent sit at 85:
# both have unreachable defensive branches (OS syscall / filesystem /
# process-bootstrap error paths) that can't be exercised in tests.
- module: api
threshold: 85
- module: cli
threshold: 90
- module: agent
threshold: 85
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.25.3"
- name: go vet
run: cd api && go vet ./...
run: cd ${{ matrix.module }} && go vet ./...
- name: gofmt
run: |
unformatted="$(gofmt -l $(find api -name '*.go' -not -path '*/internal/generated/*'))"
unformatted="$(gofmt -l $(find ${{ matrix.module }} -name '*.go' -not -path '*/internal/generated/*'))"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-formatted:"; echo "$unformatted"; exit 1
fi
- name: go test
run: cd api && go test ./... -count=1
- name: go test (race)
run: cd ${{ matrix.module }} && go test ./... -race -count=1
- name: coverage gate (excluding generated)
run: bash scripts/coverage.sh ${{ matrix.module }} ${{ matrix.threshold }}
- name: upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.module }}
path: |
${{ matrix.module }}/coverage.html
${{ matrix.module }}/coverage-badge.json
if-no-files-found: ignore

lint:
name: golangci-lint (advisory)
runs-on: ubuntu-latest
# Advisory for now: reports issues without blocking. Flip to blocking once
# the existing findings are cleared.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.3"
- uses: golangci/golangci-lint-action@v6
with:
version: latest
working-directory: api

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The new lint job covers only api, unlike the Go test matrix. agent and cli can accumulate unchecked errors and other lint regressions unnoticed; this PR already changes both. Matrix lint across all three modules (with per-module working directories/config if needed), pin the linter version for reproducibility, and make it blocking once the 38 current errcheck findings are resolved.


ui:
name: Admin UI build
Expand All @@ -39,13 +78,20 @@ jobs:
docker:
name: Docker image builds
runs-on: ubuntu-latest
strategy:
matrix:
include:
- file: api/Dockerfile
tag: pulse:ci
- file: agent/Dockerfile
tag: pulse-agent:ci
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: build image
uses: docker/build-push-action@v6
with:
context: .
file: api/Dockerfile
file: ${{ matrix.file }}
push: false
tags: pulse:ci
tags: ${{ matrix.tag }}
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ data/

# superpowers brainstorm
.superpowers/
.worktrees/

# Node
ui/node_modules/
Expand All @@ -36,3 +37,9 @@ api/internal/web/dist/admin/*
# Stale local build outputs (binary names vary by entrypoint)
api/api
api/pulse

# Coverage artifacts (generated by scripts/coverage.sh)
coverage.out
coverage.nogen.out
coverage.html
coverage-badge.json
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: up down test sqlc lint ui build run
.PHONY: up down test cover cover-html sqlc lint ui build run

ui:
cd ui && NEXT_PUBLIC_API_URL="" npm ci && NEXT_PUBLIC_API_URL="" npm run build
Expand All @@ -14,6 +14,19 @@ run: build
test:
cd api && go test ./... -count=1

# Coverage gate (excludes internal/generated). Override module/threshold:
# make cover MODULE=agent THRESHOLD=90
MODULE ?= api
THRESHOLD ?= 90
cover:
bash scripts/coverage.sh $(MODULE) $(THRESHOLD)

cover-html:
cd $(MODULE) && go test ./... -covermode=atomic -coverprofile=coverage.out >/dev/null && \
grep -v internal/generated/ coverage.out > coverage.nogen.out && \
go tool cover -html=coverage.nogen.out -o coverage.html && \
echo "wrote $(MODULE)/coverage.html"

sqlc:
cd api/internal/db && sqlc generate

Expand Down
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Pulse

[![CI](https://github.com/memetics19/pulse/actions/workflows/ci.yml/badge.svg)](https://github.com/memetics19/pulse/actions/workflows/ci.yml)
[![api coverage](https://img.shields.io/badge/api%20coverage-%E2%89%A585%25-green)](.github/workflows/ci.yml)
[![cli coverage](https://img.shields.io/badge/cli%20coverage-%E2%89%A590%25-brightgreen)](.github/workflows/ci.yml)
[![agent coverage](https://img.shields.io/badge/agent%20coverage-%E2%89%A585%25-green)](.github/workflows/ci.yml)
[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](go.work)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md)

> Coverage is enforced in CI per module (excluding generated code); the badges
> show the gate each module must clear. See the `go` job in
> [`.github/workflows/ci.yml`](.github/workflows/ci.yml) and
> [`scripts/coverage.sh`](scripts/coverage.sh).

Pulse is an open-source, self-hosted status page and monitoring tool. It ships as a single Go binary with a SQLite database. The monitoring worker runs in the same process, so there is no separate database server, Node.js runtime, or reverse proxy required to run it. Pulse checks your services, records uptime and latency, opens incidents when checks fail, and serves public status pages on your own domains.

A live status page runs at [status.shreeda.xyz](https://status.shreeda.xyz). The full documentation is at [docs.shreeda.xyz](https://docs.shreeda.xyz).
Expand All @@ -18,6 +30,65 @@ A live status page runs at [status.shreeda.xyz](https://status.shreeda.xyz). The
- **Atom feed.** The public page exposes an Atom feed for incident updates.
- **Local-timezone rendering.** All times render in the visitor's local timezone.

## Architecture

Pulse runs as one Go binary with an in-process monitoring worker and a single
SQLite file. The optional `pulse-agent` pushes host metrics; everything else —
REST API, public status pages, and the embedded admin SPA — is served from the
same process.

```mermaid
flowchart TB
subgraph binary["pulse (single Go binary)"]
direction TB
HTTP["chi HTTP server<br/>REST API · public pages · embedded admin SPA"]
subgraph worker["in-process worker"]
SCHED["scheduler<br/>1 goroutine per monitor"]
CHK["checkers<br/>http · tcp · dns · ssl · ping"]
DET["incident detector"]
ALERT["alerter<br/>email · slack"]
LOOP["rollup · pruner · maintenance"]
end
SCHED --> CHK
SCHED --> DET
DET --> ALERT
end
DB[("SQLite (WAL)")]
AGENT["pulse-agent<br/>host metrics"]
USER["operator / API client"]
VISITOR["public visitor"]

HTTP <--> DB
worker <--> DB
AGENT -- "POST /api/ingest/metrics" --> HTTP
USER -- "REST + session / API key" --> HTTP
VISITOR -- "status page (Host-routed)" --> HTTP
CHK -- "netguard-gated dials" --> TARGETS["monitored targets"]
ALERT --> CHANNELS["email · slack webhook"]
```

Each monitor runs on its own interval. One check flows through latency
thresholds, gets recorded, and — after two consecutive failures with no active
maintenance window — opens an incident and fires alerts:

```mermaid
flowchart TD
START([interval tick]) --> RUN["checker.Check<br/>(shared transport, netguard-gated dial)"]
RUN --> THRESH{"apply latency<br/>thresholds"}
THRESH -->|"resp > down threshold"| DOWN[status = down]
THRESH -->|"resp > degraded threshold"| DEG[status = degraded]
THRESH -->|otherwise| UP[status = up]
DOWN --> WRITE
DEG --> WRITE
UP --> WRITE["INSERT check_results"]
WRITE --> DETECT{"2 consecutive<br/>down?"}
DETECT -->|no| DONE([wait next tick])
DETECT -->|"yes, no active incident,<br/>not in maintenance"| INC["open incident"]
INC --> NOTIFY["alerter → email / slack"]
NOTIFY --> DONE
DETECT -->|"suppressed"| DONE
```

## Quick start (60 seconds)

Pulse is a single static binary — no Docker or runtime dependencies. The
Expand Down
33 changes: 13 additions & 20 deletions agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
# Build context: pulse/ (workspace root)
FROM golang:1.25rc1-alpine AS builder
ENV GOTOOLCHAIN=auto
WORKDIR /workspace
# Build context: pulse/ (workspace root).
# The agent is a standalone Go module; build it with the workspace disabled
# (GOWORK=off) so it does not need the api/ or cli/ modules to be present.
FROM golang:1.25-alpine AS builder
ENV GOTOOLCHAIN=auto GOWORK=off CGO_ENABLED=0
WORKDIR /src

# Copy workspace manifests first (cache layer)
COPY go.work go.work.sum ./
# Dependency manifests first (cache layer).
COPY agent/go.mod agent/go.sum ./
RUN go mod download

# Copy each module's dependency manifests for better layer caching
COPY agent/go.mod agent/go.sum ./agent/
COPY api/go.mod api/go.sum ./api/
COPY worker/go.mod worker/go.sum ./worker/
# Full agent source.
COPY agent/ ./

# Download deps (workspace-aware)
RUN go work sync && go mod download -modfile agent/go.mod

# Copy full source
COPY agent/ ./agent/
COPY api/ ./api/


# Build the agent binary (CGO disabled → fully static)
RUN CGO_ENABLED=0 go build -o /pulse-agent ./agent/cmd/agent
# Build the static binary.
RUN go build -o /pulse-agent ./cmd/agent

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
Expand Down
75 changes: 58 additions & 17 deletions agent/cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand All @@ -15,35 +17,56 @@ import (
)

func main() {
server := flag.String("server", "", "Pulse API base URL, e.g. https://status.example.com (required)")
token := flag.String("token", "", "Bearer token for ingest authentication (required)")
interval := flag.Int("interval", 30, "Push interval in seconds (default 30)")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
os.Exit(parseAndRun(ctx, os.Args[1:], os.Stderr))
}

if *server == "" || *token == "" {
fmt.Fprintln(os.Stderr, "pulse-agent: --server and --token are required")
flag.Usage()
os.Exit(1)
// parseAndRun parses flags, resolves the token, validates, and runs the agent.
// It returns a process exit code so the flag/validation branches are testable
// without os.Exit. run blocks until ctx is cancelled.
func parseAndRun(ctx context.Context, args []string, stderr io.Writer) int {
fs := flag.NewFlagSet("pulse-agent", flag.ContinueOnError)
fs.SetOutput(stderr)
server := fs.String("server", "", "Pulse API base URL, e.g. https://status.example.com (required)")
token := fs.String("token", "", "Bearer token (INSECURE: visible in ps/proc; prefer PULSE_AGENT_TOKEN or --token-file)")
tokenFile := fs.String("token-file", "", "File to read the bearer token from")
interval := fs.Int("interval", 30, "Push interval in seconds (default 30)")
if err := fs.Parse(args); err != nil {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] --help is reported as a usage error. flag.FlagSet.Parse returns flag.ErrHelp after printing help, and this maps it to exit code 2. A fresh binary run confirms pulse-agent --help exits 2. Handle errors.Is(err, flag.ErrHelp) as success (0), preserve 2 for malformed flags, and cover both exit codes in table-driven CLI tests.

return 2
}

tok, err := resolveToken(*token, *tokenFile)
if err != nil {
fmt.Fprintln(stderr, "pulse-agent:", err)
return 1
}
if *server == "" || tok == "" {
fmt.Fprintln(stderr, "pulse-agent: --server and a token (PULSE_AGENT_TOKEN, --token-file, or --token) are required")
return 1
}
if *interval < 1 {
fmt.Fprintln(os.Stderr, "pulse-agent: --interval must be >= 1")
os.Exit(1)
fmt.Fprintln(stderr, "pulse-agent: --interval must be >= 1")
return 1
}

col := collector.New()
psh := pusher.New(*server, *token)
run(ctx, *server, tok, time.Duration(*interval)*time.Second)
return 0
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// run pushes a metrics snapshot immediately, then on every interval, until ctx
// is cancelled.
func run(ctx context.Context, serverURL, token string, interval time.Duration) {
col := collector.New()
psh := pusher.New(serverURL, token)

log.Printf("pulse-agent starting: server=%s interval=%ds", *server, *interval)
log.Printf("pulse-agent starting: server=%s interval=%s", serverURL, interval)

// Push immediately on startup, then on each tick.
if err := pushOnce(ctx, col, psh); err != nil {
log.Printf("push error: %v", err)
}

ticker := time.NewTicker(time.Duration(*interval) * time.Second)
ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
Expand All @@ -59,6 +82,24 @@ func main() {
}
}

// resolveToken picks the bearer token from, in order of preference:
// PULSE_AGENT_TOKEN env var, --token-file contents, then --token. The env var
// and file are preferred because a --token flag is visible to any local user
// via ps(1) and /proc/<pid>/cmdline for the agent's whole lifetime.
func resolveToken(flagToken, tokenFile string) (string, error) {
if env := os.Getenv("PULSE_AGENT_TOKEN"); env != "" {
return strings.TrimSpace(env), nil
}
if tokenFile != "" {
b, err := os.ReadFile(tokenFile)
if err != nil {
return "", fmt.Errorf("reading --token-file: %w", err)
}
return strings.TrimSpace(string(b)), nil
}
return strings.TrimSpace(flagToken), nil
}

func pushOnce(ctx context.Context, col *collector.Collector, psh *pusher.Pusher) error {
m, err := col.Snapshot()
if err != nil {
Expand Down
Loading
Loading