44 KiB
Glance - Complete Technical Documentation
Table of Contents
- Executive Summary
- Project Overview
- Architecture Deep Dive
- Core Components
- Widget System
- Configuration System
- Authentication & Security
- Data Flow & Request Lifecycle
- Build & Deployment
- Development Guide
- API Reference
- Performance Optimization
- 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
- Zero JavaScript Frameworks - Vanilla JS (~70KB total)
- Minimal Dependencies - 7 direct Go dependencies
- Single Binary - No package.json, no npm
- Hot Reload - Config changes apply without restart
- 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 renderinggopkg.in/yaml.v3- YAML parsinggithub.com/mmcdole/gofeed- RSS/Atom parsinggithub.com/shirou/gopsutil/v4- System metricsgithub.com/tidwall/gjson- Fast JSON parsinggithub.com/fsnotify/fsnotify- File watchinggolang.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
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 subcommandsserve()- Starts HTTP server, sets up file watchingparseYAMLConfig()- 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
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 configmakeHandler()- Creates HTTP handler with routinghandlePageRequest()- Serves dashboard pageshandleContentRequest()- AJAX content updateshandleWidgetRequest()- 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:
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:
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:
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:
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:
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:
- Infinite - Never updates (static widgets)
- Duration - Updates after N time (configurable)
- OnTheHour - Updates at the top of each hour
7. Static Asset Embedding
File: internal/glance/embed.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):
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
- 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: |
<div class="stars">
⭐ {{.stargazers_count}} stars
🍴 {{.forks_count}} forks
</div>
Features:
- JSONPath data extraction (using gjson)
- Go template rendering
- Custom CSS support
- Error handling
Configuration System
Configuration File Format
Main Config (glance.yml):
# 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 <head>
document:
head-html: |
<meta name="robots" content="noindex">
# 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:
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:
something: \${NOT_AN_ENV_VAR} # Literal ${NOT_AN_ENV_VAR}
File Includes
Modular Configuration:
glance.yml:
server:
port: 8080
pages:
- !include: pages/home.yml
- !include: pages/work.yml
- !include: pages/fun.yml
pages/home.yml:
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:
# 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:
- Password Hashing - bcrypt (cost 10)
- Session Tokens - HMAC-SHA256
- Rate Limiting - Failed login attempts
- Username Hashing - SHA-256 (prevents username enumeration)
Setup Authentication
Step 1: Generate Secret Key
glance secret:make
# Output: Base64-encoded 32-byte key
Step 2: Hash Password
glance password:hash mypassword123
# Output: Bcrypt hash
Step 3: Configure
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 timestamphmac- HMAC-SHA256(username_hash + timestamp, secret_key)
Validation:
- Split token by
: - Verify HMAC
- Check timestamp (7-day expiry)
- Lookup username from hash
- Set authenticated user in context
Rate Limiting
Failed Login Attempts:
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
- Use Environment Variables for secrets
- Generate Strong Secret Key (32 random bytes)
- Use HTTPS in production (reverse proxy)
- Set
server.proxied: trueif behind proxy - Don't commit secrets to git
- Rotate secret keys periodically
- 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):
// 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:
-
Infinite Cache (static widgets)
- Never updates automatically
- calendar, clock, bookmarks, html, iframe
-
Duration Cache (most widgets)
- Updates after N time
- Configurable per widget:
cache: 30m
-
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:
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:
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:
for attempt := 0; attempt < maxRetries; attempt++ {
resp, err := client.Do(req)
if err == nil || !isRetriableError(err) {
return resp, err
}
backoff := time.Duration(1<<attempt) * time.Second
time.Sleep(backoff)
}
Build & Deployment
Building from Source
Requirements:
- Go >= 1.23 (project uses 1.24.3)
- Git
Build for Current Platform:
cd /home/user/glance
go build -o build/glance .
Cross-Compile:
# 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:
go run . --config glance.yml
Docker Build
Build Image:
docker build -t glanceapp/glance:latest .
Multi-Architecture:
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
-t glanceapp/glance:latest --push .
Dockerfile Overview:
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:
goreleaser release --snapshot --clean
Artifacts:
glance-linux-amd64.tar.gzglance-linux-arm64.tar.gzglance-darwin-amd64.tar.gzglance-windows-amd64.zip- Docker images (glanceapp/glance:latest)
Deployment Options
1. Docker Compose (Recommended)
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:
docker compose up -d
2. Systemd Service
/etc/systemd/system/glance.service:
[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:
sudo systemctl enable glance
sudo systemctl start glance
sudo systemctl status glance
3. Reverse Proxy (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:
server:
proxied: true # Important for correct IP detection
4. Manual Binary
# 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:
docker run -p 8080:8080 \
-e RSS_TITLE="My Feed" \
-e API_KEY=secret123 \
-v ./config:/app/config \
glanceapp/glance
Systemd:
[Service]
Environment="API_KEY=secret123"
Environment="TZ=America/New_York"
Shell:
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:
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:
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:
{{ template "widget-base" .options }}
<div class="example-items">
{{ range .items }}
<a href="{{ .URL }}" class="example-item">
{{ .Title }}
</a>
{{ end }}
</div>
{{ template "widget-base-end" .options }}
Step 4: Add CSS (if needed)
internal/glance/static/css/widgets.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
pages:
- name: Test
columns:
- size: full
widgets:
- type: example
api-key: test123
limit: 5
Code Style Guidelines
1. Error Handling:
// Good
data, err := fetchData()
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
// Avoid
if err != nil {
widget.Error = err
return
}
2. Widget Updates:
// Always use context
func (widget *myWidget) update(ctx context.Context) {
select {
case <-ctx.Done():
return
default:
}
// ... fetch data
}
3. Configuration:
// 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:
go test ./...
Test Specific Package:
go test ./internal/glance
With Coverage:
go test -cover ./...
Example Test:
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:
GLANCE_LOG_LEVEL=debug ./glance
Use Diagnostic Commands:
# Validate config
./glance config:validate
# Print parsed config
./glance config:print
# Test temperature sensors
./glance sensors:print
# Run diagnostics
./glance diagnose
Add Debug Logging:
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
# 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 <password> # 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 IDpath...- Widget-specific path
Response: Widget-dependent (JSON, HTML, etc.)
Example: Calendar widget uses this for event actions.
GET /api/healthz
Health check endpoint.
Response:
{
"status": "ok",
"version": "v0.7.0"
}
POST /api/authenticate
Login endpoint.
Request Body:
{
"username": "admin",
"password": "password123"
}
Response:
{
"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 hashpath- Asset path
Headers:
Cache-Control: public, max-age=86400(24h)
GET /manifest.json
PWA manifest.
Response:
{
"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:
// 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):
// 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.Poolfor frequently allocated objects
Example:
posts := make([]rssPost, 0, expectedCount) // Pre-allocate
Network Optimization
1. Request Timeouts:
client := &http.Client{
Timeout: 10 * time.Second,
}
2. Connection Pooling:
- Default
http.Clientreuses connections - Keep-alive enabled
3. Early Retry:
// 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<<attempt) * time.Second)
}
Frontend Optimization
JavaScript:
- No frameworks (zero runtime parsing)
- Minified and concatenated
- Total size: ~70KB
CSS:
- Bundled at compile time
- Single CSS file
- Total size: ~74KB
Images:
- SVG icons (scalable, cacheable)
- Optimized PNGs for favicons
Database-Free Design
Benefits:
- Zero disk I/O for normal operations
- No schema migrations
- Instant startup
- Simplified deployment
Trade-offs:
- No persistent cache across restarts
- User data stored in browser (localStorage)
- Configuration in YAML files
Benchmarks
Typical Performance:
- Page Load: <1s (with warm cache)
- Memory Usage: <100MB
- Binary Size: ~20MB
- Docker Image: ~40MB (Alpine + binary)
- Cold Start: <100ms
Scaling:
- Widgets per page: 50+ (tested)
- Pages: Unlimited (limited by memory)
- Concurrent users: Hundreds (with proper resources)
Troubleshooting
Common Issues
1. Requests Timing Out
Symptoms:
- Widgets not loading
- "Request timed out" errors
- Slow page loads
Causes:
- DNS rate limiting (Pi-hole, AdGuard)
- Firewall blocking outbound requests
- Network connectivity issues
Solutions:
# Increase DNS rate limit in Pi-hole/AdGuard
# Or use different DNS server
# Docker: add --dns 8.8.8.8
# Check firewall rules
sudo iptables -L
# Test network connectivity
curl -I https://api.github.com
2. Config Parse Errors
Symptom:
Error: cannot unmarshal !!map into []glance.page
Cause: Duplicate pages key in included files
Solution:
# glance.yml
pages:
- !include: pages/home.yml # ✓ Correct
# pages/home.yml
name: Home # ✓ Correct (no 'pages:' key)
columns: [...]
# pages/home.yml (WRONG)
pages: # ✗ Remove this
- name: Home
3. Broken Layout (Dark Reader)
Symptom: Markets, bookmarks widgets look broken
Cause: Dark Reader browser extension
Solution: Disable Dark Reader for Glance domain
4. Authentication Not Working
Symptoms:
- Can't log in with correct password
- Session expires immediately
Checks:
# Verify secret key length (must be 32 bytes)
echo "dGVzdC1zZWNyZXQta2V5LTEyMzQ1Njc4OTAxMjM0NTY=" | base64 -d | wc -c
# Should output: 32
# Verify password hash
glance password:hash mypassword
# Compare with config
# Check logs for errors
docker logs glance
5. Docker Container Not Starting
Check logs:
docker logs glance
Common errors:
- Config file not found → Check volume mount
- Invalid config → Validate with
glance config:validate - Port already in use → Change port mapping
Fix:
# Verify volume mount
docker run -v ./config:/app/config glanceapp/glance
# Check config location
docker exec glance ls -la /app/config/
# Use different port
docker run -p 8081:8080 glanceapp/glance
6. Environment Variables Not Working
Symptoms:
${VAR}appears literally in rendered page- "environment variable not found" error
Solutions:
# Set in Docker
docker run -e VAR=value glanceapp/glance
# Set in docker-compose.yml
environment:
- VAR=value
# Verify variable is set
docker exec glance env | grep VAR
# Escape literal ${VAR}
something: \${NOT_AN_ENV_VAR}
7. Hot Reload Not Working
Causes:
- Config file deleted and recreated (stops watching)
- Syntax error in config (logs show error)
- File system doesn't support inotify
Solutions:
# Check logs for errors
docker logs glance
# Restart container
docker restart glance
# Validate config before saving
glance config:validate
Diagnostic Commands
# Validate configuration
glance config:validate
# Print parsed config (check env vars expanded)
glance config:print
# Check available temperature sensors
glance sensors:print
# Run full diagnostics
glance diagnose
Debug Mode
Enable verbose logging:
# Binary
GLANCE_LOG_LEVEL=debug ./glance
# Docker
docker run -e GLANCE_LOG_LEVEL=debug glanceapp/glance
Log Levels:
debug- Detailed logsinfo- General information (default)warn- Warnings onlyerror- Errors only
Performance Issues
Symptom: Slow page loads
Diagnosis:
# Check widget cache durations
glance config:print | grep cache
# Monitor network requests
# Browser DevTools → Network tab
# Check system resources
docker stats glance
Solutions:
# Increase cache durations
- type: rss
cache: 1h # Increase from 30m
# Reduce number of feeds/items
- type: rss
limit: 10 # Reduce from 25
# Reduce update frequency
- type: markets
cache: 5m # Increase from 1m
Common Error Messages
| Error | Cause | Solution |
|---|---|---|
unknown widget type: X |
Invalid widget type | Check spelling, see docs |
secret-key must be exactly 32 bytes |
Wrong key length | Generate new key with secret:make |
page slug "login" is reserved |
Using reserved slug | Choose different slug |
failed to fetch feed |
RSS feed URL invalid | Verify URL works in browser |
docker socket not accessible |
No Docker socket | Mount -v /var/run/docker.sock:/var/run/docker.sock |
Getting Help
Resources:
When Reporting Issues:
- Run
glance diagnose - Include error logs
- Share config (remove secrets!)
- Describe expected vs actual behavior
- Provide steps to reproduce
Appendix
Complete Widget Reference
See docs/configuration.md for full widget documentation.
Theme Colors
HSL Format:
theme:
background-color: 240 13 20 # Hue Saturation Lightness
primary-color: 43 100 50
Available Theme Presets: See docs/themes.md
Icon Providers
Supported:
- Simple Icons -
simple-icons:{name} - Dashboard Icons -
di:{name} - Material Design Icons -
mdi:{name} - Selfh.st Icons -
si:{name}
Example:
- type: bookmarks
groups:
- title: Social
links:
- title: GitHub
url: https://github.com
icon: simple-icons:github
File Locations
Binary Installation:
/opt/glance/
├── glance # Binary
└── glance.yml # Config
Docker:
Container:
/app/glance # Binary
/app/config/ # Config mount point
/app/assets/ # Assets mount point
Host:
./config/glance.yml # Config file
./assets/ # Custom CSS, images
Systemd:
/opt/glance/glance # Binary
/etc/glance.yml # Config
/etc/systemd/system/glance.service # Service file
Related Projects
Community:
Inspiration:
Changelog
See GitHub Releases for version history.
License
Apache License 2.0 - See LICENSE file.
Contributing
See CONTRIBUTING.md for guidelines.
Key Points:
- Submit feature requests before implementing
- Use
devbranch for new features - Avoid new dependencies
- No breaking config changes
- Use heroicons for icons
- No package.json
Document Version: 1.0 Last Updated: 2025-11-16 Glance Version: Latest (main branch) Author: Claude AI (Anthropic)