diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md new file mode 100644 index 0000000..f0da2e6 --- /dev/null +++ b/TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,2054 @@ +# Glance - Complete Technical Documentation + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Project Overview](#project-overview) +3. [Architecture Deep Dive](#architecture-deep-dive) +4. [Core Components](#core-components) +5. [Widget System](#widget-system) +6. [Configuration System](#configuration-system) +7. [Authentication & Security](#authentication--security) +8. [Data Flow & Request Lifecycle](#data-flow--request-lifecycle) +9. [Build & Deployment](#build--deployment) +10. [Development Guide](#development-guide) +11. [API Reference](#api-reference) +12. [Performance Optimization](#performance-optimization) +13. [Troubleshooting](#troubleshooting) + +--- + +## Executive Summary + +**Glance** is a self-hosted dashboard application built in Go that aggregates content from multiple sources (RSS feeds, social media, APIs, system metrics) into a customizable, themeable interface. + +### Key Metrics +- **Language**: Go 1.24.3 +- **Lines of Code**: ~9,711 lines (46 Go files) +- **Binary Size**: <20MB +- **Widget Types**: 25+ +- **License**: Apache 2.0 +- **Platforms**: Linux, Windows, macOS, FreeBSD, OpenBSD +- **Architectures**: amd64, arm64, arm, 386 + +### Core Philosophy +1. **Zero JavaScript Frameworks** - Vanilla JS (~70KB total) +2. **Minimal Dependencies** - 7 direct Go dependencies +3. **Single Binary** - No package.json, no npm +4. **Hot Reload** - Config changes apply without restart +5. **Performance First** - Intelligent caching, parallel updates + +--- + +## Project Overview + +### What is Glance? + +Glance is a lightweight dashboard that serves as: +- **Personal Homepage/Startpage** - Customizable browser landing page +- **Feed Aggregator** - Centralized RSS, Reddit, Hacker News, etc. +- **System Monitor** - Docker containers, server stats, DNS metrics +- **Development Dashboard** - GitHub releases, repository stats +- **Content Hub** - YouTube uploads, Twitch streams +- **Information Display** - Weather, stocks, calendar + +### Key Features + +**Content Aggregation:** +- RSS/Atom feeds with thumbnails +- Reddit subreddit posts +- Hacker News & Lobsters +- YouTube channel uploads +- Twitch live streams +- GitHub releases & repo stats + +**System Monitoring:** +- Docker container status +- Server stats (CPU, memory, disk) +- DNS stats (Pi-hole, AdGuard) +- Website uptime monitoring + +**Customization:** +- Multiple pages/tabs +- 3-column responsive layouts +- Theme system (HSL-based) +- Custom CSS support +- Icon provider integration + +**Performance:** +- Intelligent caching (configurable per widget) +- Parallel widget updates +- Conditional HTTP requests (ETags) +- Worker pools for concurrent API calls +- Static asset caching (24h) + +**Security:** +- Optional authentication system +- bcrypt password hashing +- Session tokens with HMAC +- Rate limiting +- Secure secret management + +--- + +## Architecture Deep Dive + +### Directory Structure + +``` +/home/user/glance/ +├── main.go # Entry point (delegates to internal/) +├── go.mod / go.sum # Go dependencies +├── Dockerfile # Container build +├── .goreleaser.yaml # Release automation +├── LICENSE # Apache 2.0 +├── README.md # User documentation +│ +├── internal/glance/ # Core application (private package) +│ ├── main.go # CLI routing & server lifecycle +│ ├── glance.go # Application struct, HTTP server +│ ├── config.go # YAML parsing, validation +│ ├── config-fields.go # Custom YAML field types +│ ├── widget.go # Widget interface & factory +│ ├── widget-*.go # 25+ widget implementations +│ ├── widget-utils.go # Shared widget utilities +│ ├── auth.go # Authentication system +│ ├── theme.go # Theme engine +│ ├── embed.go # Static asset embedding +│ ├── templates.go # Template helpers +│ ├── utils.go # General utilities +│ ├── cli.go # CLI command handlers +│ ├── diagnose.go # Diagnostic tools +│ │ +│ ├── static/ # Frontend assets (embedded) +│ │ ├── css/ # Stylesheets +│ │ ├── js/ # Vanilla JavaScript +│ │ ├── icons/ # Heroicons +│ │ ├── fonts/ # Font files +│ │ ├── app-icon.png # PWA icon +│ │ └── favicon.{svg,png} # Favicons +│ │ +│ └── templates/ # Go HTML templates +│ ├── page.html # Main page layout +│ ├── page-content.html # AJAX content +│ ├── document.html # HTML document wrapper +│ ├── footer.html # Footer template +│ ├── manifest.json # PWA manifest +│ └── widgets/ # Widget templates +│ +├── pkg/sysinfo/ # Public system info package +│ └── sysinfo.go # Cross-platform metrics +│ +└── docs/ # Documentation + ├── configuration.md # Config guide (91KB) + ├── custom-api.md # Custom API widget + ├── themes.md # Theming guide + ├── glance.yml # Example config + └── images/ # Screenshots +``` + +### Tech Stack + +**Backend (Go):** +- `net/http` - HTTP server (standard library) +- `html/template` - Template rendering +- `gopkg.in/yaml.v3` - YAML parsing +- `github.com/mmcdole/gofeed` - RSS/Atom parsing +- `github.com/shirou/gopsutil/v4` - System metrics +- `github.com/tidwall/gjson` - Fast JSON parsing +- `github.com/fsnotify/fsnotify` - File watching +- `golang.org/x/crypto` - Password hashing + +**Frontend:** +- Vanilla JavaScript (no frameworks) +- CSS with CSS Custom Properties +- Heroicons for icons +- Progressive Web App (PWA) support + +**Build & Deploy:** +- Go compiler (CGO_ENABLED=0) +- GoReleaser (multi-platform builds) +- Docker (Alpine-based) + +--- + +## Core Components + +### 1. Application Entry Point + +**File**: `/home/user/glance/main.go` +```go +package main + +import ( + "os" + "github.com/glanceapp/glance/internal/glance" +) + +func main() { + os.Exit(glance.Main()) +} +``` + +Simple delegation to internal package. + +### 2. Main Application Logic + +**File**: `internal/glance/main.go` + +**Key Functions:** +- `Main()` - CLI entry point, routes to subcommands +- `serve()` - Starts HTTP server, sets up file watching +- `parseYAMLConfig()` - Parses and validates config + +**Flow:** +``` +Main() + → Parse CLI flags + → Determine command (serve, validate, print, etc.) + → For serve: + → Parse config → Create app → Start server + → Setup file watcher (fsnotify) + → On config change → Reload app + → Wait for interrupt signal +``` + +### 3. Application Struct + +**File**: `internal/glance/glance.go` + +```go +type application struct { + Version string + CreatedAt time.Time + Config config + + parsedManifest []byte + + slugToPage map[string]*page // URL slug → page + widgetByID map[uint64]widget // Widget ID → widget instance + + // Auth + RequiresAuth bool + authSecretKey []byte + usernameHashToUsername map[string]string + failedAuthAttempts map[string]*failedAuthAttempt +} +``` + +**Key Methods:** +- `newApplication()` - Initializes app from config +- `makeHandler()` - Creates HTTP handler with routing +- `handlePageRequest()` - Serves dashboard pages +- `handleContentRequest()` - AJAX content updates +- `handleWidgetRequest()` - Widget-specific API calls + +### 4. HTTP Server & Routing + +**Routes:** +``` +GET / → First page +GET /{page} → Named page by slug +GET /api/pages/{page}/content → AJAX content update +POST /api/set-theme/{key} → Theme switcher +GET /api/widgets/{id}/{path...} → Widget API +GET /api/healthz → Health check +GET /login → Login page +POST /api/authenticate → Login handler +GET /logout → Logout handler +GET /static/{hash}/{path...} → Static assets (24h cache) +GET /manifest.json → PWA manifest +GET /assets/{path...} → User assets +``` + +**Request Flow:** +``` +HTTP Request + → Routing (ServeMux) + → Auth Check (if enabled) + → Handler Execution + → Page Lookup + → Widget Update Check + → Parallel Widget Updates (goroutines) + → Template Rendering + → Response (HTML/JSON) +``` + +### 5. Configuration System + +**File**: `internal/glance/config.go` + +**Structure:** +```go +type config struct { + Server serverConfig + Auth authConfig + Document documentConfig + Branding brandingConfig + Theme themeConfig + Pages []page +} + +type page struct { + Name string + Slug string + Columns []column + Widgets []widget +} + +type column struct { + Size string // "small" | "full" + Widgets []widget +} +``` + +**Features:** +- **Environment Variables**: `${VAR}` or `${env:VAR}` +- **Secrets**: `${secret:name}` from `/run/secrets/` +- **File Includes**: `!include: path/file.yml` +- **Recursive Includes**: Max depth 5 +- **Auto-reload**: File watching with hot-reload +- **Validation**: Comprehensive error checking + +**Example Config:** +```yaml +server: + host: 0.0.0.0 + port: 8080 + +theme: + background-color: 240 13 20 + primary-color: 43 100 50 + +pages: + - name: Home + slug: home + columns: + - size: small + widgets: + - type: weather + location: London, UK + - type: calendar + + - size: full + widgets: + - type: rss + feeds: + - url: https://example.com/feed.xml +``` + +### 6. Widget System + +**File**: `internal/glance/widget.go` + +**Widget Interface:** +```go +type widget interface { + // Exported (called in templates) + Render() template.HTML + GetType() string + GetID() uint64 + + // Internal + initialize() error + requiresUpdate(*time.Time) bool + setProviders(*widgetProviders) + update(context.Context) + setID(uint64) + handleRequest(w http.ResponseWriter, r *http.Request) + setHideHeader(bool) +} +``` + +**Base Widget:** +```go +type widgetBase struct { + ID uint64 + Type string + Title string + TitleURL string + CustomCacheDuration duration + UpdatedAt time.Time + ContentHTML template.HTML + Error error + Notice *notice + + cacheType cacheType + cacheDuration time.Duration + withTitle bool + withTitleURL bool + providers *widgetProviders +} +``` + +**Widget Factory Pattern:** +```go +func newWidget(widgetType string) (widget, error) { + switch widgetType { + case "rss": + return &rssWidget{}, nil + case "weather": + return &weatherWidget{}, nil + case "calendar": + return &calendarWidget{}, nil + // ... 22 more types + default: + return nil, fmt.Errorf("unknown widget type: %s", widgetType) + } +} +``` + +**Cache Types:** +1. **Infinite** - Never updates (static widgets) +2. **Duration** - Updates after N time (configurable) +3. **OnTheHour** - Updates at the top of each hour + +### 7. Static Asset Embedding + +**File**: `internal/glance/embed.go` + +```go +//go:embed static templates +var embedFS embed.FS + +func bundleCSS() ([]byte, string) { + // Reads all CSS files + // Concatenates them + // Returns bundled CSS + MD5 hash for cache busting +} +``` + +Assets are embedded at compile time, served with 24h cache headers. + +--- + +## Widget System + +### Widget Lifecycle + +``` +1. Config Parse + ↓ +2. Widget Factory (newWidget) + ↓ +3. YAML Unmarshal (widget-specific config) + ↓ +4. initialize() - Setup, validate config + ↓ +5. Page Load Request + ↓ +6. requiresUpdate() - Check cache expiry + ↓ +7. update() - Fetch data (if needed) + ↓ +8. Render() - Generate HTML + ↓ +9. Cache until next update +``` + +### Available Widgets + +| Widget Type | Purpose | API/Source | Cache Default | +|------------|---------|------------|---------------| +| `rss` | RSS/Atom feeds | Any RSS/Atom feed | 12h | +| `videos` | YouTube uploads | YouTube RSS | 1h | +| `weather` | Weather forecast | Open-Meteo | 1h | +| `markets` | Stock/crypto prices | Yahoo Finance | 1m | +| `reddit` | Subreddit posts | Reddit JSON | 12h | +| `hacker-news` | HN top stories | HN API | 30m | +| `lobsters` | Lobsters posts | Lobsters API | 30m | +| `calendar` | Month calendar | Local | Infinite | +| `clock` | Time display | Local | Infinite | +| `bookmarks` | Link collections | Config | Infinite | +| `docker-containers` | Container status | Docker socket | 1m | +| `server-stats` | System metrics | gopsutil | 1m | +| `dns-stats` | DNS metrics | Pi-hole/AdGuard | 5m | +| `monitor` | Uptime monitoring | HTTP GET | 1m | +| `releases` | GitHub releases | GitHub API | 1d | +| `repository` | Repo stats | GitHub API | 1h | +| `twitch-channels` | Stream status | Twitch API | 1m | +| `twitch-top-games` | Popular games | Twitch API | 30m | +| `search` | Search widget | Config | Infinite | +| `custom-api` | Custom API | Any JSON API | 30m | +| `extension` | Fetch HTML | Any URL | 30m | +| `html` | Static HTML | Config | Infinite | +| `iframe` | Embed content | Any URL | Infinite | +| `group` | Widget container | Children | N/A | +| `to-do` | Task list | Local storage | Infinite | + +### Widget Implementation Example + +**RSS Widget** (`internal/glance/widget-rss.go`): + +```go +type rssWidget struct { + widgetBase `yaml:",inline"` + Feeds []rssFeed `yaml:"feeds"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ThumbnailHeight float64 `yaml:"thumbnail-height"` + CardHeight float64 `yaml:"card-height"` + Posts []rssPost `yaml:"-"` +} + +func (widget *rssWidget) initialize() error { + widget.withTitle = true + widget.withTitleURL = true + widget.cacheType = cacheTypeDuration + widget.cacheDuration = time.Hour * 12 + + // Validation + if widget.Limit <= 0 { + widget.Limit = 25 + } + + return nil +} + +func (widget *rssWidget) update(ctx context.Context) { + // Create worker pool + requests := make([]channelRequest[[]rssPost], len(widget.Feeds)) + + for i := range widget.Feeds { + feed := &widget.Feeds[i] + requests[i] = func() ([]rssPost, error) { + // Fetch and parse RSS feed + parser := gofeed.NewParser() + parsedFeed, err := parser.ParseURL(feed.URL) + if err != nil { + return nil, err + } + + // Convert to posts + posts := make([]rssPost, 0, len(parsedFeed.Items)) + for _, item := range parsedFeed.Items { + posts = append(posts, rssPost{ + Title: item.Title, + Link: item.Link, + PublishedAt: *item.PublishedParsed, + // ... more fields + }) + } + + return posts, nil + } + } + + // Execute in parallel + results := workerPoolWithResponses(requests) + + // Aggregate results + widget.Posts = aggregateAndSortPosts(results, widget.Limit) +} + +func (widget *rssWidget) Render() template.HTML { + return widget.render(widget, assets.RSSTemplate) +} +``` + +### Custom API Widget + +**Most Flexible Widget** - Build your own widget using any JSON API. + +**Example**: GitHub Stars +```yaml +- type: custom-api + title: Repository Stars + cache: 1h + url: https://api.github.com/repos/glanceapp/glance + data: + stargazers_count: .stargazers_count + forks_count: .forks_count + template: | +
+ ⭐ {{.stargazers_count}} stars + 🍴 {{.forks_count}} forks +
+``` + +**Features:** +- JSONPath data extraction (using gjson) +- Go template rendering +- Custom CSS support +- Error handling + +--- + +## Configuration System + +### Configuration File Format + +**Main Config** (`glance.yml`): +```yaml +# Server Configuration +server: + host: 0.0.0.0 # Bind address + port: 8080 # Port number + proxied: false # Behind reverse proxy? + base-url: / # Base URL path + assets-path: /assets # Custom assets directory + +# Authentication (optional) +auth: + secret-key: ${AUTH_SECRET} # Base64-encoded 32-byte key + users: + admin: + password-hash: ${ADMIN_PASSWORD_HASH} + +# Custom HTML in +document: + head-html: | + + +# Branding +branding: + logo-url: /assets/logo.png + logo-link-url: https://example.com + favicon-url: /assets/favicon.png + app-name: My Dashboard + +# Theme +theme: + light: true + background-color: 240 13 20 # HSL format + primary-color: 43 100 50 + contrast-multiplier: 1.0 + custom-css-file: /assets/custom.css + +# Pages +pages: + - name: Home + slug: home # URL: /{slug} + columns: + - size: small # or "full" + widgets: + - type: weather + location: London, UK + units: metric +``` + +### Environment Variables + +**Syntax Options:** +- `${VAR}` +- `${env:VAR}` +- `${secret:name}` - Reads from `/run/secrets/name` + +**Example:** +```yaml +auth: + secret-key: ${AUTH_SECRET} + +- type: twitch-channels + client-id: ${TWITCH_CLIENT_ID} + +- type: rss + feeds: + - url: https://api.example.com/feed?key=${API_KEY} +``` + +**Escaping:** +```yaml +something: \${NOT_AN_ENV_VAR} # Literal ${NOT_AN_ENV_VAR} +``` + +### File Includes + +**Modular Configuration:** + +`glance.yml`: +```yaml +server: + port: 8080 + +pages: + - !include: pages/home.yml + - !include: pages/work.yml + - !include: pages/fun.yml +``` + +`pages/home.yml`: +```yaml +name: Home +slug: home +columns: + - !include: columns/sidebar.yml + - !include: columns/main.yml +``` + +**Features:** +- Recursive includes (max depth 5) +- Relative paths +- Watches all included files for changes +- Prevents circular references + +### Configuration Validation + +**CLI Commands:** +```bash +# Validate config +glance config:validate + +# Print parsed config (with env vars expanded) +glance config:print + +# Print config as JSON +glance config:print --json +``` + +**Validation Checks:** +- Required fields present +- Valid widget types +- Valid enum values (size, units, etc.) +- Valid URLs +- Valid colors (HSL format) +- No duplicate page slugs +- No reserved slugs (login, logout) + +--- + +## Authentication & Security + +### Authentication System + +**File**: `internal/glance/auth.go` + +**Components:** +1. **Password Hashing** - bcrypt (cost 10) +2. **Session Tokens** - HMAC-SHA256 +3. **Rate Limiting** - Failed login attempts +4. **Username Hashing** - SHA-256 (prevents username enumeration) + +### Setup Authentication + +**Step 1: Generate Secret Key** +```bash +glance secret:make +# Output: Base64-encoded 32-byte key +``` + +**Step 2: Hash Password** +```bash +glance password:hash mypassword123 +# Output: Bcrypt hash +``` + +**Step 3: Configure** +```yaml +auth: + secret-key: "dGVzdC1zZWNyZXQta2V5LTEyMzQ1Njc4OTAxMjM0NTY=" + users: + admin: + password-hash: "$2a$10$..." + john: + password-hash: "$2a$10$..." +``` + +### Session Token Format + +``` +{username_hash_hex}:{timestamp}:{hmac} +``` + +**Example:** +``` +a1b2c3d4e5f6:1699564800:9f8e7d6c5b4a3210 +``` + +**Components:** +- `username_hash_hex` - SHA-256(username + secret_key) +- `timestamp` - Unix timestamp +- `hmac` - HMAC-SHA256(username_hash + timestamp, secret_key) + +**Validation:** +1. Split token by `:` +2. Verify HMAC +3. Check timestamp (7-day expiry) +4. Lookup username from hash +5. Set authenticated user in context + +### Rate Limiting + +**Failed Login Attempts:** +```go +type failedAuthAttempt struct { + Count int + LastAttemptAt time.Time +} +``` + +**Rules:** +- 5 attempts allowed +- 15-minute lockout after 5 failures +- Resets on successful login +- Per-IP address tracking + +### Security Best Practices + +1. **Use Environment Variables** for secrets +2. **Generate Strong Secret Key** (32 random bytes) +3. **Use HTTPS** in production (reverse proxy) +4. **Set `server.proxied: true`** if behind proxy +5. **Don't commit secrets** to git +6. **Rotate secret keys** periodically +7. **Use password-hash** instead of plain password + +--- + +## Data Flow & Request Lifecycle + +### Page Load Request + +``` +1. Browser: GET /home + ↓ +2. Server: Route to handlePageRequest() + ↓ +3. Auth Check + - If RequiresAuth && !authenticated + → Redirect to /login + ↓ +4. Page Lookup (by slug) + - If not found → 404 + ↓ +5. For each widget: + - Call requiresUpdate() + - Check cache expiry + - If expired → add to update queue + ↓ +6. Parallel Widget Updates + - Spawn goroutine per widget + - Call widget.update(ctx) + - Fetch external data + - Parse & process + - Update widget state + ↓ +7. Template Rendering + - Execute page.html template + - Inject widget HTML + - Apply theme CSS + ↓ +8. Send Response + - HTML page + - Cache-Control headers + ↓ +9. Browser: Render page +``` + +### AJAX Content Update + +**Frontend JavaScript** (on page load): +```javascript +// Check for new content every 30s +setInterval(() => { + fetch(`/api/pages/${pageSlug}/content`) + .then(res => res.text()) + .then(html => { + document.querySelector('.page-content').innerHTML = html; + }); +}, 30000); +``` + +**Backend Flow:** +``` +1. Browser: GET /api/pages/home/content + ↓ +2. Server: handleContentRequest() + ↓ +3. Same update logic as page load + ↓ +4. Render page-content.html (widgets only) + ↓ +5. Send Response (partial HTML) + ↓ +6. Browser: Replace content div +``` + +### Widget Update Flow + +``` +widget.update(ctx) + ↓ +1. Check context cancellation + ↓ +2. Make HTTP request(s) + - Use widget-utils.go helpers + - Worker pool for parallel requests + - Early retry with exponential backoff + ↓ +3. Parse Response + - JSON: gjson or json.Unmarshal + - XML/RSS: gofeed + - HTML: goquery + ↓ +4. Transform Data + - Filter, sort, limit + - Calculate derived values + - Format timestamps + ↓ +5. Update Widget State + - widget.Posts = results + - widget.UpdatedAt = time.Now() + - widget.Error = nil (or error) + ↓ +6. Render Called (later) + - Generate HTML from template + - Cache result +``` + +### Caching Strategy + +**Cache Types:** + +1. **Infinite Cache** (static widgets) + - Never updates automatically + - calendar, clock, bookmarks, html, iframe + +2. **Duration Cache** (most widgets) + - Updates after N time + - Configurable per widget: `cache: 30m` + +3. **On-The-Hour Cache** (time-sensitive) + - Updates at top of each hour + - Not widely used + +**Cache Invalidation:** +- Config reload clears all caches +- Individual widget updates reset timer +- No persistent cache (in-memory only) + +### HTTP Request Optimization + +**Conditional Requests:** +```go +req.Header.Set("If-None-Match", etag) +req.Header.Set("If-Modified-Since", lastModified) + +resp, err := client.Do(req) +if resp.StatusCode == 304 { + // Use cached data +} +``` + +**Worker Pools:** +```go +func workerPoolWithResponses[T any](requests []channelRequest[T]) []T { + results := make([]T, len(requests)) + var wg sync.WaitGroup + + for i, request := range requests { + wg.Add(1) + go func(i int, request channelRequest[T]) { + defer wg.Done() + results[i], _ = request() + }(i, request) + } + + wg.Wait() + return results +} +``` + +**Early Retry Logic:** +```go +for attempt := 0; attempt < maxRetries; attempt++ { + resp, err := client.Do(req) + if err == nil || !isRetriableError(err) { + return resp, err + } + + backoff := time.Duration(1<= 1.23 (project uses 1.24.3) +- Git + +**Build for Current Platform:** +```bash +cd /home/user/glance +go build -o build/glance . +``` + +**Cross-Compile:** +```bash +# Linux AMD64 +GOOS=linux GOARCH=amd64 go build -o build/glance-linux-amd64 . + +# Windows AMD64 +GOOS=windows GOARCH=amd64 go build -o build/glance-windows-amd64.exe . + +# macOS ARM64 +GOOS=darwin GOARCH=arm64 go build -o build/glance-darwin-arm64 . + +# Linux ARM64 (Raspberry Pi) +GOOS=linux GOARCH=arm64 go build -o build/glance-linux-arm64 . +``` + +**Run During Development:** +```bash +go run . --config glance.yml +``` + +### Docker Build + +**Build Image:** +```bash +docker build -t glanceapp/glance:latest . +``` + +**Multi-Architecture:** +```bash +docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \ + -t glanceapp/glance:latest --push . +``` + +**Dockerfile Overview:** +```dockerfile +FROM golang:1.24.3-alpine3.21 AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o glance . + +FROM alpine:3.21 +WORKDIR /app +COPY --from=builder /app/glance /app/glance +EXPOSE 8080/tcp +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] +``` + +### GoReleaser + +**File**: `.goreleaser.yaml` + +**Features:** +- Multi-OS builds (5 operating systems) +- Multi-architecture (4 architectures) +- Archives (tar.gz, zip) +- Docker images (multi-arch) +- GitHub releases + +**Release Build:** +```bash +goreleaser release --snapshot --clean +``` + +**Artifacts:** +- `glance-linux-amd64.tar.gz` +- `glance-linux-arm64.tar.gz` +- `glance-darwin-amd64.tar.gz` +- `glance-windows-amd64.zip` +- Docker images (glanceapp/glance:latest) + +### Deployment Options + +#### 1. Docker Compose (Recommended) + +```yaml +services: + glance: + container_name: glance + image: glanceapp/glance + restart: unless-stopped + volumes: + - ./config:/app/config + - ./assets:/app/assets + ports: + - 8080:8080 + environment: + - TZ=America/New_York +``` + +**Start:** +```bash +docker compose up -d +``` + +#### 2. Systemd Service + +`/etc/systemd/system/glance.service`: +```ini +[Unit] +Description=Glance Dashboard +After=network.target + +[Service] +Type=simple +User=glance +WorkingDirectory=/opt/glance +ExecStart=/opt/glance/glance --config /etc/glance.yml +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**Enable & Start:** +```bash +sudo systemctl enable glance +sudo systemctl start glance +sudo systemctl status glance +``` + +#### 3. Reverse Proxy (Nginx) + +```nginx +server { + listen 80; + server_name glance.example.com; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +**Config Update:** +```yaml +server: + proxied: true # Important for correct IP detection +``` + +#### 4. Manual Binary + +```bash +# Download binary +wget https://github.com/glanceapp/glance/releases/latest/download/glance-linux-amd64.tar.gz +tar -xzf glance-linux-amd64.tar.gz + +# Create config +wget -O glance.yml https://raw.githubusercontent.com/glanceapp/glance/main/docs/glance.yml + +# Run +./glance --config glance.yml +``` + +### Environment Variables + +**Docker:** +```bash +docker run -p 8080:8080 \ + -e RSS_TITLE="My Feed" \ + -e API_KEY=secret123 \ + -v ./config:/app/config \ + glanceapp/glance +``` + +**Systemd:** +```ini +[Service] +Environment="API_KEY=secret123" +Environment="TZ=America/New_York" +``` + +**Shell:** +```bash +export API_KEY=secret123 +./glance +``` + +--- + +## Development Guide + +### Project Structure for Developers + +``` +internal/glance/ +├── main.go # CLI & server lifecycle +├── glance.go # HTTP server & routing +├── config.go # Configuration parsing +├── widget.go # Widget interface +├── widget-*.go # Widget implementations +├── auth.go # Authentication +├── theme.go # Theming +├── embed.go # Asset embedding +├── templates.go # Template helpers +└── utils.go # Utilities +``` + +### Adding a New Widget + +**Step 1: Create Widget File** + +`internal/glance/widget-example.go`: +```go +package glance + +import ( + "context" + "html/template" + "time" +) + +type exampleWidget struct { + widgetBase `yaml:",inline"` + APIKey string `yaml:"api-key"` + Limit int `yaml:"limit"` + Items []exampleItem `yaml:"-"` +} + +type exampleItem struct { + Title string + URL string +} + +func (widget *exampleWidget) initialize() error { + widget.withTitle = true + widget.cacheType = cacheTypeDuration + widget.cacheDuration = time.Hour + + if widget.Limit <= 0 { + widget.Limit = 10 + } + + return nil +} + +func (widget *exampleWidget) update(ctx context.Context) { + // Fetch data from API + data, err := fetchJSON[apiResponse]( + fmt.Sprintf("https://api.example.com/items?key=%s", widget.APIKey), + ) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + // Transform data + widget.Items = make([]exampleItem, 0, len(data.Results)) + for _, item := range data.Results { + widget.Items = append(widget.Items, exampleItem{ + Title: item.Title, + URL: item.Link, + }) + } + + if len(widget.Items) > widget.Limit { + widget.Items = widget.Items[:widget.Limit] + } +} + +func (widget *exampleWidget) Render() template.HTML { + return widget.render(widget, assets.ExampleTemplate) +} +``` + +**Step 2: Register Widget** + +`internal/glance/widget.go`: +```go +func newWidget(widgetType string) (widget, error) { + switch widgetType { + // ... existing cases + case "example": + w = &exampleWidget{} + // ... rest + } +} +``` + +**Step 3: Create Template** + +`internal/glance/templates/widgets/example.html`: +```html +{{ template "widget-base" .options }} +
+ {{ range .items }} + + {{ .Title }} + + {{ end }} +
+{{ template "widget-base-end" .options }} +``` + +**Step 4: Add CSS (if needed)** + +`internal/glance/static/css/widgets.css`: +```css +.example-items { + display: flex; + flex-direction: column; + gap: 10px; +} + +.example-item { + padding: 10px; + border-radius: 5px; + background: var(--color-widget-background); +} +``` + +**Step 5: Test** + +```yaml +pages: + - name: Test + columns: + - size: full + widgets: + - type: example + api-key: test123 + limit: 5 +``` + +### Code Style Guidelines + +**1. Error Handling:** +```go +// Good +data, err := fetchData() +if !widget.canContinueUpdateAfterHandlingErr(err) { + return +} + +// Avoid +if err != nil { + widget.Error = err + return +} +``` + +**2. Widget Updates:** +```go +// Always use context +func (widget *myWidget) update(ctx context.Context) { + select { + case <-ctx.Done(): + return + default: + } + + // ... fetch data +} +``` + +**3. Configuration:** +```go +// Set defaults in initialize() +func (widget *myWidget) initialize() error { + if widget.Limit <= 0 { + widget.Limit = 10 + } + return nil +} +``` + +**4. Naming:** +- Files: `widget-type-name.go` +- Structs: `typeNameWidget` +- Templates: `type-name.html` + +### Testing + +**Run Tests:** +```bash +go test ./... +``` + +**Test Specific Package:** +```bash +go test ./internal/glance +``` + +**With Coverage:** +```bash +go test -cover ./... +``` + +**Example Test:** +```go +func TestExampleWidget_Initialize(t *testing.T) { + widget := &exampleWidget{} + err := widget.initialize() + + if err != nil { + t.Errorf("initialize() error = %v", err) + } + + if widget.Limit != 10 { + t.Errorf("Expected default limit 10, got %d", widget.Limit) + } +} +``` + +### Debugging + +**Enable Verbose Logging:** +```bash +GLANCE_LOG_LEVEL=debug ./glance +``` + +**Use Diagnostic Commands:** +```bash +# Validate config +./glance config:validate + +# Print parsed config +./glance config:print + +# Test temperature sensors +./glance sensors:print + +# Run diagnostics +./glance diagnose +``` + +**Add Debug Logging:** +```go +import "log/slog" + +slog.Debug("fetching data", "url", url, "params", params) +slog.Info("widget updated", "type", widget.GetType(), "items", len(items)) +slog.Error("failed to fetch", "error", err) +``` + +--- + +## API Reference + +### CLI Commands + +```bash +# Server +glance # Start server (default) +glance --config /path/config # Custom config path +glance --version # Show version + +# Configuration +glance config:validate # Validate config file +glance config:print # Print parsed config +glance config:print --json # Print as JSON + +# Authentication +glance password:hash # Hash password for config +glance secret:make # Generate secret key + +# Diagnostics +glance sensors:print # List temperature sensors +glance diagnose # Run diagnostics +``` + +### HTTP Endpoints + +#### GET / + +Returns first page in configuration. + +**Response:** HTML page + +#### GET /{page} + +Returns named page by slug. + +**Parameters:** +- `page` - Page slug (from config) + +**Response:** HTML page +**Status:** 404 if page not found + +#### GET /api/pages/{page}/content + +Returns partial HTML for AJAX updates. + +**Parameters:** +- `page` - Page slug + +**Response:** HTML fragment (widgets only) + +#### POST /api/set-theme/{key} + +Sets theme preference. + +**Parameters:** +- `key` - Theme key (from available themes) + +**Response:** 200 OK +**Side Effect:** Sets `glance-theme` cookie + +#### GET /api/widgets/{id}/{path...} + +Widget-specific API endpoint. + +**Parameters:** +- `id` - Widget ID +- `path...` - Widget-specific path + +**Response:** Widget-dependent (JSON, HTML, etc.) + +**Example:** Calendar widget uses this for event actions. + +#### GET /api/healthz + +Health check endpoint. + +**Response:** +```json +{ + "status": "ok", + "version": "v0.7.0" +} +``` + +#### POST /api/authenticate + +Login endpoint. + +**Request Body:** +```json +{ + "username": "admin", + "password": "password123" +} +``` + +**Response:** +```json +{ + "success": true +} +``` + +**Side Effect:** Sets `glance-session-token` cookie + +**Status:** +- 200 - Success +- 401 - Invalid credentials +- 429 - Too many attempts + +#### GET /logout + +Logout endpoint. + +**Response:** Redirect to login page +**Side Effect:** Clears session cookie + +#### GET /static/{hash}/{path...} + +Static assets (CSS, JS, images). + +**Parameters:** +- `hash` - Cache busting hash +- `path` - Asset path + +**Headers:** +- `Cache-Control: public, max-age=86400` (24h) + +#### GET /manifest.json + +PWA manifest. + +**Response:** +```json +{ + "name": "Glance", + "short_name": "Glance", + "description": "Self-hosted dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/static/{hash}/app-icon.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} +``` + +--- + +## Performance Optimization + +### Caching Strategy + +**1. Widget-Level Caching:** +- Each widget caches results independently +- Configurable cache duration per widget +- In-memory storage (no disk I/O) + +**2. Static Asset Caching:** +- 24-hour browser cache +- Cache-busting via MD5 hash in URL +- Embedded at compile time (zero disk reads) + +**3. Conditional Requests:** +- Respects ETags and Last-Modified headers +- 304 Not Modified responses reduce bandwidth + +### Parallel Processing + +**Widget Updates:** +```go +// All widgets update in parallel +var wg sync.WaitGroup +for _, widget := range page.Widgets { + if widget.requiresUpdate() { + wg.Add(1) + go func(w widget) { + defer wg.Done() + w.update(ctx) + }(widget) + } +} +wg.Wait() +``` + +**Multiple Feeds (RSS Widget):** +```go +// Worker pool for concurrent feed fetching +requests := make([]channelRequest[[]rssPost], len(feeds)) +for i, feed := range feeds { + requests[i] = func() ([]rssPost, error) { + return fetchFeed(feed.URL) + } +} +results := workerPoolWithResponses(requests) +``` + +### Memory Management + +**Minimal Allocations:** +- Reuse buffers for template rendering +- Pre-allocate slices with capacity +- Use `sync.Pool` for frequently allocated objects + +**Example:** +```go +posts := make([]rssPost, 0, expectedCount) // Pre-allocate +``` + +### Network Optimization + +**1. Request Timeouts:** +```go +client := &http.Client{ + Timeout: 10 * time.Second, +} +``` + +**2. Connection Pooling:** +- Default `http.Client` reuses connections +- Keep-alive enabled + +**3. Early Retry:** +```go +// Exponential backoff for transient failures +for attempt := 0; attempt < 3; attempt++ { + resp, err := client.Do(req) + if err == nil { + return resp, nil + } + time.Sleep(time.Duration(1<