diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..7215f27 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,11 @@ +#!/bin/sh +# Reject AI co-authorship / attribution in commit messages. +# Applies to everyone (humans and agents). Enable once per clone: +# git config core.hooksPath .githooks +msg_file="$1" +if grep -qiE 'co-authored-by|generated with .*(claude|code)|🤖|noreply@anthropic' "$msg_file"; then + echo "commit-msg: AI / co-author attribution is not allowed in this repo." >&2 + echo "Remove Co-Authored-By and 'Generated with ...' lines, then commit again." >&2 + exit 1 +fi +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..387a9bc --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh +# Basic "no slop" gate: staged Go must be gofmt-clean (generated code excluded). +# Enable once per clone: git config core.hooksPath .githooks +files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' | grep -v '/internal/generated/') +[ -z "$files" ] && exit 0 +bad=$(gofmt -l $files 2>/dev/null) +if [ -n "$bad" ]; then + echo "pre-commit: these Go files are not gofmt-formatted:" >&2 + echo "$bad" >&2 + echo "Run: gofmt -w " >&2 + exit 1 +fi +exit 0 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6a2131b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,50 @@ + + +## Summary + + + +## Type of change + +- [ ] `feat` — new feature +- [ ] `fix` — bug fix +- [ ] `refactor` — no behaviour change +- [ ] `docs` +- [ ] `ci` / `build` — pipeline or tooling +- [ ] `test` +- [ ] `chore` +- [ ] **Breaking change** (`!` / `BREAKING CHANGE:` footer) + +## Changes + + + +- + +## Breaking changes + + + +## Test plan + +- [ ] `cd api && go test ./... -count=1` +- [ ] `cd agent && go test ./... -count=1` +- [ ] `cd cli && go test ./... -count=1` +- [ ] `cd ui && npx tsc --noEmit` (if the UI changed) +- [ ] Manual check: + +## Checklist + +- [ ] Commits follow Conventional Commits +- [ ] No `Co-Authored-By` / AI attribution (enforced by the `commit-msg` hook) +- [ ] `gofmt`-clean and `go vet ./...` passes +- [ ] Docs updated (`docs/`, `README.md`) where relevant +- [ ] No secrets or `.env` files committed + +## Deployment notes + + diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..302682b --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,54 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + - "deploy/Caddyfile" + pull_request: + paths: + - "docs/**" + - "mkdocs.yml" + +# Don't let two docs deploys run at once on the host. +concurrency: + group: docs-deploy + cancel-in-progress: true + +jobs: + build: + name: Build docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: mkdocs build + run: | + docker run --rm -v "$PWD:/docs" squidfunk/mkdocs-material:latest \ + build -f /docs/mkdocs.yml -d /docs/_site + + deploy: + name: Rebuild docs on homelab + needs: build + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Join Netbird VPN + # Managed Netbird cloud — default management URL, no self-hosted URL needed. + run: | + curl -fsSL https://pkgs.netbird.io/install.sh | sudo sh + sudo netbird up --setup-key "${{ secrets.NETBIRD_SETUP_KEY }}" + sudo netbird status + - name: Rebuild and serve docs over SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + password: ${{ secrets.SSH_PASSWORD }} + # The docs-build one-shot re-renders the markdown into the docs_site + # volume; the already-running Caddy serves it live (no restart needed). + script: | + cd ~/pulse + git fetch origin main && git pull --ff-only + docker compose -f deploy/docker-compose.homelab.yml run --rm docs-build diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..e756967 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,19 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.PULSE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c80739..ab4d845 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,30 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - name: Create GitHub release - uses: softprops/action-gh-release@v2 + + # Deploy runs only after the image is built and pushed, so the homelab never + # pulls a tag that does not exist yet. The GitHub release + changelog are + # created by release-please (see release-please.yml), not here. + deploy: + name: Deploy to homelab + needs: release + runs-on: ubuntu-latest + steps: + - name: Join Netbird VPN + # Managed Netbird cloud — uses the default management URL, no self-hosted URL needed. + run: | + curl -fsSL https://pkgs.netbird.io/install.sh | sudo sh + sudo netbird up --setup-key "${{ secrets.NETBIRD_SETUP_KEY }}" + sudo netbird status + - name: Pull and restart on host over SSH + uses: appleboy/ssh-action@v1 with: - generate_release_notes: true + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + password: ${{ secrets.SSH_PASSWORD }} + script: | + cd ~/pulse + git fetch --tags && git pull --ff-only + docker compose -f deploy/docker-compose.homelab.yml pull + docker compose -f deploy/docker-compose.homelab.yml up -d + docker image prune -f diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5e19c6f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to Pulse + +Thanks for contributing. This guide covers the dev setup, the commit policy +(enforced by git hooks), and how to open a pull request. + +## Development setup + +Pulse is a Go workspace (`go.work`) with three modules — `api`, `agent`, `cli` — +plus a Next.js admin UI in `ui/`. + +```sh +# Build the binary (embeds the admin UI) and run it on :8080 +make run + +# Run the test suites +cd api && go test ./... -count=1 +cd agent && go test ./... -count=1 +cd cli && go test ./... -count=1 + +# Regenerate DB access code after editing api/internal/db/queries or migrations +make sqlc # requires sqlc v1.31.1 + +# UI +cd ui && npm ci && npm run build +``` + +## Branching + +Branch off `main` with a type prefix: `feat/...`, `fix/...`, `docs/...`, +`refactor/...`, `ci/...`. Do not commit directly to `main`. + +## Commit policy (enforced) + +These rules apply to everyone, including AI assistants: + +- **Conventional Commits.** `type(scope): summary` (`feat`, `fix`, `refactor`, + `docs`, `build`, `ci`, `test`, `chore`). Mark breaking changes with a `!` after + the type or a `BREAKING CHANGE:` footer. This drives automatic versioning. +- **No AI / co-author attribution.** No `Co-Authored-By`, no "Generated with …" + lines, no bot trailers. +- **No force-pushes to `main`.** +- **No slop.** Code is `gofmt`-clean, passes `go vet ./...`, and passes the test + suite before it is committed. + +### Enable the hooks (once per clone) + +The repo ships hooks under `.githooks/` that enforce the rules above: + +```sh +git config core.hooksPath .githooks +``` + +`commit-msg` rejects AI/co-author trailers; `pre-commit` rejects un-`gofmt`'d Go. +(`--no-verify` bypasses git hooks — don't.) + +## Pull requests + +1. Push your branch and open a PR against `main`. The + [PR template](.github/PULL_REQUEST_TEMPLATE.md) loads automatically — fill in + every section (summary, type, changes, breaking changes, test plan, checklist). +2. CI must be green (vet, gofmt, tests, UI build) before review. +3. Keep PRs focused; one logical change per commit even if the PR bundles several. +4. **Merge-commit** the PR (don't squash) so each Conventional Commit reaches + `main` — release-please relies on them for the changelog and the version bump. + If you must squash, the PR title itself must be a Conventional Commit and carry + any `BREAKING CHANGE:` footer. + +## Releases & deployment + +Versioning is automated with +[release-please](https://github.com/googleapis/release-please): merging +Conventional Commits to `main` maintains a release PR; merging that PR tags +`vX.Y.Z`, publishes a GitHub release + `CHANGELOG.md`, builds and pushes the +image to GHCR, and deploys to the homelab. See +[docs/deployment.md](docs/deployment.md) for the full flow and required secrets. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..5bdc93e --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2026 memetics19 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2d8e171..3e81079 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Pulse is an open-source, self-hosted status page and monitoring tool. It ships a 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). + + ## Highlights - **Single binary and SQLite.** One Go binary plus one SQLite file. The monitoring worker runs in-process. @@ -48,4 +50,4 @@ Full documentation is at [docs.shreeda.xyz](https://docs.shreeda.xyz). The Markd ## License -Pulse is open source. See the repository for license details. +Pulse is released under the MIT License. See [LICENSE.md](LICENSE.md). diff --git a/agent/Dockerfile b/agent/Dockerfile index 5faffc2..e4bed2d 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -17,7 +17,7 @@ RUN go work sync && go mod download -modfile agent/go.mod # Copy full source COPY agent/ ./agent/ COPY api/ ./api/ -COPY worker/ ./worker/ + # Build the agent binary (CGO disabled → fully static) RUN CGO_ENABLED=0 go build -o /pulse-agent ./agent/cmd/agent diff --git a/api/internal/db/db.go b/api/internal/db/db.go index 90dfdb6..66c73cd 100644 --- a/api/internal/db/db.go +++ b/api/internal/db/db.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "embed" + "errors" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite" @@ -39,7 +40,7 @@ func runMigrations(conn *sql.DB) error { if err != nil { return err } - if err := m.Up(); err != nil && err != migrate.ErrNoChange { + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { return err } return nil diff --git a/api/internal/db/sqlc.yaml b/api/internal/db/sqlc.yaml index ce48032..b876287 100644 --- a/api/internal/db/sqlc.yaml +++ b/api/internal/db/sqlc.yaml @@ -9,3 +9,8 @@ sql: out: "../generated" emit_json_tags: true emit_pointers_for_null_types: true + overrides: + - column: "monitors.is_active" + go_type: "bool" + - column: "infra_agents.is_active" + go_type: "bool" diff --git a/api/internal/generated/models.go b/api/internal/generated/models.go index 2c3250f..d0f72d1 100644 --- a/api/internal/generated/models.go +++ b/api/internal/generated/models.go @@ -63,7 +63,7 @@ type InfraAgent struct { HostLabel string `json:"host_label"` Token string `json:"token"` LastSeenAt *time.Time `json:"last_seen_at"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` } @@ -113,7 +113,7 @@ type Monitor struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID *int64 `json:"group_id"` Source string `json:"source"` ExternalID string `json:"external_id"` diff --git a/api/internal/generated/monitors.sql.go b/api/internal/generated/monitors.sql.go index 96c52d2..9320aba 100644 --- a/api/internal/generated/monitors.sql.go +++ b/api/internal/generated/monitors.sql.go @@ -27,7 +27,7 @@ type CreateMonitorParams struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID *int64 `json:"group_id"` Source string `json:"source"` ExternalID string `json:"external_id"` @@ -254,7 +254,7 @@ type UpdateMonitorParams struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID *int64 `json:"group_id"` ID int64 `json:"id"` } diff --git a/api/internal/handlers/ingest.go b/api/internal/handlers/ingest.go index 2a23df6..abda86e 100644 --- a/api/internal/handlers/ingest.go +++ b/api/internal/handlers/ingest.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "log" "net/http" "strings" "time" @@ -55,7 +56,7 @@ func (h *Ingest) PostMetrics(w http.ResponseWriter, r *http.Request) { if err := h.q.UpdateAgentLastSeen(r.Context(), agent.ID); err != nil { // Non-fatal: log but continue - _ = err + log.Printf("failed to update agent last seen: %v", err) } w.WriteHeader(http.StatusNoContent) diff --git a/api/internal/handlers/monitors_test.go b/api/internal/handlers/monitors_test.go index aaa7494..b279128 100644 --- a/api/internal/handlers/monitors_test.go +++ b/api/internal/handlers/monitors_test.go @@ -27,7 +27,7 @@ func TestMonitorsCRUD(t *testing.T) { "timeout_seconds": 10, "degraded_threshold_ms": 500, "down_threshold_ms": 2000, - "is_active": 1, + "is_active": true, }) req := httptest.NewRequest(http.MethodPost, "/api/monitors", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") diff --git a/api/internal/handlers/overview.go b/api/internal/handlers/overview.go index f615d7b..496c444 100644 --- a/api/internal/handlers/overview.go +++ b/api/internal/handlers/overview.go @@ -31,7 +31,7 @@ func (h *Overview) Get(w http.ResponseWriter, r *http.Request) { var up, degraded, down int attention := []attentionItem{} for _, m := range monitors { - if m.IsActive == 0 { + if !m.IsActive { continue } s := status[m.ID] @@ -49,10 +49,10 @@ func (h *Overview) Get(w http.ResponseWriter, r *http.Request) { } } - // InfraAgent: Name string, IsActive int64, LastSeenAt *time.Time + // InfraAgent: Name string, IsActive bool, LastSeenAt *time.Time agents, _ := h.q.ListAgents(ctx) for _, a := range agents { - if a.IsActive == 0 || a.LastSeenAt == nil { + if !a.IsActive || a.LastSeenAt == nil { continue } if time.Since(*a.LastSeenAt) > 2*time.Minute { diff --git a/api/internal/web/feed.go b/api/internal/web/feed.go index aeef05d..4e2277d 100644 --- a/api/internal/web/feed.go +++ b/api/internal/web/feed.go @@ -42,7 +42,7 @@ func (p *Public) Feed(w http.ResponseWriter, r *http.Request) { } pageMonitors := map[int64]bool{} for _, m := range snap.Monitors { - if m.IsActive == 0 || m.GroupID == nil { + if !m.IsActive || m.GroupID == nil { continue } if allGroups || allow[*m.GroupID] { diff --git a/api/internal/web/public.go b/api/internal/web/public.go index 67e2a1f..f076c9a 100644 --- a/api/internal/web/public.go +++ b/api/internal/web/public.go @@ -257,7 +257,7 @@ func (p *Public) buildVM(ctx context.Context, rng string, rp ResolvedPage) statu pageMonitors := map[int64]bool{} byGroup := make(map[int64][]monitorVM) for _, m := range snap.Monitors { - if m.IsActive == 0 || m.GroupID == nil { + if !m.IsActive || m.GroupID == nil { continue } if !allowGroup(*m.GroupID) { diff --git a/api/internal/worker/incident/detector_test.go b/api/internal/worker/incident/detector_test.go index a71e82e..5941b1a 100644 --- a/api/internal/worker/incident/detector_test.go +++ b/api/internal/worker/incident/detector_test.go @@ -29,7 +29,7 @@ func TestDetector_IncidentOnSecondConsecutiveDown(t *testing.T) { mon, err := q.CreateMonitor(context.Background(), store.CreateMonitorParams{ Name: "Test API", Url: "http://example.com", Type: "http", IntervalSeconds: 60, TimeoutSeconds: 10, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", }) require.NoError(t, err) @@ -77,7 +77,7 @@ func TestDetector_ResetsOnUp(t *testing.T) { mon, err := q.CreateMonitor(context.Background(), store.CreateMonitorParams{ Name: "Test API", Url: "http://example.com", Type: "http", IntervalSeconds: 60, TimeoutSeconds: 10, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", }) require.NoError(t, err) diff --git a/api/internal/worker/pruner/pruner_test.go b/api/internal/worker/pruner/pruner_test.go index 7eaa9bb..0b5138a 100644 --- a/api/internal/worker/pruner/pruner_test.go +++ b/api/internal/worker/pruner/pruner_test.go @@ -19,7 +19,7 @@ func TestPruner_DeletesOldCheckResults(t *testing.T) { mon, err := q.CreateMonitor(context.Background(), store.CreateMonitorParams{ Name: "Test", Url: "http://x.com", Type: "http", IntervalSeconds: 60, TimeoutSeconds: 10, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", }) require.NoError(t, err) diff --git a/api/internal/worker/scheduler/scheduler_test.go b/api/internal/worker/scheduler/scheduler_test.go index 3ef093d..9cb60bc 100644 --- a/api/internal/worker/scheduler/scheduler_test.go +++ b/api/internal/worker/scheduler/scheduler_test.go @@ -27,7 +27,7 @@ func TestScheduler_CallsCheckerAtInterval(t *testing.T) { mon := store.Monitor{ ID: 1, Name: "test", Url: "tcp://x.com:80", Type: "tcp", IntervalSeconds: 1, TimeoutSeconds: 5, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", } @@ -51,7 +51,7 @@ func TestScheduler_ZeroIntervalDoesNotPanic(t *testing.T) { mon := store.Monitor{ ID: 1, Name: "test", Url: "tcp://x.com:80", Type: "tcp", IntervalSeconds: 0, TimeoutSeconds: 5, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", } diff --git a/cli/internal/pulseclient/client.go b/cli/internal/pulseclient/client.go index 305c9d5..a510a9f 100644 --- a/cli/internal/pulseclient/client.go +++ b/cli/internal/pulseclient/client.go @@ -78,7 +78,7 @@ type createMonitorRequest struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID int64 `json:"group_id"` Source string `json:"source"` ExternalID string `json:"external_id"` @@ -96,7 +96,7 @@ func (c *Client) CreateMonitor(m uptimekuma.PulseMonitor, groupID int64) error { KeywordCheck: m.KeywordCheck, DegradedThresholdMs: 1000, DownThresholdMs: 3000, - IsActive: 1, + IsActive: true, GroupID: groupID, Source: "uptime-kuma", ExternalID: "", diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..f596609 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,27 @@ +# Pulse reverse proxy. +# +# This VM has a private address, so public Let's Encrypt certificates cannot be +# issued here. Caddy therefore serves plain HTTP and routes by Host. Point +# status.shreeda.xyz and docs.shreeda.xyz at this host (internal DNS or an +# /etc/hosts entry). To serve public HTTPS, place a proxy with public DNS in +# front, or switch these blocks to https:// once the domains resolve publicly. + +{ + auto_https off +} + +# Public status page. +http://status.shreeda.xyz { + reverse_proxy pulse:8080 +} + +# Documentation site (static, built by the docs-build service). +http://docs.shreeda.xyz { + root * /srv/docs + file_server +} + +# Anything else (for example the raw VM IP) shows the status page. +http:// { + reverse_proxy pulse:8080 +} diff --git a/deploy/docker-compose.homelab.yml b/deploy/docker-compose.homelab.yml new file mode 100644 index 0000000..81b48bb --- /dev/null +++ b/deploy/docker-compose.homelab.yml @@ -0,0 +1,55 @@ +# Homelab deployment: pulls the released image from GHCR (no local build), +# so `docker compose pull` picks up each new release. Used by the deploy job in +# .github/workflows/release.yml and for manual runs from the repo root on the host: +# +# docker compose -f deploy/docker-compose.homelab.yml pull +# docker compose -f deploy/docker-compose.homelab.yml up -d +# +# The caddy and docs-build services read config/markdown from the host's git +# checkout, so the deploy job runs `git pull` before `compose pull`. + +services: + pulse: + image: ghcr.io/memetics19/pulse:latest + environment: + SQLITE_PATH: /data/pulse.db + RESEND_API_KEY: ${RESEND_API_KEY:-} + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + # Allow unprivileged ICMP (ping monitors) without privileged mode. + sysctls: + - net.ipv4.ping_group_range=0 2147483647 + volumes: + - pulse_data:/data + restart: unless-stopped + + # One-shot: render the Markdown docs into a static site on the docs_site volume. + docs-build: + image: squidfunk/mkdocs-material:latest + working_dir: /workspace + volumes: + - ./:/workspace:ro + - docs_site:/site + command: build -d /site -f /workspace/mkdocs.yml + restart: "no" + + caddy: + image: caddy:2-alpine + depends_on: + pulse: + condition: service_started + docs-build: + condition: service_completed_successfully + ports: + - "80:80" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - docs_site:/srv/docs:ro + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + +volumes: + pulse_data: + docs_site: + caddy_data: + caddy_config: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..6cafeaf --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,56 @@ +# Production deployment: the Pulse status page and the documentation site, +# behind a single Caddy reverse proxy that routes by Host. +# +# docker compose -f deploy/docker-compose.prod.yml up -d --build +# +# Run from the repository root so the build context and bind mounts resolve. + +services: + pulse: + build: + context: . + dockerfile: api/Dockerfile + image: pulse:latest + environment: + SQLITE_PATH: /data/pulse.db + RESEND_API_KEY: ${RESEND_API_KEY:-} + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + # Allow unprivileged ICMP (ping monitors) from inside the container. + # This is a namespaced sysctl, so no privileged mode is required. + sysctls: + - net.ipv4.ping_group_range=0 2147483647 + volumes: + - pulse_data:/data + restart: unless-stopped + + # One-shot: render the Markdown docs into a static site on the docs_site volume. + docs-build: + image: squidfunk/mkdocs-material:latest + working_dir: /workspace + volumes: + - ./:/workspace:ro + - docs_site:/site + command: build -d /site -f /workspace/mkdocs.yml + restart: "no" + + caddy: + image: caddy:2-alpine + depends_on: + pulse: + condition: service_started + docs-build: + condition: service_completed_successfully + ports: + - "80:80" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - docs_site:/srv/docs:ro + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + +volumes: + pulse_data: + docs_site: + caddy_data: + caddy_config: diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..d717ab6 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,114 @@ +# Deployment + +Pulse ships as a single container image published to the GitHub Container +Registry (GHCR) at `ghcr.io/memetics19/pulse`. Releases and deployment are +automated: merge work to `main`, merge the release PR, and the new version is +built, published, and rolled out to the homelab. + +## Release & deploy flow + +``` +commit (Conventional Commits) ─▶ main + │ + ├─ ci.yml run tests on every push / PR + │ + ├─ release-please.yml maintains a release PR from the commit history + │ │ + │ └─ merge release PR ─▶ tag vX.Y.Z + GitHub release + CHANGELOG + │ + └─ release.yml (on tag vX.Y.Z) + ├─ test re-run the suite + ├─ release build + push ghcr.io/memetics19/pulse:{version,latest} + └─ deploy join Netbird → SSH to the host → pull + restart +``` + +Tests gate everything: `release.yml` re-runs them before building, and `deploy` +only runs after `release` succeeds, so a broken build never reaches the homelab. + +## Versioning + +Versions are computed by [release-please](https://github.com/googleapis/release-please) +from Conventional Commit messages: `fix:` → patch, `feat:` → minor, `!` / +`BREAKING CHANGE:` → major (minor while pre-1.0). The seed version lives in +`.release-please-manifest.json`. + +## Homelab host + +The status page runs on a private host (`10.2.0.115`) reachable over a +[Netbird](https://netbird.io) VPN (the **managed cloud**, not self-hosted — so +no management URL is needed). The deploy job joins the same Netbird network from +the GitHub runner with an ephemeral setup key, then connects over SSH. + +### One-time host setup + +```sh +# Docker + compose plugin must be installed. +git clone https://github.com/memetics19/pulse.git ~/pulse +cd ~/pulse +# If the GHCR package is private, authenticate once (else make the package public): +# echo "$GHCR_READ_TOKEN" | docker login ghcr.io -u memetics19 --password-stdin +docker compose -f deploy/docker-compose.homelab.yml up -d +``` + +`deploy/docker-compose.homelab.yml` pulls the `pulse` image from GHCR (no local +build) and serves it, plus the docs site, behind Caddy (`deploy/Caddyfile`). + +### Required GitHub secrets + +| Secret | Purpose | +|---|---| +| `PULSE_TOKEN` | GHCR push (already configured) | +| `NETBIRD_SETUP_KEY` | Ephemeral key so the runner joins the managed Netbird network | +| `SSH_PASSWORD` | Password for the deploy user on the host | +| `DEPLOY_HOST` | `10.2.0.115` | +| `DEPLOY_USER` | `ubuntu` | + +> SSH uses password auth. The host must allow it: set `PasswordAuthentication yes` +> in `/etc/ssh/sshd_config` and `sudo systemctl restart ssh`. (Key-based auth with +> `SSH_PRIVATE_KEY` + `SSH_HOST_FINGERPRINT` is more secure if you prefer to switch +> back.) + +### What the deploy job runs on the host + +```sh +cd ~/pulse && git fetch --tags && git pull --ff-only +docker compose -f deploy/docker-compose.homelab.yml pull +docker compose -f deploy/docker-compose.homelab.yml up -d +docker image prune -f +``` + +## Docs deployment + +The documentation site (`docs.shreeda.xyz`) deploys to the **same host over the +same Netbird network**, on its own schedule, via `.github/workflows/deploy-docs.yml`: + +- **On pull requests** touching `docs/**` or `mkdocs.yml`, it runs `mkdocs build` + to validate the docs (CI gate). +- **On push to `main`** touching those paths, it joins Netbird, SSHes to the host, + `git pull`s, and re-renders the docs: + ```sh + docker compose -f deploy/docker-compose.homelab.yml run --rm docs-build + ``` + The `docs-build` one-shot writes the static site into the `docs_site` volume, + which the already-running Caddy serves live — no Caddy restart needed. + +Docs changes therefore ship on merge to `main` without waiting for an app +release. (App releases also re-render the docs as part of `compose up -d`.) + +## Rollback + +Pin the previous version and bring the stack back up on the host: + +```sh +cd ~/pulse +docker compose -f deploy/docker-compose.homelab.yml pull pulse # or edit the image tag +docker run ... ghcr.io/memetics19/pulse: # or set the tag and `up -d` +``` + +The simplest rollback is to re-point the `pulse` image tag to the previous +`vX.Y.Z` and run `docker compose ... up -d`. + +## Manual / local production stack + +For a self-built stack (no GHCR), `deploy/docker-compose.prod.yml` builds the +image locally instead of pulling it. diff --git a/docs/getting-started.md b/docs/getting-started.md index a845f56..888e572 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,7 @@ To serve a public page over HTTPS, you also need a reverse proxy in front of Pul Clone the repository and start the container: ```bash -git clone https://github.com/your-org/pulse.git +git clone https://github.com/memetics19/pulse.git cd pulse docker compose up -d ``` @@ -76,10 +76,13 @@ To produce it in Uptime Kuma: ### Run the import ```bash -pulse-cli import uptime-kuma --file backup.json +pulse-cli import uptime-kuma \ + --file backup.json \ + --server https://status.example.com \ + --token pulse_live_xxxxxxxx ``` -The command reads the backup file and creates the corresponding monitors and groups in Pulse. Historical check data is not imported, because Uptime Kuma backups do not include it. +The command reads the backup file and creates the monitors in Pulse under a single "Imported" group, through the REST API. `--server` is your Pulse base URL and `--token` is an API key with the `monitors:write` scope (create one under Admin → API keys). Historical check data is not imported, because Uptime Kuma backups do not include it. ## Next steps diff --git a/docs/index.md b/docs/index.md index 03b884e..509ee3b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ Pulse is an open-source, self-hosted status page and monitoring tool. It runs as Pulse checks your services on a schedule, records uptime and latency, opens incidents when checks fail, and serves public status pages on your own domains. A live example runs at [status.shreeda.xyz](https://status.shreeda.xyz). These docs are hosted at [docs.shreeda.xyz](https://docs.shreeda.xyz). +![Pulse status page](assets/status-page.gif) + ## Highlights - **Single binary and SQLite.** One Go binary plus one SQLite file. The monitoring worker runs in-process. @@ -30,4 +32,5 @@ Pulse checks your services on a schedule, records uptime and latency, opens inci | [API](api.md) | The REST API base path, API keys and scopes, Bearer usage, curl examples, and the Atom feed. | | [Security](security.md) | Password hashing, sessions, TOTP, password reset, and API key handling. | | [Architecture](architecture.md) | The single binary, the in-process worker, the embedded admin, SQLite, and host-based page resolution. | +| [Deployment](deployment.md) | The GHCR image, release-please versioning, and the CI/CD release-and-deploy flow to the homelab. | | [Roadmap](roadmap.md) | Planned features that are not yet built. | diff --git a/mkdocs.yml b/mkdocs.yml index 870f9ef..d454209 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ nav: - API: api.md - Security: security.md - Architecture: architecture.md + - Deployment: deployment.md - Roadmap: roadmap.md # Internal planning notes live under docs/superpowers and are not published. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..c26aba8 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "packages": { + ".": { + "package-name": "pulse", + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/ui/src/app/admin/monitors/page.tsx b/ui/src/app/admin/monitors/page.tsx index 4641b4e..c2f9f57 100644 --- a/ui/src/app/admin/monitors/page.tsx +++ b/ui/src/app/admin/monitors/page.tsx @@ -9,7 +9,7 @@ import { const TYPES = ['http', 'tcp', 'ping', 'dns', 'ssl', 'infra'] const EMPTY: Partial = { name: '', url: '', type: 'http', interval_seconds: 60, timeout_seconds: 10, - degraded_threshold_ms: 500, down_threshold_ms: 2000, is_active: 1, + degraded_threshold_ms: 500, down_threshold_ms: 2000, is_active: true, keyword_check: '', source: 'internal', external_id: '', } diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 9c70264..84ea58c 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -11,7 +11,7 @@ export interface Monitor { keyword_check: string degraded_threshold_ms: number down_threshold_ms: number - is_active: number + is_active: boolean group_id: number | null source: string external_id: string @@ -81,7 +81,7 @@ export interface InfraAgent { host_label: string token: string last_seen_at: string | null - is_active: number + is_active: boolean created_at: string }