From 4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 17 Jan 2026 23:17:49 -0800 Subject: feat: Initial commit --- .gitignore | 37 ++ Dockerfile | 51 +++ Makefile | 86 ++++ README.md | 149 +++++++ config.example.yaml | 141 ++++++ go.mod | 21 + go.sum | 57 +++ internal/config/config.go | 299 +++++++++++++ internal/monitor/http.go | 182 ++++++++ internal/monitor/monitor.go | 86 ++++ internal/monitor/scheduler.go | 182 ++++++++ internal/monitor/tcp.go | 89 ++++ internal/server/server.go | 839 +++++++++++++++++++++++++++++++++++ internal/server/static/style.css | 417 +++++++++++++++++ internal/server/templates/index.html | 357 +++++++++++++++ internal/storage/sqlite.go | 679 ++++++++++++++++++++++++++++ 16 files changed, 3672 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 config.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/monitor/http.go create mode 100644 internal/monitor/monitor.go create mode 100644 internal/monitor/scheduler.go create mode 100644 internal/monitor/tcp.go create mode 100644 internal/server/server.go create mode 100644 internal/server/static/style.css create mode 100644 internal/server/templates/index.html create mode 100644 internal/storage/sqlite.go 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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e4a0d4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..31b30b0 --- /dev/null +++ b/go.sum @@ -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 @@ + + + + + + {{.Site.Name}} + + {{if .Site.Favicon}}{{end}} + + + + +
+ +
+
+
+ {{if .Site.Logo}} + Logo + {{end}} +
+

{{.Site.Name}}

+

{{.Site.Description}}

+
+
+ {{if .ShowThemeToggle}} + + {{end}} +
+
+ + +
+
+
+
+ {{if eq .OverallStatus "All Systems Operational"}} +
+ {{else if eq .OverallStatus "Partial Outage"}} +
+ {{else}} +
+ {{end}} +
+ {{.OverallStatus}} +
+ {{.CurrentTime}} +
+
+ + +
+ {{range $groupIndex, $group := .Groups}} +
+
+
+
+ + + +

{{$group.Name}}

+
+ {{if $group.ShowGroupUptime}} + {{formatUptime $group.GroupUptime}} + {{end}} +
+
+
+ {{range .Monitors}} +
+
+
+
+ {{if eq .Status "up"}} +
+ {{else if eq .Status "degraded"}} +
+ {{else if eq .Status "down"}} +
+ {{else}} +
+ {{end}} + {{.Name}} + {{.Type}} +
+
+ {{formatDuration .ResponseTime}} + {{if gt .SSLDaysLeft 0}} + SSL: {{.SSLDaysLeft}}d + {{end}} + {{if .LastError}} + {{if .LastError}}{{.LastError}}{{end}} + {{end}} +
+
+
+ {{formatUptime .UptimePercent}} +
+
+ +
+ {{range .Ticks}} +
+ {{else}} + {{range seq $.TickCount}} +
+ {{end}} + {{end}} +
+
+ {{end}} +
+
+ {{end}} +
+ + + {{if .Incidents}} +
+

Incidents

+
+ {{range .Incidents}} +
+
+
+
+
+ {{if eq .Status "resolved"}} + + + + {{else if eq .Status "scheduled"}} + + + + {{else}} + + + + {{end}} + {{.Title}} +
+

{{.Message}}

+ {{if .IsScheduled}} +

+ Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}} +

+ {{end}} +
+
+ + {{.Status}} + +
+
+
+ {{if .Updates}} +
+ {{range .Updates}} +
+
+ {{.Status}} + {{formatTime .Time}} +
+

{{.Message}}

+
+ {{end}} +
+ {{end}} +
+ {{end}} +
+
+ {{end}} + + +
+
+ Updated {{timeAgo .LastUpdated}} + Powered by Kaze +
+
+
+ + +
+ + + + 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() +} -- cgit v1.2.3