diff --git a/.gitignore b/.gitignore
index 9224b4e..3aaa6f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,9 @@ coverage.html
.DS_Store
Thumbs.db
+# Claude Code
+.claude/worktrees/
+
# Config with secrets (not the example)
config.yaml
!config.example.yaml
diff --git a/README.md b/README.md
index 3be7bbc..ae34bdf 100644
--- a/README.md
+++ b/README.md
@@ -13,12 +13,17 @@ Universal release/version aggregator for microservices. Checks releases across G
- **Container Registry**: Query image tags from any OCI-compatible registry (GHCR, Docker Hub, ECR, etc.)
- **Kubernetes**: Read deployed versions, auto-discover services, compare release vs deployed
- **Security**: CVE checking via OSV.dev database
-- **Web Dashboard**: Real-time service status dashboard at `/dashboard`
-- **Notifications**: Webhook notifications on new releases
+- **Web Dashboard**: Interactive htmx-powered dashboard with live updates at `/dashboard`
+- **REST API**: Full JSON API for programmatic access
+- **Notifications**: Slack, Discord, and webhook notifications on new releases
+- **Background Daemon**: Automatic polling with configurable intervals
+- **Multi-tenant**: API key management for team access control
+- **GitHub App**: Automatic release detection via GitHub webhooks
- **CLI**: Direct commands for querying releases, tags, and service status
- **Concurrent**: Checks multiple services in parallel
- **Cached**: In-memory cache with configurable TTL
- **Rate-limited**: Per-provider rate limiting to respect API limits
+- **SQLite Persistence**: Release history and tool call logging
- **Single binary**: No runtime dependencies, cross-platform
## Quick Start
@@ -26,19 +31,17 @@ Universal release/version aggregator for microservices. Checks releases across G
### Install
```bash
+# Homebrew
+brew install UnityInFlow/tap/releasewave
+
# From source
go install github.com/UnityInFlow/releasewave/cmd/releasewave@latest
-# Or download a release binary
-# https://github.com/UnityInFlow/releasewave/releases
-
# Docker
docker run -p 7891:7891 ghcr.io/unityinflow/releasewave:latest
-# Or build from repo
-git clone https://github.com/UnityInFlow/releasewave.git
-cd releasewave
-make build
+# Download a release binary
+# https://github.com/UnityInFlow/releasewave/releases
```
### Configure
@@ -59,7 +62,7 @@ releasewave install
# Or start manually
releasewave serve # stdio (default, for AI tools)
-releasewave serve --transport=sse # HTTP+SSE on port 7891 (+ web dashboard)
+releasewave serve --transport=sse # HTTP+SSE on port 7891 (+ web dashboard + API)
```
### Use as CLI
@@ -72,11 +75,61 @@ releasewave check # check all configured services
# GitLab support
releasewave releases gitlab-org/gitlab --platform gitlab
-releasewave latest my-org/my-project --platform gitlab
# Kubernetes auto-discovery
releasewave discover --namespace production
releasewave discover --merge # auto-add discovered services to config
+
+# Background monitoring
+releasewave daemon --interval=5m
+```
+
+### Web Dashboard
+
+When running in SSE mode, an interactive dashboard is available at `http://localhost:7891/dashboard`:
+
+- Live service status with 30s auto-refresh (htmx)
+- Add/remove services from the UI
+- Platform badges, version tags, release links
+
+```bash
+releasewave serve --transport=sse --port=7891
+# Open http://localhost:7891/dashboard
+```
+
+### REST API
+
+```bash
+# List services with latest releases
+curl http://localhost:7891/api/v1/services
+
+# Release history for a service
+curl http://localhost:7891/api/v1/services/my-api/releases
+
+# Cross-service timeline
+curl http://localhost:7891/api/v1/timeline
+
+# Add a service
+curl -X POST http://localhost:7891/api/v1/services \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"my-api","repo":"github.com/org/my-api"}'
+
+# Delete a service
+curl -X DELETE http://localhost:7891/api/v1/services/my-api
+```
+
+### Multi-tenant Access
+
+```bash
+# Create a tenant with API key
+releasewave tenant create my-team
+# => Tenant created:
+# => Name: my-team
+# => API Key: rw_abc123...
+# => Save this API key — it won't be shown again.
+
+releasewave tenant list
+releasewave tenant delete my-team
```
## MCP Tools
@@ -113,6 +166,8 @@ releasewave discover --merge # auto-add discovered services to config
| `changelog_between_versions` | Aggregate release notes between two versions |
| `security_advisories` | Check for CVEs affecting a package version (OSV.dev) |
| `release_timeline` | Cross-service release timeline sorted by date |
+| `release_diff` | Compare deployed vs latest with changelog |
+| `release_history` | Query local release history from SQLite |
### Dependency & Upgrade Tools
@@ -121,7 +176,7 @@ releasewave discover --merge # auto-add discovered services to config
| `get_repo_file` | Fetch file content from a repo (go.mod, package.json, etc.) |
| `dependency_matrix` | Analyze shared dependencies across configured services |
| `upgrade_plan` | Generate prioritized upgrade plan for outdated services |
-| `watch_releases` | Detect new releases and send webhook notifications |
+| `watch_releases` | Detect new releases and send notifications |
| `service_graph` | Build a dependency graph showing shared libraries across services |
## Configuration
@@ -132,8 +187,9 @@ releasewave discover --merge # auto-add discovered services to config
services:
- name: my-api
repo: github.com/my-org/my-api
- - name: billing
- repo: gitlab.com/my-org/billing
+ registry: ghcr.io/my-org/my-api
+ - name: frontend
+ repo: github.com/my-org/frontend
tokens:
github: "" # or set GITHUB_TOKEN env var
@@ -144,6 +200,7 @@ cache:
server:
port: 7891
+ api_key: "" # or set RELEASEWAVE_API_KEY env var
rate_limit:
github: 5 # requests per second
@@ -152,10 +209,20 @@ rate_limit:
notifications:
enabled: false
webhook_url: "https://hooks.slack.com/services/..."
+ slack:
+ webhook_url: ""
+ discord:
+ webhook_url: ""
+
+storage:
+ path: "" # e.g. ~/.config/releasewave/releasewave.db
+
+daemon:
+ interval: 5m
log:
- level: info
- format: text
+ level: info # debug, info, warn, error
+ format: text # text, json
```
### Kubernetes Auto-Discovery
@@ -172,15 +239,6 @@ metadata:
If no annotations are present, ReleaseWave will attempt to infer the repository from the container image name.
-## Web Dashboard
-
-When running in SSE mode, a web dashboard is available at `http://localhost:7891/dashboard` showing real-time status of all configured services.
-
-```bash
-releasewave serve --transport=sse --port=7891
-# Open http://localhost:7891/dashboard
-```
-
## Docker
```bash
@@ -207,22 +265,29 @@ make clean # clean build artifacts
```
cmd/releasewave/ CLI entry point (Cobra commands)
internal/
+ api/ REST API (JSON endpoints)
+ cache/ Thread-safe in-memory TTL cache
config/ YAML config loading + validation
+ daemon/ Background polling daemon
+ discovery/ K8s service auto-discovery
+ errors/ Typed errors (NotFound, RateLimit, Auth)
+ githubapp/ GitHub App integration (JWT, webhooks)
+ k8s/ Kubernetes integration (client-go)
+ logging/ Structured logging setup (slog)
+ mcpserver/ MCP server (stdio + SSE, 18 tools)
+ metrics/ Prometheus tool call metrics
+ middleware/ HTTP middleware (auth, metrics)
model/ Core data types (Release, Tag, Service)
+ notify/ Notifications (Slack, Discord, webhook)
provider/ Provider interface + cached decorator
github/ GitHub REST API client
gitlab/ GitLab REST API client
- mcpserver/ MCP server (stdio + SSE, 18 tools)
+ ratelimit/ Token-bucket rate limiter
registry/ OCI container registry client
- k8s/ Kubernetes integration (client-go)
security/ Vulnerability checking (OSV.dev API)
- discovery/ K8s service auto-discovery
- notify/ Webhook notification system
- web/ Web dashboard (html/template)
- cache/ Thread-safe in-memory TTL cache
- ratelimit/ Token-bucket rate limiter
- errors/ Typed errors (NotFound, RateLimit, Auth)
- logging/ Structured logging setup (slog)
+ store/ SQLite persistence (releases, tool calls)
+ tenant/ Multi-tenant CRUD + API key management
+ web/ Web dashboard (htmx + Tailwind)
```
## License
diff --git a/cmd/releasewave/tenant.go b/cmd/releasewave/tenant.go
index 91d83ca..9431916 100644
--- a/cmd/releasewave/tenant.go
+++ b/cmd/releasewave/tenant.go
@@ -28,8 +28,6 @@ var tenantCreateCmd = &cobra.Command{
Short: "Create a new tenant",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- plan, _ := cmd.Flags().GetString("plan")
-
db, err := openTenantDB()
if err != nil {
return err
@@ -41,7 +39,7 @@ var tenantCreateCmd = &cobra.Command{
return err
}
- t, err := ts.Create(args[0], plan)
+ t, err := ts.Create(args[0])
if err != nil {
return fmt.Errorf("create tenant: %w", err)
}
@@ -59,7 +57,6 @@ var tenantCreateCmd = &cobra.Command{
fmt.Printf("Tenant created:\n")
fmt.Printf(" Name: %s\n", t.Name)
- fmt.Printf(" Plan: %s\n", t.Plan)
fmt.Printf(" API Key: %s\n", rawKey)
fmt.Printf("\nSave this API key — it won't be shown again.\n")
return nil
@@ -91,9 +88,9 @@ var tenantListCmd = &cobra.Command{
return nil
}
- fmt.Printf("%-20s %-8s %s\n", "NAME", "PLAN", "CREATED")
+ fmt.Printf("%-20s %s\n", "NAME", "CREATED")
for _, t := range tenants {
- fmt.Printf("%-20s %-8s %s\n", t.Name, t.Plan, t.CreatedAt.Format("2006-01-02"))
+ fmt.Printf("%-20s %s\n", t.Name, t.CreatedAt.Format("2006-01-02"))
}
return nil
},
@@ -124,7 +121,6 @@ var tenantDeleteCmd = &cobra.Command{
}
func init() {
- tenantCreateCmd.Flags().String("plan", "free", "tenant plan (free, pro)")
tenantCmd.AddCommand(tenantCreateCmd)
tenantCmd.AddCommand(tenantListCmd)
tenantCmd.AddCommand(tenantDeleteCmd)
diff --git a/config.example.yaml b/config.example.yaml
index b8e61b5..dddc166 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -6,8 +6,8 @@ services:
# - name: my-api
# repo: github.com/my-org/my-api
# registry: ghcr.io/my-org/my-api
- # - name: billing
- # repo: gitlab.com/my-org/billing
+ # - name: frontend
+ # repo: github.com/my-org/frontend
# API tokens (can also use GITHUB_TOKEN / GITLAB_TOKEN env vars)
tokens:
diff --git a/internal/config/config.go b/internal/config/config.go
index 7c0c804..e9753c1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -279,8 +279,8 @@ services:
# - name: my-api
# repo: github.com/my-org/my-api
# registry: ghcr.io/my-org/my-api
- # - name: billing
- # repo: gitlab.com/my-org/billing
+ # - name: frontend
+ # repo: github.com/my-org/frontend
# API tokens (can also use GITHUB_TOKEN / GITLAB_TOKEN env vars)
tokens:
diff --git a/internal/notify/multi_test.go b/internal/notify/multi_test.go
new file mode 100644
index 0000000..d10dd9a
--- /dev/null
+++ b/internal/notify/multi_test.go
@@ -0,0 +1,189 @@
+package notify
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+)
+
+// fakeNotifier is a test helper that records calls and optionally returns an error.
+type fakeNotifier struct {
+ called bool
+ event Event
+ err error
+}
+
+func (f *fakeNotifier) Notify(_ context.Context, event Event) error {
+ f.called = true
+ f.event = event
+ return f.err
+}
+
+func TestMultiNotifier_AllSucceed(t *testing.T) {
+ n1 := &fakeNotifier{}
+ n2 := &fakeNotifier{}
+ n3 := &fakeNotifier{}
+
+ multi := NewMultiNotifier(n1, n2, n3)
+ event := Event{
+ ServiceName: "api",
+ OldVersion: "v1.0.0",
+ NewVersion: "v2.0.0",
+ ReleaseURL: "https://example.com/release",
+ Platform: "github",
+ }
+
+ err := multi.Notify(context.Background(), event)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ for i, n := range []*fakeNotifier{n1, n2, n3} {
+ if !n.called {
+ t.Errorf("notifier %d was not called", i)
+ }
+ if n.event.ServiceName != "api" {
+ t.Errorf("notifier %d: service = %q, want %q", i, n.event.ServiceName, "api")
+ }
+ if n.event.NewVersion != "v2.0.0" {
+ t.Errorf("notifier %d: version = %q, want %q", i, n.event.NewVersion, "v2.0.0")
+ }
+ }
+}
+
+func TestMultiNotifier_OneFailsOthersContinue(t *testing.T) {
+ n1 := &fakeNotifier{}
+ n2 := &fakeNotifier{err: errors.New("slack is down")}
+ n3 := &fakeNotifier{}
+
+ multi := NewMultiNotifier(n1, n2, n3)
+ event := Event{ServiceName: "api", NewVersion: "v1.0.0"}
+
+ err := multi.Notify(context.Background(), event)
+ if err == nil {
+ t.Fatal("expected error when one notifier fails, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "slack is down") {
+ t.Errorf("error should contain 'slack is down', got %q", err.Error())
+ }
+
+ // All notifiers should still have been called.
+ if !n1.called {
+ t.Error("notifier 1 should have been called")
+ }
+ if !n2.called {
+ t.Error("notifier 2 should have been called")
+ }
+ if !n3.called {
+ t.Error("notifier 3 should have been called")
+ }
+}
+
+func TestMultiNotifier_AllFail(t *testing.T) {
+ n1 := &fakeNotifier{err: errors.New("error-one")}
+ n2 := &fakeNotifier{err: errors.New("error-two")}
+
+ multi := NewMultiNotifier(n1, n2)
+ event := Event{ServiceName: "api", NewVersion: "v1.0.0"}
+
+ err := multi.Notify(context.Background(), event)
+ if err == nil {
+ t.Fatal("expected error when all notifiers fail")
+ }
+
+ if !strings.Contains(err.Error(), "error-one") {
+ t.Errorf("error should contain 'error-one', got %q", err.Error())
+ }
+ if !strings.Contains(err.Error(), "error-two") {
+ t.Errorf("error should contain 'error-two', got %q", err.Error())
+ }
+}
+
+func TestMultiNotifier_Empty(t *testing.T) {
+ multi := NewMultiNotifier()
+ event := Event{ServiceName: "api", NewVersion: "v1.0.0"}
+
+ err := multi.Notify(context.Background(), event)
+ if err != nil {
+ t.Fatalf("expected nil error for empty notifier list, got %v", err)
+ }
+}
+
+func TestMultiNotifier_SingleNotifier(t *testing.T) {
+ n := &fakeNotifier{}
+ multi := NewMultiNotifier(n)
+ event := Event{ServiceName: "svc", NewVersion: "v3.0.0"}
+
+ err := multi.Notify(context.Background(), event)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !n.called {
+ t.Error("notifier should have been called")
+ }
+}
+
+// --- FromConfig tests ---
+
+func TestFromConfig_NoURLs_ReturnsNil(t *testing.T) {
+ n := FromConfig("", "", "")
+ if n != nil {
+ t.Fatalf("expected nil for no config, got %T", n)
+ }
+}
+
+func TestFromConfig_WebhookOnly(t *testing.T) {
+ n := FromConfig("https://example.com/hook", "", "")
+ if n == nil {
+ t.Fatal("expected non-nil notifier")
+ }
+ if _, ok := n.(*WebhookNotifier); !ok {
+ t.Errorf("expected *WebhookNotifier, got %T", n)
+ }
+}
+
+func TestFromConfig_SlackOnly(t *testing.T) {
+ n := FromConfig("", "https://hooks.slack.com/services/xxx", "")
+ if n == nil {
+ t.Fatal("expected non-nil notifier")
+ }
+ if _, ok := n.(*SlackNotifier); !ok {
+ t.Errorf("expected *SlackNotifier, got %T", n)
+ }
+}
+
+func TestFromConfig_DiscordOnly(t *testing.T) {
+ n := FromConfig("", "", "https://discord.com/api/webhooks/xxx")
+ if n == nil {
+ t.Fatal("expected non-nil notifier")
+ }
+ if _, ok := n.(*DiscordNotifier); !ok {
+ t.Errorf("expected *DiscordNotifier, got %T", n)
+ }
+}
+
+func TestFromConfig_TwoURLs_ReturnsMulti(t *testing.T) {
+ n := FromConfig("https://example.com/hook", "https://hooks.slack.com/xxx", "")
+ if n == nil {
+ t.Fatal("expected non-nil notifier")
+ }
+ if _, ok := n.(*MultiNotifier); !ok {
+ t.Errorf("expected *MultiNotifier, got %T", n)
+ }
+}
+
+func TestFromConfig_AllThreeURLs_ReturnsMulti(t *testing.T) {
+ n := FromConfig("https://example.com/hook", "https://hooks.slack.com/xxx", "https://discord.com/api/webhooks/xxx")
+ if n == nil {
+ t.Fatal("expected non-nil notifier")
+ }
+ multi, ok := n.(*MultiNotifier)
+ if !ok {
+ t.Fatalf("expected *MultiNotifier, got %T", n)
+ }
+ if len(multi.notifiers) != 3 {
+ t.Errorf("expected 3 notifiers, got %d", len(multi.notifiers))
+ }
+}
diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go
index da60eed..343fcd7 100644
--- a/internal/tenant/tenant.go
+++ b/internal/tenant/tenant.go
@@ -11,7 +11,6 @@ import (
type Tenant struct {
ID int64 `json:"id"`
Name string `json:"name"`
- Plan string `json:"plan"` // "free" or "pro"
CreatedAt time.Time `json:"created_at"`
}
@@ -45,7 +44,6 @@ func (s *Store) migrate() error {
`CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
- plan TEXT NOT NULL DEFAULT 'free',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS tenant_services (
@@ -65,13 +63,10 @@ func (s *Store) migrate() error {
}
// Create adds a new tenant.
-func (s *Store) Create(name, plan string) (*Tenant, error) {
- if plan == "" {
- plan = "free"
- }
+func (s *Store) Create(name string) (*Tenant, error) {
result, err := s.db.Exec(
- `INSERT INTO tenants (name, plan) VALUES (?, ?)`,
- name, plan,
+ `INSERT INTO tenants (name) VALUES (?)`,
+ name,
)
if err != nil {
return nil, fmt.Errorf("create tenant: %w", err)
@@ -80,15 +75,15 @@ func (s *Store) Create(name, plan string) (*Tenant, error) {
if err != nil {
return nil, fmt.Errorf("get tenant id: %w", err)
}
- return &Tenant{ID: id, Name: name, Plan: plan, CreatedAt: time.Now()}, nil
+ return &Tenant{ID: id, Name: name, CreatedAt: time.Now()}, nil
}
// Get retrieves a tenant by name.
func (s *Store) Get(name string) (*Tenant, error) {
var t Tenant
err := s.db.QueryRow(
- `SELECT id, name, plan, created_at FROM tenants WHERE name = ?`, name,
- ).Scan(&t.ID, &t.Name, &t.Plan, &t.CreatedAt)
+ `SELECT id, name, created_at FROM tenants WHERE name = ?`, name,
+ ).Scan(&t.ID, &t.Name, &t.CreatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("tenant %q not found", name)
}
@@ -100,7 +95,7 @@ func (s *Store) Get(name string) (*Tenant, error) {
// List returns all tenants.
func (s *Store) List() ([]Tenant, error) {
- rows, err := s.db.Query(`SELECT id, name, plan, created_at FROM tenants ORDER BY name`)
+ rows, err := s.db.Query(`SELECT id, name, created_at FROM tenants ORDER BY name`)
if err != nil {
return nil, err
}
@@ -109,7 +104,7 @@ func (s *Store) List() ([]Tenant, error) {
var tenants []Tenant
for rows.Next() {
var t Tenant
- if err := rows.Scan(&t.ID, &t.Name, &t.Plan, &t.CreatedAt); err != nil {
+ if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt); err != nil {
return nil, err
}
tenants = append(tenants, t)
diff --git a/internal/tenant/tenant_test.go b/internal/tenant/tenant_test.go
index 014a18e..ffa614a 100644
--- a/internal/tenant/tenant_test.go
+++ b/internal/tenant/tenant_test.go
@@ -2,7 +2,6 @@ package tenant
import (
"database/sql"
- "strings"
"testing"
_ "modernc.org/sqlite"
@@ -41,11 +40,11 @@ func setupStores(t *testing.T) (*sql.DB, *Store, *KeyStore) {
func TestStore_CreateAndGet(t *testing.T) {
_, store, _ := setupStores(t)
- created, err := store.Create("acme", "pro")
+ created, err := store.Create("acme")
if err != nil {
t.Fatalf("create: %v", err)
}
- if created.Name != "acme" || created.Plan != "pro" {
+ if created.Name != "acme" {
t.Fatalf("unexpected tenant: %+v", created)
}
@@ -59,18 +58,15 @@ func TestStore_CreateAndGet(t *testing.T) {
if got.Name != "acme" {
t.Fatalf("name mismatch: got %q, want %q", got.Name, "acme")
}
- if got.Plan != "pro" {
- t.Fatalf("plan mismatch: got %q, want %q", got.Plan, "pro")
- }
}
func TestStore_CreateDuplicateFails(t *testing.T) {
_, store, _ := setupStores(t)
- if _, err := store.Create("acme", "free"); err != nil {
+ if _, err := store.Create("acme"); err != nil {
t.Fatalf("first create: %v", err)
}
- _, err := store.Create("acme", "pro")
+ _, err := store.Create("acme")
if err == nil {
t.Fatal("expected error for duplicate tenant, got nil")
}
@@ -81,7 +77,7 @@ func TestStore_ListReturnsAll(t *testing.T) {
names := []string{"alpha", "beta", "gamma"}
for _, n := range names {
- if _, err := store.Create(n, "free"); err != nil {
+ if _, err := store.Create(n); err != nil {
t.Fatalf("create %s: %v", n, err)
}
}
@@ -104,7 +100,7 @@ func TestStore_ListReturnsAll(t *testing.T) {
func TestStore_DeleteSucceeds(t *testing.T) {
_, store, _ := setupStores(t)
- if _, err := store.Create("acme", "free"); err != nil {
+ if _, err := store.Create("acme"); err != nil {
t.Fatalf("create: %v", err)
}
if err := store.Delete("acme"); err != nil {
@@ -137,7 +133,7 @@ func TestStore_GetNonExistentReturnsError(t *testing.T) {
func TestStore_AddServiceAndListServices(t *testing.T) {
_, store, _ := setupStores(t)
- tenant, err := store.Create("acme", "free")
+ tenant, err := store.Create("acme")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -180,7 +176,7 @@ func TestStore_ForeignKeyCascadeDeletesServices(t *testing.T) {
t.Fatal("PRAGMA foreign_keys is OFF; cascade will not work")
}
- tenant, err := store.Create("acme", "free")
+ tenant, err := store.Create("acme")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -209,7 +205,7 @@ func TestStore_ForeignKeyCascadeDeletesServices(t *testing.T) {
func TestKeyStore_GenerateHasRWPrefix(t *testing.T) {
_, store, ks := setupStores(t)
- tenant, err := store.Create("acme", "free")
+ tenant, err := store.Create("acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
@@ -218,7 +214,7 @@ func TestKeyStore_GenerateHasRWPrefix(t *testing.T) {
if err != nil {
t.Fatalf("generate: %v", err)
}
- if !strings.HasPrefix(rawKey, "rw_") {
+ if len(rawKey) < 4 || rawKey[:3] != "rw_" {
t.Fatalf("raw key %q does not start with 'rw_'", rawKey)
}
if key.TenantID != tenant.ID {
@@ -232,7 +228,7 @@ func TestKeyStore_GenerateHasRWPrefix(t *testing.T) {
func TestKeyStore_ValidateCorrectKey(t *testing.T) {
_, store, ks := setupStores(t)
- tenant, err := store.Create("acme", "free")
+ tenant, err := store.Create("acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
@@ -254,7 +250,7 @@ func TestKeyStore_ValidateCorrectKey(t *testing.T) {
func TestKeyStore_ValidateWrongKeyReturnsError(t *testing.T) {
_, store, ks := setupStores(t)
- if _, err := store.Create("acme", "free"); err != nil {
+ if _, err := store.Create("acme"); err != nil {
t.Fatalf("create tenant: %v", err)
}
@@ -267,7 +263,7 @@ func TestKeyStore_ValidateWrongKeyReturnsError(t *testing.T) {
func TestKeyStore_ListForTenant(t *testing.T) {
_, store, ks := setupStores(t)
- tenant, err := store.Create("acme", "free")
+ tenant, err := store.Create("acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
@@ -300,7 +296,7 @@ func TestKeyStore_ListForTenant(t *testing.T) {
func TestKeyStore_RevokeRemovesKey(t *testing.T) {
_, store, ks := setupStores(t)
- tenant, err := store.Create("acme", "free")
+ tenant, err := store.Create("acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
@@ -351,7 +347,7 @@ func TestKeyStore_ForeignKeyCascadeDeletesKeys(t *testing.T) {
t.Fatal("PRAGMA foreign_keys is OFF; cascade will not work")
}
- tenant, err := store.Create("acme", "free")
+ tenant, err := store.Create("acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
diff --git a/internal/web/dashboard.html b/internal/web/dashboard.html
index 3a43e15..3d19390 100644
--- a/internal/web/dashboard.html
+++ b/internal/web/dashboard.html
@@ -3,201 +3,127 @@
-
ReleaseWave Dashboard
+
+
-
-
-
-
-
-
Services
-
{{.TotalServices}}
-
-
-
Healthy
-
{{.HealthyCount}}
-
-
-
Errors
-
{{.ErrorCount}}
-
+
+
+ ReleaseWave
+ Last updated: {{.UpdatedAt}}
+
+
+
+ {{/* Stats cards — polled every 30s */}}
+
+ {{template "stats" .}}
- {{if .Services}}
-
-
-
- | Status |
- Service |
- Platform |
- Latest Version |
- Released |
- Link |
-
-
-
- {{range .Services}}
-
- |
- {{if .Error}}
-
- {{else}}
-
- {{end}}
- |
- {{.Name}} |
-
- {{.Platform}}
- |
-
- {{if .Error}}
- {{.Error}}
- {{else}}
- {{.LatestTag}}
- {{end}}
- |
- {{.ReleasedAt}} |
-
- {{if .URL}}
- View
- {{end}}
- |
-
- {{end}}
-
-
- {{else}}
-
-
No services configured.
-
Add services to ~/.config/releasewave/config.yaml
+ {{/* Add service form */}}
+
- {{end}}
-
-
+
+ {{/* Service table — polled every 30s */}}
+
+ {{template "services" .}}
+
+
+
+
+
+{{define "stats"}}
+
+
+
Services
+
{{.TotalServices}}
+
+
+
Healthy
+
{{.HealthyCount}}
+
+
+
Errors
+
{{.ErrorCount}}
+
+
+{{end}}
+
+{{define "services"}}
+{{if .Services}}
+
+
+
+
+ | Status |
+ Service |
+ Platform |
+ Latest Version |
+ Released |
+ Actions |
+
+
+
+ {{range .Services}}
+
+ |
+ {{if .Error}}
+
+ {{else}}
+
+ {{end}}
+ |
+ {{.Name}} |
+
+ {{.Platform}}
+ |
+
+ {{if .Error}}
+ {{.Error}}
+ {{else}}
+ {{.LatestTag}}
+ {{end}}
+ |
+ {{.ReleasedAt}} |
+
+ {{if .URL}}
+ View
+ {{end}}
+
+ |
+
+ {{end}}
+
+
+
+{{else}}
+
+
No services configured.
+
Add a service above or edit ~/.config/releasewave/config.yaml
+
+{{end}}
+{{end}}
diff --git a/internal/web/web.go b/internal/web/web.go
index a397bb9..7d312fc 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -1,12 +1,14 @@
-// Package web provides a minimal web dashboard for ReleaseWave.
+// Package web provides the web dashboard for ReleaseWave.
package web
import (
"context"
+ "encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
+ "strings"
"sync"
"time"
@@ -33,24 +35,149 @@ type dashboardData struct {
UpdatedAt string
}
-// Handler returns an http.Handler that serves the web dashboard.
+// Handler returns an http.Handler that serves the web dashboard with htmx partials.
func Handler(cfg *config.Config, providers map[string]provider.Provider) (http.Handler, error) {
tmpl, err := template.ParseFS(templateFS, "dashboard.html")
if err != nil {
return nil, fmt.Errorf("web: failed to parse embedded template: %w", err)
}
+ h := &dashboardHandler{cfg: cfg, providers: providers, tmpl: tmpl}
+
mux := http.NewServeMux()
- mux.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
- data := fetchDashboardData(r.Context(), cfg, providers)
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := tmpl.Execute(w, data); err != nil {
- slog.Error("web.render", "error", err)
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ mux.HandleFunc("/dashboard", h.fullPage)
+ mux.HandleFunc("/dashboard/partials/stats", h.partialStats)
+ mux.HandleFunc("/dashboard/partials/services", h.partialServices)
+ mux.HandleFunc("POST /dashboard/services", h.addService)
+ mux.HandleFunc("DELETE /dashboard/services/{name}", h.deleteService)
+
+ return mux, nil
+}
+
+type dashboardHandler struct {
+ cfg *config.Config
+ providers map[string]provider.Provider
+ tmpl *template.Template
+ mu sync.RWMutex
+}
+
+func (h *dashboardHandler) fullPage(w http.ResponseWriter, r *http.Request) {
+ data := h.getData(r.Context())
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.tmpl.Execute(w, data); err != nil {
+ slog.Error("web.render", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+}
+
+func (h *dashboardHandler) partialStats(w http.ResponseWriter, r *http.Request) {
+ data := h.getData(r.Context())
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.tmpl.ExecuteTemplate(w, "stats", data); err != nil {
+ slog.Error("web.render.stats", "error", err)
+ }
+}
+
+func (h *dashboardHandler) partialServices(w http.ResponseWriter, r *http.Request) {
+ data := h.getData(r.Context())
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.tmpl.ExecuteTemplate(w, "services", data); err != nil {
+ slog.Error("web.render.services", "error", err)
+ }
+}
+
+func (h *dashboardHandler) addService(w http.ResponseWriter, r *http.Request) {
+ name := r.FormValue("name")
+ repo := r.FormValue("repo")
+ registry := r.FormValue("registry")
+
+ if name == "" || repo == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ if err := json.NewEncoder(w).Encode(map[string]string{"error": "name and repo are required"}); err != nil {
+ slog.Error("web.write", "error", err)
+ }
+ return
+ }
+
+ parts := strings.Split(repo, "/")
+ if len(parts) < 3 {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ if err := json.NewEncoder(w).Encode(map[string]string{"error": "repo must be host/owner/repo format"}); err != nil {
+ slog.Error("web.write", "error", err)
+ }
+ return
+ }
+
+ h.mu.Lock()
+ for _, svc := range h.cfg.Services {
+ if svc.Name == name {
+ h.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusConflict)
+ if err := json.NewEncoder(w).Encode(map[string]string{"error": "service already exists"}); err != nil {
+ slog.Error("web.write", "error", err)
+ }
+ return
}
+ }
+ h.cfg.Services = append(h.cfg.Services, config.ServiceConfig{
+ Name: name,
+ Repo: repo,
+ Registry: registry,
})
+ h.mu.Unlock()
- return mux, nil
+ slog.Info("web.add_service", "name", name, "repo", repo)
+
+ // Return updated services partial.
+ data := h.getData(r.Context())
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.tmpl.ExecuteTemplate(w, "services", data); err != nil {
+ slog.Error("web.render.services", "error", err)
+ }
+}
+
+func (h *dashboardHandler) deleteService(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+
+ h.mu.Lock()
+ found := false
+ services := make([]config.ServiceConfig, 0, len(h.cfg.Services))
+ for _, svc := range h.cfg.Services {
+ if svc.Name == name {
+ found = true
+ continue
+ }
+ services = append(services, svc)
+ }
+ if found {
+ h.cfg.Services = services
+ }
+ h.mu.Unlock()
+
+ if !found {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ if err := json.NewEncoder(w).Encode(map[string]string{"error": "service not found"}); err != nil {
+ slog.Error("web.write", "error", err)
+ }
+ return
+ }
+
+ slog.Info("web.delete_service", "name", name)
+
+ // Return updated services partial.
+ data := h.getData(r.Context())
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.tmpl.ExecuteTemplate(w, "services", data); err != nil {
+ slog.Error("web.render.services", "error", err)
+ }
+}
+
+func (h *dashboardHandler) getData(ctx context.Context) dashboardData {
+ return fetchDashboardData(ctx, h.cfg, h.providers)
}
// fetchDashboardData queries all configured services concurrently and returns dashboard data.
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 587021b..543e540 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
@@ -38,6 +39,15 @@ func (m *mockProvider) GetFileContent(_ context.Context, _, _, _ string) ([]byte
var _ provider.Provider = (*mockProvider)(nil)
+func testHandler(t *testing.T, cfg *config.Config, providers map[string]provider.Provider) http.Handler {
+ t.Helper()
+ handler, err := Handler(cfg, providers)
+ if err != nil {
+ t.Fatalf("Handler() error: %v", err)
+ }
+ return handler
+}
+
func TestHandler_RendersDashboard(t *testing.T) {
cfg := &config.Config{
Services: []config.ServiceConfig{
@@ -53,11 +63,7 @@ func TestHandler_RendersDashboard(t *testing.T) {
},
}
- providers := map[string]provider.Provider{"github": mock}
- handler, err := Handler(cfg, providers)
- if err != nil {
- t.Fatalf("Handler() error: %v", err)
- }
+ handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock})
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rec := httptest.NewRecorder()
@@ -77,6 +83,9 @@ func TestHandler_RendersDashboard(t *testing.T) {
if !strings.Contains(body, "v1.2.3") {
t.Error("expected page to contain version 'v1.2.3'")
}
+ if !strings.Contains(body, "htmx") {
+ t.Error("expected page to contain htmx reference")
+ }
}
func TestHandler_ProviderError(t *testing.T) {
@@ -86,16 +95,8 @@ func TestHandler_ProviderError(t *testing.T) {
},
}
- mock := &mockProvider{
- name: "github",
- err: errors.New("API error"),
- }
-
- providers := map[string]provider.Provider{"github": mock}
- handler, err := Handler(cfg, providers)
- if err != nil {
- t.Fatalf("Handler() error: %v", err)
- }
+ mock := &mockProvider{name: "github", err: errors.New("API error")}
+ handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock})
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rec := httptest.NewRecorder()
@@ -112,20 +113,170 @@ func TestHandler_ProviderError(t *testing.T) {
}
func TestHandler_NoServices(t *testing.T) {
+ handler := testHandler(t, &config.Config{}, map[string]provider.Provider{})
+
+ req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "No services configured") {
+ t.Error("expected empty state message")
+ }
+}
+
+func TestPartialStats(t *testing.T) {
+ cfg := &config.Config{
+ Services: []config.ServiceConfig{
+ {Name: "svc", Repo: "github.com/org/svc"},
+ },
+ }
+ mock := &mockProvider{name: "github", release: &model.Release{Tag: "v1.0.0"}}
+ handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock})
+
+ req := httptest.NewRequest(http.MethodGet, "/dashboard/partials/stats", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Services") {
+ t.Error("expected stats partial to contain 'Services'")
+ }
+}
+
+func TestPartialServices(t *testing.T) {
+ cfg := &config.Config{
+ Services: []config.ServiceConfig{
+ {Name: "svc", Repo: "github.com/org/svc"},
+ },
+ }
+ mock := &mockProvider{name: "github", release: &model.Release{Tag: "v2.0.0"}}
+ handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock})
+
+ req := httptest.NewRequest(http.MethodGet, "/dashboard/partials/services", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "v2.0.0") {
+ t.Error("expected services partial to contain 'v2.0.0'")
+ }
+}
+
+func TestAddService(t *testing.T) {
cfg := &config.Config{}
- providers := map[string]provider.Provider{}
- handler, err := Handler(cfg, providers)
- if err != nil {
- t.Fatalf("Handler() error: %v", err)
+ mock := &mockProvider{name: "github", release: &model.Release{Tag: "v1.0.0"}}
+ handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock})
+
+ form := url.Values{}
+ form.Set("name", "new-svc")
+ form.Set("repo", "github.com/org/new-svc")
+
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
}
- req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
+ if len(cfg.Services) != 1 || cfg.Services[0].Name != "new-svc" {
+ t.Fatalf("expected service to be added, got %v", cfg.Services)
+ }
+}
+
+func TestAddService_Duplicate(t *testing.T) {
+ cfg := &config.Config{
+ Services: []config.ServiceConfig{{Name: "existing", Repo: "github.com/org/existing"}},
+ }
+ handler := testHandler(t, cfg, map[string]provider.Provider{})
+
+ form := url.Values{}
+ form.Set("name", "existing")
+ form.Set("repo", "github.com/org/existing")
+
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("expected 409, got %d", rec.Code)
+ }
+}
+
+func TestAddService_BadRepo(t *testing.T) {
+ handler := testHandler(t, &config.Config{}, map[string]provider.Provider{})
+
+ form := url.Values{}
+ form.Set("name", "bad")
+ form.Set("repo", "not-valid")
+
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+}
+
+func TestAddService_MissingFields(t *testing.T) {
+ handler := testHandler(t, &config.Config{}, map[string]provider.Provider{})
+
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/services", strings.NewReader(""))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+}
+
+func TestDeleteService(t *testing.T) {
+ cfg := &config.Config{
+ Services: []config.ServiceConfig{
+ {Name: "svc-a", Repo: "github.com/org/a"},
+ {Name: "svc-b", Repo: "github.com/org/b"},
+ },
+ }
+ mock := &mockProvider{name: "github", release: &model.Release{Tag: "v1.0.0"}}
+ handler := testHandler(t, cfg, map[string]provider.Provider{"github": mock})
+
+ req := httptest.NewRequest(http.MethodDelete, "/dashboard/services/svc-a", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
+
+ if len(cfg.Services) != 1 || cfg.Services[0].Name != "svc-b" {
+ t.Fatalf("expected svc-a removed, got %v", cfg.Services)
+ }
+}
+
+func TestDeleteService_NotFound(t *testing.T) {
+ handler := testHandler(t, &config.Config{}, map[string]provider.Provider{})
+
+ req := httptest.NewRequest(http.MethodDelete, "/dashboard/services/ghost", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rec.Code)
+ }
}
func TestFetchDashboardData(t *testing.T) {
@@ -136,13 +287,7 @@ func TestFetchDashboardData(t *testing.T) {
},
}
- mock := &mockProvider{
- name: "github",
- release: &model.Release{
- Tag: "v3.0.0",
- },
- }
-
+ mock := &mockProvider{name: "github", release: &model.Release{Tag: "v3.0.0"}}
providers := map[string]provider.Provider{"github": mock}
data := fetchDashboardData(context.Background(), cfg, providers)