aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-17 23:17:49 -0800
committerFuwn <[email protected]>2026-01-17 23:17:49 -0800
commit4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch)
treee7c3bb335a1efd48f82d365169e8b4a66b7abe1d
downloadkaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz
kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip
feat: Initial commit
-rw-r--r--.gitignore37
-rw-r--r--Dockerfile51
-rw-r--r--Makefile86
-rw-r--r--README.md149
-rw-r--r--config.example.yaml141
-rw-r--r--go.mod21
-rw-r--r--go.sum57
-rw-r--r--internal/config/config.go299
-rw-r--r--internal/monitor/http.go182
-rw-r--r--internal/monitor/monitor.go86
-rw-r--r--internal/monitor/scheduler.go182
-rw-r--r--internal/monitor/tcp.go89
-rw-r--r--internal/server/server.go839
-rw-r--r--internal/server/static/style.css417
-rw-r--r--internal/server/templates/index.html357
-rw-r--r--internal/storage/sqlite.go679
16 files changed, 3672 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..557cc2f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,37 @@
+# Binaries
+kaze
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool
+*.out
+coverage.html
+
+# Database
+*.db
+*.db-shm
+*.db-wal
+
+# Configuration (keep example)
+config.yaml
+!config.example.yaml
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Build artifacts
+dist/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bce712d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,51 @@
+# Build stage
+FROM golang:1.22-alpine AS builder
+
+WORKDIR /app
+
+# Install build dependencies
+RUN apk add --no-cache git
+
+# Copy go mod files
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy source code
+COPY . .
+
+# Build the binary
+RUN CGO_ENABLED=0 GOOS=linux go build \
+ -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" \
+ -o kaze ./cmd/kaze
+
+# Runtime stage
+FROM alpine:3.19
+
+WORKDIR /app
+
+# Install CA certificates for HTTPS requests
+RUN apk add --no-cache ca-certificates tzdata
+
+# Create non-root user
+RUN adduser -D -u 1000 kaze
+USER kaze
+
+# Copy binary from builder
+COPY --from=builder /app/kaze .
+
+# Create data directory
+RUN mkdir -p /app/data
+
+# Expose port
+EXPOSE 8080
+
+# Default config location
+ENV KAZE_CONFIG=/app/config.yaml
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
+
+# Run the application
+ENTRYPOINT ["./kaze"]
+CMD ["--config", "/app/config.yaml"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..77e485a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,86 @@
+.PHONY: build run dev clean test
+
+# Build variables
+VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
+COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
+DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
+LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)"
+
+# Default target
+all: build
+
+# Build the binary
+build:
+ go build $(LDFLAGS) -o kaze ./cmd/kaze
+
+# Build for production (smaller binary)
+build-prod:
+ CGO_ENABLED=0 go build $(LDFLAGS) -o kaze ./cmd/kaze
+
+# Run the application
+run: build
+ ./kaze
+
+# Run with debug logging
+dev: build
+ ./kaze --debug
+
+# Clean build artifacts
+clean:
+ rm -f kaze
+ rm -f *.db *.db-shm *.db-wal
+
+# Run tests
+test:
+ go test -v ./...
+
+# Run tests with coverage
+test-cover:
+ go test -v -coverprofile=coverage.out ./...
+ go tool cover -html=coverage.out -o coverage.html
+
+# Format code
+fmt:
+ go fmt ./...
+
+# Lint code
+lint:
+ go vet ./...
+
+# Tidy dependencies
+tidy:
+ go mod tidy
+
+# Build for multiple platforms
+build-all: build-linux build-darwin build-windows
+
+build-linux:
+ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-linux-amd64 ./cmd/kaze
+ GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-linux-arm64 ./cmd/kaze
+
+build-darwin:
+ GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-darwin-amd64 ./cmd/kaze
+ GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-darwin-arm64 ./cmd/kaze
+
+build-windows:
+ GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o dist/kaze-windows-amd64.exe ./cmd/kaze
+
+# Docker build
+docker:
+ docker build -t kaze:$(VERSION) .
+
+# Help
+help:
+ @echo "Available targets:"
+ @echo " build - Build the binary"
+ @echo " build-prod - Build optimized production binary"
+ @echo " run - Build and run"
+ @echo " dev - Build and run with debug logging"
+ @echo " clean - Remove build artifacts and databases"
+ @echo " test - Run tests"
+ @echo " test-cover - Run tests with coverage report"
+ @echo " fmt - Format code"
+ @echo " lint - Lint code"
+ @echo " tidy - Tidy dependencies"
+ @echo " build-all - Build for all platforms"
+ @echo " docker - Build Docker image"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..16b42b6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,149 @@
+# Kaze
+
+A minimal, performant status page application written in Go.
+
+## Features
+
+- **Monitor Types**: HTTP, HTTPS (with SSL certificate tracking), TCP
+- **Full Metrics**: Response time, SSL expiry, status codes, uptime percentage
+- **Configurable History**: 7, 30, or 90 days retention
+- **Monitor Groups**: Organize monitors into logical categories
+- **Incidents**: Manual incident tracking via YAML configuration
+- **Dark/Light Mode**: System preference detection with manual toggle
+- **Single Binary**: All assets embedded, no external dependencies
+- **SQLite Storage**: Lightweight, file-based database
+
+## Quick Start
+
+```bash
+# Build
+make build
+
+# Copy and edit configuration
+cp config.example.yaml config.yaml
+
+# Run
+./kaze
+```
+
+Open http://localhost:8080 to view the status page.
+
+## Configuration
+
+```yaml
+site:
+ name: "My Status Page"
+ description: "Service Status"
+
+server:
+ host: "0.0.0.0"
+ port: 8080
+
+storage:
+ path: "./kaze.db"
+ history_days: 90
+
+groups:
+ - name: "Web Services"
+ monitors:
+ - name: "API"
+ type: https
+ target: "https://api.example.com/health"
+ interval: 30s
+ timeout: 10s
+ expected_status: 200
+
+ - name: "Infrastructure"
+ monitors:
+ - name: "Database"
+ type: tcp
+ target: "db.example.com:5432"
+ interval: 30s
+ timeout: 5s
+
+incidents:
+ - title: "Scheduled Maintenance"
+ status: scheduled
+ message: "Database upgrade"
+ scheduled_start: "2026-02-01T02:00:00Z"
+ scheduled_end: "2026-02-01T04:00:00Z"
+```
+
+See `config.example.yaml` for full configuration reference.
+
+## Monitor Types
+
+### HTTP/HTTPS
+
+```yaml
+- name: "API"
+ type: https
+ target: "https://api.example.com"
+ interval: 30s
+ timeout: 10s
+ expected_status: 200
+ verify_ssl: true
+ method: GET
+ headers:
+ Authorization: "Bearer token"
+```
+
+### TCP
+
+```yaml
+- name: "Database"
+ type: tcp
+ target: "db.example.com:5432"
+ interval: 30s
+ timeout: 5s
+```
+
+## API Endpoints
+
+| Endpoint | Description |
+|----------|-------------|
+| `GET /` | Status page HTML |
+| `GET /api/status` | All monitors status (JSON) |
+| `GET /api/monitor/{name}` | Single monitor status (JSON) |
+| `GET /api/history/{name}` | Monitor uptime history (JSON) |
+
+## Building
+
+```bash
+# Development build
+make build
+
+# Production build (optimized)
+make build-prod
+
+# Cross-platform builds
+make build-all
+```
+
+## Docker
+
+```bash
+# Build image
+docker build -t kaze .
+
+# Run
+docker run -p 8080:8080 -v ./config.yaml:/app/config.yaml kaze
+```
+
+## Command Line Options
+
+```
+Usage: kaze [options]
+
+Options:
+ -config string
+ Path to configuration file (default "config.yaml")
+ -debug
+ Enable debug logging
+ -version
+ Show version information
+```
+
+## License
+
+MIT
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 0000000..aca9c6a
--- /dev/null
+++ b/config.example.yaml
@@ -0,0 +1,141 @@
+# Kaze Status Page Configuration
+# Copy this file to config.yaml and customize for your needs
+
+# Site metadata
+site:
+ name: "Kaze Status"
+ description: "Service Status Page"
+ # logo: "https://example.com/logo.svg" # Optional logo URL
+ # favicon: "https://example.com/favicon.ico" # Optional favicon URL
+
+# HTTP server settings
+server:
+ host: "0.0.0.0"
+ port: 8080
+
+# Storage settings
+storage:
+ path: "./kaze.db"
+ history_days: 90 # How many days of history to retain (7, 30, or 90)
+
+# Display settings
+display:
+ # Tick aggregation mode for history bar:
+ # ping - Show individual pings (most granular)
+ # minute - Aggregate by minute
+ # hour - Aggregate by hour (default)
+ # day - Aggregate by day (like OpenStatus)
+ tick_mode: hour
+
+ # Number of ticks/bars to display in the history
+ tick_count: 45
+
+ # For 'ping' mode only:
+ # true = Show fixed slots (empty bars for missing data)
+ # false = Grow dynamically as pings come in
+ ping_fixed_slots: true
+
+ # Timezone for display (e.g., "UTC", "America/New_York", "Local")
+ timezone: "Local"
+
+ # Show the theme toggle button (true by default)
+ # Set to false to hide the light/dark mode toggle
+ show_theme_toggle: true
+
+# Monitor groups
+groups:
+ - name: "Web Services"
+ # default_collapsed: false # Start collapsed (false = expanded by default)
+ # show_group_uptime: true # Show aggregate uptime percentage (true by default)
+ monitors:
+ - name: "Website"
+ type: https
+ target: "https://example.com"
+ interval: 30s
+ timeout: 10s
+ expected_status: 200
+ verify_ssl: true
+
+ - name: "API"
+ type: https
+ target: "https://api.example.com/health"
+ interval: 30s
+ timeout: 10s
+ expected_status: 200
+ method: GET
+ # headers:
+ # Authorization: "Bearer token"
+
+ - name: "Infrastructure"
+ # default_collapsed: false
+ # show_group_uptime: true
+ monitors:
+ - name: "Database"
+ type: tcp
+ target: "localhost:5432"
+ interval: 30s
+ timeout: 5s
+
+ - name: "Redis"
+ type: tcp
+ target: "localhost:6379"
+ interval: 30s
+ timeout: 5s
+
+# Incidents and maintenance (optional)
+incidents:
+ # Scheduled maintenance example
+ - title: "Scheduled Maintenance"
+ status: scheduled
+ message: "Database maintenance window - expect brief interruptions"
+ scheduled_start: "2026-02-01T02:00:00Z"
+ scheduled_end: "2026-02-01T04:00:00Z"
+ affected_monitors:
+ - "Database"
+
+ # Past incident example (resolved)
+ # - title: "API Performance Degradation"
+ # status: resolved
+ # message: "Users experienced slow API response times"
+ # created_at: "2026-01-15T10:00:00Z"
+ # resolved_at: "2026-01-15T12:30:00Z"
+ # updates:
+ # - time: "2026-01-15T10:30:00Z"
+ # status: investigating
+ # message: "Investigating reports of slow API responses"
+ # - time: "2026-01-15T11:00:00Z"
+ # status: identified
+ # message: "Identified database connection pool exhaustion as root cause"
+ # - time: "2026-01-15T12:30:00Z"
+ # status: resolved
+ # message: "Increased connection pool size. Monitoring for stability."
+
+# Monitor Configuration Reference
+# ================================
+#
+# Common fields for all monitor types:
+# name: string (required) - Display name for the monitor
+# type: string (required) - Monitor type: http, https, or tcp
+# target: string (required) - URL or host:port to monitor
+# interval: duration - Check interval (default: 30s)
+# timeout: duration - Request timeout (default: 10s)
+#
+# HTTP/HTTPS specific fields:
+# expected_status: int - Expected HTTP status code (default: 200)
+# method: string - HTTP method (default: GET)
+# headers: map[string]string - Custom headers to send
+# body: string - Request body for POST/PUT/PATCH
+# verify_ssl: bool - Verify SSL certificate (default: true)
+#
+# TCP specific fields:
+# (none - just needs host:port target)
+#
+# Duration format:
+# Use Go duration strings: 30s, 1m, 5m, 1h, etc.
+#
+# Incident statuses:
+# scheduled - Planned maintenance
+# investigating - Looking into the issue
+# identified - Root cause found
+# monitoring - Fix applied, monitoring
+# resolved - Issue resolved
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{.Site.Name}}</title>
+ <meta name="description" content="{{.Site.Description}}">
+ {{if .Site.Favicon}}<link rel="icon" href="{{.Site.Favicon}}">{{end}}
+ <link rel="stylesheet" href="/static/style.css">
+ <script>
+ // Theme detection
+ if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+ document.documentElement.classList.add('dark');
+ }
+ </script>
+</head>
+<body class="bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 min-h-screen font-mono">
+ <div class="max-w-4xl mx-auto px-4 py-8 sm:py-12">
+ <!-- Header -->
+ <header class="mb-8 sm:mb-12">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ {{if .Site.Logo}}
+ <img src="{{.Site.Logo}}" alt="Logo" class="h-8 w-8">
+ {{end}}
+ <div>
+ <h1 class="text-xl sm:text-2xl font-bold tracking-tight">{{.Site.Name}}</h1>
+ <p class="text-sm text-neutral-500 dark:text-neutral-400">{{.Site.Description}}</p>
+ </div>
+ </div>
+ {{if .ShowThemeToggle}}
+ <button onclick="toggleTheme()" class="p-2 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors" aria-label="Toggle theme">
+ <svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
+ </svg>
+ <svg class="w-5 h-5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
+ </svg>
+ </button>
+ {{end}}
+ </div>
+ </header>
+
+ <!-- Overall Status Banner -->
+ <div class="mb-8 p-4 rounded-lg border {{if eq .OverallStatus "All Systems Operational"}}bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-900{{else if eq .OverallStatus "Partial Outage"}}bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-900{{else}}bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900{{end}}">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ <div class="flex-shrink-0">
+ {{if eq .OverallStatus "All Systems Operational"}}
+ <div class="w-3 h-3 rounded-full bg-emerald-500 animate-pulse"></div>
+ {{else if eq .OverallStatus "Partial Outage"}}
+ <div class="w-3 h-3 rounded-full bg-yellow-500 animate-pulse"></div>
+ {{else}}
+ <div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div>
+ {{end}}
+ </div>
+ <span class="font-medium">{{.OverallStatus}}</span>
+ </div>
+ <span class="text-sm text-neutral-500 dark:text-neutral-400" data-tooltip='{{.TimezoneTooltip}}'>{{.CurrentTime}}</span>
+ </div>
+ </div>
+
+ <!-- Monitor Groups -->
+ <div class="space-y-6">
+ {{range $groupIndex, $group := .Groups}}
+ <section class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden" data-group="{{$group.Name}}">
+ <div class="px-4 py-3 bg-neutral-100 dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-800 cursor-pointer hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors" onclick="toggleGroup('{{$group.Name}}')">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ <svg class="w-4 h-4 text-neutral-600 dark:text-neutral-400 transition-transform" data-group-icon="{{$group.Name}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
+ </svg>
+ <h2 class="font-semibold text-sm uppercase tracking-wider text-neutral-600 dark:text-neutral-400">{{$group.Name}}</h2>
+ </div>
+ {{if $group.ShowGroupUptime}}
+ <span class="text-sm font-medium {{if ge $group.GroupUptime 99.0}}text-emerald-600 dark:text-emerald-400{{else if ge $group.GroupUptime 95.0}}text-yellow-600 dark:text-yellow-400{{else}}text-red-600 dark:text-red-400{{end}}">{{formatUptime $group.GroupUptime}}</span>
+ {{end}}
+ </div>
+ </div>
+ <div class="divide-y divide-neutral-200 dark:divide-neutral-800 group-content" data-group-content="{{$group.Name}}" data-default-collapsed="{{$group.DefaultCollapsed}}">
+ {{range .Monitors}}
+ <div class="p-4 hover:bg-neutral-100/50 dark:hover:bg-neutral-900/50 transition-colors">
+ <div class="flex items-start justify-between gap-4">
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2 mb-2">
+ {{if eq .Status "up"}}
+ <div class="w-2 h-2 rounded-full bg-emerald-500 flex-shrink-0"></div>
+ {{else if eq .Status "degraded"}}
+ <div class="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0"></div>
+ {{else if eq .Status "down"}}
+ <div class="w-2 h-2 rounded-full bg-red-500 flex-shrink-0"></div>
+ {{else}}
+ <div class="w-2 h-2 rounded-full bg-neutral-400 flex-shrink-0"></div>
+ {{end}}
+ <span class="font-medium truncate">{{.Name}}</span>
+ <span class="text-xs px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 uppercase">{{.Type}}</span>
+ </div>
+ <div class="flex items-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
+ <span>{{formatDuration .ResponseTime}}</span>
+ {{if gt .SSLDaysLeft 0}}
+ <span class="{{if lt .SSLDaysLeft 14}}text-yellow-600 dark:text-yellow-400{{else if lt .SSLDaysLeft 7}}text-red-600 dark:text-red-400{{end}}" data-tooltip='{{.SSLTooltip}}'>SSL: {{.SSLDaysLeft}}d</span>
+ {{end}}
+ {{if .LastError}}
+ {{if .LastError}}<span class="text-red-600 dark:text-red-400 truncate max-w-[200px]" data-tooltip='{"header":"Last Error","error":"{{.LastError}}"}'>{{.LastError}}</span>{{end}}
+ {{end}}
+ </div>
+ </div>
+ <div class="flex items-center gap-2 flex-shrink-0">
+ <span class="text-sm font-medium {{if ge .UptimePercent 99.0}}text-emerald-600 dark:text-emerald-400{{else if ge .UptimePercent 95.0}}text-yellow-600 dark:text-yellow-400{{else}}text-red-600 dark:text-red-400{{end}}">{{formatUptime .UptimePercent}}</span>
+ </div>
+ </div>
+ <!-- History Bar -->
+ <div class="mt-3 flex gap-px">
+ {{range .Ticks}}
+ <div class="flex-1 h-6 rounded-sm {{tickColor .}}" data-tooltip='{{tickTooltipData . $.TickMode $.Timezone}}'></div>
+ {{else}}
+ {{range seq $.TickCount}}
+ <div class="flex-1 h-6 rounded-sm bg-neutral-200 dark:bg-neutral-800" data-tooltip='{"header":"No data"}'></div>
+ {{end}}
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </section>
+ {{end}}
+ </div>
+
+ <!-- Incidents -->
+ {{if .Incidents}}
+ <section class="mt-8">
+ <h2 class="text-lg font-semibold mb-4">Incidents</h2>
+ <div class="space-y-4">
+ {{range .Incidents}}
+ <div class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden">
+ <div class="p-4 {{if .IsActive}}bg-yellow-50 dark:bg-yellow-950/20{{else}}bg-neutral-50 dark:bg-neutral-900/50{{end}}">
+ <div class="flex items-start justify-between gap-4">
+ <div>
+ <div class="flex items-center gap-2 mb-1">
+ {{if eq .Status "resolved"}}
+ <svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
+ </svg>
+ {{else if eq .Status "scheduled"}}
+ <svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
+ </svg>
+ {{else}}
+ <svg class="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
+ </svg>
+ {{end}}
+ <span class="font-medium">{{.Title}}</span>
+ </div>
+ <p class="text-sm text-neutral-600 dark:text-neutral-400">{{.Message}}</p>
+ {{if .IsScheduled}}
+ <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
+ Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}}
+ </p>
+ {{end}}
+ </div>
+ <div class="flex-shrink-0">
+ <span class="text-xs px-2 py-1 rounded-full {{if eq .Status "resolved"}}bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300{{else if eq .Status "scheduled"}}bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300{{else if eq .Status "investigating"}}bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300{{else if eq .Status "identified"}}bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300{{else}}bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300{{end}} capitalize">
+ {{.Status}}
+ </span>
+ </div>
+ </div>
+ </div>
+ {{if .Updates}}
+ <div class="border-t border-neutral-200 dark:border-neutral-800 p-4 space-y-3 bg-white dark:bg-neutral-950">
+ {{range .Updates}}
+ <div class="text-sm">
+ <div class="flex items-center gap-2 text-neutral-500 dark:text-neutral-400 mb-1">
+ <span class="capitalize font-medium">{{.Status}}</span>
+ <span class="text-xs">{{formatTime .Time}}</span>
+ </div>
+ <p class="text-neutral-700 dark:text-neutral-300">{{.Message}}</p>
+ </div>
+ {{end}}
+ </div>
+ {{end}}
+ </div>
+ {{end}}
+ </div>
+ </section>
+ {{end}}
+
+ <!-- Footer -->
+ <footer class="mt-12 pt-6 border-t border-neutral-200 dark:border-neutral-800">
+ <div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-400">
+ <span data-tooltip='{{.LastUpdatedTooltip}}'>Updated {{timeAgo .LastUpdated}}</span>
+ <span>Powered by <a href="https://github.com/Fuwn/kaze" class="hover:text-neutral-900 dark:hover:text-neutral-100 underline underline-offset-2">Kaze</a></span>
+ </div>
+ </footer>
+ </div>
+
+ <!-- Tooltip container -->
+ <div id="tooltip" class="tooltip"></div>
+
+ <script>
+ function toggleTheme() {
+ if (document.documentElement.classList.contains('dark')) {
+ document.documentElement.classList.remove('dark');
+ localStorage.theme = 'light';
+ } else {
+ document.documentElement.classList.add('dark');
+ localStorage.theme = 'dark';
+ }
+ }
+
+ // Group collapse/expand functionality
+ function toggleGroup(groupName) {
+ const content = document.querySelector('[data-group-content="' + groupName + '"]');
+ const icon = document.querySelector('[data-group-icon="' + groupName + '"]');
+
+ if (!content || !icon) return;
+
+ const isCollapsed = content.classList.contains('collapsed');
+
+ if (isCollapsed) {
+ content.classList.remove('collapsed');
+ icon.classList.remove('rotated');
+ localStorage.setItem('group-' + groupName, 'expanded');
+ } else {
+ content.classList.add('collapsed');
+ icon.classList.add('rotated');
+ localStorage.setItem('group-' + groupName, 'collapsed');
+ }
+ }
+
+ // Initialize group states on page load
+ (function initGroupStates() {
+ document.querySelectorAll('[data-group-content]').forEach(function(content) {
+ const groupName = content.getAttribute('data-group-content');
+ const defaultCollapsed = content.getAttribute('data-default-collapsed') === 'true';
+ const savedState = localStorage.getItem('group-' + groupName);
+ const icon = document.querySelector('[data-group-icon="' + groupName + '"]');
+
+ // Determine initial state: localStorage > config default > expanded
+ let shouldCollapse = false;
+ if (savedState !== null) {
+ shouldCollapse = savedState === 'collapsed';
+ } else {
+ shouldCollapse = defaultCollapsed;
+ }
+
+ if (shouldCollapse) {
+ content.classList.add('collapsed');
+ if (icon) icon.classList.add('rotated');
+ }
+ });
+ })();
+
+ // Custom tooltip handling
+ (function() {
+ const tooltip = document.getElementById('tooltip');
+ let currentTarget = null;
+ let hideTimeout = null;
+
+ function renderTooltip(data) {
+ let html = '<span class="tooltip-header">' + data.header + '</span>';
+ if (data.error) {
+ html += '<div style="white-space:normal;max-width:260px;margin-top:0.25rem">' + data.error + '</div>';
+ } else if (data.rows) {
+ data.rows.forEach(function(row) {
+ html += '<div class="tooltip-row">';
+ html += '<span class="tooltip-label">' + row.label + '</span>';
+ html += '<span class="tooltip-value ' + (row.class || '') + '">' + row.value + '</span>';
+ html += '</div>';
+ });
+ }
+ return html;
+ }
+
+ function showTooltip(e) {
+ const target = e.target.closest('[data-tooltip]');
+ if (!target) return;
+
+ clearTimeout(hideTimeout);
+ currentTarget = target;
+
+ // Parse and render tooltip content
+ try {
+ const data = JSON.parse(target.getAttribute('data-tooltip'));
+ tooltip.innerHTML = renderTooltip(data);
+ } catch (err) {
+ tooltip.innerHTML = target.getAttribute('data-tooltip');
+ }
+
+ // Make visible to calculate dimensions
+ tooltip.classList.add('visible');
+
+ // Position tooltip
+ const rect = target.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+
+ // Calculate horizontal position (center above the element)
+ let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
+
+ // Keep tooltip within viewport horizontally
+ const padding = 8;
+ if (left < padding) {
+ left = padding;
+ } else if (left + tooltipRect.width > window.innerWidth - padding) {
+ left = window.innerWidth - tooltipRect.width - padding;
+ }
+
+ // Calculate vertical position (above element by default)
+ let top = rect.top - tooltipRect.height - 8;
+
+ // If not enough space above, show below
+ if (top < padding) {
+ top = rect.bottom + 8;
+ tooltip.classList.add('tooltip-top');
+ } else {
+ tooltip.classList.remove('tooltip-top');
+ }
+
+ tooltip.style.left = left + 'px';
+ tooltip.style.top = top + 'px';
+ }
+
+ function hideTooltip() {
+ hideTimeout = setTimeout(() => {
+ tooltip.classList.remove('visible');
+ currentTarget = null;
+ }, 100);
+ }
+
+ // Event delegation for tooltip triggers
+ document.addEventListener('mouseenter', showTooltip, true);
+ document.addEventListener('mouseleave', function(e) {
+ if (e.target.closest('[data-tooltip]')) {
+ hideTooltip();
+ }
+ }, true);
+
+ // Handle touch devices
+ document.addEventListener('touchstart', function(e) {
+ const target = e.target.closest('[data-tooltip]');
+ if (target) {
+ if (currentTarget === target) {
+ hideTooltip();
+ } else {
+ showTooltip(e);
+ }
+ } else {
+ hideTooltip();
+ }
+ }, { passive: true });
+ })();
+
+ // Auto-refresh every 30 seconds
+ setTimeout(() => location.reload(), 30000);
+ </script>
+</body>
+</html>
diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go
new file mode 100644
index 0000000..e08e4ee
--- /dev/null
+++ b/internal/storage/sqlite.go
@@ -0,0 +1,679 @@
+package storage
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// Storage handles all database operations
+type Storage struct {
+ db *sql.DB
+ historyDays int
+}
+
+// CheckResult represents a single monitor check result
+type CheckResult struct {
+ ID int64
+ MonitorName string
+ Timestamp time.Time
+ Status string // up, down, degraded
+ ResponseTime int64 // milliseconds
+ StatusCode int // HTTP status code (0 for non-HTTP)
+ Error string
+ SSLExpiry *time.Time
+ SSLDaysLeft int
+}
+
+// DailyStatus represents aggregated daily status for a monitor
+type DailyStatus struct {
+ Date time.Time
+ MonitorName string
+ SuccessCount int
+ FailureCount int
+ TotalChecks int
+ AvgResponse float64
+ UptimePercent float64
+}
+
+// MonitorStats represents overall statistics for a monitor
+type MonitorStats struct {
+ MonitorName string
+ CurrentStatus string
+ LastCheck time.Time
+ LastResponseTime int64
+ LastError string
+ UptimePercent float64
+ AvgResponseTime float64
+ SSLExpiry *time.Time
+ SSLDaysLeft int
+ TotalChecks int64
+}
+
+// TickData represents aggregated data for one tick in the history bar
+type TickData struct {
+ Timestamp time.Time
+ TotalChecks int
+ SuccessCount int
+ FailureCount int
+ AvgResponse float64
+ UptimePercent float64
+ // For ping mode only
+ Status string // up, down, degraded (only for ping mode)
+ ResponseTime int64 // milliseconds (only for ping mode)
+}
+
+// New creates a new storage instance
+func New(dbPath string, historyDays int) (*Storage, error) {
+ // Add connection parameters for better concurrency handling
+ // _txlock=immediate ensures transactions acquire locks immediately
+ // _busy_timeout=5000 waits up to 5 seconds for locks
+ // _journal_mode=WAL enables write-ahead logging for better concurrency
+ // _synchronous=NORMAL balances safety and performance
+ dsn := fmt.Sprintf("%s?_txlock=immediate&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL", dbPath)
+
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+
+ // CRITICAL: Set max open connections to 1 for SQLite
+ // SQLite only supports one writer at a time, so we serialize all writes
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ db.SetConnMaxLifetime(0) // Don't close idle connections
+
+ // Verify WAL mode is enabled
+ var journalMode string
+ if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to check journal mode: %w", err)
+ }
+
+ // Enable foreign keys (must be done per-connection, but with 1 conn it's fine)
+ if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
+ }
+
+ s := &Storage{
+ db: db,
+ historyDays: historyDays,
+ }
+
+ if err := s.migrate(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to run migrations: %w", err)
+ }
+
+ return s, nil
+}
+
+// migrate creates the database schema
+func (s *Storage) migrate() error {
+ schema := `
+ CREATE TABLE IF NOT EXISTS check_results (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ monitor_name TEXT NOT NULL,
+ timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ status TEXT NOT NULL CHECK(status IN ('up', 'down', 'degraded')),
+ response_time_ms INTEGER NOT NULL DEFAULT 0,
+ status_code INTEGER DEFAULT 0,
+ error_message TEXT,
+ ssl_expiry DATETIME,
+ ssl_days_left INTEGER DEFAULT 0
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_check_results_monitor_time
+ ON check_results(monitor_name, timestamp DESC);
+
+ CREATE INDEX IF NOT EXISTS idx_check_results_timestamp
+ ON check_results(timestamp);
+
+ CREATE TABLE IF NOT EXISTS daily_stats (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ monitor_name TEXT NOT NULL,
+ date DATE NOT NULL,
+ success_count INTEGER NOT NULL DEFAULT 0,
+ failure_count INTEGER NOT NULL DEFAULT 0,
+ total_checks INTEGER NOT NULL DEFAULT 0,
+ avg_response_ms REAL DEFAULT 0,
+ uptime_percent REAL DEFAULT 0,
+ UNIQUE(monitor_name, date)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_daily_stats_monitor_date
+ ON daily_stats(monitor_name, date DESC);
+
+ CREATE TABLE IF NOT EXISTS monitor_state (
+ monitor_name TEXT PRIMARY KEY,
+ current_status TEXT NOT NULL DEFAULT 'unknown',
+ last_check DATETIME,
+ last_response_time_ms INTEGER DEFAULT 0,
+ last_error TEXT,
+ ssl_expiry DATETIME,
+ ssl_days_left INTEGER DEFAULT 0
+ );
+ `
+
+ _, err := s.db.Exec(schema)
+ return err
+}
+
+// SaveCheckResult saves a check result and updates monitor state
+func (s *Storage) SaveCheckResult(ctx context.Context, result *CheckResult) error {
+ // Retry logic for transient lock issues
+ var lastErr error
+ for attempt := 0; attempt < 3; attempt++ {
+ if attempt > 0 {
+ // Wait before retry with exponential backoff
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(time.Duration(attempt*100) * time.Millisecond):
+ }
+ }
+
+ lastErr = s.saveCheckResultOnce(ctx, result)
+ if lastErr == nil {
+ return nil
+ }
+
+ // Only retry on lock errors
+ if !isLockError(lastErr) {
+ return lastErr
+ }
+ }
+ return fmt.Errorf("failed after 3 attempts: %w", lastErr)
+}
+
+// isLockError checks if the error is a database lock error
+func isLockError(err error) bool {
+ if err == nil {
+ return false
+ }
+ errStr := err.Error()
+ return strings.Contains(errStr, "database is locked") ||
+ strings.Contains(errStr, "SQLITE_BUSY") ||
+ strings.Contains(errStr, "database table is locked")
+}
+
+// saveCheckResultOnce performs a single attempt to save the check result
+func (s *Storage) saveCheckResultOnce(ctx context.Context, result *CheckResult) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("failed to begin transaction: %w", err)
+ }
+ defer tx.Rollback()
+
+ // Insert check result
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO check_results (
+ monitor_name, timestamp, status, response_time_ms,
+ status_code, error_message, ssl_expiry, ssl_days_left
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `, result.MonitorName, result.Timestamp, result.Status,
+ result.ResponseTime, result.StatusCode, result.Error,
+ result.SSLExpiry, result.SSLDaysLeft)
+ if err != nil {
+ return fmt.Errorf("failed to insert check result: %w", err)
+ }
+
+ // Update or insert monitor state
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO monitor_state (
+ monitor_name, current_status, last_check,
+ last_response_time_ms, last_error, ssl_expiry, ssl_days_left
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(monitor_name) DO UPDATE SET
+ current_status = excluded.current_status,
+ last_check = excluded.last_check,
+ last_response_time_ms = excluded.last_response_time_ms,
+ last_error = excluded.last_error,
+ ssl_expiry = excluded.ssl_expiry,
+ ssl_days_left = excluded.ssl_days_left
+ `, result.MonitorName, result.Status, result.Timestamp,
+ result.ResponseTime, result.Error, result.SSLExpiry, result.SSLDaysLeft)
+ if err != nil {
+ return fmt.Errorf("failed to update monitor state: %w", err)
+ }
+
+ // Update daily stats
+ date := result.Timestamp.Format("2006-01-02")
+ successIncr := 0
+ failureIncr := 0
+ if result.Status == "up" {
+ successIncr = 1
+ } else {
+ failureIncr = 1
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO daily_stats (
+ monitor_name, date, success_count, failure_count,
+ total_checks, avg_response_ms, uptime_percent
+ ) VALUES (?, ?, ?, ?, 1, ?, ?)
+ ON CONFLICT(monitor_name, date) DO UPDATE SET
+ success_count = daily_stats.success_count + ?,
+ failure_count = daily_stats.failure_count + ?,
+ total_checks = daily_stats.total_checks + 1,
+ avg_response_ms = (daily_stats.avg_response_ms * daily_stats.total_checks + ?) / (daily_stats.total_checks + 1),
+ uptime_percent = CAST((daily_stats.success_count + ?) AS REAL) / (daily_stats.total_checks + 1) * 100
+ `, result.MonitorName, date, successIncr, failureIncr,
+ float64(result.ResponseTime), float64(successIncr)*100,
+ successIncr, failureIncr, float64(result.ResponseTime), successIncr)
+ if err != nil {
+ return fmt.Errorf("failed to update daily stats: %w", err)
+ }
+
+ return tx.Commit()
+}
+
+// GetMonitorStats returns statistics for a specific monitor
+func (s *Storage) GetMonitorStats(ctx context.Context, monitorName string) (*MonitorStats, error) {
+ stats := &MonitorStats{MonitorName: monitorName}
+
+ // Get current state
+ err := s.db.QueryRowContext(ctx, `
+ SELECT current_status, last_check, last_response_time_ms,
+ last_error, ssl_expiry, ssl_days_left
+ FROM monitor_state
+ WHERE monitor_name = ?
+ `, monitorName).Scan(&stats.CurrentStatus, &stats.LastCheck,
+ &stats.LastResponseTime, &stats.LastError,
+ &stats.SSLExpiry, &stats.SSLDaysLeft)
+ if err == sql.ErrNoRows {
+ stats.CurrentStatus = "unknown"
+ return stats, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to get monitor state: %w", err)
+ }
+
+ // Get aggregate stats from check results within history window
+ cutoff := time.Now().AddDate(0, 0, -s.historyDays)
+ err = s.db.QueryRowContext(ctx, `
+ SELECT
+ COUNT(*) as total,
+ AVG(response_time_ms) as avg_response,
+ CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime
+ FROM check_results
+ WHERE monitor_name = ? AND timestamp >= ?
+ `, monitorName, cutoff).Scan(&stats.TotalChecks, &stats.AvgResponseTime, &stats.UptimePercent)
+ if err != nil && err != sql.ErrNoRows {
+ return nil, fmt.Errorf("failed to get aggregate stats: %w", err)
+ }
+
+ return stats, nil
+}
+
+// GetAllMonitorStats returns statistics for all monitors
+func (s *Storage) GetAllMonitorStats(ctx context.Context) (map[string]*MonitorStats, error) {
+ stats := make(map[string]*MonitorStats)
+ cutoff := time.Now().AddDate(0, 0, -s.historyDays)
+
+ // Get all monitor states
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT monitor_name, current_status, last_check,
+ last_response_time_ms, last_error, ssl_expiry, ssl_days_left
+ FROM monitor_state
+ `)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query monitor states: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var ms MonitorStats
+ var lastError sql.NullString
+ var sslExpiry sql.NullTime
+ err := rows.Scan(&ms.MonitorName, &ms.CurrentStatus, &ms.LastCheck,
+ &ms.LastResponseTime, &lastError, &sslExpiry, &ms.SSLDaysLeft)
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan monitor state: %w", err)
+ }
+ if lastError.Valid {
+ ms.LastError = lastError.String
+ }
+ if sslExpiry.Valid {
+ ms.SSLExpiry = &sslExpiry.Time
+ }
+ stats[ms.MonitorName] = &ms
+ }
+
+ // Get aggregate stats for all monitors
+ rows, err = s.db.QueryContext(ctx, `
+ SELECT
+ monitor_name,
+ COUNT(*) as total,
+ AVG(response_time_ms) as avg_response,
+ CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime
+ FROM check_results
+ WHERE timestamp >= ?
+ GROUP BY monitor_name
+ `, cutoff)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query aggregate stats: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var name string
+ var total int64
+ var avgResponse, uptime float64
+ if err := rows.Scan(&name, &total, &avgResponse, &uptime); err != nil {
+ return nil, fmt.Errorf("failed to scan aggregate stats: %w", err)
+ }
+ if ms, ok := stats[name]; ok {
+ ms.TotalChecks = total
+ ms.AvgResponseTime = avgResponse
+ ms.UptimePercent = uptime
+ }
+ }
+
+ return stats, nil
+}
+
+// GetDailyStats returns daily statistics for a monitor
+func (s *Storage) GetDailyStats(ctx context.Context, monitorName string, days int) ([]DailyStatus, error) {
+ cutoff := time.Now().AddDate(0, 0, -days)
+
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT date, success_count, failure_count, total_checks,
+ avg_response_ms, uptime_percent
+ FROM daily_stats
+ WHERE monitor_name = ? AND date >= ?
+ ORDER BY date ASC
+ `, monitorName, cutoff.Format("2006-01-02"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to query daily stats: %w", err)
+ }
+ defer rows.Close()
+
+ var results []DailyStatus
+ for rows.Next() {
+ var ds DailyStatus
+ var dateStr string
+ ds.MonitorName = monitorName
+ if err := rows.Scan(&dateStr, &ds.SuccessCount, &ds.FailureCount,
+ &ds.TotalChecks, &ds.AvgResponse, &ds.UptimePercent); err != nil {
+ return nil, fmt.Errorf("failed to scan daily stats: %w", err)
+ }
+ ds.Date, _ = time.Parse("2006-01-02", dateStr)
+ results = append(results, ds)
+ }
+
+ return results, nil
+}
+
+// GetUptimeHistory returns uptime data for the history visualization
+// Returns a slice of daily uptime percentages, one per day for the specified period
+func (s *Storage) GetUptimeHistory(ctx context.Context, monitorName string, days int) ([]float64, error) {
+ // Create a map of date to uptime
+ uptimeMap := make(map[string]float64)
+
+ // Use local time for date range calculation (matches how we store dates)
+ cutoffDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
+
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT date, uptime_percent
+ FROM daily_stats
+ WHERE monitor_name = ? AND date >= ?
+ ORDER BY date ASC
+ `, monitorName, cutoffDate)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query uptime history: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var dateStr string
+ var uptime float64
+ if err := rows.Scan(&dateStr, &uptime); err != nil {
+ return nil, fmt.Errorf("failed to scan uptime: %w", err)
+ }
+ // Normalize date format (strip any time component if present)
+ if len(dateStr) > 10 {
+ dateStr = dateStr[:10]
+ }
+ uptimeMap[dateStr] = uptime
+ }
+
+ // Build result array with -1 for days with no data
+ result := make([]float64, days)
+ today := time.Now().Format("2006-01-02")
+ for i := 0; i < days; i++ {
+ date := time.Now().AddDate(0, 0, -days+i+1).Format("2006-01-02")
+ if uptime, ok := uptimeMap[date]; ok {
+ result[i] = uptime
+ } else if date == today {
+ // For today, if we have monitor data but no daily stats yet,
+ // check if there's recent data and show it
+ result[i] = -1
+ } else {
+ result[i] = -1 // No data for this day
+ }
+ }
+
+ return result, nil
+}
+
+// PingResult represents a single ping for the history display
+type PingResult struct {
+ Status string // up, down, degraded
+ ResponseTime int64 // milliseconds
+ Timestamp time.Time
+}
+
+// GetRecentPings returns the last N check results for a monitor
+// Results are ordered from oldest to newest (left to right on display)
+func (s *Storage) GetRecentPings(ctx context.Context, monitorName string, limit int) ([]PingResult, error) {
+ // Query in descending order then reverse, so we get the most recent N results
+ // but in chronological order for display
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT status, response_time_ms, timestamp
+ FROM check_results
+ WHERE monitor_name = ?
+ ORDER BY timestamp DESC
+ LIMIT ?
+ `, monitorName, limit)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query recent pings: %w", err)
+ }
+ defer rows.Close()
+
+ var results []PingResult
+ for rows.Next() {
+ var p PingResult
+ if err := rows.Scan(&p.Status, &p.ResponseTime, &p.Timestamp); err != nil {
+ return nil, fmt.Errorf("failed to scan ping: %w", err)
+ }
+ results = append(results, p)
+ }
+
+ // Reverse to get chronological order (oldest first)
+ for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
+ results[i], results[j] = results[j], results[i]
+ }
+
+ return results, nil
+}
+
+// GetAggregatedHistory returns tick data for the history visualization
+// tickMode: "ping", "minute", "hour", "day"
+// fixedSlots: when true, returns exactly tickCount elements with nil for missing periods
+// Returns a slice where nil entries indicate no data for that period
+func (s *Storage) GetAggregatedHistory(ctx context.Context, monitorName string, tickCount int, tickMode string, fixedSlots bool) ([]*TickData, error) {
+ switch tickMode {
+ case "ping":
+ return s.getAggregatedPings(ctx, monitorName, tickCount, fixedSlots)
+ case "minute":
+ return s.getAggregatedByTime(ctx, monitorName, tickCount, time.Minute, "%Y-%m-%d %H:%M")
+ case "hour":
+ return s.getAggregatedByTime(ctx, monitorName, tickCount, time.Hour, "%Y-%m-%d %H")
+ case "day":
+ return s.getAggregatedByTime(ctx, monitorName, tickCount, 24*time.Hour, "%Y-%m-%d")
+ default:
+ return nil, fmt.Errorf("invalid tick mode: %s", tickMode)
+ }
+}
+
+// getAggregatedPings returns individual pings as tick data
+func (s *Storage) getAggregatedPings(ctx context.Context, monitorName string, tickCount int, fixedSlots bool) ([]*TickData, error) {
+ pings, err := s.GetRecentPings(ctx, monitorName, tickCount)
+ if err != nil {
+ return nil, err
+ }
+
+ result := make([]*TickData, 0, tickCount)
+
+ // If fixedSlots is true, pad the beginning with nils
+ if fixedSlots && len(pings) < tickCount {
+ for i := 0; i < tickCount-len(pings); i++ {
+ result = append(result, nil)
+ }
+ }
+
+ // Convert pings to TickData
+ for _, p := range pings {
+ uptime := 0.0
+ if p.Status == "up" {
+ uptime = 100.0
+ } else if p.Status == "degraded" {
+ uptime = 50.0
+ }
+ result = append(result, &TickData{
+ Timestamp: p.Timestamp,
+ TotalChecks: 1,
+ SuccessCount: boolToInt(p.Status == "up"),
+ FailureCount: boolToInt(p.Status == "down"),
+ AvgResponse: float64(p.ResponseTime),
+ UptimePercent: uptime,
+ Status: p.Status,
+ ResponseTime: p.ResponseTime,
+ })
+ }
+
+ return result, nil
+}
+
+// getAggregatedByTime returns aggregated tick data by time bucket
+func (s *Storage) getAggregatedByTime(ctx context.Context, monitorName string, tickCount int, bucketDuration time.Duration, sqlFormat string) ([]*TickData, error) {
+ // Calculate the start time for our window
+ now := time.Now()
+ startTime := now.Add(-time.Duration(tickCount) * bucketDuration)
+
+ // Query aggregated data grouped by time bucket
+ // First extract just the datetime part (first 19 chars: "2006-01-02 15:04:05")
+ // then apply strftime to that
+ query := `
+ SELECT
+ strftime('` + sqlFormat + `', substr(timestamp, 1, 19)) as bucket,
+ COUNT(*) as total_checks,
+ SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count,
+ SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END) as failure_count,
+ AVG(response_time_ms) as avg_response
+ FROM check_results
+ WHERE monitor_name = ? AND timestamp >= ?
+ GROUP BY bucket
+ HAVING bucket IS NOT NULL
+ ORDER BY bucket ASC
+ `
+
+ rows, err := s.db.QueryContext(ctx, query, monitorName, startTime)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query aggregated history: %w", err)
+ }
+ defer rows.Close()
+
+ // Build a map of bucket -> data
+ dataMap := make(map[string]*TickData)
+ for rows.Next() {
+ var bucket sql.NullString
+ var td TickData
+ if err := rows.Scan(&bucket, &td.TotalChecks, &td.SuccessCount, &td.FailureCount, &td.AvgResponse); err != nil {
+ return nil, fmt.Errorf("failed to scan aggregated data: %w", err)
+ }
+ if !bucket.Valid || bucket.String == "" {
+ continue // Skip null buckets
+ }
+ if td.TotalChecks > 0 {
+ td.UptimePercent = float64(td.SuccessCount) / float64(td.TotalChecks) * 100
+ }
+ dataMap[bucket.String] = &td
+ }
+
+ // Generate all time slots and fill in data
+ result := make([]*TickData, tickCount)
+ for i := 0; i < tickCount; i++ {
+ slotTime := startTime.Add(time.Duration(i+1) * bucketDuration)
+ bucket := formatTimeBucket(slotTime, bucketDuration)
+
+ if td, ok := dataMap[bucket]; ok {
+ td.Timestamp = slotTime
+ result[i] = td
+ } else {
+ result[i] = nil // No data for this slot
+ }
+ }
+
+ return result, nil
+}
+
+// formatTimeBucket formats a time into the bucket key format
+func formatTimeBucket(t time.Time, duration time.Duration) string {
+ switch {
+ case duration >= 24*time.Hour:
+ return t.Format("2006-01-02")
+ case duration >= time.Hour:
+ return t.Format("2006-01-02 15")
+ case duration >= time.Minute:
+ return t.Format("2006-01-02 15:04")
+ default:
+ return t.Format("2006-01-02 15:04:05")
+ }
+}
+
+// boolToInt converts a boolean to an int (1 for true, 0 for false)
+func boolToInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+// Cleanup removes old data beyond the history retention period
+func (s *Storage) Cleanup(ctx context.Context) error {
+ cutoff := time.Now().AddDate(0, 0, -s.historyDays)
+
+ _, err := s.db.ExecContext(ctx, `
+ DELETE FROM check_results WHERE timestamp < ?
+ `, cutoff)
+ if err != nil {
+ return fmt.Errorf("failed to cleanup check_results: %w", err)
+ }
+
+ _, err = s.db.ExecContext(ctx, `
+ DELETE FROM daily_stats WHERE date < ?
+ `, cutoff.Format("2006-01-02"))
+ if err != nil {
+ return fmt.Errorf("failed to cleanup daily_stats: %w", err)
+ }
+
+ // Vacuum to reclaim space
+ _, err = s.db.ExecContext(ctx, "VACUUM")
+ if err != nil {
+ return fmt.Errorf("failed to vacuum database: %w", err)
+ }
+
+ return nil
+}
+
+// Close closes the database connection
+func (s *Storage) Close() error {
+ return s.db.Close()
+}