diff options
author | Felix Hanley <felix@userspace.com.au> | 2020-02-28 02:19:40 +0000 |
---|---|---|
committer | Felix Hanley <felix@userspace.com.au> | 2020-02-28 02:19:40 +0000 |
commit | 71f16081700ccdae5442aa2abf969012a999c774 (patch) | |
tree | 6b57b0466ff3a2858c8f87a2e86354dd5eab0ca6 | |
parent | e3733a11476f580709ed27f89464aaf83349288d (diff) | |
download | sws-71f16081700ccdae5442aa2abf969012a999c774.tar.gz sws-71f16081700ccdae5442aa2abf969012a999c774.tar.bz2 |
Update auth and timezone fixes
-rw-r--r-- | browser_set.go | 78 | ||||
-rw-r--r-- | charts.go | 3 | ||||
-rw-r--r-- | cmd/server/auth.go | 6 | ||||
-rw-r--r-- | cmd/server/charts.go | 14 | ||||
-rw-r--r-- | cmd/server/handlers.go | 18 | ||||
-rw-r--r-- | cmd/server/helpers.go | 14 | ||||
-rw-r--r-- | cmd/server/main.go | 52 | ||||
-rw-r--r-- | cmd/server/sites.go | 21 | ||||
-rw-r--r-- | hit.go | 2 | ||||
-rw-r--r-- | hit_set.go | 187 | ||||
-rw-r--r-- | hit_set_test.go | 119 | ||||
-rw-r--r-- | hit_test.go | 2 | ||||
-rw-r--r-- | page.go | 5 | ||||
-rw-r--r-- | page_set.go | 94 | ||||
-rw-r--r-- | static/default.css | 94 | ||||
-rw-r--r-- | templates/charts.tmpl | 16 | ||||
-rw-r--r-- | templates/login.tmpl | 2 | ||||
-rw-r--r-- | templates/site.tmpl | 20 | ||||
-rw-r--r-- | time_buckets_test.go | 2 | ||||
-rw-r--r-- | user_agent_set.go | 54 |
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 +} @@ -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 { @@ -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 { @@ -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 ( @@ -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 -} |