├── .gitignore ├── Procfile ├── README.md ├── go.mod ├── go.sum ├── gogal ├── assets │ ├── .gitkeep │ └── GeoLite2-City.mmdb ├── handler │ └── event.go ├── model │ └── event.go ├── repository │ └── event.go ├── route │ └── route.go ├── server.go ├── service │ └── event.go ├── utils │ └── counter │ │ └── counter.go └── web │ ├── css │ └── style.css │ ├── index.html │ └── js │ └── script.js └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/go,linux,macos,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=go,linux,macos,visualstudiocode 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | ### Go Patch ### 23 | /vendor/ 24 | /Godeps/ 25 | 26 | ### Linux ### 27 | *~ 28 | 29 | # temporary files which can be created if a process still has a handle open of a deleted file 30 | .fuse_hidden* 31 | 32 | # KDE directory preferences 33 | .directory 34 | 35 | # Linux trash folder which might appear on any partition or disk 36 | .Trash-* 37 | 38 | # .nfs files are created when an open file is removed but is still being accessed 39 | .nfs* 40 | 41 | ### macOS ### 42 | # General 43 | .DS_Store 44 | .AppleDouble 45 | .LSOverride 46 | 47 | # Icon must end with two \r 48 | Icon 49 | 50 | # Thumbnails 51 | ._* 52 | 53 | # Files that might appear in the root of a volume 54 | .DocumentRevisions-V100 55 | .fseventsd 56 | .Spotlight-V100 57 | .TemporaryItems 58 | .Trashes 59 | .VolumeIcon.icns 60 | .com.apple.timemachine.donotpresent 61 | 62 | # Directories potentially created on remote AFP share 63 | .AppleDB 64 | .AppleDesktop 65 | Network Trash Folder 66 | Temporary Items 67 | .apdisk 68 | 69 | ### VisualStudioCode ### 70 | .vscode/* 71 | !.vscode/settings.json 72 | !.vscode/tasks.json 73 | !.vscode/launch.json 74 | !.vscode/extensions.json 75 | 76 | ### VisualStudioCode Patch ### 77 | # Ignore all local history of files 78 | .history 79 | 80 | # End of https://www.gitignore.io/api/go,linux,macos,visualstudiocode -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./bin/go-gal-analytics -p $PORT -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-gal-analytics 2 | This repo is part of a blog post tutorial [Build Google Analytics in Go](https://eryb.space/2020/06/05/build-google-analytics-in-go.html) 3 | 4 | Feel free to make pull requests by adding features or fixing bugs. 5 | Remember, we all were once beginners. 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/erybz/go-gal-analytics 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1 7 | github.com/davecgh/go-spew v1.1.1 // indirect 8 | github.com/julienschmidt/httprouter v1.3.0 9 | github.com/kr/pretty v0.1.0 // indirect 10 | github.com/oschwald/geoip2-golang v1.4.0 11 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce 12 | golang.org/x/text v0.3.0 13 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1 h1:9h8f71kuF1pqovnn9h7LTHLEjxzyQaj0j1rQq5nsMM4= 2 | github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1/go.mod h1:noBAuukeYOXa0aXGqxr24tADqkwDO2KRD15FsuaZ5a8= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 8 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 9 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 10 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 11 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 12 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 13 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 14 | github.com/oschwald/geoip2-golang v1.4.0 h1:5RlrjCgRyIGDz/mBmPfnAF4h8k0IAcRv9PvrpOfz+Ug= 15 | github.com/oschwald/geoip2-golang v1.4.0/go.mod h1:8QwxJvRImBH+Zl6Aa6MaIcs5YdlZSTKtzmPGzQqi9ng= 16 | github.com/oschwald/maxminddb-golang v1.6.0 h1:KAJSjdHQ8Kv45nFIbtoLGrGWqHFajOIm7skTyz/+Dls= 17 | github.com/oschwald/maxminddb-golang v1.6.0/go.mod h1:DUJFucBg2cvqx42YmDa/+xHvb0elJtOm3o4aFQ/nb/w= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 22 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 23 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= 24 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= 25 | golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 h1:Dho5nD6R3PcW2SH1or8vS0dszDaXRxIw55lBX7XiE5g= 26 | golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 31 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 33 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 34 | -------------------------------------------------------------------------------- /gogal/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erybz/go-gal-analytics/61b29cf03dfb26ddb932a2c428bc1a2f835365da/gogal/assets/.gitkeep -------------------------------------------------------------------------------- /gogal/assets/GeoLite2-City.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erybz/go-gal-analytics/61b29cf03dfb26ddb932a2c428bc1a2f835365da/gogal/assets/GeoLite2-City.mmdb -------------------------------------------------------------------------------- /gogal/handler/event.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/erybz/go-gal-analytics/gogal/repository" 9 | "github.com/erybz/go-gal-analytics/gogal/service" 10 | "github.com/julienschmidt/httprouter" 11 | ) 12 | 13 | // EventHandler is handler for Events 14 | type EventHandler struct { 15 | eventService *service.EventService 16 | } 17 | 18 | // NewEventHandler creates and returns new EventHandler 19 | func NewEventHandler() *EventHandler { 20 | return &EventHandler{ 21 | eventService: service.NewEventService(), 22 | } 23 | } 24 | 25 | // Track accepts analytics request and builds event from it 26 | func (h *EventHandler) Track(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 27 | if r.Method != http.MethodGet { 28 | http.Error(w, "Request method is not GET", http.StatusNotFound) 29 | return 30 | } 31 | event, err := h.eventService.BuildEvent(r) 32 | if err != nil { 33 | log.Println(err) 34 | } 35 | 36 | if event != nil && event.Valid() { 37 | h.eventService.LogEvent(event) 38 | } 39 | 40 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 41 | w.Header().Set("Content-Type", "image/gif") 42 | w.Write(createPixel()) 43 | } 44 | 45 | // Stats retrieves stats for the specified query 46 | func (h *EventHandler) Stats(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 47 | if r.Method != http.MethodGet { 48 | http.Error(w, "Request method is not GET", http.StatusNotFound) 49 | return 50 | } 51 | 52 | urlVals := r.URL.Query() 53 | query := urlVals.Get("q") 54 | 55 | stats := h.eventService.Stats( 56 | repository.Stats(query), 57 | ) 58 | 59 | w.Header().Set("Content-Type", "application/json") 60 | json.NewEncoder(w).Encode(stats) 61 | } 62 | 63 | func createPixel() []byte { 64 | return []byte{ 65 | 71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 128, 0, 0, 0, 0, 0, 66 | 255, 255, 255, 33, 249, 4, 1, 0, 0, 0, 0, 44, 0, 0, 0, 0, 67 | 1, 0, 1, 0, 0, 2, 1, 68, 0, 59, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /gogal/model/event.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Event struct { 4 | Location EventLocation `json:"location"` 5 | Device EventDevice `json:"device"` 6 | Referral string `json:"referral"` 7 | } 8 | 9 | type EventLocation struct { 10 | Country string `json:"country"` 11 | City string `json:"city"` 12 | } 13 | 14 | type EventDevice struct { 15 | Type string `json:"type"` 16 | Platform string `json:"platform"` 17 | OS string `json:"os"` 18 | Browser string `json:"browser"` 19 | Language string `json:"language"` 20 | } 21 | 22 | func (e *Event) Valid() bool { 23 | if e.Location.City != "" || 24 | e.Location.Country != "" || 25 | e.Device.Type != "" || 26 | e.Device.Platform != "" || 27 | e.Device.OS != "" || 28 | e.Device.Browser != "" { 29 | return true 30 | } 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /gogal/repository/event.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/erybz/go-gal-analytics/gogal/model" 5 | "github.com/erybz/go-gal-analytics/gogal/utils/counter" 6 | ) 7 | 8 | // Stats are constants for which event stats can be retrieved 9 | type Stats string 10 | 11 | const ( 12 | // StatsLocationCountry is stats for Country 13 | StatsLocationCountry Stats = "country" 14 | // StatsLocationCity is stats for City 15 | StatsLocationCity = "city" 16 | // StatsDeviceType is stats for Device Type 17 | StatsDeviceType = "deviceType" 18 | // StatsDevicePlatform is stats for Device Platform 19 | StatsDevicePlatform = "devicePlatform" 20 | // StatsDeviceOS is stats for OS 21 | StatsDeviceOS = "os" 22 | // StatsDeviceBrowser is stats for Browser 23 | StatsDeviceBrowser = "browser" 24 | // StatsDeviceLanguage is stats for Language 25 | StatsDeviceLanguage = "language" 26 | // StatsReferral is stats for Referral 27 | StatsReferral = "referral" 28 | ) 29 | 30 | // EventRepository is storage repository for Events 31 | type EventRepository struct { 32 | locationCountry *counter.Counter 33 | locationCity *counter.Counter 34 | deviceType *counter.Counter 35 | devicePlatform *counter.Counter 36 | deviceOS *counter.Counter 37 | deviceBrowser *counter.Counter 38 | deviceLanguage *counter.Counter 39 | referral *counter.Counter 40 | } 41 | 42 | // NewEventRepository creates and returns new EventRepository 43 | func NewEventRepository() *EventRepository { 44 | return &EventRepository{ 45 | locationCountry: counter.NewCounter(), 46 | locationCity: counter.NewCounter(), 47 | deviceType: counter.NewCounter(), 48 | devicePlatform: counter.NewCounter(), 49 | deviceOS: counter.NewCounter(), 50 | deviceBrowser: counter.NewCounter(), 51 | deviceLanguage: counter.NewCounter(), 52 | referral: counter.NewCounter(), 53 | } 54 | } 55 | 56 | // AddEvent adds event to the repository 57 | func (tr *EventRepository) AddEvent(ev *model.Event) { 58 | tr.locationCountry.Incr(ev.Location.Country) 59 | tr.locationCity.Incr(ev.Location.City) 60 | tr.deviceType.Incr(ev.Device.Type) 61 | tr.devicePlatform.Incr(ev.Device.Platform) 62 | tr.deviceOS.Incr(ev.Device.OS) 63 | tr.deviceBrowser.Incr(ev.Device.Browser) 64 | tr.deviceLanguage.Incr(ev.Device.Language) 65 | tr.referral.Incr(ev.Referral) 66 | } 67 | 68 | // Events returns stats for the specified query 69 | func (tr *EventRepository) Events(d Stats) map[string]uint64 { 70 | m := make(map[string]uint64) 71 | switch d { 72 | case StatsLocationCountry: 73 | m = tr.locationCountry.Items() 74 | case StatsLocationCity: 75 | m = tr.locationCity.Items() 76 | case StatsDeviceType: 77 | m = tr.deviceType.Items() 78 | case StatsDevicePlatform: 79 | m = tr.devicePlatform.Items() 80 | case StatsDeviceOS: 81 | m = tr.deviceOS.Items() 82 | case StatsDeviceBrowser: 83 | m = tr.deviceBrowser.Items() 84 | case StatsDeviceLanguage: 85 | m = tr.deviceLanguage.Items() 86 | case StatsReferral: 87 | m = tr.referral.Items() 88 | } 89 | return m 90 | } 91 | -------------------------------------------------------------------------------- /gogal/route/route.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/erybz/go-gal-analytics/gogal/handler" 7 | "github.com/julienschmidt/httprouter" 8 | ) 9 | 10 | // Routes initializes the routes 11 | func Routes() http.Handler { 12 | rt := httprouter.New() 13 | 14 | eventHandler := handler.NewEventHandler() 15 | rt.GET("/knock-knock", eventHandler.Track) 16 | rt.GET("/stats", eventHandler.Stats) 17 | 18 | rt.ServeFiles("/dashboard/*filepath", http.Dir("./gogal/web")) 19 | 20 | return rt 21 | } 22 | -------------------------------------------------------------------------------- /gogal/server.go: -------------------------------------------------------------------------------- 1 | package gogal 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | // Server struct containing hostname and port 10 | type Server struct { 11 | Hostname string `json:"hostname"` 12 | HTTPPort string `json:"httpPort"` 13 | } 14 | 15 | // NewServer creates new instance of server 16 | func NewServer(host, port string) *Server { 17 | return &Server{ 18 | Hostname: host, 19 | HTTPPort: port, 20 | } 21 | } 22 | 23 | // Run starts the server at specified host and port 24 | func (s *Server) Run(h http.Handler) { 25 | fmt.Println(s.Message()) 26 | 27 | log.Printf("Listening at %s", s.Address()) 28 | log.Fatal(http.ListenAndServe(s.Address(), h)) 29 | } 30 | 31 | // Address returns formatted hostname and port 32 | func (s *Server) Address() string { 33 | return fmt.Sprintf("%s:%s", s.Hostname, s.HTTPPort) 34 | } 35 | 36 | // Message is the server start message 37 | func (s *Server) Message() string { 38 | m := ` 39 | .__ 40 | ____ ____ _________ | | 41 | / ___\ / _ \ ______ / ___\__ \ | | 42 | / /_/ ( <_> ) /_____/ / /_/ / __ \| |__ 43 | \___ / \____/ \___ (____ |____/ 44 | /_____/ /_____/ \/ 45 | Analytics 46 | 47 | ` 48 | return m 49 | } 50 | -------------------------------------------------------------------------------- /gogal/service/event.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/avct/uasurfer" 10 | "github.com/erybz/go-gal-analytics/gogal/model" 11 | "github.com/erybz/go-gal-analytics/gogal/repository" 12 | "github.com/oschwald/geoip2-golang" 13 | "github.com/tomasen/realip" 14 | "golang.org/x/text/language" 15 | ) 16 | 17 | // EventService is service for event logging and stats 18 | type EventService struct { 19 | eventRepo *repository.EventRepository 20 | geoIPReader *geoip2.Reader 21 | } 22 | 23 | // NewEventService returns new EventService 24 | func NewEventService() *EventService { 25 | return &EventService{ 26 | eventRepo: repository.NewEventRepository(), 27 | geoIPReader: initGeoIPReader("gogal/assets/GeoLite2-City.mmdb"), 28 | } 29 | } 30 | 31 | // BuildEvent builds a trackable event from the request 32 | func (ts *EventService) BuildEvent(r *http.Request) (*model.Event, error) { 33 | clientIP := net.ParseIP(realip.FromRequest(r)) 34 | userAgent := uasurfer.Parse(r.UserAgent()) 35 | referrerURL, _ := url.Parse(r.Referer()) 36 | langTags, _, _ := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")) 37 | 38 | userLanguage := "" 39 | if langTags != nil && len(langTags) >= 1 { 40 | userLanguage = langTags[0].String() 41 | } 42 | 43 | geoData, err := ts.geoIPReader.City(clientIP) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | if userAgent.IsBot() { 49 | return nil, nil 50 | } 51 | 52 | event := &model.Event{ 53 | Location: model.EventLocation{ 54 | Country: geoData.Country.Names["en"], 55 | City: geoData.City.Names["en"], 56 | }, 57 | Device: model.EventDevice{ 58 | Type: userAgent.DeviceType.StringTrimPrefix(), 59 | Platform: userAgent.OS.Platform.StringTrimPrefix(), 60 | OS: userAgent.OS.Name.StringTrimPrefix(), 61 | Browser: userAgent.Browser.Name.StringTrimPrefix(), 62 | Language: userLanguage, 63 | }, 64 | Referral: referrerURL.Hostname(), 65 | } 66 | return event, nil 67 | } 68 | 69 | // LogEvent logs the event to repository 70 | func (ts *EventService) LogEvent(event *model.Event) { 71 | ts.eventRepo.AddEvent(event) 72 | } 73 | 74 | // Stats retrieves event statistics from the repository 75 | func (ts *EventService) Stats(dim repository.Stats) []map[string]interface{} { 76 | allStats := make([]map[string]interface{}, 0, 1) 77 | for k, v := range ts.eventRepo.Events(dim) { 78 | stat := map[string]interface{}{ 79 | string(dim): k, 80 | "pageViews": v, 81 | } 82 | allStats = append(allStats, stat) 83 | } 84 | return allStats 85 | } 86 | 87 | func initGeoIPReader(path string) *geoip2.Reader { 88 | db, err := geoip2.Open(path) 89 | if err != nil { 90 | log.Fatal(err) 91 | } 92 | return db 93 | } 94 | -------------------------------------------------------------------------------- /gogal/utils/counter/counter.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // Counter is go routine safe counter used to count events 8 | type Counter struct { 9 | sync.RWMutex 10 | counter map[string]uint64 11 | } 12 | 13 | // NewCounter returns new Counter 14 | func NewCounter() *Counter { 15 | return &Counter{ 16 | counter: make(map[string]uint64), 17 | } 18 | } 19 | 20 | // Incr increments counter for specified key 21 | func (c *Counter) Incr(k string) { 22 | c.Lock() 23 | c.counter[k]++ 24 | c.Unlock() 25 | } 26 | 27 | // Val returns current value for specified key 28 | func (c *Counter) Val(k string) uint64 { 29 | var v uint64 30 | c.RLock() 31 | v = c.counter[k] 32 | c.RUnlock() 33 | return v 34 | } 35 | 36 | // Items returns all the counter items 37 | func (c *Counter) Items() map[string]uint64 { 38 | c.RLock() 39 | items := make(map[string]uint64, len(c.counter)) 40 | for k, v := range c.counter { 41 | items[k] = v 42 | } 43 | c.RUnlock() 44 | return items 45 | } 46 | -------------------------------------------------------------------------------- /gogal/web/css/style.css: -------------------------------------------------------------------------------- 1 | .table thead th { 2 | position: sticky; 3 | top: 0; 4 | background: white; 5 | } 6 | 7 | .table-wrap { 8 | margin: 40px 0; 9 | max-height: 400px; 10 | overflow-y: scroll; 11 | } 12 | 13 | .chart-wrap { 14 | margin: 40px 0; 15 | } 16 | 17 | @media (max-width: 425px) { 18 | pre { 19 | font-size: 50%; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /gogal/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |21 | .__ 22 | ____ ____ _________ | | 23 | / ___\ / _ \ ______ / ___\__ \ | | 24 | / /_/ ( <_> ) /_____/ / /_/ / __ \| |__ 25 | \___ / \____/ \___ (____ |____/ 26 | /_____/ /_____/ \/ 27 | Analytics 28 |29 |