Files
Artifacto/internal/server/metrics.go
dtoro c7d7ee287c initial commit: Artifacto v0.1
Self-hosted HTML artifact publisher. Single Go binary, SQLite metadata,
HTML on disk. Features: password-protected artifacts, per-artifact expiration,
admin dashboard with view metrics (privacy-preserving daily-salt visitor hash,
sparklines, 30-day SVG chart, top referrers), rate-limited unlock endpoint.

Packaged as a Docker image (two-stage, CGO_ENABLED=0, pure-Go SQLite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:45:57 +02:00

137 lines
3.6 KiB
Go

package server
import (
"fmt"
"html/template"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"git.hubris.network/dtoro/artifacto/internal/store"
)
type detailData struct {
BaseURL string
Artifact *store.Artifact
Series []store.DayPoint
Referrers []store.ReferrerCount
ChartSVG template.HTML
}
func (s *Server) getArtifactDetail(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
art, err := s.store.GetArtifact(slug)
if err != nil {
http.NotFound(w, r)
return
}
series, err := s.store.SeriesFor(slug, 30)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
refs, err := s.store.TopReferrers(slug, 10)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.render(w, "detail", detailData{
BaseURL: s.cfg.BaseURL,
Artifact: art,
Series: series,
Referrers: refs,
ChartSVG: template.HTML(renderChart(series)),
})
}
// renderChart produces a tiny SVG line+area chart with day labels.
func renderChart(series []store.DayPoint) string {
const w = 720
const h = 160
const padLeft = 30
const padBottom = 20
const padTop = 10
const padRight = 10
innerW := w - padLeft - padRight
innerH := h - padTop - padBottom
var max int64
for _, p := range series {
if p.Views > max {
max = p.Views
}
}
if max < 1 {
max = 1
}
var b strings.Builder
fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" class="w-full h-40 text-slate-400" xmlns="http://www.w3.org/2000/svg">`, w, h)
// axes
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-width="1"/>`,
padLeft, padTop, padLeft, padTop+innerH)
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-width="1"/>`,
padLeft, padTop+innerH, padLeft+innerW, padTop+innerH)
// horizontal gridline at max
fmt.Fprintf(&b, `<text x="%d" y="%d" font-size="10" fill="currentColor" text-anchor="end">%d</text>`,
padLeft-4, padTop+6, max)
fmt.Fprintf(&b, `<text x="%d" y="%d" font-size="10" fill="currentColor" text-anchor="end">0</text>`,
padLeft-4, padTop+innerH)
n := len(series)
if n == 0 {
b.WriteString(`</svg>`)
return b.String()
}
step := float64(innerW) / float64(maxInt(n-1, 1))
// build polyline path
var pts strings.Builder
for i, p := range series {
x := float64(padLeft) + float64(i)*step
y := float64(padTop+innerH) - float64(p.Views)/float64(max)*float64(innerH)
if i > 0 {
pts.WriteByte(' ')
}
fmt.Fprintf(&pts, "%.1f,%.1f", x, y)
}
// area under curve
var area strings.Builder
fmt.Fprintf(&area, "M%d,%d ", padLeft, padTop+innerH)
for i, p := range series {
x := float64(padLeft) + float64(i)*step
y := float64(padTop+innerH) - float64(p.Views)/float64(max)*float64(innerH)
fmt.Fprintf(&area, "L%.1f,%.1f ", x, y)
}
fmt.Fprintf(&area, "L%.1f,%d Z", float64(padLeft)+float64(n-1)*step, padTop+innerH)
fmt.Fprintf(&b, `<path d="%s" fill="rgb(99 102 241 / 0.15)" stroke="none"/>`, area.String())
fmt.Fprintf(&b, `<polyline fill="none" stroke="rgb(99 102 241)" stroke-width="2" points="%s"/>`, pts.String())
// x-axis tick labels: first, middle, last day
labelIdx := []int{0, n / 2, n - 1}
for _, i := range labelIdx {
if i < 0 || i >= n {
continue
}
x := float64(padLeft) + float64(i)*step
fmt.Fprintf(&b, `<text x="%.1f" y="%d" font-size="10" fill="currentColor" text-anchor="middle">%s</text>`,
x, padTop+innerH+14, shortDay(series[i].Day))
}
b.WriteString(`</svg>`)
return b.String()
}
func shortDay(iso string) string {
// yyyy-mm-dd → mm-dd
if len(iso) >= 10 {
return iso[5:10]
}
return iso
}