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(` Last updated: {{.Hub.LastUpdated}} Current # of servers: {{.TotalServers}} Current # of players: {{.Hub.Players}}
\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 = `
+
+
+
+ SS13.se
+
+
+
| Players | +Server | +
| {{.Players}} | +{{.Title}} | +
| 0 | Sorry, no servers yet! |
Last updated: {{.Server.LastUpdated}}
+Current players: {{.Server.Players}}
+{{if .Server.SiteURL}} + Web site