aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFelix Hanley <felix@userspace.com.au>2020-02-28 02:19:40 +0000
committerFelix Hanley <felix@userspace.com.au>2020-02-28 02:19:40 +0000
commit71f16081700ccdae5442aa2abf969012a999c774 (patch)
tree6b57b0466ff3a2858c8f87a2e86354dd5eab0ca6
parente3733a11476f580709ed27f89464aaf83349288d (diff)
downloadsws-71f16081700ccdae5442aa2abf969012a999c774.tar.gz
sws-71f16081700ccdae5442aa2abf969012a999c774.tar.bz2
Update auth and timezone fixes
-rw-r--r--browser_set.go78
-rw-r--r--charts.go3
-rw-r--r--cmd/server/auth.go6
-rw-r--r--cmd/server/charts.go14
-rw-r--r--cmd/server/handlers.go18
-rw-r--r--cmd/server/helpers.go14
-rw-r--r--cmd/server/main.go52
-rw-r--r--cmd/server/sites.go21
-rw-r--r--hit.go2
-rw-r--r--hit_set.go187
-rw-r--r--hit_set_test.go119
-rw-r--r--hit_test.go2
-rw-r--r--page.go5
-rw-r--r--page_set.go94
-rw-r--r--static/default.css94
-rw-r--r--templates/charts.tmpl16
-rw-r--r--templates/login.tmpl2
-rw-r--r--templates/site.tmpl20
-rw-r--r--time_buckets_test.go2
-rw-r--r--user_agent_set.go54
20 files changed, 595 insertions, 208 deletions
diff --git a/browser_set.go b/browser_set.go
new file mode 100644
index 0000000..c316480
--- /dev/null
+++ b/browser_set.go
@@ -0,0 +1,78 @@
+package sws
+
+import (
+ "time"
+
+ detector "github.com/mssola/user_agent"
+)
+
+type Browser struct {
+ Name string `json:"name"`
+ LastSeenAt time.Time `json:"last_seen_at" db:"last_seen_at"`
+ hitSet *HitSet
+}
+
+type BrowserSet []*Browser
+
+func NewBrowserSet(hs *HitSet) BrowserSet {
+ tmp := make(map[string]*Browser)
+ for _, h := range hs.Hits() {
+ browser := ""
+ if h.UserAgentHash != nil {
+ d := detector.New(h.UserAgent.Name)
+ browser, _ = d.Browser()
+ }
+ if _, ok := tmp[browser]; ok {
+ // Already captured this UA
+ continue
+ }
+ b := &Browser{
+ Name: browser,
+ LastSeenAt: h.CreatedAt,
+ hitSet: hs.Filter(func(t *Hit) bool {
+ if t.UserAgentHash == nil {
+ return browser == ""
+ }
+ test, _ := detector.New(t.UserAgent.Name).Browser()
+ return browser == test
+ }),
+ }
+ // if b.LastSeenAt.Before(h.CreatedAt) {
+ // b.LastSeenAt = h.CreatedAt
+ // }
+ //b.hitSet.Add(h)
+ tmp[browser] = b
+ }
+ out := make([]*Browser, len(tmp))
+ i := 0
+ for _, b := range tmp {
+ out[i] = b
+ i++
+ }
+ return BrowserSet(out)
+}
+
+func (b Browser) Label() string {
+ return b.Name
+}
+
+func (b Browser) Count() int {
+ return b.hitSet.Count()
+}
+
+func (b Browser) YValue() int {
+ return b.hitSet.Count()
+}
+
+func (bs BrowserSet) YMax() int {
+ max := 0
+ for _, b := range bs {
+ if b.hitSet.Count() > max {
+ max = b.hitSet.Count()
+ }
+ }
+ return max
+}
+func (bs BrowserSet) XSeries() []*Browser {
+ return bs
+}
diff --git a/charts.go b/charts.go
index 8bb1e64..0dcf2a8 100644
--- a/charts.go
+++ b/charts.go
@@ -48,7 +48,7 @@ func NewChart(data HitSet, opts ...ChartOption) (*chart, error) {
type ChartOption func(*chart) error
-func Dimentions(height, width int) ChartOption {
+func Dimensions(height, width int) ChartOption {
return func(c *chart) error {
if height < 0 || width < 0 {
return fmt.Errorf("invalid chart dimensions")
@@ -69,6 +69,7 @@ func SparklineSVG(w io.Writer, data *HitSet, d time.Duration) error {
},
}
+ data.SortByDate()
hits.XValues, hits.YValues = data.XYValues()
graph := gochart.Chart{
diff --git a/cmd/server/auth.go b/cmd/server/auth.go
index 06973d3..642eb7c 100644
--- a/cmd/server/auth.go
+++ b/cmd/server/auth.go
@@ -68,6 +68,12 @@ func handleAuth(db sws.UserStore, rndr Renderer) http.HandlerFunc {
})
r = r.WithContext(context.WithValue(r.Context(), "user", user))
r = flashSet(r, flashSuccess, "authenticated successfully")
+ qs := r.URL.Query()
+ if returnPath := qs.Get("return_to"); returnPath != "" {
+ qs.Del("return_to")
+ r.URL.RawQuery = qs.Encode()
+ http.Redirect(w, r, flashURL(r, returnPath), http.StatusSeeOther)
+ }
http.Redirect(w, r, flashURL(r, "/sites"), http.StatusSeeOther)
}
}
diff --git a/cmd/server/charts.go b/cmd/server/charts.go
index 7c7ab9d..59fd6d2 100644
--- a/cmd/server/charts.go
+++ b/cmd/server/charts.go
@@ -45,7 +45,12 @@ func sparklineHandler(db sws.HitStore) http.HandlerFunc {
return
}
debug("retrieved", len(hits), "hits")
- data := sws.NewHitSet(hits, begin, end, time.Minute)
+ data, err := sws.NewHitSet(sws.FromHits(hits))
+ if err != nil {
+ log(err)
+ http.Error(w, http.StatusText(500), 500)
+ return
+ }
// Ensure the buckets start and end at the right time
data.Fill(&begin, &end)
@@ -96,7 +101,12 @@ func svgChartHandler(db sws.HitStore) http.HandlerFunc {
}
debug("retrieved", len(hits), "hits")
- data := sws.NewHitSet(hits, begin, end, time.Minute)
+ data, err := sws.NewHitSet(sws.FromHits(hits))
+ if err != nil {
+ log(err)
+ http.Error(w, http.StatusText(500), 500)
+ return
+ }
data.Fill(&begin, &end)
w.Header().Set("Content-Type", "image/svg+xml")
diff --git a/cmd/server/handlers.go b/cmd/server/handlers.go
index d3d07d7..603f7e9 100644
--- a/cmd/server/handlers.go
+++ b/cmd/server/handlers.go
@@ -8,15 +8,15 @@ import (
)
type templateData struct {
- User *sws.User
- Flashes []flashMsg
- Begin *time.Time
- End *time.Time
- Site *sws.Site
- Sites []*sws.Site
- Pages *sws.PageSet
- UserAgents *sws.UserAgentSet
- Hits *sws.HitSet
+ User *sws.User
+ Flashes []flashMsg
+ Begin *time.Time
+ End *time.Time
+ Site *sws.Site
+ Sites []*sws.Site
+ Pages *sws.PageSet
+ Browsers *sws.BrowserSet
+ Hits *sws.HitSet
}
func newTemplateData(r *http.Request) *templateData {
diff --git a/cmd/server/helpers.go b/cmd/server/helpers.go
index 38f2a19..a91744d 100644
--- a/cmd/server/helpers.go
+++ b/cmd/server/helpers.go
@@ -16,6 +16,11 @@ var funcMap = template.FuncMap{
then := now.Add(-24 * time.Hour)
return fmt.Sprintf("/sites/%d/sparklines/%d-%d.svg", id, then.Unix(), now.Unix())
},
+ "tz": func(s string, t time.Time) time.Time {
+ tz, _ := time.LoadLocation(s)
+ // TODO error
+ return t.In(tz)
+ },
"timeShort": func(t time.Time) string {
return t.Format("2006-01-02 15:04")
},
@@ -38,6 +43,15 @@ func httpError(w http.ResponseWriter, code int, msg string) {
http.Error(w, http.StatusText(code), code)
}
+func authRedirect(w http.ResponseWriter, r *http.Request, msg string) {
+ flashSet(r, flashError, msg)
+ log(msg)
+ qs := r.URL.Query()
+ qs.Set("return_to", r.URL.Path)
+ r.URL.RawQuery = qs.Encode()
+ http.Redirect(w, r, flashURL(r, "/login"), http.StatusSeeOther)
+}
+
func extractTimeRange(r *http.Request) (*time.Time, *time.Time) {
begin := timePtr(time.Now().Truncate(time.Hour).Add(-168 * time.Hour))
end := timePtr(time.Now())
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 6342585..d220a03 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -150,7 +150,6 @@ func main() {
// Authed routes
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(tokenAuth))
- r.Use(jwtauth.Authenticator)
r.Use(userCtx)
r.Get(logoutURL, func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
@@ -213,45 +212,32 @@ func main() {
http.ListenAndServe(*addr, r)
}
-func getSiteCtx(db sws.SiteStore) func(http.Handler) http.Handler {
+func getUserCtx(db sws.UserStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- id, err := strconv.Atoi(chi.URLParam(r, "siteID"))
- if err != nil {
- panic(err)
- }
- site, err := db.GetSiteByID(id)
+ token, claims, err := jwtauth.FromContext(r.Context())
+
if err != nil {
- http.Error(w, http.StatusText(404), 404)
+ authRedirect(w, r, "token error")
return
}
- ctx := context.WithValue(r.Context(), "site", site)
- next.ServeHTTP(w, r.WithContext(ctx))
- })
- }
-}
-func getUserCtx(db sws.UserStore) func(http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, claims, err := jwtauth.FromContext(r.Context())
- if err != nil {
- log("missing claims")
- http.Redirect(w, r, flashURL(r, "/login"), http.StatusUnauthorized)
+ if token == nil || !token.Valid {
+ authRedirect(w, r, "invalid token")
return
}
+ // Token is authenticated, get claims
+
id, ok := claims["user_id"]
if !ok {
- log("missing user_id")
- http.Redirect(w, r, flashURL(r, "/login"), http.StatusUnauthorized)
+ authRedirect(w, r, "missing user ID")
return
}
user, err := db.GetUserByID(int(id.(float64)))
if err != nil {
- log("missing user")
- http.Redirect(w, r, flashURL(r, "/login"), http.StatusUnauthorized)
+ authRedirect(w, r, "missing user")
return
}
debug("found user, adding to context")
@@ -260,3 +246,21 @@ func getUserCtx(db sws.UserStore) func(http.Handler) http.Handler {
})
}
}
+
+func getSiteCtx(db sws.SiteStore) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(chi.URLParam(r, "siteID"))
+ if err != nil {
+ panic(err)
+ }
+ site, err := db.GetSiteByID(id)
+ if err != nil {
+ http.Error(w, http.StatusText(404), 404)
+ return
+ }
+ ctx := context.WithValue(r.Context(), "site", site)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
diff --git a/cmd/server/sites.go b/cmd/server/sites.go
index 9f5c58c..cf78fda 100644
--- a/cmd/server/sites.go
+++ b/cmd/server/sites.go
@@ -2,7 +2,6 @@ package main
import (
"net/http"
- "time"
"src.userspace.com.au/sws"
)
@@ -48,15 +47,27 @@ func handleSite(db sws.SiteStore, rndr Renderer) http.HandlerFunc {
return
}
- hitSet := sws.NewHitSet(hits, *begin, *end, time.Hour)
+ hitSet, err := sws.NewHitSet(sws.FromHits(hits))
+ if err != nil {
+ httpError(w, 406, err.Error())
+ return
+ }
hitSet.Fill(begin, end)
- pageSet := sws.NewPageSet(hitSet)
- uaSet := sws.NewUserAgentSet(hitSet)
+ hitSet.SortByDate()
+
+ pageSet, err := sws.NewPageSet(hitSet)
+ if err != nil {
+ httpError(w, 406, err.Error())
+ return
+ }
+ pageSet.SortByHits()
+
+ browserSet := sws.NewBrowserSet(hitSet)
payload := newTemplateData(r)
payload.Site = site
payload.Pages = &pageSet
- payload.UserAgents = &uaSet
+ payload.Browsers = &browserSet
payload.Hits = hitSet
if err := rndr.Render(w, "site", payload); err != nil {
diff --git a/hit.go b/hit.go
index e227f39..4e19307 100644
--- a/hit.go
+++ b/hit.go
@@ -34,10 +34,12 @@ type Hit struct {
}
type Hitter interface {
+ //Filter(FilterFunc) *HitSet
Hits() []*Hit
Begin() time.Time
End() time.Time
Duration() time.Duration
+ Location() *time.Location
}
func (h Hit) String() string {
diff --git a/hit_set.go b/hit_set.go
index eb83ae4..98a5b6e 100644
--- a/hit_set.go
+++ b/hit_set.go
@@ -7,9 +7,11 @@ import (
type HitSet struct {
duration time.Duration
- tMin, tMax time.Time
- cMin, cMax int
+ location *time.Location
+ tMin, tMax *time.Time
+ cMin, cMax *int
hits []*Hit
+ filter FilterFunc
buckets []*bucket
}
@@ -18,17 +20,81 @@ type bucket struct {
hits []*Hit
}
+type HitSetOption func(*HitSet) error
+
+func TimeZone(s string) HitSetOption {
+ return func(hs *HitSet) error {
+ var err error
+ hs.location, err = time.LoadLocation(s)
+ return err
+ }
+}
+
+func Duration(s string) HitSetOption {
+ return func(hs *HitSet) error {
+ var err error
+ hs.duration, err = time.ParseDuration(s)
+ return err
+ }
+}
+func FromHits(hits []*Hit) HitSetOption {
+ return func(hs *HitSet) error {
+ hs.hits = hits
+ return nil
+ }
+}
+
+func WithFilter(f FilterFunc) HitSetOption {
+ return func(hs *HitSet) error {
+ hs.filter = f
+ return nil
+ }
+}
+
// NewHitSet converts a slice of hits to time buckets, group by duration.
-func NewHitSet(hits []*Hit, b, e time.Time, d time.Duration) *HitSet {
+func NewHitSet(opts ...HitSetOption) (*HitSet, error) {
out := &HitSet{
- duration: d,
+ duration: time.Hour,
+ location: time.UTC,
buckets: make([]*bucket, 0),
}
- for _, h := range hits {
- out.Add(h)
+ for _, o := range opts {
+ if err := o(out); err != nil {
+ return nil, err
+ }
+ }
+ if out.hits != nil {
+ for _, h := range out.hits {
+ if out.filter == nil || out.filter(h) {
+ out.Add(h)
+ }
+ }
+ }
+ return out, nil
+}
+
+type FilterFunc func(*Hit) bool
+
+func (hs *HitSet) Filter(f FilterFunc) *HitSet {
+ out := &HitSet{
+ duration: hs.duration,
+ location: hs.location,
+ tMin: hs.tMin,
+ tMax: hs.tMax,
+ buckets: make([]*bucket, len(hs.buckets)),
+ }
+ for i, b := range hs.buckets {
+ nb := &bucket{
+ t: b.t,
+ hits: make([]*Hit, 0),
+ }
+ for _, h := range b.hits {
+ if f(h) {
+ nb.hits = append(nb.hits, h)
+ }
+ }
+ out.buckets[i] = nb
}
- //out.updateMinMax()
- sort.Sort(out)
return out
}
@@ -40,17 +106,19 @@ func (hs *HitSet) Hits() []*Hit {
out = append(out, h)
}
}
- SortHits(out)
return out
}
func (hs *HitSet) Add(h *Hit) {
- k := h.CreatedAt.Truncate(hs.duration)
- if k.Before(hs.tMin) {
- hs.tMin = k
+ hs.updateMinMax()
+ k := h.CreatedAt.In(hs.location).Truncate(hs.duration)
+ h.CreatedAt = h.CreatedAt.In(hs.location)
+
+ if k.Before(*hs.tMin) {
+ hs.tMin = &k
}
- if k.After(hs.tMax) {
- hs.tMax = k
+ if k.After(*hs.tMax) {
+ hs.tMax = &k
}
var bk *bucket
@@ -65,27 +133,33 @@ func (hs *HitSet) Add(h *Hit) {
hs.buckets = append(hs.buckets, bk)
}
c := len(bk.hits)
- if c < hs.cMin {
- hs.cMin = c
+ if c < *hs.cMin {
+ hs.cMin = &c
}
- if c > hs.cMax {
- hs.cMax = c
+ if c > *hs.cMax {
+ hs.cMax = &c
}
}
// Implement Hitter interface.
func (hs *HitSet) Begin() time.Time {
- return hs.tMin
+ hs.updateMinMax()
+ return *hs.tMin
}
func (hs *HitSet) End() time.Time {
- return hs.tMax
+ hs.updateMinMax()
+ return *hs.tMax
}
func (hs *HitSet) Duration() time.Duration {
return hs.duration
}
+func (hs *HitSet) Location() *time.Location {
+ return hs.location
+}
+
func (hs HitSet) Count() int {
out := 0
for _, b := range hs.buckets {
@@ -94,25 +168,39 @@ func (hs HitSet) Count() int {
return out
}
-// Implement sort.Interface
-func (hs HitSet) Len() int { return len(hs.buckets) }
-func (hs HitSet) Less(i, j int) bool { return hs.buckets[i].t.Before(hs.buckets[i].t) }
-func (hs HitSet) Swap(i, j int) { hs.buckets[i], hs.buckets[j] = hs.buckets[j], hs.buckets[i] }
+func (hs *HitSet) SortByDate() {
+ sort.Slice(hs.buckets, func(i, j int) bool {
+ return hs.buckets[i].t.Before(hs.buckets[j].t)
+ })
+}
+func (hs *HitSet) SortByHits() {
+ sort.Slice(hs.buckets, func(i, j int) bool {
+ return len(hs.buckets[i].hits) > len(hs.buckets[j].hits)
+ })
+}
func (hs *HitSet) Fill(b, e *time.Time) {
- begin := hs.tMin
+ hs.updateMinMax()
+ if hs.Count() < 1 {
+ return
+ }
+ begin := *hs.tMin
if b != nil {
- begin = *b
+ begin = b.In(hs.location)
}
- end := hs.tMax
+ end := *hs.tMax
if e != nil {
- end = *e
+ end = e.In(hs.location)
}
+ begin = begin.Truncate(hs.duration)
+ end = end.Truncate(hs.duration)
total := diffDurations(begin, end, hs.duration)
newBuckets := make([]*bucket, total)
+ hs.SortByDate()
+
var existing int
var idx int
for n := begin; idx < total && !n.After(end); n = n.Add(hs.duration) {
@@ -129,9 +217,8 @@ func (hs *HitSet) Fill(b, e *time.Time) {
}
idx++
}
- hs.tMin = begin
- hs.tMax = end
- //hs.updateMinMax()
+ hs.tMin = &begin
+ hs.tMax = &end
hs.buckets = newBuckets
}
@@ -148,7 +235,8 @@ func (hs HitSet) XYValues() ([]time.Time, []float64) {
}
func (hs HitSet) YMax() int {
- return hs.cMax
+ hs.updateMinMax()
+ return *hs.cMax
}
func (hs HitSet) XSeries() []*bucket {
return hs.buckets
@@ -166,29 +254,36 @@ func (b bucket) Time() time.Time {
return b.t
}
-/*
func (hs *HitSet) updateMinMax() {
+ if hs.tMin != nil && hs.tMax != nil && hs.cMax != nil && hs.cMin != nil {
+ return
+ }
if len(hs.buckets) < 1 {
+ now := time.Now().Truncate(hs.duration)
+ zero := 0
+ hs.tMin, hs.tMax = &now, &now
+ hs.cMin, hs.cMax = &zero, &zero
return
}
- hs.cMin = len(hs.buckets[0].hits)
- hs.cMax = len(hs.buckets[0].hits)
- hs.tMin = hs.buckets[0].t
- hs.tMax = hs.buckets[0].t
+ lHits := len(hs.buckets[0].hits)
+ hs.cMin = &lHits
+ hs.cMax = &lHits
+ hs.tMin = &hs.buckets[0].t
+ hs.tMax = &hs.buckets[0].t
+
for _, b := range hs.buckets {
c := len(b.hits)
- if c < hs.cMin {
- hs.cMin = c
+ if c < *hs.cMin {
+ hs.cMin = &c
}
- if c > hs.cMax {
- hs.cMax = c
+ if c > *hs.cMax {
+ hs.cMax = &c
}
- if b.t.Before(hs.tMin) {
- hs.tMin = b.t
+ if b.t.Before(*hs.tMin) {
+ hs.tMin = &b.t
}
- if b.t.After(hs.tMax) {
- hs.tMax = b.t
+ if b.t.After(*hs.tMax) {
+ hs.tMax = &b.t
}
}
}
-*/
diff --git a/hit_set_test.go b/hit_set_test.go
new file mode 100644
index 0000000..59849a4
--- /dev/null
+++ b/hit_set_test.go
@@ -0,0 +1,119 @@
+package sws
+
+import (
+ "testing"
+ "time"
+)
+
+func TestHitSetSortByDate(t *testing.T) {
+ now := time.Now()
+ then := now.Add(-10 * time.Hour)
+ tests := []struct {
+ hits []*Hit
+ begin, end time.Time
+ d time.Duration
+ }{
+ {
+ hits: []*Hit{
+ {CreatedAt: now},
+ {CreatedAt: then.Add(2 * time.Hour)},
+ {CreatedAt: then.Add(time.Hour)},
+ {CreatedAt: then.Add(3 * time.Hour)},
+ },
+ begin: then,
+ end: now,
+ d: time.Hour,
+ },
+ }
+
+ for i, tt := range tests {
+ hs, err := NewHitSet(FromHits(tt.hits))
+ if err != nil {
+ t.Fatalf("%d => failed %s", i, err)
+ }
+ hs.duration = tt.d
+ hs.SortByDate()
+
+ for j := 0; j < len(hs.buckets)-1; j++ {
+ if hs.buckets[j].t.After(hs.buckets[j+1].t) {
+ t.Errorf("%d => %d is after %d", i, j, j+1)
+ }
+ }
+ }
+}
+
+func TestHitSetFill(t *testing.T) {
+ dur := time.Hour
+ now := time.Now()
+ then := now.Add(-10 * dur)
+
+ tests := []struct {
+ hits []*Hit
+ begin *time.Time
+ end *time.Time
+ }{
+ {
+ hits: []*Hit{
+ {CreatedAt: now},
+ {CreatedAt: then.Add(2 * time.Hour)},
+ {CreatedAt: then.Add(time.Hour)},
+ {CreatedAt: then.Add(3 * time.Hour)},
+ },
+ begin: &then,
+ end: &now,
+ },
+ {
+ hits: []*Hit{
+ {CreatedAt: then.Add(2 * time.Hour)},
+ },
+ begin: &then,
+ end: &now,
+ },
+ {
+ hits: []*Hit{
+ {CreatedAt: now},
+ },
+ begin: &then,
+ end: &now,
+ },
+ {
+ hits: []*Hit{
+ {CreatedAt: now},
+ },
+ begin: &then,
+ },
+ {
+ hits: []*Hit{
+ {CreatedAt: then},
+ },
+ end: &now,
+ },
+ {
+ hits: []*Hit{},
+ end: &now,
+ },
+ }
+
+ for i, tt := range tests {
+ hs, err := NewHitSet(FromHits(tt.hits))
+ if err != nil {
+ t.Fatalf("%d => failed %s", i, err)
+ }
+ hs.duration = dur
+ hs.Fill(tt.begin, tt.end)
+
+ expectedTime := then.Truncate(dur)
+ for j, b := range hs.buckets {
+ if !b.t.Equal(expectedTime) {
+ t.Errorf("%d => expected bucket %d to equal %s, got %s", i, j, expectedTime, b.t)
+ }
+ expectedTime = expectedTime.Add(dur)
+ }
+ if len(tt.hits) > 0 {
+ total := diffDurations(then, now, dur)
+ if len(hs.buckets) != total {
+ t.Errorf("%d => expected %d buckets, got %d", i, total, len(hs.buckets))
+ }
+ }
+ }
+}
diff --git a/hit_test.go b/hit_test.go
index 286e864..9d1571e 100644
--- a/hit_test.go
+++ b/hit_test.go
@@ -1,3 +1,5 @@
+// +build ignore
+
package sws
import (
diff --git a/page.go b/page.go
index 63ffc7b..fa99e6a 100644
--- a/page.go
+++ b/page.go
@@ -1,7 +1,6 @@
package sws
import (
- "sort"
"time"
)
@@ -19,8 +18,8 @@ func (p Page) YMax() int {
return p.hitSet.YMax()
}
func (p Page) XSeries() []*bucket {
- p.hitSet.Fill(nil, nil)
- sort.Sort(p.hitSet)
+ //p.hitSet.Fill(nil, nil)
+ //p.hitSet.SortByDate()
return p.hitSet.XSeries()
}
diff --git a/page_set.go b/page_set.go
index f3ab745..d97c963 100644
--- a/page_set.go
+++ b/page_set.go
@@ -1,39 +1,69 @@
package sws
-type PageSet map[string]*Page
-
-func NewPageSet(hitter Hitter) PageSet {
- out := make(map[string]*Page)
- for _, h := range hitter.Hits() {
- p, ok := out[h.Path]
- if !ok {
- p = &Page{
- Path: h.Path,
- SiteID: *h.SiteID,
- Title: h.Title,
- LastVisitedAt: h.CreatedAt,
- hitSet: &HitSet{
- duration: hitter.Duration(),
- },
- }
+import (
+ "sort"
+)
+
+type PageSet []*Page
+
+func NewPageSet(hs *HitSet) (PageSet, error) {
+ tmp := make(map[string]*Page)
+ for _, h := range hs.Hits() {
+ if _, ok := tmp[h.Path]; ok {
+ // Already captured this path
+ continue
}
- if p.LastVisitedAt.Before(h.CreatedAt) {
- p.LastVisitedAt = h.CreatedAt
+ p := &Page{
+ Path: h.Path,
+ SiteID: *h.SiteID,
+ Title: h.Title,
+ LastVisitedAt: h.CreatedAt,
+ hitSet: hs.Filter(func(t *Hit) bool {
+ return t.Path == h.Path
+ }),
}
- p.hitSet.Add(h)
- out[h.Path] = p
+ // if p.LastVisitedAt.Before(h.CreatedAt) {
+ // p.LastVisitedAt = h.CreatedAt
+ // }
+ //p.hitSet.Add(h)
+ tmp[h.Path] = p
+ }
+ out := make([]*Page, len(tmp))
+ i := 0
+ for _, p := range tmp {
+ out[i] = p
+ i++
}
- b := hitter.Begin()
- e := hitter.End()
- for _, p := range out {
- p.hitSet.Fill(&b, &e)
+ return PageSet(out), nil
+}
+
+func (ps PageSet) Hits() []*Hit {
+ out := make([]*Hit, 0)
+ for _, p := range ps {
+ out = append(out, p.hitSet.Hits()...)
}
- return PageSet(out)
+ return out
+}
+
+func (ps *PageSet) SortByPath() {
+ sort.Slice(*ps, func(i, j int) bool {
+ return (*ps)[i].Path < (*ps)[j].Path
+ })
+}
+
+func (ps *PageSet) SortByHits() {
+ sort.Slice(*ps, func(i, j int) bool {
+ return (*ps)[i].hitSet.Count() > (*ps)[j].hitSet.Count()
+ })
}
-func (ps PageSet) Page(p string) *Page {
- pg, _ := ps[p]
- return pg
+func (ps PageSet) Page(s string) *Page {
+ for _, p := range ps {
+ if p.Path == s {
+ return p
+ }
+ }
+ return nil
}
func (ps PageSet) YMax() int {
@@ -46,11 +76,5 @@ func (ps PageSet) YMax() int {
return max
}
func (ps PageSet) XSeries() []*Page {
- out := make([]*Page, len(ps))
- i := 0
- for _, v := range ps {
- out[i] = v
- i++
- }
- return out
+ return ps
}
diff --git a/static/default.css b/static/default.css
index 5faf3e4..8080298 100644
--- a/static/default.css
+++ b/static/default.css
@@ -1,8 +1,8 @@
*, *:before, *:after {
- box-sizing: inherit;
+ box-sizing: inherit;
}
html {
- box-sizing: border-box;
+ box-sizing: border-box;
}
body {
background-color: #fffaf7;
@@ -68,17 +68,38 @@ header.site {
.chart {
align-items: stretch;
display: flex;
- height: 100px;
margin: 0;
+ width: 100%;
+}
+.chart.time {
+ flex-direction: row;
+ height: 200px;
padding-top: 2em;
padding-bottom: 2em;
- width: 100%;
+}
+.chart.vertical {
+ align-items: stretch;
+ flex-direction: row;
+ height: 100px;
+ padding-top: 2em;
+ padding-bottom: 2em;
+}
+.chart.horizontal {
+ flex-direction: column;
+ height: 300px;
+ padding-left: 20px;
}
.chart .slot {
background: #fafafa;
+ display: block;
flex: 1 1;
+ margin: 0;
position: relative;
}
+.chart.horizontal .slot {
+ margin-top: 1px;
+ margin-bottom: 1px;
+}
.chart .slot.midnight {
border-left: 1px solid #ddd;
}
@@ -87,28 +108,51 @@ header.site {
}
.chart .bar {
background: #4c92ff;
- border-top-left-radius: 3px;
- border-top-right-radius: 3px;
bottom: 0;
left: 0;
- margin-left: 2px;
- margin-right: 2px;
+ margin: 0;
position: absolute;
+}
+.chart.time .bar,
+.chart.vertical .bar {
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
right: 0;
}
-.chart.time .bar {
- margin-left: 0;
- margin-right: 0;
+.chart.vertical .bar {
+ margin-left: 2px;
+ margin-right: 2px;
+}
+.chart.horizontal .bar {
+ border-bottom-right-radius: 3px;
+ border-top-right-radius: 3px;
+ bottom: unset;
+ height: 100%;
+ right: unset;
+ top: 0;
}
.chart .slot:hover .bar {
background: #88b6ff;
}
+.chart .slot:hover:before,
+.chart .slot:hover:after {
+ display: block;
+ position: relative;
+}
.chart .slot:before,
.chart .slot:after {
- bottom: 100%;
content: attr(data-y);
display: none;
font-size: .75em;
+}
+.chart .slot:after {
+ content: attr(data-x);
+}
+.chart.time .slot:before,
+.chart.time .slot:after,
+.chart.vertical .slot:before,
+.chart.vertical .slot:after {
+ bottom: 100%;
left: 50%;
margin-bottom: .5rem;
margin-left: -2.5rem;
@@ -116,13 +160,27 @@ header.site {
text-align: center;
width: 5rem;
}
-/* time */
-.chart .slot:after {
- content: attr(data-x);
+.chart.time .slot:after,
+.chart.vertical .slot:after {
margin-top: .5rem;
top: 100%;
}
-.chart .slot:hover:before,
-.chart .slot:hover:after {
- display: block;
+.chart.horizontal .slot:before,
+.chart.horizontal .slot:after {
+ font-size: 1em;
+ height: 100%;
+ margin-left: 10px;
+ margin-bottom: 0;
+ position: absolute;
+}
+.chart.horizontal .slot:before {
+ left: -20px;
+ margin-left: 0;
+ text-align: center;
+ width: 20px;
+}
+.chart.horizontal .slot:after {
+ left: 0;
+ width: auto;
+ z-index: 10;
}
diff --git a/templates/charts.tmpl b/templates/charts.tmpl
index 8d3bba1..173cbab 100644
--- a/templates/charts.tmpl
+++ b/templates/charts.tmpl
@@ -3,14 +3,14 @@
{{ $max := .YMax }}
{{ range .XSeries }}
<li class="slot{{ if eq .Time.Hour 0 }} midnight{{ end }}" data-x="{{ .Label }}" data-y="{{ .YValue }}" data-percent="{{ percent .YValue $max }}">
- <div class="bar" style="height:{{ percent .YValue $max }}%" />
+ <time class="bar" style="height:{{ percent .YValue $max }}%" datetime="{{ .Time }}"/>
</li>
{{ end }}
</ul>
{{ end }}
{{ define "barChart" }}
- <ul class="chart bar category">
+ <ul class="chart bar vertical">
{{ $max := .YMax }}
{{ range .XSeries }}
<li class="slot" data-x="{{ .Label }}" data-y="{{ .YValue }}" data-percent="{{ percent .YValue $max }}">
@@ -21,6 +21,18 @@
</ul>
{{ end }}
+{{ define "barChartHorizontal" }}
+ <ul class="chart bar horizontal">
+ {{ $max := .YMax }}
+ {{ range .XSeries }}
+ <li class="slot" data-x="{{ .Label }}" data-y="{{ .YValue }}" data-percent="{{ percent .YValue $max }}">
+ <div class="bar" style="width:{{ percent .YValue $max }}%" />
+ </li>
+ {{ else }}
+ {{ end }}
+ </ul>
+{{ end }}
+
{{ define "stackedBarChart" }}
<ul class="chart bar stacked">
{{ $max := .CountMax }}
diff --git a/templates/login.tmpl b/templates/login.tmpl
index 80badee..13a942c 100644
--- a/templates/login.tmpl
+++ b/templates/login.tmpl
@@ -1,7 +1,7 @@
{{ define "content" }}
<main>
<h2>Login</h2>
- <form method="post" action="/login">
+ <form method="post">
<div class="field">
<input type="email" name="email" placeholder="your email" />
</div>
diff --git a/templates/site.tmpl b/templates/site.tmpl
index d46bbd5..236f7f4 100644
--- a/templates/site.tmpl
+++ b/templates/site.tmpl
@@ -9,7 +9,12 @@
<fig>
{{ template "timeBarChart" .Hits }}
</fig>
+
<h2>Popular pages</h2>
+ <fig>
+ {{ template "barChartHorizontal" .Pages }}
+ </fig>
+
<ul class="pages">
{{ $pages := .Pages }}
{{ range .Pages }}
@@ -21,13 +26,13 @@
{{ end }}
</ul>
<h2>User Agents</h2>
- {{ if .UserAgents }}
+ {{ if .Browsers }}
<fig>
- {{ template "barChart" .UserAgents }}
+ {{ template "barChart" .Browsers }}
</fig>
- <ul class="agents">
- {{ range .UserAgents }}
- {{ template "uaForList" . }}
+ <ul class="browsers">
+ {{ range .Browsers }}
+ {{ template "browserForList" . }}
{{ end }}
</ul>
{{ else }}
@@ -45,10 +50,9 @@
</li>
{{ end }}
-{{ define "uaForList" }}
+{{ define "browserForList" }}
<li>
- <h4 class="engine">{{ .Browser }}</h4>
- <span class="user-agent">{{ .Name }}</span>
+ <h4 class="name">{{ .Name }}</h4>
<span class="last-seen">{{ .LastSeenAt|timeLong }}</span>
<span class="count">{{ .Count }}</span>
</li>
diff --git a/time_buckets_test.go b/time_buckets_test.go
index c97d1f1..813845a 100644
--- a/time_buckets_test.go
+++ b/time_buckets_test.go
@@ -1,3 +1,5 @@
+// +build ignore
+
package sws
import (
diff --git a/user_agent_set.go b/user_agent_set.go
deleted file mode 100644
index 435e9cd..0000000
--- a/user_agent_set.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package sws
-
-import (
- detector "github.com/mssola/user_agent"
-)
-
-type UserAgentSet map[string]*UserAgent
-
-// NewUserAgentSet collects the browsers from provided hits.
-func NewUserAgentSet(hitter Hitter) UserAgentSet {
- out := make(map[string]*UserAgent)
- for _, h := range hitter.Hits() {
- if h.UserAgentHash == nil {
- // TODO
- continue
- }
- d := detector.New(h.UserAgent.Name)
- browser, _ := d.Browser()
- b, ok := out[browser]
- if !ok {
- b = &UserAgent{
- Name: h.UserAgent.Name,
- LastSeenAt: h.CreatedAt,
- hitSet: &HitSet{},
- ua: d,
- }
- }
- if b.LastSeenAt.Before(h.CreatedAt) {
- b.LastSeenAt = h.CreatedAt
- }
- b.hitSet.Add(h)
- out[browser] = b
- }
- return UserAgentSet(out)
-}
-
-func (uas UserAgentSet) YMax() int {
- max := 0
- for _, ua := range uas {
- if ua.Count() > max {
- max = ua.Count()
- }
- }
- return max
-}
-func (uas UserAgentSet) XSeries() []*UserAgent {
- out := make([]*UserAgent, len(uas))
- i := 0
- for _, v := range uas {
- out[i] = v
- i++
- }
- return out
-}