Skip to content
Merged
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
12 changes: 9 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'

# No explicit version: action-setup reads the pinned pnpm from the
# packageManager field in web/package.json, matching the Docker build.
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: latest
package_json_file: web/package.json

- name: Install frontend dependencies
working-directory: web
Expand All @@ -30,10 +32,14 @@ jobs:
working-directory: web
run: pnpm build

- name: Test frontend
working-directory: web
run: pnpm test

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.25'

- name: Build
run: go build -v ./...
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'

# No explicit version: action-setup reads the pinned pnpm from the
# packageManager field in web/package.json, matching the Docker build.
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: latest
package_json_file: web/package.json

- name: Install frontend dependencies
working-directory: web
Expand All @@ -35,7 +37,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.25'

- name: Get version
id: version
Expand Down
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ RUN pnpm install --frozen-lockfile
COPY web/ .
RUN pnpm build

# Backend build stage
FROM golang:1.22-alpine3.19 AS builder
# Backend build stage. Go version tracks go.mod's directive; keep CI
# (build.yml / release.yml) on the same minor so dev, CI, and release binaries
# are built with one toolchain.
FROM golang:1.25-alpine3.21 AS builder

WORKDIR /app

Expand All @@ -42,7 +44,7 @@ ARG COMMIT=none
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o wol-nut

# Runtime stage
FROM alpine:3.19
FROM alpine:3.21

RUN apk add --no-cache ca-certificates tzdata

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Lightweight Wake-on-LAN and NUT UPS monitoring dashboard for Raspberry Pi and Li
- **Detailed UPS Stats** - View power draw, voltage, runtime, temperature, and more
- **Mobile Friendly** - Responsive design works on any device
- **Single Binary** - No dependencies, embedded frontend
- **Auto-refresh** - UPS status updates every 30 seconds
- **Auto-refresh** - UPS status updates every 15 seconds
- **Backup/Restore** - Export and import configuration

