diff options
| author | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
| commit | 4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch) | |
| tree | e7c3bb335a1efd48f82d365169e8b4a66b7abe1d | |
| download | kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip | |
feat: Initial commit
| -rw-r--r-- | .gitignore | 37 | ||||
| -rw-r--r-- | Dockerfile | 51 | ||||
| -rw-r--r-- | Makefile | 86 | ||||
| -rw-r--r-- | README.md | 149 | ||||
| -rw-r--r-- | config.example.yaml | 141 | ||||
| -rw-r--r-- | go.mod | 21 | ||||
| -rw-r--r-- | go.sum | 57 | ||||
| -rw-r--r-- | internal/config/config.go | 299 | ||||
| -rw-r--r-- | internal/monitor/http.go | 182 | ||||
| -rw-r--r-- | internal/monitor/monitor.go | 86 | ||||
| -rw-r--r-- | internal/monitor/scheduler.go | 182 | ||||
| -rw-r--r-- | internal/monitor/tcp.go | 89 | ||||
| -rw-r--r-- | internal/server/server.go | 839 | ||||
| -rw-r--r-- | internal/server/static/style.css | 417 | ||||
| -rw-r--r-- | internal/server/templates/index.html | 357 | ||||
| -rw-r--r-- | internal/storage/sqlite.go | 679 |
16 files changed, 3672 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..557cc2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Binaries +kaze +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.html + +# Database +*.db +*.db-shm +*.db-wal + +# Configuration (keep example) +config.yaml +!config.example.yaml + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bce712d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Build stage +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" \ + -o kaze ./cmd/kaze + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install CA certificates for HTTPS requests +RUN apk add --no-cache ca-certificates tzdata + +# Create non-root user +RUN adduser -D -u 1000 kaze +USER kaze + +# Copy binary from builder +COPY --from=builder /app/kaze . + +# Create data directory +RUN mkdir -p /app/data + +# Expose port +EXPOSE 8080 + +# Default config location +ENV KAZE_CONFIG=/app/config.yaml + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1 + +# Run the application +ENTRYPOINT ["./kaze"] +CMD ["--config", "/app/config.yaml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77e485a --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +.PHONY: build run dev clean test + +# Build variables +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" + +# Default target +all: build + +# Build the binary +build: + go build $(LDFLAGS) -o kaze ./cmd/kaze + +# Build for production (smaller binary) +build-prod: + CGO_ENABLED=0 go build $(LDFLAGS) -o kaze ./cmd/kaze + +# Run the application +run: build + ./kaze + +# Run with debug logging +dev: build + ./kaze --debug + +# Clean build artifacts +clean: + rm -f kaze + rm -f *.db *.db-shm *.db-wal + +# Run tests +test: + go test -v ./... + +# Run tests with coverage +test-cover: + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Format code +fmt: + go fmt ./... + +# Lint code +lint: + go vet ./... + +# Tidy dependencies +tidy: + go mod tidy + +# Build for multiple platforms +build-all: build-linux build-darwin build-windows + +build-linux: + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-linux-amd64 ./cmd/kaze + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-linux-arm64 ./cmd/kaze + +build-darwin: + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-darwin-amd64 ./cmd/kaze + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-darwin-arm64 ./cmd/kaze + +build-windows: + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-windows-amd64.exe ./cmd/kaze + +# Docker build +docker: + docker build -t kaze:$(VERSION) . + +# Help +help: + @echo "Available targets:" + @echo " build - Build the binary" + @echo " build-prod - Build optimized production binary" + @echo " run - Build and run" + @echo " dev - Build and run with debug logging" + @echo " clean - Remove build artifacts and databases" + @echo " test - Run tests" + @echo " test-cover - Run tests with coverage report" + @echo " fmt - Format code" + @echo " lint - Lint code" + @echo " tidy - Tidy dependencies" + @echo " build-all - Build for all platforms" + @echo " docker - Build Docker image" diff --git a/README.md b/README.md new file mode 100644 index 0000000..16b42b6 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# Kaze + +A minimal, performant status page application written in Go. + +## Features + +- **Monitor Types**: HTTP, HTTPS (with SSL certificate tracking), TCP +- **Full Metrics**: Response time, SSL expiry, status codes, uptime percentage +- **Configurable History**: 7, 30, or 90 days retention +- **Monitor Groups**: Organize monitors into logical categories +- **Incidents**: Manual incident tracking via YAML configuration +- **Dark/Light Mode**: System preference detection with manual toggle +- **Single Binary**: All assets embedded, no external dependencies +- **SQLite Storage**: Lightweight, file-based database + +## Quick Start + +```bash +# Build +make build + +# Copy and edit configuration +cp config.example.yaml config.yaml + +# Run +./kaze +``` + +Open http://localhost:8080 to view the status page. + +## Configuration + +```yaml +site: + name: "My Status Page" + description: "Service Status" + +server: + host: "0.0.0.0" + port: 8080 + +storage: + path: "./kaze.db" + history_days: 90 + +groups: + - name: "Web Services" + monitors: + - name: "API" + type: https + target: "https://api.example.com/health" + interval: 30s + timeout: 10s + expected_status: 200 + + - name: "Infrastructure" + monitors: + - name: "Database" + type: tcp + target: "db.example.com:5432" + interval: 30s + timeout: 5s + +incidents: + - title: "Scheduled Maintenance" + status: scheduled + message: "Database upgrade" + scheduled_start: "2026-02-01T02:00:00Z" + scheduled_end: "2026-02-01T04:00:00Z" +``` + +See `config.example.yaml` for full configuration reference. + +## Monitor Types + +### HTTP/HTTPS + +```yaml +- name: "API" + type: https + target: "https://api.example.com" + interval: 30s + timeout: 10s + expected_status: 200 + verify_ssl: true + method: GET + headers: + Authorization: "Bearer token" +``` + +### TCP + +```yaml +- name: "Database" + type: tcp + target: "db.example.com:5432" + interval: 30s + timeout: 5s +``` + +## API Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /` | Status page HTML | +| `GET /api/status` | All monitors status (JSON) | +| `GET /api/monitor/{name}` | Single monitor status (JSON) | +| `GET /api/history/{name}` | Monitor uptime history (JSON) | + +## Building + +```bash +# Development build +make build + +# Production build (optimized) +make build-prod + +# Cross-platform builds +make build-all +``` + +## Docker + +```bash +# Build image +docker build -t kaze . + +# Run +docker run -p 8080:8080 -v ./config.yaml:/app/config.yaml kaze +``` + +## Command Line Options + +``` +Usage: kaze [options] + +Options: + -config string + Path to configuration file (default "config.yaml") + -debug + Enable debug logging + -version + Show version information +``` + +## License + +MIT diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..aca9c6a --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,141 @@ +# Kaze Status Page Configuration +# Copy this file to config.yaml and customize for your needs + +# Site metadata +site: + name: "Kaze Status" + description: "Service Status Page" + # logo: "https://example.com/logo.svg" # Optional logo URL + # favicon: "https://example.com/favicon.ico" # Optional favicon URL + +# HTTP server settings +server: + host: "0.0.0.0" + port: 8080 + +# Storage settings +storage: + path: "./kaze.db" + history_days: 90 # How many days of history to retain (7, 30, or 90) + +# Display settings +display: + # Tick aggregation mode for history bar: + # ping - Show individual pings (most granular) + # minute - Aggregate by minute + # hour - Aggregate by hour (default) + # day - Aggregate by day (like OpenStatus) + tick_mode: hour + + # Number of ticks/bars to display in the history + tick_count: 45 + + # For 'ping' mode only: + # true = Show fixed slots (empty bars for missing data) + # false = Grow dynamically as pings come in + ping_fixed_slots: true + + # Timezone for display (e.g., "UTC", "America/New_York", "Local") + timezone: "Local" + + # Show the theme toggle button (true by default) + # Set to false to hide the light/dark mode toggle + show_theme_toggle: true + +# Monitor groups +groups: + - name: "Web Services" + # default_collapsed: false # Start collapsed (false = expanded by default) + # show_group_uptime: true # Show aggregate uptime percentage (true by default) + monitors: + - name: "Website" + type: https + target: "https://example.com" + interval: 30s + timeout: 10s + expected_status: 200 + verify_ssl: true + + - name: "API" + type: https + target: "https://api.example.com/health" + interval: 30s + timeout: 10s + expected_status: 200 + method: GET + # headers: + # Authorization: "Bearer token" + + - name: "Infrastructure" + # default_collapsed: false + # show_group_uptime: true + monitors: + - name: "Database" + type: tcp + target: "localhost:5432" + interval: 30s + timeout: 5s + + - name: "Redis" + type: tcp + target: "localhost:6379" + interval: 30s + timeout: 5s + +# Incidents and maintenance (optional) +incidents: + # Scheduled maintenance example + - title: "Scheduled Maintenance" + status: scheduled + message: "Database maintenance window - expect brief interruptions" + scheduled_start: "2026-02-01T02:00:00Z" + scheduled_end: "2026-02-01T04:00:00Z" + affected_monitors: + - "Database" + + # Past incident example (resolved) + # - title: "API Performance Degradation" + # status: resolved + # message: "Users experienced slow API response times" + # created_at: "2026-01-15T10:00:00Z" + # resolved_at: "2026-01-15T12:30:00Z" + # updates: + # - time: "2026-01-15T10:30:00Z" + # status: investigating + # message: "Investigating reports of slow API responses" + # - time: "2026-01-15T11:00:00Z" + # status: identified + # message: "Identified database connection pool exhaustion as root cause" + # - time: "2026-01-15T12:30:00Z" + # status: resolved + # message: "Increased connection pool size. Monitoring for stability." + +# Monitor Configuration Reference +# ================================ +# +# Common fields for all monitor types: +# name: string (required) - Display name for the monitor +# type: string (required) - Monitor type: http, https, or tcp +# target: string (required) - URL or host:port to monitor +# interval: duration - Check interval (default: 30s) +# timeout: duration - Request timeout (default: 10s) +# +# HTTP/HTTPS specific fields: +# expected_status: int - Expected HTTP status code (default: 200) +# method: string - HTTP method (default: GET) +# headers: map[string]string - Custom headers to send +# body: string - Request body for POST/PUT/PATCH +# verify_ssl: bool - Verify SSL certificate (default: true) +# +# TCP specific fields: +# (none - just needs host:port target) +# +# Duration format: +# Use Go duration strings: 30s, 1m, 5m, 1h, etc. +# +# Incident statuses: +# scheduled - Planned maintenance +# investigating - Looking into the issue +# identified - Root cause found +# monitoring - Fix applied, monitoring +# resolved - Issue resolved @@ -0,0 +1,21 @@ +module github.com/Fuwn/kaze + +go 1.24.3 + +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.44.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) @@ -0,0 +1,57 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.44.1 h1:qybx/rNpfQipX/t47OxbHmkkJuv2JWifCMH8SVUiDas= +modernc.org/sqlite v1.44.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d4e096f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,299 @@ +package config + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// Config represents the root configuration structure +type Config struct { + Site SiteConfig `yaml:"site"` + Server ServerConfig `yaml:"server"` + Storage StorageConfig `yaml:"storage"` + Display DisplayConfig `yaml:"display"` + Groups []GroupConfig `yaml:"groups"` + Incidents []IncidentConfig `yaml:"incidents"` +} + +// DisplayConfig contains display/UI settings +type DisplayConfig struct { + // TickMode controls how history is aggregated: ping, minute, hour, day + TickMode string `yaml:"tick_mode"` + // TickCount is the number of ticks/bars to display in the history + TickCount int `yaml:"tick_count"` + // PingFixedSlots: when true and tick_mode=ping, shows fixed slots with empty bars + // when false, bars grow dynamically as pings come in + PingFixedSlots bool `yaml:"ping_fixed_slots"` + // Timezone for display (e.g., "UTC", "America/New_York", "Local") + Timezone string `yaml:"timezone"` + // ShowThemeToggle controls whether to show the theme toggle button (defaults to true) + ShowThemeToggle *bool `yaml:"show_theme_toggle"` +} + +// SiteConfig contains site metadata +type SiteConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Logo string `yaml:"logo"` + Favicon string `yaml:"favicon"` +} + +// ServerConfig contains HTTP server settings +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +// StorageConfig contains database settings +type StorageConfig struct { + Path string `yaml:"path"` + HistoryDays int `yaml:"history_days"` +} + +// GroupConfig represents a group of monitors +type GroupConfig struct { + Name string `yaml:"name"` + Monitors []MonitorConfig `yaml:"monitors"` + DefaultCollapsed *bool `yaml:"default_collapsed"` // nil = false (expanded by default) + ShowGroupUptime *bool `yaml:"show_group_uptime"` // nil = true (show by default) +} + +// MonitorConfig represents a single monitor +type MonitorConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` // http, https, tcp + Target string `yaml:"target"` + Interval Duration `yaml:"interval"` + Timeout Duration `yaml:"timeout"` + ExpectedStatus int `yaml:"expected_status,omitempty"` + VerifySSL *bool `yaml:"verify_ssl,omitempty"` + Method string `yaml:"method,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body string `yaml:"body,omitempty"` +} + +// IncidentConfig represents an incident or maintenance +type IncidentConfig struct { + Title string `yaml:"title"` + Status string `yaml:"status"` // scheduled, investigating, identified, monitoring, resolved + Message string `yaml:"message"` + ScheduledStart *time.Time `yaml:"scheduled_start,omitempty"` + ScheduledEnd *time.Time `yaml:"scheduled_end,omitempty"` + CreatedAt *time.Time `yaml:"created_at,omitempty"` + ResolvedAt *time.Time `yaml:"resolved_at,omitempty"` + AffectedMonitors []string `yaml:"affected_monitors,omitempty"` + Updates []IncidentUpdate `yaml:"updates,omitempty"` +} + +// IncidentUpdate represents an update to an incident +type IncidentUpdate struct { + Time time.Time `yaml:"time"` + Status string `yaml:"status"` + Message string `yaml:"message"` +} + +// Duration is a wrapper around time.Duration for YAML parsing +type Duration struct { + time.Duration +} + +// UnmarshalYAML implements yaml.Unmarshaler for Duration +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + var s string + if err := value.Decode(&s); err != nil { + return err + } + duration, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", s, err) + } + d.Duration = duration + return nil +} + +// MarshalYAML implements yaml.Marshaler for Duration +func (d Duration) MarshalYAML() (interface{}, error) { + return d.Duration.String(), nil +} + +// Load reads and parses a configuration file +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Apply defaults + cfg.applyDefaults() + + // Validate configuration + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return &cfg, nil +} + +// applyDefaults sets default values for missing configuration +func (c *Config) applyDefaults() { + if c.Site.Name == "" { + c.Site.Name = "Kaze Status" + } + if c.Site.Description == "" { + c.Site.Description = "Service Status Page" + } + if c.Server.Host == "" { + c.Server.Host = "0.0.0.0" + } + if c.Server.Port == 0 { + c.Server.Port = 8080 + } + if c.Storage.Path == "" { + c.Storage.Path = "./kaze.db" + } + if c.Storage.HistoryDays == 0 { + c.Storage.HistoryDays = 90 + } + + // Apply display defaults + if c.Display.TickMode == "" { + c.Display.TickMode = "hour" + } + if c.Display.TickCount == 0 { + c.Display.TickCount = 45 + } + if c.Display.Timezone == "" { + c.Display.Timezone = "Local" + } + if c.Display.ShowThemeToggle == nil { + defaultShow := true + c.Display.ShowThemeToggle = &defaultShow + } + + // Apply group defaults + for i := range c.Groups { + grp := &c.Groups[i] + if grp.DefaultCollapsed == nil { + defaultCollapsed := false + grp.DefaultCollapsed = &defaultCollapsed + } + if grp.ShowGroupUptime == nil { + defaultShow := true + grp.ShowGroupUptime = &defaultShow + } + + for j := range c.Groups[i].Monitors { + m := &c.Groups[i].Monitors[j] + if m.Interval.Duration == 0 { + m.Interval.Duration = 30 * time.Second + } + if m.Timeout.Duration == 0 { + m.Timeout.Duration = 10 * time.Second + } + if m.Type == "http" || m.Type == "https" { + if m.ExpectedStatus == 0 { + m.ExpectedStatus = 200 + } + if m.Method == "" { + m.Method = "GET" + } + if m.VerifySSL == nil { + defaultVerify := true + m.VerifySSL = &defaultVerify + } + } + } + } +} + +// validate checks the configuration for errors +func (c *Config) validate() error { + if len(c.Groups) == 0 { + return fmt.Errorf("at least one group with monitors is required") + } + + monitorNames := make(map[string]bool) + for _, group := range c.Groups { + if group.Name == "" { + return fmt.Errorf("group name cannot be empty") + } + if len(group.Monitors) == 0 { + return fmt.Errorf("group %q must have at least one monitor", group.Name) + } + for _, monitor := range group.Monitors { + if monitor.Name == "" { + return fmt.Errorf("monitor name cannot be empty in group %q", group.Name) + } + if monitorNames[monitor.Name] { + return fmt.Errorf("duplicate monitor name: %q", monitor.Name) + } + monitorNames[monitor.Name] = true + + if monitor.Target == "" { + return fmt.Errorf("monitor %q must have a target", monitor.Name) + } + + switch monitor.Type { + case "http", "https", "tcp": + // Valid types + default: + return fmt.Errorf("monitor %q has invalid type %q (must be http, https, or tcp)", monitor.Name, monitor.Type) + } + } + } + + // Validate incidents + for _, incident := range c.Incidents { + if incident.Title == "" { + return fmt.Errorf("incident title cannot be empty") + } + switch incident.Status { + case "scheduled", "investigating", "identified", "monitoring", "resolved": + // Valid statuses + default: + return fmt.Errorf("incident %q has invalid status %q", incident.Title, incident.Status) + } + } + + // Validate display config + switch c.Display.TickMode { + case "ping", "minute", "hour", "day": + // Valid modes + default: + return fmt.Errorf("invalid tick_mode %q (must be ping, minute, hour, or day)", c.Display.TickMode) + } + + if c.Display.TickCount < 1 || c.Display.TickCount > 200 { + return fmt.Errorf("tick_count must be between 1 and 200, got %d", c.Display.TickCount) + } + + return nil +} + +// GetAllMonitors returns all monitors from all groups with their group names +func (c *Config) GetAllMonitors() []MonitorWithGroup { + var monitors []MonitorWithGroup + for _, group := range c.Groups { + for _, monitor := range group.Monitors { + monitors = append(monitors, MonitorWithGroup{ + GroupName: group.Name, + Monitor: monitor, + }) + } + } + return monitors +} + +// MonitorWithGroup pairs a monitor with its group name +type MonitorWithGroup struct { + GroupName string + Monitor MonitorConfig +} diff --git a/internal/monitor/http.go b/internal/monitor/http.go new file mode 100644 index 0000000..8432401 --- /dev/null +++ b/internal/monitor/http.go @@ -0,0 +1,182 @@ +package monitor + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/Fuwn/kaze/internal/config" +) + +// HTTPMonitor monitors HTTP and HTTPS endpoints +type HTTPMonitor struct { + name string + monitorType string + target string + interval time.Duration + timeout time.Duration + method string + headers map[string]string + body string + expectedStatus int + verifySSL bool + client *http.Client +} + +// NewHTTPMonitor creates a new HTTP/HTTPS monitor +func NewHTTPMonitor(cfg config.MonitorConfig) (*HTTPMonitor, error) { + // Validate target URL + target := cfg.Target + if cfg.Type == "https" && !strings.HasPrefix(target, "https://") { + if strings.HasPrefix(target, "http://") { + target = strings.Replace(target, "http://", "https://", 1) + } else { + target = "https://" + target + } + } else if cfg.Type == "http" && !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { + target = "http://" + target + } + + verifySSL := true + if cfg.VerifySSL != nil { + verifySSL = *cfg.VerifySSL + } + + // Create HTTP client with custom transport + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !verifySSL, + }, + DialContext: (&net.Dialer{ + Timeout: cfg.Timeout.Duration, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: cfg.Timeout.Duration, + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + } + + client := &http.Client{ + Transport: transport, + Timeout: cfg.Timeout.Duration, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + + return &HTTPMonitor{ + name: cfg.Name, + monitorType: cfg.Type, + target: target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + method: cfg.Method, + headers: cfg.Headers, + body: cfg.Body, + expectedStatus: cfg.ExpectedStatus, + verifySSL: verifySSL, + client: client, + }, nil +} + +// Name returns the monitor's name +func (m *HTTPMonitor) Name() string { + return m.name +} + +// Type returns the monitor type +func (m *HTTPMonitor) Type() string { + return m.monitorType +} + +// Target returns the monitor target +func (m *HTTPMonitor) Target() string { + return m.target +} + +// Interval returns the check interval +func (m *HTTPMonitor) Interval() time.Duration { + return m.interval +} + +// Check performs the HTTP/HTTPS check +func (m *HTTPMonitor) Check(ctx context.Context) *Result { + result := &Result{ + MonitorName: m.name, + Timestamp: time.Now(), + } + + // Create request + var bodyReader io.Reader + if m.body != "" { + bodyReader = strings.NewReader(m.body) + } + + req, err := http.NewRequestWithContext(ctx, m.method, m.target, bodyReader) + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to create request: %w", err) + return result + } + + // Set headers + req.Header.Set("User-Agent", "Kaze-Monitor/1.0") + for key, value := range m.headers { + req.Header.Set(key, value) + } + + // Perform request and measure response time + start := time.Now() + resp, err := m.client.Do(req) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("request failed: %w", err) + return result + } + defer resp.Body.Close() + + // Discard body to allow connection reuse + io.Copy(io.Discard, resp.Body) + + result.StatusCode = resp.StatusCode + + // Check SSL certificate for HTTPS + if m.monitorType == "https" && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + cert := resp.TLS.PeerCertificates[0] + result.SSLExpiry = &cert.NotAfter + result.SSLDaysLeft = int(time.Until(cert.NotAfter).Hours() / 24) + } + + // Determine status based on response code + if resp.StatusCode == m.expectedStatus { + result.Status = StatusUp + } else if resp.StatusCode >= 200 && resp.StatusCode < 400 { + // Got a success code but not the expected one + result.Status = StatusDegraded + result.Error = fmt.Errorf("unexpected status code: got %d, expected %d", resp.StatusCode, m.expectedStatus) + } else { + result.Status = StatusDown + result.Error = fmt.Errorf("bad status code: %d", resp.StatusCode) + } + + // Check for slow response (degraded if > 2 seconds) + if result.Status == StatusUp && result.ResponseTime > 2*time.Second { + result.Status = StatusDegraded + result.Error = fmt.Errorf("slow response: %v", result.ResponseTime) + } + + return result +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..4f4ab0f --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,86 @@ +package monitor + +import ( + "context" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/Fuwn/kaze/internal/storage" +) + +// Result represents the outcome of a monitor check +type Result struct { + MonitorName string + Timestamp time.Time + Status Status + ResponseTime time.Duration + StatusCode int // HTTP status code (0 for non-HTTP) + Error error + SSLExpiry *time.Time + SSLDaysLeft int +} + +// Status represents the status of a monitor +type Status string + +const ( + StatusUp Status = "up" + StatusDown Status = "down" + StatusDegraded Status = "degraded" +) + +// Monitor is the interface that all monitor types must implement +type Monitor interface { + // Name returns the monitor's name + Name() string + + // Type returns the monitor type (http, https, tcp) + Type() string + + // Target returns the monitor target (URL or host:port) + Target() string + + // Interval returns the check interval + Interval() time.Duration + + // Check performs the monitoring check and returns the result + Check(ctx context.Context) *Result +} + +// New creates a new monitor based on the configuration +func New(cfg config.MonitorConfig) (Monitor, error) { + switch cfg.Type { + case "http", "https": + return NewHTTPMonitor(cfg) + case "tcp": + return NewTCPMonitor(cfg) + default: + return nil, &UnsupportedTypeError{Type: cfg.Type} + } +} + +// UnsupportedTypeError is returned when an unknown monitor type is specified +type UnsupportedTypeError struct { + Type string +} + +func (e *UnsupportedTypeError) Error() string { + return "unsupported monitor type: " + e.Type +} + +// ToCheckResult converts a monitor Result to a storage CheckResult +func (r *Result) ToCheckResult() *storage.CheckResult { + cr := &storage.CheckResult{ + MonitorName: r.MonitorName, + Timestamp: r.Timestamp, + Status: string(r.Status), + ResponseTime: r.ResponseTime.Milliseconds(), + StatusCode: r.StatusCode, + SSLExpiry: r.SSLExpiry, + SSLDaysLeft: r.SSLDaysLeft, + } + if r.Error != nil { + cr.Error = r.Error.Error() + } + return cr +} diff --git a/internal/monitor/scheduler.go b/internal/monitor/scheduler.go new file mode 100644 index 0000000..7a06131 --- /dev/null +++ b/internal/monitor/scheduler.go @@ -0,0 +1,182 @@ +package monitor + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/Fuwn/kaze/internal/storage" +) + +// Scheduler manages and runs all monitors +type Scheduler struct { + monitors []Monitor + storage *storage.Storage + logger *slog.Logger + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +// NewScheduler creates a new monitor scheduler +func NewScheduler(cfg *config.Config, store *storage.Storage, logger *slog.Logger) (*Scheduler, error) { + ctx, cancel := context.WithCancel(context.Background()) + + s := &Scheduler{ + storage: store, + logger: logger, + ctx: ctx, + cancel: cancel, + } + + // Create monitors from configuration + for _, group := range cfg.Groups { + for _, monCfg := range group.Monitors { + mon, err := New(monCfg) + if err != nil { + cancel() + return nil, err + } + s.monitors = append(s.monitors, mon) + logger.Info("registered monitor", + "name", mon.Name(), + "type", mon.Type(), + "target", mon.Target(), + "interval", mon.Interval()) + } + } + + return s, nil +} + +// Start begins running all monitors +func (s *Scheduler) Start() { + s.logger.Info("starting scheduler", "monitors", len(s.monitors)) + + for _, mon := range s.monitors { + s.wg.Add(1) + go s.runMonitor(mon) + } + + // Start cleanup routine + s.wg.Add(1) + go s.runCleanup() +} + +// Stop gracefully stops all monitors +func (s *Scheduler) Stop() { + s.logger.Info("stopping scheduler") + s.cancel() + s.wg.Wait() + s.logger.Info("scheduler stopped") +} + +// runMonitor runs a single monitor in a loop +func (s *Scheduler) runMonitor(mon Monitor) { + defer s.wg.Done() + + // Run immediately on start + s.executeCheck(mon) + + ticker := time.NewTicker(mon.Interval()) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + s.logger.Info("monitor stopped", "name", mon.Name()) + return + case <-ticker.C: + s.executeCheck(mon) + } + } +} + +// executeCheck performs a single check and saves the result +func (s *Scheduler) executeCheck(mon Monitor) { + // Create a context with timeout for this check + checkCtx, cancel := context.WithTimeout(s.ctx, mon.Interval()) + defer cancel() + + result := mon.Check(checkCtx) + + // Log the result + logAttrs := []any{ + "name", mon.Name(), + "status", result.Status, + "response_time", result.ResponseTime, + } + if result.StatusCode > 0 { + logAttrs = append(logAttrs, "status_code", result.StatusCode) + } + if result.SSLDaysLeft > 0 { + logAttrs = append(logAttrs, "ssl_days_left", result.SSLDaysLeft) + } + if result.Error != nil { + logAttrs = append(logAttrs, "error", result.Error) + } + + if result.Status == StatusUp { + s.logger.Debug("check completed", logAttrs...) + } else { + s.logger.Warn("check completed", logAttrs...) + } + + // Save to storage + if err := s.storage.SaveCheckResult(s.ctx, result.ToCheckResult()); err != nil { + s.logger.Error("failed to save check result", + "name", mon.Name(), + "error", err) + } +} + +// runCleanup periodically cleans up old data +func (s *Scheduler) runCleanup() { + defer s.wg.Done() + + // Run cleanup daily + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.logger.Info("running database cleanup") + if err := s.storage.Cleanup(s.ctx); err != nil { + s.logger.Error("cleanup failed", "error", err) + } else { + s.logger.Info("cleanup completed") + } + } + } +} + +// GetMonitors returns all registered monitors +func (s *Scheduler) GetMonitors() []Monitor { + return s.monitors +} + +// RunCheck manually triggers a check for a specific monitor +func (s *Scheduler) RunCheck(name string) *Result { + for _, mon := range s.monitors { + if mon.Name() == name { + ctx, cancel := context.WithTimeout(context.Background(), mon.Interval()) + defer cancel() + result := mon.Check(ctx) + + // Save the result + if err := s.storage.SaveCheckResult(context.Background(), result.ToCheckResult()); err != nil { + s.logger.Error("failed to save manual check result", + "name", mon.Name(), + "error", err) + } + + return result + } + } + return nil +} diff --git a/internal/monitor/tcp.go b/internal/monitor/tcp.go new file mode 100644 index 0000000..f93ae10 --- /dev/null +++ b/internal/monitor/tcp.go @@ -0,0 +1,89 @@ +package monitor + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/Fuwn/kaze/internal/config" +) + +// TCPMonitor monitors TCP endpoints +type TCPMonitor struct { + name string + target string + interval time.Duration + timeout time.Duration +} + +// NewTCPMonitor creates a new TCP monitor +func NewTCPMonitor(cfg config.MonitorConfig) (*TCPMonitor, error) { + // Validate target format (should be host:port) + _, _, err := net.SplitHostPort(cfg.Target) + if err != nil { + return nil, fmt.Errorf("invalid TCP target %q: must be host:port format: %w", cfg.Target, err) + } + + return &TCPMonitor{ + name: cfg.Name, + target: cfg.Target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + }, nil +} + +// Name returns the monitor's name +func (m *TCPMonitor) Name() string { + return m.name +} + +// Type returns the monitor type +func (m *TCPMonitor) Type() string { + return "tcp" +} + +// Target returns the monitor target +func (m *TCPMonitor) Target() string { + return m.target +} + +// Interval returns the check interval +func (m *TCPMonitor) Interval() time.Duration { + return m.interval +} + +// Check performs the TCP connection check +func (m *TCPMonitor) Check(ctx context.Context) *Result { + result := &Result{ + MonitorName: m.name, + Timestamp: time.Now(), + } + + // Create a dialer with timeout + dialer := &net.Dialer{ + Timeout: m.timeout, + } + + // Attempt to connect + start := time.Now() + conn, err := dialer.DialContext(ctx, "tcp", m.target) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("connection failed: %w", err) + return result + } + defer conn.Close() + + result.Status = StatusUp + + // Check for slow response (degraded if > 1 second for TCP) + if result.ResponseTime > 1*time.Second { + result.Status = StatusDegraded + result.Error = fmt.Errorf("slow connection: %v", result.ResponseTime) + } + + return result +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..04532b9 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,839 @@ +package server + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "sort" + "strconv" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/Fuwn/kaze/internal/monitor" + "github.com/Fuwn/kaze/internal/storage" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// Server handles HTTP requests for the status page +type Server struct { + config *config.Config + storage *storage.Storage + scheduler *monitor.Scheduler + logger *slog.Logger + server *http.Server + templates *template.Template +} + +// New creates a new HTTP server +func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, logger *slog.Logger) (*Server, error) { + // Parse templates + tmpl, err := template.New("").Funcs(templateFuncs()).ParseFS(templatesFS, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse templates: %w", err) + } + + s := &Server{ + config: cfg, + storage: store, + scheduler: sched, + logger: logger, + templates: tmpl, + } + + // Setup routes + mux := http.NewServeMux() + + // Static files + staticContent, err := fs.Sub(staticFS, "static") + if err != nil { + return nil, fmt.Errorf("failed to get static fs: %w", err) + } + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) + + // Pages + mux.HandleFunc("GET /", s.handleIndex) + mux.HandleFunc("GET /api/status", s.handleAPIStatus) + mux.HandleFunc("GET /api/monitor/{name}", s.handleAPIMonitor) + mux.HandleFunc("GET /api/history/{name}", s.handleAPIHistory) + + // Create HTTP server + s.server = &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), + Handler: s.withMiddleware(mux), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return s, nil +} + +// Start begins serving HTTP requests +func (s *Server) Start() error { + s.logger.Info("starting HTTP server", "addr", s.server.Addr) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +// Stop gracefully shuts down the server +func (s *Server) Stop(ctx context.Context) error { + s.logger.Info("stopping HTTP server") + return s.server.Shutdown(ctx) +} + +// withMiddleware adds common middleware to the handler +func (s *Server) withMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Add security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + + next.ServeHTTP(w, r) + + s.logger.Debug("request", + "method", r.Method, + "path", r.URL.Path, + "duration", time.Since(start)) + }) +} + +// PageData contains data for rendering the status page +type PageData struct { + Site config.SiteConfig + Groups []GroupData + Incidents []IncidentData + OverallStatus string + LastUpdated time.Time + CurrentTime string // Formatted date/time for display (without timezone) + TimezoneTooltip string // JSON data for timezone tooltip + LastUpdatedTooltip string // JSON data for last updated tooltip + TickMode string // ping, minute, hour, day + TickCount int + ShowThemeToggle bool + Timezone string // Timezone for display +} + +// GroupData contains data for a monitor group +type GroupData struct { + Name string + Monitors []MonitorData + DefaultCollapsed bool + ShowGroupUptime bool + GroupUptime float64 +} + +// MonitorData contains data for a single monitor +type MonitorData struct { + Name string + Type string + Status string + StatusClass string + ResponseTime int64 + UptimePercent float64 + Ticks []*storage.TickData // Aggregated tick data for history bar + SSLDaysLeft int + SSLExpiryDate time.Time + SSLTooltip string // JSON data for SSL expiration tooltip + LastCheck time.Time + LastError string +} + +// IncidentData contains data for an incident +type IncidentData struct { + Title string + Status string + StatusClass string + Message string + ScheduledStart *time.Time + ScheduledEnd *time.Time + CreatedAt *time.Time + ResolvedAt *time.Time + Updates []IncidentUpdateData + IsScheduled bool + IsActive bool +} + +// IncidentUpdateData contains data for an incident update +type IncidentUpdateData struct { + Time time.Time + Status string + Message string +} + +// handleIndex renders the main status page +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get all monitor stats + stats, err := s.storage.GetAllMonitorStats(ctx) + if err != nil { + s.logger.Error("failed to get monitor stats", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Build page data + data := PageData{ + Site: s.config.Site, + TickMode: s.config.Display.TickMode, + TickCount: s.config.Display.TickCount, + ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, + Timezone: s.config.Display.Timezone, + } + + overallUp := true + hasDegraded := false + var mostRecentCheck time.Time + + // Build groups + for _, group := range s.config.Groups { + gd := GroupData{ + Name: group.Name, + DefaultCollapsed: group.DefaultCollapsed != nil && *group.DefaultCollapsed, + ShowGroupUptime: group.ShowGroupUptime == nil || *group.ShowGroupUptime, + } + + var totalUptime float64 + var monitorsWithUptime int + + for _, monCfg := range group.Monitors { + md := MonitorData{ + Name: monCfg.Name, + Type: monCfg.Type, + } + + if stat, ok := stats[monCfg.Name]; ok { + md.Status = stat.CurrentStatus + md.ResponseTime = stat.LastResponseTime + md.UptimePercent = stat.UptimePercent + md.SSLDaysLeft = stat.SSLDaysLeft + md.LastCheck = stat.LastCheck + md.LastError = stat.LastError + + // Set SSL expiry date and tooltip + if stat.SSLExpiry != nil { + md.SSLExpiryDate = *stat.SSLExpiry + md.SSLTooltip = formatSSLTooltip(*stat.SSLExpiry, stat.SSLDaysLeft, s.config.Display.Timezone) + } + + // Track most recent check time for footer + if stat.LastCheck.After(mostRecentCheck) { + mostRecentCheck = stat.LastCheck + } + + // Get aggregated history for display + ticks, err := s.storage.GetAggregatedHistory( + ctx, + monCfg.Name, + s.config.Display.TickCount, + s.config.Display.TickMode, + s.config.Display.PingFixedSlots, + ) + if err != nil { + s.logger.Error("failed to get tick history", "monitor", monCfg.Name, "error", err) + } else { + md.Ticks = ticks + } + + // Update overall status + if stat.CurrentStatus == "down" { + overallUp = false + } else if stat.CurrentStatus == "degraded" { + hasDegraded = true + } + } else { + md.Status = "unknown" + } + + md.StatusClass = statusToClass(md.Status) + gd.Monitors = append(gd.Monitors, md) + + // Accumulate uptime for group average + if md.UptimePercent >= 0 { + totalUptime += md.UptimePercent + monitorsWithUptime++ + } + } + + // Calculate group average uptime + if monitorsWithUptime > 0 { + gd.GroupUptime = totalUptime / float64(monitorsWithUptime) + } + + data.Groups = append(data.Groups, gd) + } + + // Set last updated time from most recent check + now := time.Now() + if !mostRecentCheck.IsZero() { + data.LastUpdated = mostRecentCheck + } else { + data.LastUpdated = now + } + + // Format current time for display + data.CurrentTime = formatCurrentTime(now, s.config.Display.Timezone) + data.TimezoneTooltip = formatTimezoneTooltip(now, s.config.Display.Timezone) + data.LastUpdatedTooltip = formatLastUpdatedTooltip(data.LastUpdated, s.config.Display.Timezone) + + // Determine overall status + if !overallUp { + data.OverallStatus = "Major Outage" + } else if hasDegraded { + data.OverallStatus = "Partial Outage" + } else { + data.OverallStatus = "All Systems Operational" + } + + // Build incidents + for _, inc := range s.config.Incidents { + id := IncidentData{ + Title: inc.Title, + Status: inc.Status, + StatusClass: incidentStatusToClass(inc.Status), + Message: inc.Message, + ScheduledStart: inc.ScheduledStart, + ScheduledEnd: inc.ScheduledEnd, + CreatedAt: inc.CreatedAt, + ResolvedAt: inc.ResolvedAt, + IsScheduled: inc.Status == "scheduled", + } + + // Check if incident is active (not resolved and not future scheduled) + if inc.Status != "resolved" { + if inc.Status == "scheduled" { + if inc.ScheduledStart != nil && inc.ScheduledStart.After(time.Now()) { + id.IsActive = false + } else { + id.IsActive = true + } + } else { + id.IsActive = true + } + } + + // Add updates + for _, upd := range inc.Updates { + id.Updates = append(id.Updates, IncidentUpdateData{ + Time: upd.Time, + Status: upd.Status, + Message: upd.Message, + }) + } + + data.Incidents = append(data.Incidents, id) + } + + // Sort incidents: active first, then by date + sort.Slice(data.Incidents, func(i, j int) bool { + if data.Incidents[i].IsActive != data.Incidents[j].IsActive { + return data.Incidents[i].IsActive + } + return false + }) + + // Render template + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil { + s.logger.Error("failed to render template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// handleAPIStatus returns JSON status for all monitors +func (s *Server) handleAPIStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + stats, err := s.storage.GetAllMonitorStats(ctx) + if err != nil { + s.jsonError(w, "Failed to get stats", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, stats) +} + +// handleAPIMonitor returns JSON status for a specific monitor +func (s *Server) handleAPIMonitor(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + s.jsonError(w, "Monitor name required", http.StatusBadRequest) + return + } + + stats, err := s.storage.GetMonitorStats(r.Context(), name) + if err != nil { + s.jsonError(w, "Failed to get monitor stats", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, stats) +} + +// handleAPIHistory returns aggregated history for a monitor +func (s *Server) handleAPIHistory(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + s.jsonError(w, "Monitor name required", http.StatusBadRequest) + return + } + + // Allow optional parameters, default to config values + mode := s.config.Display.TickMode + if modeParam := r.URL.Query().Get("mode"); modeParam != "" { + switch modeParam { + case "ping", "minute", "hour", "day": + mode = modeParam + } + } + + count := s.config.Display.TickCount + if countParam := r.URL.Query().Get("count"); countParam != "" { + if c, err := strconv.Atoi(countParam); err == nil && c > 0 && c <= 200 { + count = c + } + } + + ticks, err := s.storage.GetAggregatedHistory(r.Context(), name, count, mode, s.config.Display.PingFixedSlots) + if err != nil { + s.jsonError(w, "Failed to get history", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, map[string]interface{}{ + "monitor": name, + "mode": mode, + "count": count, + "ticks": ticks, + }) +} + +// jsonResponse writes a JSON response +func (s *Server) jsonResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + s.logger.Error("failed to encode JSON response", "error", err) + } +} + +// jsonError writes a JSON error response +func (s *Server) jsonError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + +// templateFuncs returns custom template functions +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "formatTime": func(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("Jan 2, 15:04 UTC") + }, + "formatDate": func(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("Jan 2, 2006") + }, + "formatDuration": func(ms int64) string { + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + return fmt.Sprintf("%.2fs", float64(ms)/1000) + }, + "formatUptime": func(pct float64) string { + if pct < 0 { + return "-" + } + return fmt.Sprintf("%.2f%%", pct) + }, + "timeAgo": func(t time.Time) string { + if t.IsZero() { + return "never" + } + d := time.Since(t) + if d < time.Minute { + return fmt.Sprintf("%d seconds ago", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%d hours ago", int(d.Hours())) + } + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + }, + "tickColor": func(tick *storage.TickData) string { + if tick == nil { + return "bg-neutral-200 dark:bg-neutral-800" // No data + } + if tick.UptimePercent >= 99 { + return "bg-emerald-500" + } + if tick.UptimePercent >= 95 { + return "bg-yellow-500" + } + if tick.UptimePercent > 0 { + return "bg-red-500" + } + // For ping mode with status + switch tick.Status { + case "up": + return "bg-emerald-500" + case "degraded": + return "bg-yellow-500" + case "down": + return "bg-red-500" + } + return "bg-neutral-200 dark:bg-neutral-800" + }, + "tickTooltipData": func(tick *storage.TickData, mode, timezone string) string { + if tick == nil { + data := map[string]interface{}{"header": "No data"} + b, _ := json.Marshal(data) + return string(b) + } + + // Convert timestamp to configured timezone + loc := time.Local + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + t := tick.Timestamp.In(loc) + + // Get timezone info + tzAbbr := t.Format("MST") + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + var header, statusClass string + data := make(map[string]interface{}) + rows := []map[string]string{} + + switch mode { + case "ping": + header = t.Format("Jan 2, 15:04:05") + statusClass = tickStatusClass(tick.Status) + rows = append(rows, + map[string]string{"label": "Status", "value": tick.Status, "class": statusClass}, + map[string]string{"label": "Response", "value": fmt.Sprintf("%dms", tick.ResponseTime), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "minute": + header = t.Format("Jan 2, 15:04") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "hour": + header = t.Format("Jan 2, 15:00") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "day": + header = t.Format("Jan 2, 2006") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + default: + header = t.Format("Jan 2, 15:04") + } + + data["header"] = header + data["rows"] = rows + + b, _ := json.Marshal(data) + return string(b) + }, + "seq": func(n int) []int { + result := make([]int, n) + for i := range result { + result[i] = i + } + return result + }, + } +} + +// formatCurrentTime formats the current time for display without timezone +func formatCurrentTime(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + return t.Format("Jan 2, 2006 15:04") +} + +// formatTimezoneTooltip creates JSON data for timezone tooltip +func formatTimezoneTooltip(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + + // Get timezone abbreviation (like PST, EST, etc.) + tzAbbr := t.Format("MST") + + // Get UTC offset in format like "UTC-8" or "UTC+5:30" + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset in same format + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + data := map[string]interface{}{ + "header": "Timezone", + "rows": []map[string]string{ + {"label": "Abbreviation", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// abs returns the absolute value of an integer +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// formatSSLTooltip creates JSON data for SSL expiration tooltip +func formatSSLTooltip(expiryDate time.Time, daysLeft int, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t := expiryDate.In(loc) + + // Get timezone abbreviation + tzAbbr := t.Format("MST") + + // Get UTC offset + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + // Format the expiry date + expiryStr := t.Format("Jan 2, 2006 15:04:05") + + // Determine status message + var statusMsg, statusClass string + if daysLeft < 0 { + statusMsg = "Expired" + statusClass = "error" + } else if daysLeft < 7 { + statusMsg = fmt.Sprintf("%d days (Critical)", daysLeft) + statusClass = "error" + } else if daysLeft < 14 { + statusMsg = fmt.Sprintf("%d days (Warning)", daysLeft) + statusClass = "warning" + } else { + statusMsg = fmt.Sprintf("%d days", daysLeft) + statusClass = "success" + } + + data := map[string]interface{}{ + "header": "SSL Certificate", + "rows": []map[string]string{ + {"label": "Expires", "value": expiryStr, "class": ""}, + {"label": "Days Left", "value": statusMsg, "class": statusClass}, + {"label": "Timezone", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// formatLastUpdatedTooltip creates JSON data for last updated tooltip +func formatLastUpdatedTooltip(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + + // Get timezone abbreviation + tzAbbr := t.Format("MST") + + // Get UTC offset + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + // Format the datetime + datetime := t.Format("Jan 2, 2006 15:04:05") + + data := map[string]interface{}{ + "header": "Last Check", + "rows": []map[string]string{ + {"label": "Date & Time", "value": datetime, "class": ""}, + {"label": "Timezone", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// statusToClass converts a status to a CSS class +func statusToClass(status string) string { + switch status { + case "up": + return "status-up" + case "down": + return "status-down" + case "degraded": + return "status-degraded" + default: + return "status-unknown" + } +} + +// tickStatusClass returns CSS class for tooltip status text +func tickStatusClass(status string) string { + switch status { + case "up": + return "success" + case "degraded": + return "warning" + case "down": + return "error" + default: + return "" + } +} + +// uptimeStatusClass returns CSS class based on uptime percentage +func uptimeStatusClass(pct float64) string { + if pct >= 99 { + return "success" + } + if pct >= 95 { + return "warning" + } + return "error" +} + +// incidentStatusToClass converts an incident status to a CSS class +func incidentStatusToClass(status string) string { + switch status { + case "scheduled": + return "incident-scheduled" + case "investigating": + return "incident-investigating" + case "identified": + return "incident-identified" + case "monitoring": + return "incident-monitoring" + case "resolved": + return "incident-resolved" + default: + return "incident-unknown" + } +} diff --git a/internal/server/static/style.css b/internal/server/static/style.css new file mode 100644 index 0000000..fc7c4a5 --- /dev/null +++ b/internal/server/static/style.css @@ -0,0 +1,417 @@ +/* Kaze Status Page - OpenCode-inspired Theme */ + +/* Reset and base */ +*, *::before, *::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; +} + +html { + -webkit-text-size-adjust: 100%; + tab-size: 4; +} + +/* Color scheme support */ +:root { + color-scheme: light dark; +} + +/* Font */ +@font-face { + font-family: 'JetBrains Mono'; + src: local('JetBrains Mono'), local('JetBrainsMono-Regular'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: local('JetBrains Mono Medium'), local('JetBrainsMono-Medium'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: local('JetBrains Mono Bold'), local('JetBrainsMono-Bold'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +/* Base styles */ +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Utility classes - Tailwind-inspired */ + +/* Font */ +.font-mono { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; +} + +/* Colors - Light mode */ +.bg-neutral-50 { background-color: #fafafa; } +.bg-neutral-100 { background-color: #f5f5f5; } +.bg-neutral-200 { background-color: #e5e5e5; } +.bg-neutral-800 { background-color: #262626; } +.bg-neutral-900 { background-color: #171717; } +.bg-neutral-950 { background-color: #0a0a0a; } +.bg-white { background-color: #ffffff; } + +.bg-emerald-50 { background-color: #ecfdf5; } +.bg-emerald-100 { background-color: #d1fae5; } +.bg-emerald-500 { background-color: #10b981; } + +.bg-yellow-50 { background-color: #fefce8; } +.bg-yellow-100 { background-color: #fef9c3; } +.bg-yellow-500 { background-color: #eab308; } + +.bg-red-50 { background-color: #fef2f2; } +.bg-red-100 { background-color: #fee2e2; } +.bg-red-500 { background-color: #ef4444; } + +.bg-blue-100 { background-color: #dbeafe; } +.bg-blue-500 { background-color: #3b82f6; } + +.bg-orange-100 { background-color: #ffedd5; } + +.text-neutral-100 { color: #f5f5f5; } +.text-neutral-300 { color: #d4d4d4; } +.text-neutral-400 { color: #a3a3a3; } +.text-neutral-500 { color: #737373; } +.text-neutral-600 { color: #525252; } +.text-neutral-700 { color: #404040; } +.text-neutral-900 { color: #171717; } + +.text-emerald-300 { color: #6ee7b7; } +.text-emerald-400 { color: #34d399; } +.text-emerald-500 { color: #10b981; } +.text-emerald-600 { color: #059669; } +.text-emerald-700 { color: #047857; } + +.text-yellow-300 { color: #fde047; } +.text-yellow-400 { color: #facc15; } +.text-yellow-500 { color: #eab308; } +.text-yellow-600 { color: #ca8a04; } +.text-yellow-700 { color: #a16207; } + +.text-red-400 { color: #f87171; } +.text-red-500 { color: #ef4444; } +.text-red-600 { color: #dc2626; } + +.text-blue-300 { color: #93c5fd; } +.text-blue-500 { color: #3b82f6; } +.text-blue-700 { color: #1d4ed8; } + +.text-orange-700 { color: #c2410c; } +.text-orange-300 { color: #fdba74; } + +/* Border colors */ +.border-neutral-200 { border-color: #e5e5e5; } +.border-neutral-800 { border-color: #262626; } +.border-emerald-200 { border-color: #a7f3d0; } +.border-emerald-900 { border-color: #064e3b; } +.border-yellow-200 { border-color: #fef08a; } +.border-yellow-900 { border-color: #713f12; } +.border-red-200 { border-color: #fecaca; } +.border-red-900 { border-color: #7f1d1d; } + +/* Dark mode */ +.dark .dark\:bg-neutral-800 { background-color: #262626; } +.dark .dark\:bg-neutral-900 { background-color: #171717; } +.dark .dark\:bg-neutral-900\/50 { background-color: rgba(23, 23, 23, 0.5); } +.dark .dark\:bg-neutral-950 { background-color: #0a0a0a; } +.dark .dark\:bg-emerald-900\/50 { background-color: rgba(6, 78, 59, 0.5); } +.dark .dark\:bg-emerald-950\/30 { background-color: rgba(2, 44, 34, 0.3); } +.dark .dark\:bg-yellow-900\/50 { background-color: rgba(113, 63, 18, 0.5); } +.dark .dark\:bg-yellow-950\/20 { background-color: rgba(66, 32, 6, 0.2); } +.dark .dark\:bg-yellow-950\/30 { background-color: rgba(66, 32, 6, 0.3); } +.dark .dark\:bg-red-900\/50 { background-color: rgba(127, 29, 29, 0.5); } +.dark .dark\:bg-red-950\/30 { background-color: rgba(69, 10, 10, 0.3); } +.dark .dark\:bg-blue-900\/50 { background-color: rgba(30, 58, 138, 0.5); } +.dark .dark\:bg-orange-900\/50 { background-color: rgba(124, 45, 18, 0.5); } + +.dark .dark\:text-neutral-100 { color: #f5f5f5; } +.dark .dark\:text-neutral-300 { color: #d4d4d4; } +.dark .dark\:text-neutral-400 { color: #a3a3a3; } +.dark .dark\:text-neutral-500 { color: #737373; } +.dark .dark\:text-emerald-300 { color: #6ee7b7; } +.dark .dark\:text-emerald-400 { color: #34d399; } +.dark .dark\:text-yellow-300 { color: #fde047; } +.dark .dark\:text-yellow-400 { color: #facc15; } +.dark .dark\:text-red-400 { color: #f87171; } +.dark .dark\:text-blue-300 { color: #93c5fd; } +.dark .dark\:text-orange-300 { color: #fdba74; } + +.dark .dark\:border-neutral-800 { border-color: #262626; } +.dark .dark\:border-emerald-900 { border-color: #064e3b; } +.dark .dark\:border-yellow-900 { border-color: #713f12; } +.dark .dark\:border-red-900 { border-color: #7f1d1d; } + +.dark .dark\:divide-neutral-800 > :not([hidden]) ~ :not([hidden]) { border-color: #262626; } + +.dark .dark\:hover\:bg-neutral-800:hover { background-color: #262626; } +.dark .dark\:hover\:bg-neutral-900\/50:hover { background-color: rgba(23, 23, 23, 0.5); } +.dark .dark\:hover\:text-neutral-100:hover { color: #f5f5f5; } + +/* Display */ +.block { display: block; } +.hidden { display: none; } +.flex { display: flex; } +.dark .dark\:block { display: block; } +.dark .dark\:hidden { display: none; } + +/* Flexbox */ +.flex-1 { flex: 1 1 0%; } +.flex-shrink-0 { flex-shrink: 0; } +.items-start { align-items: flex-start; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } + +/* Gap */ +.gap-px { gap: 1px; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } + +/* Sizing */ +.w-2 { width: 0.5rem; } +.w-3 { width: 0.75rem; } +.w-4 { width: 1rem; } +.w-5 { width: 1.25rem; } +.w-8 { width: 2rem; } +.h-2 { height: 0.5rem; } +.h-3 { height: 0.75rem; } +.h-4 { height: 1rem; } +.h-5 { height: 1.25rem; } +.h-6 { height: 1.5rem; } +.h-8 { height: 2rem; } +.min-h-screen { min-height: 100vh; } +.min-w-0 { min-width: 0px; } +.max-w-4xl { max-width: 56rem; } +.max-w-\[200px\] { max-width: 200px; } + +/* Spacing */ +.mx-auto { margin-left: auto; margin-right: auto; } +.mb-1 { margin-bottom: 0.25rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-4 { margin-bottom: 1rem; } +.mb-8 { margin-bottom: 2rem; } +.mt-2 { margin-top: 0.5rem; } +.mt-3 { margin-top: 0.75rem; } +.mt-8 { margin-top: 2rem; } +.mt-12 { margin-top: 3rem; } +.p-2 { padding: 0.5rem; } +.p-4 { padding: 1rem; } +.px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; } +.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; } +.px-4 { padding-left: 1rem; padding-right: 1rem; } +.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; } +.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } +.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } +.py-8 { padding-top: 2rem; padding-bottom: 2rem; } +.pt-6 { padding-top: 1.5rem; } + +/* Borders */ +.border { border-width: 1px; } +.border-b { border-bottom-width: 1px; } +.border-t { border-top-width: 1px; } +.rounded-sm { border-radius: 0.125rem; } +.rounded-md { border-radius: 0.375rem; } +.rounded-lg { border-radius: 0.5rem; } +.rounded-full { border-radius: 9999px; } +.divide-y > :not([hidden]) ~ :not([hidden]) { border-top-width: 1px; } +.divide-neutral-200 > :not([hidden]) ~ :not([hidden]) { border-color: #e5e5e5; } + +/* Typography */ +.text-xs { font-size: 0.75rem; line-height: 1rem; } +.text-sm { font-size: 0.875rem; line-height: 1.25rem; } +.text-lg { font-size: 1.125rem; line-height: 1.75rem; } +.text-xl { font-size: 1.25rem; line-height: 1.75rem; } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-bold { font-weight: 700; } +.uppercase { text-transform: uppercase; } +.capitalize { text-transform: capitalize; } +.tracking-tight { letter-spacing: -0.025em; } +.tracking-wider { letter-spacing: 0.05em; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.underline { text-decoration-line: underline; } +.underline-offset-2 { text-underline-offset: 2px; } + +/* Overflow */ +.overflow-hidden { overflow: hidden; } + +/* Transitions */ +.transition-colors { + transition-property: color, background-color, border-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Hover */ +.hover\:bg-neutral-200:hover { background-color: #e5e5e5; } +.hover\:bg-neutral-100\/50:hover { background-color: rgba(245, 245, 245, 0.5); } +.hover\:text-neutral-900:hover { color: #171717; } + +/* Animation */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + +/* Responsive */ +@media (min-width: 640px) { + .sm\:py-12 { padding-top: 3rem; padding-bottom: 3rem; } + .sm\:mb-12 { margin-bottom: 3rem; } + .sm\:text-2xl { font-size: 1.5rem; line-height: 2rem; } +} + +/* Space */ +.space-y-3 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.75rem; } +.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; } +.space-y-6 > :not([hidden]) ~ :not([hidden]) { margin-top: 1.5rem; } + +/* Fill/Stroke */ +svg { fill: none; } + +/* Collapsible Groups */ +.group-content { + max-height: 10000px; + overflow: hidden; + transition: max-height 0.3s ease-out, opacity 0.3s ease-out; + opacity: 1; +} + +.group-content.collapsed { + max-height: 0; + opacity: 0; + transition: max-height 0.3s ease-in, opacity 0.2s ease-in; +} + +.cursor-pointer { + cursor: pointer; +} + +[data-group-icon] { + transition: transform 0.3s ease; +} + +[data-group-icon].rotated { + transform: rotate(-90deg); +} + +/* Custom Tooltip */ +.tooltip { + position: fixed; + z-index: 1000; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + line-height: 1.4; + background-color: #171717; + color: #f5f5f5; + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + pointer-events: none; + opacity: 0; + transform: translateY(4px); + transition: opacity 150ms ease, transform 150ms ease; + max-width: 280px; + white-space: normal; +} + +.tooltip.visible { + opacity: 1; + transform: translateY(0); +} + +.tooltip::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-bottom-color: #171717; +} + +.tooltip.tooltip-top::before { + bottom: auto; + top: 100%; + border-bottom-color: transparent; + border-top-color: #171717; +} + +/* Light mode tooltip */ +:root:not(.dark) .tooltip { + background-color: #ffffff; + color: #171717; + border: 1px solid #e5e5e5; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +} + +:root:not(.dark) .tooltip::before { + border-bottom-color: #ffffff; + /* Account for border */ + margin-bottom: -1px; +} + +:root:not(.dark) .tooltip.tooltip-top::before { + border-bottom-color: transparent; + border-top-color: #ffffff; + margin-bottom: 0; + margin-top: -1px; +} + +/* Tooltip content styling */ +.tooltip-header { + font-weight: 500; + margin-bottom: 0.25rem; + color: inherit; +} + +.tooltip-row { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.tooltip-label { + color: #a3a3a3; +} + +:root:not(.dark) .tooltip-label { + color: #737373; +} + +.tooltip-value { + font-weight: 500; +} + +.tooltip-value.success { + color: #10b981; +} + +.tooltip-value.warning { + color: #eab308; +} + +.tooltip-value.error { + color: #ef4444; +} + +/* Tooltip trigger */ +[data-tooltip] { + cursor: pointer; +} diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html new file mode 100644 index 0000000..c351c73 --- /dev/null +++ b/internal/server/templates/index.html @@ -0,0 +1,357 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Site.Name}}</title> + <meta name="description" content="{{.Site.Description}}"> + {{if .Site.Favicon}}<link rel="icon" href="{{.Site.Favicon}}">{{end}} + <link rel="stylesheet" href="/static/style.css"> + <script> + // Theme detection + if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark'); + } + </script> +</head> +<body class="bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 min-h-screen font-mono"> + <div class="max-w-4xl mx-auto px-4 py-8 sm:py-12"> + <!-- Header --> + <header class="mb-8 sm:mb-12"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + {{if .Site.Logo}} + <img src="{{.Site.Logo}}" alt="Logo" class="h-8 w-8"> + {{end}} + <div> + <h1 class="text-xl sm:text-2xl font-bold tracking-tight">{{.Site.Name}}</h1> + <p class="text-sm text-neutral-500 dark:text-neutral-400">{{.Site.Description}}</p> + </div> + </div> + {{if .ShowThemeToggle}} + <button onclick="toggleTheme()" class="p-2 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors" aria-label="Toggle theme"> + <svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/> + </svg> + <svg class="w-5 h-5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/> + </svg> + </button> + {{end}} + </div> + </header> + + <!-- Overall Status Banner --> + <div class="mb-8 p-4 rounded-lg border {{if eq .OverallStatus "All Systems Operational"}}bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-900{{else if eq .OverallStatus "Partial Outage"}}bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-900{{else}}bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900{{end}}"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + <div class="flex-shrink-0"> + {{if eq .OverallStatus "All Systems Operational"}} + <div class="w-3 h-3 rounded-full bg-emerald-500 animate-pulse"></div> + {{else if eq .OverallStatus "Partial Outage"}} + <div class="w-3 h-3 rounded-full bg-yellow-500 animate-pulse"></div> + {{else}} + <div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div> + {{end}} + </div> + <span class="font-medium">{{.OverallStatus}}</span> + </div> + <span class="text-sm text-neutral-500 dark:text-neutral-400" data-tooltip='{{.TimezoneTooltip}}'>{{.CurrentTime}}</span> + </div> + </div> + + <!-- Monitor Groups --> + <div class="space-y-6"> + {{range $groupIndex, $group := .Groups}} + <section class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden" data-group="{{$group.Name}}"> + <div class="px-4 py-3 bg-neutral-100 dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-800 cursor-pointer hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors" onclick="toggleGroup('{{$group.Name}}')"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + <svg class="w-4 h-4 text-neutral-600 dark:text-neutral-400 transition-transform" data-group-icon="{{$group.Name}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> + </svg> + <h2 class="font-semibold text-sm uppercase tracking-wider text-neutral-600 dark:text-neutral-400">{{$group.Name}}</h2> + </div> + {{if $group.ShowGroupUptime}} + <span class="text-sm font-medium {{if ge $group.GroupUptime 99.0}}text-emerald-600 dark:text-emerald-400{{else if ge $group.GroupUptime 95.0}}text-yellow-600 dark:text-yellow-400{{else}}text-red-600 dark:text-red-400{{end}}">{{formatUptime $group.GroupUptime}}</span> + {{end}} + </div> + </div> + <div class="divide-y divide-neutral-200 dark:divide-neutral-800 group-content" data-group-content="{{$group.Name}}" data-default-collapsed="{{$group.DefaultCollapsed}}"> + {{range .Monitors}} + <div class="p-4 hover:bg-neutral-100/50 dark:hover:bg-neutral-900/50 transition-colors"> + <div class="flex items-start justify-between gap-4"> + <div class="flex-1 min-w-0"> + <div class="flex items-center gap-2 mb-2"> + {{if eq .Status "up"}} + <div class="w-2 h-2 rounded-full bg-emerald-500 flex-shrink-0"></div> + {{else if eq .Status "degraded"}} + <div class="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0"></div> + {{else if eq .Status "down"}} + <div class="w-2 h-2 rounded-full bg-red-500 flex-shrink-0"></div> + {{else}} + <div class="w-2 h-2 rounded-full bg-neutral-400 flex-shrink-0"></div> + {{end}} + <span class="font-medium truncate">{{.Name}}</span> + <span class="text-xs px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 uppercase">{{.Type}}</span> + </div> + <div class="flex items-center gap-4 text-xs text-neutral-500 dark:text-neutral-400"> + <span>{{formatDuration .ResponseTime}}</span> + {{if gt .SSLDaysLeft 0}} + <span class="{{if lt .SSLDaysLeft 14}}text-yellow-600 dark:text-yellow-400{{else if lt .SSLDaysLeft 7}}text-red-600 dark:text-red-400{{end}}" data-tooltip='{{.SSLTooltip}}'>SSL: {{.SSLDaysLeft}}d</span> + {{end}} + {{if .LastError}} + {{if .LastError}}<span class="text-red-600 dark:text-red-400 truncate max-w-[200px]" data-tooltip='{"header":"Last Error","error":"{{.LastError}}"}'>{{.LastError}}</span>{{end}} + {{end}} + </div> + </div> + <div class="flex items-center gap-2 flex-shrink-0"> + <span class="text-sm font-medium {{if ge .UptimePercent 99.0}}text-emerald-600 dark:text-emerald-400{{else if ge .UptimePercent 95.0}}text-yellow-600 dark:text-yellow-400{{else}}text-red-600 dark:text-red-400{{end}}">{{formatUptime .UptimePercent}}</span> + </div> + </div> + <!-- History Bar --> + <div class="mt-3 flex gap-px"> + {{range .Ticks}} + <div class="flex-1 h-6 rounded-sm {{tickColor .}}" data-tooltip='{{tickTooltipData . $.TickMode $.Timezone}}'></div> + {{else}} + {{range seq $.TickCount}} + <div class="flex-1 h-6 rounded-sm bg-neutral-200 dark:bg-neutral-800" data-tooltip='{"header":"No data"}'></div> + {{end}} + {{end}} + </div> + </div> + {{end}} + </div> + </section> + {{end}} + </div> + + <!-- Incidents --> + {{if .Incidents}} + <section class="mt-8"> + <h2 class="text-lg font-semibold mb-4">Incidents</h2> + <div class="space-y-4"> + {{range .Incidents}} + <div class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden"> + <div class="p-4 {{if .IsActive}}bg-yellow-50 dark:bg-yellow-950/20{{else}}bg-neutral-50 dark:bg-neutral-900/50{{end}}"> + <div class="flex items-start justify-between gap-4"> + <div> + <div class="flex items-center gap-2 mb-1"> + {{if eq .Status "resolved"}} + <svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/> + </svg> + {{else if eq .Status "scheduled"}} + <svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/> + </svg> + {{else}} + <svg class="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> + </svg> + {{end}} + <span class="font-medium">{{.Title}}</span> + </div> + <p class="text-sm text-neutral-600 dark:text-neutral-400">{{.Message}}</p> + {{if .IsScheduled}} + <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2"> + Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}} + </p> + {{end}} + </div> + <div class="flex-shrink-0"> + <span class="text-xs px-2 py-1 rounded-full {{if eq .Status "resolved"}}bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300{{else if eq .Status "scheduled"}}bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300{{else if eq .Status "investigating"}}bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300{{else if eq .Status "identified"}}bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300{{else}}bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300{{end}} capitalize"> + {{.Status}} + </span> + </div> + </div> + </div> + {{if .Updates}} + <div class="border-t border-neutral-200 dark:border-neutral-800 p-4 space-y-3 bg-white dark:bg-neutral-950"> + {{range .Updates}} + <div class="text-sm"> + <div class="flex items-center gap-2 text-neutral-500 dark:text-neutral-400 mb-1"> + <span class="capitalize font-medium">{{.Status}}</span> + <span class="text-xs">{{formatTime .Time}}</span> + </div> + <p class="text-neutral-700 dark:text-neutral-300">{{.Message}}</p> + </div> + {{end}} + </div> + {{end}} + </div> + {{end}} + </div> + </section> + {{end}} + + <!-- Footer --> + <footer class="mt-12 pt-6 border-t border-neutral-200 dark:border-neutral-800"> + <div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-400"> + <span data-tooltip='{{.LastUpdatedTooltip}}'>Updated {{timeAgo .LastUpdated}}</span> + <span>Powered by <a href="https://github.com/Fuwn/kaze" class="hover:text-neutral-900 dark:hover:text-neutral-100 underline underline-offset-2">Kaze</a></span> + </div> + </footer> + </div> + + <!-- Tooltip container --> + <div id="tooltip" class="tooltip"></div> + + <script> + function toggleTheme() { + if (document.documentElement.classList.contains('dark')) { + document.documentElement.classList.remove('dark'); + localStorage.theme = 'light'; + } else { + document.documentElement.classList.add('dark'); + localStorage.theme = 'dark'; + } + } + + // Group collapse/expand functionality + function toggleGroup(groupName) { + const content = document.querySelector('[data-group-content="' + groupName + '"]'); + const icon = document.querySelector('[data-group-icon="' + groupName + '"]'); + + if (!content || !icon) return; + + const isCollapsed = content.classList.contains('collapsed'); + + if (isCollapsed) { + content.classList.remove('collapsed'); + icon.classList.remove('rotated'); + localStorage.setItem('group-' + groupName, 'expanded'); + } else { + content.classList.add('collapsed'); + icon.classList.add('rotated'); + localStorage.setItem('group-' + groupName, 'collapsed'); + } + } + + // Initialize group states on page load + (function initGroupStates() { + document.querySelectorAll('[data-group-content]').forEach(function(content) { + const groupName = content.getAttribute('data-group-content'); + const defaultCollapsed = content.getAttribute('data-default-collapsed') === 'true'; + const savedState = localStorage.getItem('group-' + groupName); + const icon = document.querySelector('[data-group-icon="' + groupName + '"]'); + + // Determine initial state: localStorage > config default > expanded + let shouldCollapse = false; + if (savedState !== null) { + shouldCollapse = savedState === 'collapsed'; + } else { + shouldCollapse = defaultCollapsed; + } + + if (shouldCollapse) { + content.classList.add('collapsed'); + if (icon) icon.classList.add('rotated'); + } + }); + })(); + + // Custom tooltip handling + (function() { + const tooltip = document.getElementById('tooltip'); + let currentTarget = null; + let hideTimeout = null; + + function renderTooltip(data) { + let html = '<span class="tooltip-header">' + data.header + '</span>'; + if (data.error) { + html += '<div style="white-space:normal;max-width:260px;margin-top:0.25rem">' + data.error + '</div>'; + } else if (data.rows) { + data.rows.forEach(function(row) { + html += '<div class="tooltip-row">'; + html += '<span class="tooltip-label">' + row.label + '</span>'; + html += '<span class="tooltip-value ' + (row.class || '') + '">' + row.value + '</span>'; + html += '</div>'; + }); + } + return html; + } + + function showTooltip(e) { + const target = e.target.closest('[data-tooltip]'); + if (!target) return; + + clearTimeout(hideTimeout); + currentTarget = target; + + // Parse and render tooltip content + try { + const data = JSON.parse(target.getAttribute('data-tooltip')); + tooltip.innerHTML = renderTooltip(data); + } catch (err) { + tooltip.innerHTML = target.getAttribute('data-tooltip'); + } + + // Make visible to calculate dimensions + tooltip.classList.add('visible'); + + // Position tooltip + const rect = target.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + + // Calculate horizontal position (center above the element) + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + + // Keep tooltip within viewport horizontally + const padding = 8; + if (left < padding) { + left = padding; + } else if (left + tooltipRect.width > window.innerWidth - padding) { + left = window.innerWidth - tooltipRect.width - padding; + } + + // Calculate vertical position (above element by default) + let top = rect.top - tooltipRect.height - 8; + + // If not enough space above, show below + if (top < padding) { + top = rect.bottom + 8; + tooltip.classList.add('tooltip-top'); + } else { + tooltip.classList.remove('tooltip-top'); + } + + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; + } + + function hideTooltip() { + hideTimeout = setTimeout(() => { + tooltip.classList.remove('visible'); + currentTarget = null; + }, 100); + } + + // Event delegation for tooltip triggers + document.addEventListener('mouseenter', showTooltip, true); + document.addEventListener('mouseleave', function(e) { + if (e.target.closest('[data-tooltip]')) { + hideTooltip(); + } + }, true); + + // Handle touch devices + document.addEventListener('touchstart', function(e) { + const target = e.target.closest('[data-tooltip]'); + if (target) { + if (currentTarget === target) { + hideTooltip(); + } else { + showTooltip(e); + } + } else { + hideTooltip(); + } + }, { passive: true }); + })(); + + // Auto-refresh every 30 seconds + setTimeout(() => location.reload(), 30000); + </script> +</body> +</html> diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..e08e4ee --- /dev/null +++ b/internal/storage/sqlite.go @@ -0,0 +1,679 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// Storage handles all database operations +type Storage struct { + db *sql.DB + historyDays int +} + +// CheckResult represents a single monitor check result +type CheckResult struct { + ID int64 + MonitorName string + Timestamp time.Time + Status string // up, down, degraded + ResponseTime int64 // milliseconds + StatusCode int // HTTP status code (0 for non-HTTP) + Error string + SSLExpiry *time.Time + SSLDaysLeft int +} + +// DailyStatus represents aggregated daily status for a monitor +type DailyStatus struct { + Date time.Time + MonitorName string + SuccessCount int + FailureCount int + TotalChecks int + AvgResponse float64 + UptimePercent float64 +} + +// MonitorStats represents overall statistics for a monitor +type MonitorStats struct { + MonitorName string + CurrentStatus string + LastCheck time.Time + LastResponseTime int64 + LastError string + UptimePercent float64 + AvgResponseTime float64 + SSLExpiry *time.Time + SSLDaysLeft int + TotalChecks int64 +} + +// TickData represents aggregated data for one tick in the history bar +type TickData struct { + Timestamp time.Time + TotalChecks int + SuccessCount int + FailureCount int + AvgResponse float64 + UptimePercent float64 + // For ping mode only + Status string // up, down, degraded (only for ping mode) + ResponseTime int64 // milliseconds (only for ping mode) +} + +// New creates a new storage instance +func New(dbPath string, historyDays int) (*Storage, error) { + // Add connection parameters for better concurrency handling + // _txlock=immediate ensures transactions acquire locks immediately + // _busy_timeout=5000 waits up to 5 seconds for locks + // _journal_mode=WAL enables write-ahead logging for better concurrency + // _synchronous=NORMAL balances safety and performance + dsn := fmt.Sprintf("%s?_txlock=immediate&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL", dbPath) + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // CRITICAL: Set max open connections to 1 for SQLite + // SQLite only supports one writer at a time, so we serialize all writes + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) // Don't close idle connections + + // Verify WAL mode is enabled + var journalMode string + if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil { + db.Close() + return nil, fmt.Errorf("failed to check journal mode: %w", err) + } + + // Enable foreign keys (must be done per-connection, but with 1 conn it's fine) + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + db.Close() + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + s := &Storage{ + db: db, + historyDays: historyDays, + } + + if err := s.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + return s, nil +} + +// migrate creates the database schema +func (s *Storage) migrate() error { + schema := ` + CREATE TABLE IF NOT EXISTS check_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_name TEXT NOT NULL, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + status TEXT NOT NULL CHECK(status IN ('up', 'down', 'degraded')), + response_time_ms INTEGER NOT NULL DEFAULT 0, + status_code INTEGER DEFAULT 0, + error_message TEXT, + ssl_expiry DATETIME, + ssl_days_left INTEGER DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_check_results_monitor_time + ON check_results(monitor_name, timestamp DESC); + + CREATE INDEX IF NOT EXISTS idx_check_results_timestamp + ON check_results(timestamp); + + CREATE TABLE IF NOT EXISTS daily_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_name TEXT NOT NULL, + date DATE NOT NULL, + success_count INTEGER NOT NULL DEFAULT 0, + failure_count INTEGER NOT NULL DEFAULT 0, + total_checks INTEGER NOT NULL DEFAULT 0, + avg_response_ms REAL DEFAULT 0, + uptime_percent REAL DEFAULT 0, + UNIQUE(monitor_name, date) + ); + + CREATE INDEX IF NOT EXISTS idx_daily_stats_monitor_date + ON daily_stats(monitor_name, date DESC); + + CREATE TABLE IF NOT EXISTS monitor_state ( + monitor_name TEXT PRIMARY KEY, + current_status TEXT NOT NULL DEFAULT 'unknown', + last_check DATETIME, + last_response_time_ms INTEGER DEFAULT 0, + last_error TEXT, + ssl_expiry DATETIME, + ssl_days_left INTEGER DEFAULT 0 + ); + ` + + _, err := s.db.Exec(schema) + return err +} + +// SaveCheckResult saves a check result and updates monitor state +func (s *Storage) SaveCheckResult(ctx context.Context, result *CheckResult) error { + // Retry logic for transient lock issues + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + // Wait before retry with exponential backoff + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt*100) * time.Millisecond): + } + } + + lastErr = s.saveCheckResultOnce(ctx, result) + if lastErr == nil { + return nil + } + + // Only retry on lock errors + if !isLockError(lastErr) { + return lastErr + } + } + return fmt.Errorf("failed after 3 attempts: %w", lastErr) +} + +// isLockError checks if the error is a database lock error +func isLockError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "database is locked") || + strings.Contains(errStr, "SQLITE_BUSY") || + strings.Contains(errStr, "database table is locked") +} + +// saveCheckResultOnce performs a single attempt to save the check result +func (s *Storage) saveCheckResultOnce(ctx context.Context, result *CheckResult) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Insert check result + _, err = tx.ExecContext(ctx, ` + INSERT INTO check_results ( + monitor_name, timestamp, status, response_time_ms, + status_code, error_message, ssl_expiry, ssl_days_left + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, result.MonitorName, result.Timestamp, result.Status, + result.ResponseTime, result.StatusCode, result.Error, + result.SSLExpiry, result.SSLDaysLeft) + if err != nil { + return fmt.Errorf("failed to insert check result: %w", err) + } + + // Update or insert monitor state + _, err = tx.ExecContext(ctx, ` + INSERT INTO monitor_state ( + monitor_name, current_status, last_check, + last_response_time_ms, last_error, ssl_expiry, ssl_days_left + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(monitor_name) DO UPDATE SET + current_status = excluded.current_status, + last_check = excluded.last_check, + last_response_time_ms = excluded.last_response_time_ms, + last_error = excluded.last_error, + ssl_expiry = excluded.ssl_expiry, + ssl_days_left = excluded.ssl_days_left + `, result.MonitorName, result.Status, result.Timestamp, + result.ResponseTime, result.Error, result.SSLExpiry, result.SSLDaysLeft) + if err != nil { + return fmt.Errorf("failed to update monitor state: %w", err) + } + + // Update daily stats + date := result.Timestamp.Format("2006-01-02") + successIncr := 0 + failureIncr := 0 + if result.Status == "up" { + successIncr = 1 + } else { + failureIncr = 1 + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO daily_stats ( + monitor_name, date, success_count, failure_count, + total_checks, avg_response_ms, uptime_percent + ) VALUES (?, ?, ?, ?, 1, ?, ?) + ON CONFLICT(monitor_name, date) DO UPDATE SET + success_count = daily_stats.success_count + ?, + failure_count = daily_stats.failure_count + ?, + total_checks = daily_stats.total_checks + 1, + avg_response_ms = (daily_stats.avg_response_ms * daily_stats.total_checks + ?) / (daily_stats.total_checks + 1), + uptime_percent = CAST((daily_stats.success_count + ?) AS REAL) / (daily_stats.total_checks + 1) * 100 + `, result.MonitorName, date, successIncr, failureIncr, + float64(result.ResponseTime), float64(successIncr)*100, + successIncr, failureIncr, float64(result.ResponseTime), successIncr) + if err != nil { + return fmt.Errorf("failed to update daily stats: %w", err) + } + + return tx.Commit() +} + +// GetMonitorStats returns statistics for a specific monitor +func (s *Storage) GetMonitorStats(ctx context.Context, monitorName string) (*MonitorStats, error) { + stats := &MonitorStats{MonitorName: monitorName} + + // Get current state + err := s.db.QueryRowContext(ctx, ` + SELECT current_status, last_check, last_response_time_ms, + last_error, ssl_expiry, ssl_days_left + FROM monitor_state + WHERE monitor_name = ? + `, monitorName).Scan(&stats.CurrentStatus, &stats.LastCheck, + &stats.LastResponseTime, &stats.LastError, + &stats.SSLExpiry, &stats.SSLDaysLeft) + if err == sql.ErrNoRows { + stats.CurrentStatus = "unknown" + return stats, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get monitor state: %w", err) + } + + // Get aggregate stats from check results within history window + cutoff := time.Now().AddDate(0, 0, -s.historyDays) + err = s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*) as total, + AVG(response_time_ms) as avg_response, + CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime + FROM check_results + WHERE monitor_name = ? AND timestamp >= ? + `, monitorName, cutoff).Scan(&stats.TotalChecks, &stats.AvgResponseTime, &stats.UptimePercent) + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("failed to get aggregate stats: %w", err) + } + + return stats, nil +} + +// GetAllMonitorStats returns statistics for all monitors +func (s *Storage) GetAllMonitorStats(ctx context.Context) (map[string]*MonitorStats, error) { + stats := make(map[string]*MonitorStats) + cutoff := time.Now().AddDate(0, 0, -s.historyDays) + + // Get all monitor states + rows, err := s.db.QueryContext(ctx, ` + SELECT monitor_name, current_status, last_check, + last_response_time_ms, last_error, ssl_expiry, ssl_days_left + FROM monitor_state + `) + if err != nil { + return nil, fmt.Errorf("failed to query monitor states: %w", err) + } + defer rows.Close() + + for rows.Next() { + var ms MonitorStats + var lastError sql.NullString + var sslExpiry sql.NullTime + err := rows.Scan(&ms.MonitorName, &ms.CurrentStatus, &ms.LastCheck, + &ms.LastResponseTime, &lastError, &sslExpiry, &ms.SSLDaysLeft) + if err != nil { + return nil, fmt.Errorf("failed to scan monitor state: %w", err) + } + if lastError.Valid { + ms.LastError = lastError.String + } + if sslExpiry.Valid { + ms.SSLExpiry = &sslExpiry.Time + } + stats[ms.MonitorName] = &ms + } + + // Get aggregate stats for all monitors + rows, err = s.db.QueryContext(ctx, ` + SELECT + monitor_name, + COUNT(*) as total, + AVG(response_time_ms) as avg_response, + CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime + FROM check_results + WHERE timestamp >= ? + GROUP BY monitor_name + `, cutoff) + if err != nil { + return nil, fmt.Errorf("failed to query aggregate stats: %w", err) + } + defer rows.Close() + + for rows.Next() { + var name string + var total int64 + var avgResponse, uptime float64 + if err := rows.Scan(&name, &total, &avgResponse, &uptime); err != nil { + return nil, fmt.Errorf("failed to scan aggregate stats: %w", err) + } + if ms, ok := stats[name]; ok { + ms.TotalChecks = total + ms.AvgResponseTime = avgResponse + ms.UptimePercent = uptime + } + } + + return stats, nil +} + +// GetDailyStats returns daily statistics for a monitor +func (s *Storage) GetDailyStats(ctx context.Context, monitorName string, days int) ([]DailyStatus, error) { + cutoff := time.Now().AddDate(0, 0, -days) + + rows, err := s.db.QueryContext(ctx, ` + SELECT date, success_count, failure_count, total_checks, + avg_response_ms, uptime_percent + FROM daily_stats + WHERE monitor_name = ? AND date >= ? + ORDER BY date ASC + `, monitorName, cutoff.Format("2006-01-02")) + if err != nil { + return nil, fmt.Errorf("failed to query daily stats: %w", err) + } + defer rows.Close() + + var results []DailyStatus + for rows.Next() { + var ds DailyStatus + var dateStr string + ds.MonitorName = monitorName + if err := rows.Scan(&dateStr, &ds.SuccessCount, &ds.FailureCount, + &ds.TotalChecks, &ds.AvgResponse, &ds.UptimePercent); err != nil { + return nil, fmt.Errorf("failed to scan daily stats: %w", err) + } + ds.Date, _ = time.Parse("2006-01-02", dateStr) + results = append(results, ds) + } + + return results, nil +} + +// GetUptimeHistory returns uptime data for the history visualization +// Returns a slice of daily uptime percentages, one per day for the specified period +func (s *Storage) GetUptimeHistory(ctx context.Context, monitorName string, days int) ([]float64, error) { + // Create a map of date to uptime + uptimeMap := make(map[string]float64) + + // Use local time for date range calculation (matches how we store dates) + cutoffDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") + + rows, err := s.db.QueryContext(ctx, ` + SELECT date, uptime_percent + FROM daily_stats + WHERE monitor_name = ? AND date >= ? + ORDER BY date ASC + `, monitorName, cutoffDate) + if err != nil { + return nil, fmt.Errorf("failed to query uptime history: %w", err) + } + defer rows.Close() + + for rows.Next() { + var dateStr string + var uptime float64 + if err := rows.Scan(&dateStr, &uptime); err != nil { + return nil, fmt.Errorf("failed to scan uptime: %w", err) + } + // Normalize date format (strip any time component if present) + if len(dateStr) > 10 { + dateStr = dateStr[:10] + } + uptimeMap[dateStr] = uptime + } + + // Build result array with -1 for days with no data + result := make([]float64, days) + today := time.Now().Format("2006-01-02") + for i := 0; i < days; i++ { + date := time.Now().AddDate(0, 0, -days+i+1).Format("2006-01-02") + if uptime, ok := uptimeMap[date]; ok { + result[i] = uptime + } else if date == today { + // For today, if we have monitor data but no daily stats yet, + // check if there's recent data and show it + result[i] = -1 + } else { + result[i] = -1 // No data for this day + } + } + + return result, nil +} + +// PingResult represents a single ping for the history display +type PingResult struct { + Status string // up, down, degraded + ResponseTime int64 // milliseconds + Timestamp time.Time +} + +// GetRecentPings returns the last N check results for a monitor +// Results are ordered from oldest to newest (left to right on display) +func (s *Storage) GetRecentPings(ctx context.Context, monitorName string, limit int) ([]PingResult, error) { + // Query in descending order then reverse, so we get the most recent N results + // but in chronological order for display + rows, err := s.db.QueryContext(ctx, ` + SELECT status, response_time_ms, timestamp + FROM check_results + WHERE monitor_name = ? + ORDER BY timestamp DESC + LIMIT ? + `, monitorName, limit) + if err != nil { + return nil, fmt.Errorf("failed to query recent pings: %w", err) + } + defer rows.Close() + + var results []PingResult + for rows.Next() { + var p PingResult + if err := rows.Scan(&p.Status, &p.ResponseTime, &p.Timestamp); err != nil { + return nil, fmt.Errorf("failed to scan ping: %w", err) + } + results = append(results, p) + } + + // Reverse to get chronological order (oldest first) + for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 { + results[i], results[j] = results[j], results[i] + } + + return results, nil +} + +// GetAggregatedHistory returns tick data for the history visualization +// tickMode: "ping", "minute", "hour", "day" +// fixedSlots: when true, returns exactly tickCount elements with nil for missing periods +// Returns a slice where nil entries indicate no data for that period +func (s *Storage) GetAggregatedHistory(ctx context.Context, monitorName string, tickCount int, tickMode string, fixedSlots bool) ([]*TickData, error) { + switch tickMode { + case "ping": + return s.getAggregatedPings(ctx, monitorName, tickCount, fixedSlots) + case "minute": + return s.getAggregatedByTime(ctx, monitorName, tickCount, time.Minute, "%Y-%m-%d %H:%M") + case "hour": + return s.getAggregatedByTime(ctx, monitorName, tickCount, time.Hour, "%Y-%m-%d %H") + case "day": + return s.getAggregatedByTime(ctx, monitorName, tickCount, 24*time.Hour, "%Y-%m-%d") + default: + return nil, fmt.Errorf("invalid tick mode: %s", tickMode) + } +} + +// getAggregatedPings returns individual pings as tick data +func (s *Storage) getAggregatedPings(ctx context.Context, monitorName string, tickCount int, fixedSlots bool) ([]*TickData, error) { + pings, err := s.GetRecentPings(ctx, monitorName, tickCount) + if err != nil { + return nil, err + } + + result := make([]*TickData, 0, tickCount) + + // If fixedSlots is true, pad the beginning with nils + if fixedSlots && len(pings) < tickCount { + for i := 0; i < tickCount-len(pings); i++ { + result = append(result, nil) + } + } + + // Convert pings to TickData + for _, p := range pings { + uptime := 0.0 + if p.Status == "up" { + uptime = 100.0 + } else if p.Status == "degraded" { + uptime = 50.0 + } + result = append(result, &TickData{ + Timestamp: p.Timestamp, + TotalChecks: 1, + SuccessCount: boolToInt(p.Status == "up"), + FailureCount: boolToInt(p.Status == "down"), + AvgResponse: float64(p.ResponseTime), + UptimePercent: uptime, + Status: p.Status, + ResponseTime: p.ResponseTime, + }) + } + + return result, nil +} + +// getAggregatedByTime returns aggregated tick data by time bucket +func (s *Storage) getAggregatedByTime(ctx context.Context, monitorName string, tickCount int, bucketDuration time.Duration, sqlFormat string) ([]*TickData, error) { + // Calculate the start time for our window + now := time.Now() + startTime := now.Add(-time.Duration(tickCount) * bucketDuration) + + // Query aggregated data grouped by time bucket + // First extract just the datetime part (first 19 chars: "2006-01-02 15:04:05") + // then apply strftime to that + query := ` + SELECT + strftime('` + sqlFormat + `', substr(timestamp, 1, 19)) as bucket, + COUNT(*) as total_checks, + SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count, + SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END) as failure_count, + AVG(response_time_ms) as avg_response + FROM check_results + WHERE monitor_name = ? AND timestamp >= ? + GROUP BY bucket + HAVING bucket IS NOT NULL + ORDER BY bucket ASC + ` + + rows, err := s.db.QueryContext(ctx, query, monitorName, startTime) + if err != nil { + return nil, fmt.Errorf("failed to query aggregated history: %w", err) + } + defer rows.Close() + + // Build a map of bucket -> data + dataMap := make(map[string]*TickData) + for rows.Next() { + var bucket sql.NullString + var td TickData + if err := rows.Scan(&bucket, &td.TotalChecks, &td.SuccessCount, &td.FailureCount, &td.AvgResponse); err != nil { + return nil, fmt.Errorf("failed to scan aggregated data: %w", err) + } + if !bucket.Valid || bucket.String == "" { + continue // Skip null buckets + } + if td.TotalChecks > 0 { + td.UptimePercent = float64(td.SuccessCount) / float64(td.TotalChecks) * 100 + } + dataMap[bucket.String] = &td + } + + // Generate all time slots and fill in data + result := make([]*TickData, tickCount) + for i := 0; i < tickCount; i++ { + slotTime := startTime.Add(time.Duration(i+1) * bucketDuration) + bucket := formatTimeBucket(slotTime, bucketDuration) + + if td, ok := dataMap[bucket]; ok { + td.Timestamp = slotTime + result[i] = td + } else { + result[i] = nil // No data for this slot + } + } + + return result, nil +} + +// formatTimeBucket formats a time into the bucket key format +func formatTimeBucket(t time.Time, duration time.Duration) string { + switch { + case duration >= 24*time.Hour: + return t.Format("2006-01-02") + case duration >= time.Hour: + return t.Format("2006-01-02 15") + case duration >= time.Minute: + return t.Format("2006-01-02 15:04") + default: + return t.Format("2006-01-02 15:04:05") + } +} + +// boolToInt converts a boolean to an int (1 for true, 0 for false) +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// Cleanup removes old data beyond the history retention period +func (s *Storage) Cleanup(ctx context.Context) error { + cutoff := time.Now().AddDate(0, 0, -s.historyDays) + + _, err := s.db.ExecContext(ctx, ` + DELETE FROM check_results WHERE timestamp < ? + `, cutoff) + if err != nil { + return fmt.Errorf("failed to cleanup check_results: %w", err) + } + + _, err = s.db.ExecContext(ctx, ` + DELETE FROM daily_stats WHERE date < ? + `, cutoff.Format("2006-01-02")) + if err != nil { + return fmt.Errorf("failed to cleanup daily_stats: %w", err) + } + + // Vacuum to reclaim space + _, err = s.db.ExecContext(ctx, "VACUUM") + if err != nil { + return fmt.Errorf("failed to vacuum database: %w", err) + } + + return nil +} + +// Close closes the database connection +func (s *Storage) Close() error { + return s.db.Close() +} |