Skip to content

Commit cf05edd

Browse files
committed
feat: add read-only dashboard to standalone server
Add GET /dashboard with a dark-themed HTML page showing all links and click stats across all registered processor types. Capped at 500 links to bound memory usage. Dashboard lives in cmd/deeplink, not the library. Also exposes Service.Types() and sorts Registry.Types() output for deterministic ordering. Closes #4
1 parent 4ec6013 commit cf05edd

5 files changed

Lines changed: 359 additions & 1 deletion

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ go run ./cmd/deeplink
8484
| GET | `/links/{type}/{shortID}` | Link detail with click count |
8585
| GET | `/health` | Health check |
8686

87+
The standalone server (`cmd/deeplink`) also registers:
88+
89+
| Method | Path | Description |
90+
| --- | --- | --- |
91+
| GET | `/dashboard` | Read-only link stats page (requires `dashboard.html` in template dir) |
92+
8793
When any store URL is set (`AndroidStoreURL`, `IOSStoreURL`, `WebFallbackURL`), these are also registered:
8894

8995
| Method | Path | Description |

cmd/deeplink/main.go

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package main
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/subtle"
67
"encoding/json"
78
"errors"
89
"fmt"
10+
"html/template"
911
"log/slog"
1012
"net/http"
1113
"os"
@@ -85,7 +87,15 @@ func run() error {
8587
defer service.Close() //nolint:errcheck
8688
service.Register(deeplink.RedirectProcessor{})
8789

88-
handler := service.Handler()
90+
mux := http.NewServeMux()
91+
mux.Handle("/", service.Handler())
92+
93+
if dashTmpl := loadDashboardTemplate(templateDir); dashTmpl != nil {
94+
mux.HandleFunc("GET /dashboard", handleDashboard(service, dashTmpl, logger))
95+
logger.Info("dashboard enabled at /dashboard")
96+
}
97+
98+
var handler http.Handler = mux
8999
if apiKey := os.Getenv("DEEPLINK_API_KEY"); apiKey != "" {
90100
handler = withAPIKey(handler, apiKey)
91101
logger.Info("API key protection enabled for mutating endpoints")
@@ -188,6 +198,95 @@ func withAPIKey(next http.Handler, key string) http.Handler {
188198
})
189199
}
190200

201+
// dashboardMaxLinks caps the number of links loaded into memory per
202+
// request. The dashboard is a single-page HTML view, not a paginated
203+
// API — keeping this bounded avoids memory spikes on large instances.
204+
const dashboardMaxLinks = 500
205+
206+
type dashboardLink struct {
207+
ShortID string
208+
ShortLink string
209+
URL string
210+
Clicks int64
211+
}
212+
213+
type dashboardData struct {
214+
Links []dashboardLink
215+
TotalLinks int
216+
TotalClicks int64
217+
}
218+
219+
func loadDashboardTemplate(templateDir string) *template.Template {
220+
if templateDir == "" {
221+
return nil
222+
}
223+
path := filepath.Join(templateDir, "dashboard.html")
224+
if _, err := os.Stat(path); err != nil {
225+
return nil
226+
}
227+
tmpl, err := template.ParseFiles(path)
228+
if err != nil {
229+
return nil
230+
}
231+
return tmpl
232+
}
233+
234+
func handleDashboard(service *deeplink.Service, tmpl *template.Template, logger *slog.Logger) http.HandlerFunc {
235+
cfg := service.Config()
236+
return func(w http.ResponseWriter, r *http.Request) {
237+
var raw []deeplink.LinkInfo
238+
for _, linkType := range service.Types() {
239+
var cursor uint64
240+
for {
241+
links, next, err := cfg.Store.List(r.Context(), linkType, cfg.DefaultEnvironment, cursor, 100)
242+
if err != nil {
243+
logger.Error("dashboard: failed to list links", "error", err, "type", linkType)
244+
break
245+
}
246+
raw = append(raw, links...)
247+
if len(raw) >= dashboardMaxLinks {
248+
break
249+
}
250+
if next == 0 {
251+
break
252+
}
253+
cursor = next
254+
}
255+
if len(raw) >= dashboardMaxLinks {
256+
raw = raw[:dashboardMaxLinks]
257+
break
258+
}
259+
}
260+
261+
links := make([]dashboardLink, len(raw))
262+
var totalClicks int64
263+
for i, l := range raw {
264+
links[i] = dashboardLink{
265+
ShortID: l.ShortLink,
266+
ShortLink: cfg.BaseURL + l.ShortLink,
267+
URL: l.URL,
268+
Clicks: l.Clicks,
269+
}
270+
totalClicks += l.Clicks
271+
}
272+
273+
var buf bytes.Buffer
274+
if err := tmpl.Execute(&buf, dashboardData{
275+
Links: links,
276+
TotalLinks: len(links),
277+
TotalClicks: totalClicks,
278+
}); err != nil {
279+
logger.Error("dashboard: template error", "error", err)
280+
http.Error(w, "render error", http.StatusInternalServerError)
281+
return
282+
}
283+
284+
w.Header().Set("Content-Type", "text/html")
285+
w.Header().Set("Cache-Control", "public, max-age=60")
286+
_, _ = w.Write(buf.Bytes())
287+
}
288+
}
289+
191290
func loadSkipPaths(configured string) ([]string, error) {
192291
path := configured
193292
if path == "" {

registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package deeplink
33
import (
44
"context"
55
"fmt"
6+
"slices"
67
"sync"
78
)
89

@@ -57,5 +58,6 @@ func (r *Registry) Types() []string {
5758
for t := range r.processors {
5859
types = append(types, t)
5960
}
61+
slices.Sort(types)
6062
return types
6163
}

service.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ func (s *Service) Config() Config {
5858
return s.config
5959
}
6060

61+
// Types returns all registered processor type names.
62+
func (s *Service) Types() []string {
63+
return s.registry.Types()
64+
}
65+
6166
// Close releases resources held by the service.
6267
// It drains any buffered click events and closes the HTTP transport.
6368
func (s *Service) Close() error {

0 commit comments

Comments
 (0)