## Screenshots
Expand All @@ -36,10 +36,15 @@ curl -fsSL https://raw.githubusercontent.com/aloks98/wolnut/master/scripts/insta
docker run -d \
--name wol-nut \
--network host \
--cap-add NET_RAW \
-v wol-nut-data:/data \
ghcr.io/aloks98/wolnut:latest
```

> `--cap-add NET_RAW` lets the device online-status check use ICMP ping. It's
> optional — without it the check falls back to probing common TCP ports, so a
> host that's up but exposes none of them would show as offline.

### Docker Compose

```bash
Expand Down
82 changes: 67 additions & 15 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,46 +54,98 @@ func RegisterAPIHandlers(mux *http.ServeMux, state *AppState, statusCache *Statu
// latestReleaseCache memoizes the GitHub /releases/latest response so that
// browser-side polls don't burn the unauthenticated GitHub rate limit
// (60/h/IP) — multiple tabs or NAT'd users would otherwise silently exhaust it.
//
// On a failed fetch it backs off for errTTL before contacting GitHub again and
// keeps serving the last good value (stale) in the meantime. Without this, a
// GitHub outage or a 403 rate-limit response would turn every poll into yet
// another GitHub call — amplifying the very rate-limiting the cache prevents.
type latestReleaseCache struct {
mu sync.RWMutex
version string
url string
fetchedAt time.Time
ttl time.Duration
ok bool // a successful fetch has happened at least once
fetchedAt time.Time // time of last successful fetch
lastErrAt time.Time // time of last failed fetch
ttl time.Duration // freshness window for a successful value
errTTL time.Duration // backoff window after a failed fetch
}

func newLatestReleaseCache(ttl time.Duration) *latestReleaseCache {
return &latestReleaseCache{ttl: ttl}
return &latestReleaseCache{ttl: ttl, errTTL: 5 * time.Minute}
}

func (c *latestReleaseCache) get() (string, string, bool) {
type releaseSnapshot struct {
version string
url string
fresh bool // a successful value within ttl — serve it directly
stale bool // an older value exists, worth serving while GitHub is down
backoff bool // a fetch failed recently — don't contact GitHub yet
}

func (c *latestReleaseCache) snapshot() releaseSnapshot {
c.mu.RLock()
defer c.mu.RUnlock()
if c.fetchedAt.IsZero() || time.Since(c.fetchedAt) > c.ttl {
return "", "", false
s := releaseSnapshot{version: c.version, url: c.url}
if c.ok {
s.stale = true
s.fresh = time.Since(c.fetchedAt) <= c.ttl
}
return c.version, c.url, true
if !c.lastErrAt.IsZero() && time.Since(c.lastErrAt) < c.errTTL {
s.backoff = true
}
return s
}

func (c *latestReleaseCache) setSuccess(version, url string) {
c.mu.Lock()
defer c.mu.Unlock()
c.version, c.url, c.ok, c.fetchedAt = version, url, true, time.Now()
c.lastErrAt = time.Time{} // a fresh success clears the backoff window
}

func (c *latestReleaseCache) set(version, url string) {
func (c *latestReleaseCache) setError() {
c.mu.Lock()
defer c.mu.Unlock()
c.version, c.url, c.fetchedAt = version, url, time.Now()
c.lastErrAt = time.Now()
}

func handleAPILatestRelease(cache *latestReleaseCache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if v, u, ok := cache.get(); ok {
writeSuccess(w, map[string]string{"version": v, "url": u})
snap := cache.snapshot()
if snap.fresh {
writeSuccess(w, map[string]string{"version": snap.version, "url": snap.url})
return
}
// A recent fetch failed: serve the stale value if we have one, otherwise
// fail fast — either way, don't contact GitHub again until errTTL passes.
if snap.backoff {
if snap.stale {
writeSuccess(w, map[string]string{"version": snap.version, "url": snap.url})
return
}
writeError(w, http.StatusServiceUnavailable, "Latest release temporarily unavailable")
return
}

// serveFailure records the failure (starting the backoff window) and
// serves the stale value if available, else the given error.
serveFailure := func(status int, msg string) {
cache.setError()
if snap.stale {
writeSuccess(w, map[string]string{"version": snap.version, "url": snap.url})
return
}
writeError(w, status, msg)
}

ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

const url = "https://api.github.com/repos/aloks98/wolnut/releases/latest"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
// Building the request never touches GitHub, so this isn't a reason
// to start the backoff window.
writeError(w, http.StatusInternalServerError, "Failed to build request")
return
}
Expand All @@ -102,12 +154,12 @@ func handleAPILatestRelease(cache *latestReleaseCache) http.HandlerFunc {

resp, err := http.DefaultClient.Do(req)
if err != nil {
writeError(w, http.StatusBadGateway, "Failed to reach GitHub: "+err.Error())
serveFailure(http.StatusBadGateway, "Failed to reach GitHub: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
writeError(w, http.StatusBadGateway, fmt.Sprintf("GitHub responded with %d", resp.StatusCode))
serveFailure(http.StatusBadGateway, fmt.Sprintf("GitHub responded with %d", resp.StatusCode))
return
}

Expand All @@ -117,15 +169,15 @@ func handleAPILatestRelease(cache *latestReleaseCache) http.HandlerFunc {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
writeError(w, http.StatusBadGateway, "Failed to parse GitHub response")
serveFailure(http.StatusBadGateway, "Failed to parse GitHub response")
return
}

ver := strings.TrimPrefix(rel.TagName, "v")
if ver == "" {
ver = rel.Name
}
cache.set(ver, rel.HTMLURL)
cache.setSuccess(ver, rel.HTMLURL)
writeSuccess(w, map[string]string{"version": ver, "url": rel.HTMLURL})
}
}
Expand Down
57 changes: 57 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"testing"
"time"
)

func TestLatestReleaseCacheSnapshot(t *testing.T) {
c := newLatestReleaseCache(time.Hour)

// Cold cache: nothing fresh, nothing stale, no backoff.
if s := c.snapshot(); s.fresh || s.stale || s.backoff {
t.Errorf("cold snapshot = %+v, want all false", s)
}

// After a success, value is both fresh and stale, no backoff.
c.setSuccess("1.2.3", "https://example.com/r")
s := c.snapshot()
if !s.fresh || !s.stale || s.backoff || s.version != "1.2.3" {
t.Errorf("post-success snapshot = %+v, want fresh+stale, version 1.2.3", s)
}

// Age the success past ttl: stale but no longer fresh.
c.mu.Lock()
c.fetchedAt = time.Now().Add(-2 * time.Hour)
c.mu.Unlock()
if s := c.snapshot(); s.fresh || !s.stale {
t.Errorf("aged snapshot = %+v, want stale and not fresh", s)
}

// A failure starts the backoff window while keeping the stale value.
c.setError()
if s := c.snapshot(); !s.backoff || !s.stale {
t.Errorf("post-error snapshot = %+v, want backoff and stale", s)
}

// A later success clears the backoff window and refreshes.
c.setSuccess("1.3.0", "https://example.com/r2")
if s := c.snapshot(); !s.fresh || s.backoff || s.version != "1.3.0" {
t.Errorf("recovered snapshot = %+v, want fresh, no backoff, version 1.3.0", s)
}
}

func TestLatestReleaseCacheBackoffExpiry(t *testing.T) {
c := newLatestReleaseCache(time.Hour)
c.setError()
if s := c.snapshot(); !s.backoff {
t.Fatal("expected backoff immediately after error")
}
// Push the failure past errTTL: backoff window has elapsed.
c.mu.Lock()
c.lastErrAt = time.Now().Add(-c.errTTL - time.Minute)
c.mu.Unlock()
if s := c.snapshot(); s.backoff {
t.Error("expected backoff to expire after errTTL")
}
}
13 changes: 9 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,18 @@ func LoadConfig(path string) (Config, error) {
Log: LogConfig{Level: "info"},
}

// Load from file if exists
// Load from file if exists. An explicitly-passed path that can't be read
// (typo, bad permissions) is fatal — silently falling back to defaults
// would run against the wrong data directory without any signal. A missing
// file is tolerated so first-run with a not-yet-created config works.
if path != "" {
data, err := os.ReadFile(path)
if err == nil {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse %s: %w", path, err)
if err != nil {
if !os.IsNotExist(err) {
return cfg, fmt.Errorf("read %s: %w", path, err)
}
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse %s: %w", path, err)
}
}

Expand Down
Loading
Loading