diff --git a/charts.go b/charts.go new file mode 100644 index 0000000..a603f91 --- /dev/null +++ b/charts.go @@ -0,0 +1,164 @@ +package ss13hub + +import ( + "bytes" + "fmt" + "io" + "net/http" + "time" + + chart "github.com/wcharczuk/go-chart" +) + +type renderableChart interface { + Render(chart.RendererProvider, io.Writer) error +} + +func (a *App) renderChart(w http.ResponseWriter, c renderableChart) error { + buf := &bytes.Buffer{} + err := c.Render(chart.PNG, buf) + + if err != nil { + //a.Log("Error while rendering chart: %s", err) + return HttpError{ + Status: http.StatusInternalServerError, + Err: fmt.Errorf("error while rendering chart"), + } + } + + w.Header().Add("Content-Type", "image/png") + _, err = io.Copy(w, buf) + if err != nil { + a.Log("Error while sending chart: %s", err) + return HttpError{ + Status: http.StatusInternalServerError, + Err: fmt.Errorf("error while sending chart"), + } + } + + return nil +} + +func makeHistoryChart(title string, points []ServerPoint) chart.Chart { + var xVals []time.Time + var yVals []float64 + for _, p := range points { + xVals = append(xVals, p.Time) + yVals = append(yVals, float64(p.Players)) + } + + series := chart.TimeSeries{ + Name: "Players", + XValues: xVals, + YValues: yVals, + } + li := &chart.LinearRegressionSeries{ + Name: "Linear regression", + InnerSeries: series, + } + sma := &chart.SMASeries{ + Name: "Simple moving avg.", + InnerSeries: series, + } + + c := chart.Chart{ + Title: title, + TitleStyle: chart.Style{ + Show: true, + }, + Background: chart.Style{ + Padding: chart.Box{ + Left: 120, + }, + }, + XAxis: chart.XAxis{ + Style: chart.Style{ + Show: true, + }, + }, + YAxis: chart.YAxis{ + Style: chart.Style{ + Show: true, + }, + }, + Series: []chart.Series{ + series, + li, + sma, + }, + } + // Add a legend + c.Elements = []chart.Renderable{ + chart.LegendLeft(&c), + } + return c +} + +// NOTE: The chart won't be renderable unless we've got at least two days of history +func makeDayAverageChart(title string, points []ServerPoint) chart.BarChart { + days := make(map[time.Weekday][]int) + for _, p := range points { + day := p.Time.Weekday() + days[day] = append(days[day], p.Players) + } + + avgDays := make(map[time.Weekday]float64) + for day, vals := range days { + sum := 0 + for _, v := range vals { + sum += v + } + avg := sum / len(vals) + avgDays[day] = float64(avg) + } + + prettyName := func(d time.Weekday) string { + return fmt.Sprintf("%s (%.0f)", d, avgDays[d]) + } + + return chart.BarChart{ + Title: title, + TitleStyle: chart.Style{ + Show: true, + }, + BarWidth: 60, + XAxis: chart.Style{ + Show: true, + }, + YAxis: chart.YAxis{ + Style: chart.Style{ + Show: true, + }, + }, + Bars: []chart.Value{ + chart.Value{ + Label: prettyName(time.Monday), + Value: avgDays[time.Monday], + }, + chart.Value{ + Label: prettyName(time.Tuesday), + Value: avgDays[time.Tuesday], + }, + chart.Value{ + Label: prettyName(time.Wednesday), + Value: avgDays[time.Wednesday], + }, + chart.Value{ + Label: prettyName(time.Thursday), + Value: avgDays[time.Thursday], + }, + chart.Value{ + Label: prettyName(time.Friday), + Value: avgDays[time.Friday], + }, + chart.Value{ + Label: prettyName(time.Saturday), + Value: avgDays[time.Saturday], + }, + chart.Value{ + Label: prettyName(time.Sunday), + Value: avgDays[time.Sunday], + }, + }, + } +} diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..baa8a2f --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,29 @@ +package main + +import ( + "time" + + "github.com/lmas/ss13hub" +) + +func main() { + // TODO: load config from a toml file + conf := ss13hub.Conf{ + WebAddr: ":8000", + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + ScrapeTimeout: 10 * time.Minute, + Storage: &ss13hub.StorageSqlite{ + Path: "./tmp/servers.db", + }, + } + app, err := ss13hub.New(conf) + if err != nil { + panic(err) + } + + err = app.Run() + if err != nil { + panic(err) + } +} diff --git a/handler_helpers.go b/handler_helpers.go new file mode 100644 index 0000000..2305293 --- /dev/null +++ b/handler_helpers.go @@ -0,0 +1,49 @@ +package ss13hub + +import ( + "fmt" + "log" + "net/http" + "time" + + "github.com/gorilla/mux" +) + +type HttpError struct { + Status int + Err error +} + +func (s HttpError) Error() string { + return fmt.Sprintf("%d %s", s.Status, s.Err.Error()) +} + +type handlerVars map[string]string + +type handler func(http.ResponseWriter, *http.Request, handlerVars) error + +func (h handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + start := time.Now() + err := h(rw, req, mux.Vars(req)) + dur := time.Since(start) + + if err != nil { + switch e := err.(type) { + case HttpError: + http.Error(rw, e.Error(), e.Status) + default: + http.Error(rw, http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError) + } + } + + log.Printf("%s %s \t%s \terr: %v\n", + //req.RemoteAddr, + req.Method, + req.URL.String(), + //req.UserAgent(), + dur, + //resp.Status, + err, + ) +} diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..3b329c5 --- /dev/null +++ b/handlers.go @@ -0,0 +1,104 @@ +package ss13hub + +import ( + "fmt" + "net/http" +) + +func (a *App) pageIndex(w http.ResponseWriter, r *http.Request, vars handlerVars) error { + servers, err := a.store.GetServers() + if err != nil { + return err + } + + // Remove the internal entry used to count total players + index := -1 + for i, s := range servers { + if s.Title == internalServerTitle { + index = i + break + } + } + var hub ServerEntry + if index > -1 { + hub = servers[index] + servers = append(servers[:index], servers[index+1:]...) + } + + return a.templates["index"].Execute(w, map[string]interface{}{ + "Servers": servers, + "Hub": hub, + "TotalServers": len(servers), + }) +} + +func (a *App) pageServer(w http.ResponseWriter, r *http.Request, vars handlerVars) error { + id := vars["id"] + server, err := a.store.GetServer(id) + if err != nil { + // TODO: handle and log the error properly + return HttpError{ + Status: 404, + Err: fmt.Errorf("server not found"), + } + } + + if server.Title == internalServerTitle { + server.Title = "Global stats" + } + + return a.templates["server"].Execute(w, map[string]interface{}{ + "Server": server, + }) +} + +func (a *App) pageDailyChart(w http.ResponseWriter, r *http.Request, vars handlerVars) error { + id := vars["id"] + points, err := a.store.GetSingleServerHistory(id, 1) + if err != nil { + return err + } + if len(points) < 1 { + return HttpError{ + Status: 404, + Err: fmt.Errorf("server not found"), + } + } + + c := makeHistoryChart("Daily history", points) + return a.renderChart(w, c) +} + +func (a *App) pageWeeklyChart(w http.ResponseWriter, r *http.Request, vars handlerVars) error { + id := vars["id"] + points, err := a.store.GetSingleServerHistory(id, 7) + if err != nil { + return err + } + if len(points) < 1 { + return HttpError{ + Status: 404, + Err: fmt.Errorf("server not found"), + } + } + + c := makeHistoryChart("Weekly history", points) + return a.renderChart(w, c) +} + +func (a *App) pageAverageChart(w http.ResponseWriter, r *http.Request, vars handlerVars) error { + id := vars["id"] + points, err := a.store.GetSingleServerHistory(id, 7) + if err != nil { + return err + } + if len(points) < 1 { + return HttpError{ + Status: 404, + Err: fmt.Errorf("server not found"), + } + } + + c := makeDayAverageChart("Average per day", points) + return a.renderChart(w, c) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a7a243e --- /dev/null +++ b/main.go @@ -0,0 +1,184 @@ +package ss13hub + +import ( + "html/template" + "log" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" +) + +// Used internally for logging a global # of players +const internalServerTitle string = "_ss13.se" + +type Conf struct { + // Web stuff + WebAddr string + ReadTimeout time.Duration + WriteTimeout time.Duration + + // Scraper stuff + ScrapeTimeout time.Duration + + // Misc. + Storage Storage +} + +type App struct { + conf Conf + web *http.Server + store Storage + templates map[string]*template.Template +} + +func New(c Conf) (*App, error) { + templates, err := loadTemplates() + if err != nil { + return nil, err + } + + w := &http.Server{ + Addr: c.WebAddr, + ReadTimeout: c.ReadTimeout, + WriteTimeout: c.WriteTimeout, + } + + a := &App{ + conf: c, + web: w, + store: c.Storage, + templates: templates, + } + + r := mux.NewRouter() + r.Handle("/", handler(a.pageIndex)) + r.Handle("/server/{id}", handler(a.pageServer)) + r.Handle("/server/{id}/daily", handler(a.pageDailyChart)) + r.Handle("/server/{id}/weekly", handler(a.pageWeeklyChart)) + r.Handle("/server/{id}/average", handler(a.pageAverageChart)) + a.web.Handler = r + + return a, nil +} + +func (a *App) Log(msg string, args ...interface{}) { + log.Printf(msg+"\n", args...) +} + +func (a *App) Run() error { + a.Log("Opening storage...") + err := a.store.Open() + if err != nil { + return err + } + + a.Log("Running updater") + go a.runUpdater() + + a.Log("Running server on %s", a.conf.WebAddr) + return a.web.ListenAndServe() +} + +func (a *App) runUpdater() { + webClient := &http.Client{ + Timeout: 60 * time.Second, + } + + for { + now := time.Now() + servers, err := scrapeByond(webClient, now) + dur := time.Since(now) + a.Log("Scrape done in %s, errors: %v", dur, err) + + if err == nil { + servers = append(servers, a.makeHubEntry(now, servers)) + + if err := a.store.SaveServers(servers); err != nil { + a.Log("Error saving servers: %s", err) + } + + if err := a.updateHistory(now, servers); err != nil { + a.Log("Error saving server history: %s", err) + } + + if err := a.updateOldServers(now, servers); err != nil { + a.Log("Error updating old servers: %s", err) + } + } + + time.Sleep(a.conf.ScrapeTimeout) + } +} + +func (a *App) updateHistory(t time.Time, servers []ServerEntry) error { + var history []ServerPoint + for _, s := range servers { + history = append(history, ServerPoint{ + Time: t, + ServerID: s.ID, + Players: s.Players, + }) + } + return a.store.SaveServerHistory(history) +} + +func (a *App) updateOldServers(t time.Time, servers []ServerEntry) error { + var old []ServerEntry + for _, s := range servers { + if !s.Time.Equal(t) { + s.Players = 0 + old = append(old, s) + } + } + + var remove []ServerEntry + for index, s := range old { + delta := t.Sub(s.Time) + if delta.Hours() > 24*1 { // TODO: CHANGE TO A WEEK AFTER TESTING + remove = append(remove, s) + old = append(old[:index], old[index+1:]...) + } + } + + if len(remove) > 0 { + a.Log("Removing servers: %s", serverNameList(remove)) // TODO: remove after testing? + if err := a.store.RemoveServers(remove); err != nil { + return err + } + } + + if len(old) > 0 { + a.Log("Old servers: %s", serverNameList(old)) // TODO: remove after testing + if err := a.updateHistory(t, old); err != nil { + return err + } + } + return nil +} + +// TODO: can probably remove this func after we're done testing +func serverNameList(servers []ServerEntry) string { + var names []string + for _, s := range servers { + names = append(names, s.Title) + } + return strings.Join(names, ", ") +} + +func (a *App) makeHubEntry(t time.Time, servers []ServerEntry) ServerEntry { + var totalPlayers int + for _, s := range servers { + totalPlayers += s.Players + } + + return ServerEntry{ + ID: makeID(internalServerTitle), + Title: internalServerTitle, + SiteURL: "", + GameURL: "", + Time: t, + Players: totalPlayers, + } +} diff --git a/scraper.go b/scraper.go new file mode 100644 index 0000000..1c0d267 --- /dev/null +++ b/scraper.go @@ -0,0 +1,155 @@ +package ss13hub + +import ( + "crypto/sha256" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "golang.org/x/text/encoding/charmap" +) + +const ( + byondURL string = "http://www.byond.com/games/Exadv1/SpaceStation13" + //byondURL string = "./tmp/dump.html" // For testing + userAgent string = "ss13hub/2.0pre" +) + +var ( + rePlayers = regexp.MustCompile(`Logged in: (\d+) player`) + //rePlayers = regexp.MustCompile(`
\s*
\s*Logged in: (\d+) player.* ? ORDER BY time DESC, server_id ASC;` + err := store.Select(&points, q, delta) + if err != nil { + return nil, err + } + return points, nil +} + +func (store *StorageSqlite) GetSingleServerHistory(id string, days int) ([]ServerPoint, error) { + var points []ServerPoint + delta := time.Now().AddDate(0, 0, -days) + q := `SELECT time,server_id,players FROM server_history WHERE server_id = ? AND time > ? ORDER BY time DESC;` + err := store.Select(&points, q, id, delta) + if err != nil { + return nil, err + } + return points, nil +} diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..872f709 --- /dev/null +++ b/templates.go @@ -0,0 +1,130 @@ +package ss13hub + +import ( + "html/template" +) + +func loadTemplates() (map[string]*template.Template, error) { + tmpls := make(map[string]*template.Template) + for name, src := range tmplList { + t, err := parseTemplate(tmplBase, src) + if err != nil { + return nil, err + } + tmpls[name] = t + } + return tmpls, nil +} + +func parseTemplate(src ...string) (*template.Template, error) { + var err error + t := template.New("*") + for _, s := range src { + t, err = t.Parse(s) + if err != nil { + return nil, err + } + } + return t, nil +} + +const tmplBase string = ` + + + + + {{block "title" .}}NO TITLE{{end}} | ss13.se + + + + +
+

SS13.se

+
+ +
+ {{block "body" .}}NO BODY{{end}} +
+ + + +` + +var tmplList = map[string]string{ + "index": `{{define "title"}}Index{{end}} +{{define "body"}} +

Last updated: {{.Hub.LastUpdated}}

+

Current # of servers: {{.TotalServers}}

+

Current # of players: {{.Hub.Players}}

+Global stats
+
+ + + + + + + + {{range .Servers}} + + + + + {{else}} + + {{end}} + +
PlayersServer
{{.Players}}{{.Title}}
0Sorry, no servers yet!
+{{end}} +`, + + "server": `{{define "title"}}{{.Server.Title}}{{end}} +{{define "body"}} +

{{.Server.Title}}

+ +

Last updated: {{.Server.LastUpdated}}

+

Current players: {{.Server.Players}}

+{{if .Server.SiteURL}} + Web site
+{{end}} + +{{if .Server.ByondURL}} + Join game
+{{end}} + +
+Daily history
+Weekly history
+Average per day
+{{end}} +`, +}