From 919e361353526f84c9a3a5c0f1f0953ba1a06c92 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sat, 7 Feb 2015 19:32:44 +0100 Subject: [PATCH] Initial version --- .gitignore | 1 + README.md | 30 +++++++ apiary.apib | 97 +++++++++++++++++++++ main.go | 184 +++++++++++++++++++++++++++++++++++++++ strings.go | 10 +++ structs.go | 139 +++++++++++++++++++++++++++++ templates/dashboard.html | 103 ++++++++++++++++++++++ welcome_runner.go | 52 +++++++++++ 8 files changed, 616 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apiary.apib create mode 100644 main.go create mode 100644 strings.go create mode 100644 structs.go create mode 100644 templates/dashboard.html create mode 100644 welcome_runner.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fd0696 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gin-bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee60b85 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Luzifer / mondash + +MonDash is a service for everyone having to display monitoring results to people who have not +the time or knowledge to get familar with Nagios / Icinga or similar monitoring systems. Therefore +MonDash provides a simple API to submit monitoring results and a simple dashboard to view those +results. + +## Hosted + +There is an instance of MonDash running on [mondash.org](https://mondash.org/) you can use for free. This means you can just head over there, create your own dashboard with one click and start to push your own metrics to your dashboard within 5 minutes. No registration, no fees, just your dashboard and you. + +## Installation + +However maybe you want to use MonDash for data you don't like to have public visible on the internet. As it is open source you also can host your own instance: The most simple way to install your own instance is to download a binary distribution on [gobuild.luzifer.io](http://gobuild.luzifer.io/github.com/Luzifer/mondash). + +This archive will contain the binary you need to run your own instance and the template used for the display. If you want just edit the template, restart the daemon and you're done customizing MonDash. If you do so please do me one small favor: Include a hint to this repository / my instance. + +MonDash needs some environment variables set when running: + ++ `AWS_ACCESS_KEY_ID` - Your AWS Access-Key with access to the `S3Bucket` ++ `AWS_SECRET_ACCESS_KEY` - Your AWS Secret-Access-Key with access to the `S3Bucket` ++ `S3Bucket` - The S3 bucket used to store the dashboard metrics ++ `BASE_URL` - The Base-URL the application is running on for example `https://www.mondash.org` ++ `API_TOKEN` - API Token used for the /welcome dashboard (you can choose your own) + +## Security + +Just some words regarding security: MonDash was designed to be an open platform for creating dashboards without any hazzle. You just open a dashboard, send some data to it and you're already done. No need to think about OAuth or other authentication mechanisms. + +The downpath of that concept is of course everyone can access every dashboard and see every data placed on it. So please don't use the public instances for private and/or secret data. You can just set up your own instance within 5 minutes (okay maybe 10 minutes if you want to do it right) and you can ensure that this instance is hidden from the internet. \ No newline at end of file diff --git a/apiary.apib b/apiary.apib new file mode 100644 index 0000000..41c2693 --- /dev/null +++ b/apiary.apib @@ -0,0 +1,97 @@ +FORMAT: 1A +HOST: www.mondash.org + +# MonDash +MonDash is a service for everyone having to display monitoring results to people who have not +the time or knowledge to get familar with Nagios / Icinga or similar monitoring systems. Therefore +MonDash provides a simple API to submit monitoring results and a simple dashboard to view those +results. + +For the API to work you will need the APIToken assigned to your dashboard. This token is displayed +on the dashboard itself as long as no metrics are available to display. + +To start just create a [randomly named dashboard](http://www.mondash.org/create) or start with a +named dashboard by simply visiting http://www.mondash.org/mydashboardname (if you plan to use the +named version please pay attention this will be easily guessable and you data is lesser protected +than with the random naming. + +## Dashboard [/{dashid}] + +This API controls your dashboard itself + ++ Parameters + + dashid (required, string, `098f6bcd4621d373cade`) ... The id of your dashboard to be found in the URL + +### Delete your dashboard [DELETE] + +This request will delete all your monitoring results available on your dashboard and release the +dashboard URL to the public. + +_Please pay attention your dashboard URL will be available for others to register immediately +as we are not storing any data beyond this DELETE request._ + ++ Request + + + Header + + Authorization: MyAPIToken + ++ Response 200 (text/plain) + + OK + +## Metric [/{dashid}/{metricid}] + +This API controls the metrics on your dashboard + ++ Parameters + + dashid (required, string, `098f6bcd4621d373cade`) ... The id of your dashboard to be found in the URL + + metricid (required, string, `beer_available`) ... The unique name for your metric + +### Submit a monitoring result [PUT] ++ Request (application/json) + + + Header + + Authorization: MyAPIToken + + + Body + + { + "title": "Amount of beer in the fridge", + "description": "Currently there are 12 bottles of beer in the fridge", + "status": "OK", + "expires": 604800, + "freshness": 3600 + } + + + Attributes (object) + + title (required, string) - The title of the metric to display on the dashboard + + description (required, string) - A descriptive text for the current state of the metric + + status (required, enum[string]) + + `OK` + + `Warning` + + `Critical` + + `Unknown` + + expires: 604800 (optional, number) - Time in seconds when to remove the metric if there is no update (Valid: `0 < x < 604800`) + + freshness: 3600 (optional, number) - Time in seconds when to switch to `Unkown` state of there is no update (Valid: `0 < x < 604800`) + ++ Response 200 (text/plain) + + + Body + + OK + +### Delete a metric from your dashboard [DELETE] + ++ Request + + + Header + + Authorization: MyAPIToken + ++ Response 200 (text/plain) + + + Body + + OK \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..3add166 --- /dev/null +++ b/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "crypto/md5" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "sort" + "time" + + "log" + + "launchpad.net/goamz/aws" + "launchpad.net/goamz/s3" + + "github.com/flosch/pongo2" + "github.com/go-martini/martini" + + _ "github.com/flosch/pongo2-addons" +) + +var templates = make(map[string]*pongo2.Template) +var s3Storage *s3.Bucket + +func main() { + preloadTemplates() + + // Initialize S3 storage + awsAuth, err := aws.EnvAuth() + if err != nil { + log.Fatal(err) + } + s3Conn := s3.New(awsAuth, aws.EUWest) + s3Storage = s3Conn.Bucket(os.Getenv("S3Bucket")) + + m := martini.Classic() + + // Assets are in assets folder + m.Use(martini.Static("assets", martini.StaticOptions{Prefix: "/assets"})) + + // Real handlers + m.Get("/", func(res http.ResponseWriter, req *http.Request) { + http.Redirect(res, req, "/welcome", 302) + }) + + m.Get("/create", func(res http.ResponseWriter, req *http.Request) { + urlProposal := generateAPIKey()[0:20] + _, err := s3Storage.Get(urlProposal) + for err == nil { + urlProposal = generateAPIKey()[0:20] + _, err = s3Storage.Get(urlProposal) + } + http.Redirect(res, req, fmt.Sprintf("/%s", urlProposal), http.StatusTemporaryRedirect) + }) + + m.Get("/:dashid", func(params martini.Params, res http.ResponseWriter) { + dash, err := LoadDashboard(params["dashid"]) + if err != nil { + dash = &Dashboard{APIKey: generateAPIKey(), Metrics: DashboardMetrics{}} + } + sort.Sort(sort.Reverse(DashboardMetrics(dash.Metrics))) + renderTemplate("dashboard.html", pongo2.Context{ + "dashid": params["dashid"], + "metrics": dash.Metrics, + "apikey": dash.APIKey, + "baseurl": os.Getenv("BASE_URL"), + }, res) + }) + + m.Delete("/:dashid", func(params martini.Params, req *http.Request, res http.ResponseWriter) { + dash, err := LoadDashboard(params["dashid"]) + if err != nil { + http.Error(res, "This dashboard does not exist.", http.StatusInternalServerError) + return + } + + if dash.APIKey != req.Header.Get("Authorization") { + http.Error(res, "APIKey did not match.", http.StatusUnauthorized) + return + } + + s3Storage.Del(params["dashid"]) + http.Error(res, "OK", http.StatusOK) + }) + + m.Put("/:dashid/:metricid", func(params martini.Params, req *http.Request, res http.ResponseWriter) { + body, err := ioutil.ReadAll(req.Body) + if err != nil { + http.Error(res, "Internal Server Error", http.StatusInternalServerError) + return + } + + metricUpdate := NewDashboardMetric() + err = json.Unmarshal(body, metricUpdate) + if err != nil { + http.Error(res, "Unable to unmarshal json", http.StatusInternalServerError) + return + } + + dash, err := LoadDashboard(params["dashid"]) + if err != nil { + dash = &Dashboard{APIKey: req.Header.Get("Authorization"), Metrics: DashboardMetrics{}, DashboardID: params["dashid"]} + } + + if dash.APIKey != req.Header.Get("Authorization") { + http.Error(res, "APIKey did not match.", http.StatusUnauthorized) + return + } + + updated := false + for _, m := range dash.Metrics { + if m.MetricID == params["metricid"] { + m.Update(metricUpdate) + updated = true + break + } + } + + if !updated { + tmp := &DashboardMetric{MetricID: params["metricid"]} + tmp.Update(metricUpdate) + dash.Metrics = append(dash.Metrics, tmp) + } + + dash.Save() + + http.Error(res, "OK", http.StatusOK) + }) + + m.Delete("/:dashid/:metricid", func(params martini.Params, req *http.Request, res http.ResponseWriter) { + dash, err := LoadDashboard(params["dashid"]) + if err != nil { + dash = &Dashboard{APIKey: req.Header.Get("Authorization"), Metrics: DashboardMetrics{}, DashboardID: params["dashid"]} + } + + if dash.APIKey != req.Header.Get("Authorization") { + http.Error(res, "APIKey did not match.", http.StatusUnauthorized) + return + } + + tmp := DashboardMetrics{} + for _, m := range dash.Metrics { + if m.MetricID != params["metricid"] { + tmp = append(tmp, m) + } + } + dash.Metrics = tmp + dash.Save() + + http.Error(res, "OK", http.StatusOK) + }) + + go RunWelcomePage() + + // GO! + m.Run() +} + +func generateAPIKey() string { + t := time.Now().String() + sum := md5.Sum([]byte(t)) + return fmt.Sprintf("%x", sum) +} + +func renderTemplate(templateName string, context pongo2.Context, res http.ResponseWriter) { + if tpl, ok := templates[templateName]; ok { + tpl.ExecuteWriter(context, res) + } else { + res.WriteHeader(http.StatusInternalServerError) + res.Write([]byte(fmt.Sprintf("Template %s not found!", templateName))) + } +} + +func preloadTemplates() { + templateNames, err := ioutil.ReadDir("templates") + if err != nil { + panic("Templates directory not available!") + } + for _, tplname := range templateNames { + templates[tplname.Name()] = pongo2.Must(pongo2.FromFile(fmt.Sprintf("templates/%s", tplname.Name()))) + } +} diff --git a/strings.go b/strings.go new file mode 100644 index 0000000..48918f9 --- /dev/null +++ b/strings.go @@ -0,0 +1,10 @@ +package main + +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} diff --git a/structs.go b/structs.go new file mode 100644 index 0000000..8573356 --- /dev/null +++ b/structs.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "errors" + "log" + "time" + + "launchpad.net/goamz/s3" +) + +type Dashboard struct { + DashboardID string `json:"-"` + APIKey string `json:"api_key"` + Metrics DashboardMetrics `json:"metrics"` +} + +func LoadDashboard(dashid string) (*Dashboard, error) { + data, err := s3Storage.Get(dashid) + if err != nil { + return &Dashboard{}, errors.New("Dashboard not found") + } + + tmp := &Dashboard{DashboardID: dashid} + json.Unmarshal(data, tmp) + + return tmp, nil +} + +func (d *Dashboard) Save() { + data, err := json.Marshal(d) + if err != nil { + log.Println(err) + } + err = s3Storage.Put(d.DashboardID, data, "application/json", s3.Private) + if err != nil { + log.Println(err) + } +} + +type DashboardMetrics []*DashboardMetric + +func (a DashboardMetrics) Len() int { return len(a) } +func (a DashboardMetrics) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a DashboardMetrics) Less(i, j int) bool { + return a[i].HistoricalData[0].Time.Before(a[j].HistoricalData[0].Time) +} + +type DashboardMetric struct { + MetricID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Expires int64 `json:"expires,omitifempty"` + Freshness int64 `json:"freshness,omitifempty"` + HistoricalData DashboardMetricHistory `json:"history,omitifempty"` + Meta DashboardMetricMeta `json:"meta,omitifempty"` +} + +type DashboardMetricStatus struct { + Time time.Time `json:"time"` + Status string `json:"status"` +} + +type DashboardMetricMeta struct { + LastUpdate time.Time + LastOK time.Time + PercOK float64 + PercWarn float64 + PercCrit float64 +} + +type DashboardMetricHistory []DashboardMetricStatus + +func NewDashboardMetric() *DashboardMetric { + return &DashboardMetric{ + Status: "Unknown", + Expires: 604800, + Freshness: 3600, + HistoricalData: DashboardMetricHistory{}, + } +} + +func (dm *DashboardMetric) Update(m *DashboardMetric) { + dm.Title = m.Title + dm.Description = m.Description + dm.Status = m.Status + if m.Expires != 0 { + dm.Expires = m.Expires + } + if m.Freshness != 0 { + dm.Freshness = m.Freshness + } + dm.HistoricalData = append(DashboardMetricHistory{DashboardMetricStatus{ + Time: time.Now(), + Status: m.Status, + }}, dm.HistoricalData...) + + countStatus := make(map[string]float64) + + expired := time.Now().Add(time.Duration(dm.Expires*-1) * time.Second) + tmp := DashboardMetricHistory{} + for _, s := range dm.HistoricalData { + if s.Time.After(expired) { + tmp = append(tmp, s) + countStatus[s.Status] = countStatus[s.Status] + 1 + countStatus["Total"] = countStatus["Total"] + 1 + if dm.Meta.LastOK.Before(s.Time) && s.Status == "OK" { + dm.Meta.LastOK = s.Time + } + } + } + dm.HistoricalData = tmp + + dm.Meta.LastUpdate = time.Now() + dm.Meta.PercCrit = countStatus["Critical"] / countStatus["Total"] * 100 + dm.Meta.PercWarn = countStatus["Warning"] / countStatus["Total"] * 100 + dm.Meta.PercOK = countStatus["OK"] / countStatus["Total"] * 100 +} + +func (dm *DashboardMetric) IsValid() (bool, string) { + if dm.Expires > 604800 || dm.Expires < 0 { + return false, "Expires not in range 0 < x < 640800" + } + + if dm.Freshness > 604800 || dm.Freshness < 0 { + return false, "Freshness not in range 0 < x < 640800" + } + + if !stringInSlice(dm.Status, []string{"OK", "Warning", "Critical", "Unknowm"}) { + return false, "Status not allowed" + } + + if len(dm.Title) > 512 || len(dm.Description) > 1024 { + return false, "Title or Description too long" + } + + return true, "" +} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..15131d6 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,103 @@ + + + + + + + MonDash - Dashboard + + + + + + + + + + + + +
+ {% if dashid == "welcome" %} +
+
+

Welcome to MonDash!

+

You're currently seeing a demo dashboard updated with random numbers below. To get started read the API documentation and create your own dashboard by clicking the button in the upper right hand corner… +

If you have any questions about this project don't hesitate to ask Knut.

+
+
+ {% endif %} + + {% if metrics|length == 0 and dashid != "welcome" %} +
+
+

Welcome to your new dashboard. Your API-key is:

+
{{ apikey }}
+

After you sent your first metric you can reach your dashboard here:

+ {{ baseurl }}/{{ dashid }} +
+
+ {% else %} + {% for metric in metrics %} + {% if metric.Status == "OK" %} +
+ {% elif metric.Status == "Warning" %} +
+ {% elif metric.Status == "Critical" %} +
+ {% else %} +
+ {% endif %} +
+

{{ metric.Title }}

+

{{ metric.Description }}

+ + Updated {{ metric.Meta.LastUpdate|naturaltime}} + {% if metric.Status != "OK" %} + / Last ok {{ metric.Meta.LastOK|naturaltime }} + {% endif %} + +
+ +
+ {% endfor %} + {% endif %} + +
+ + + + + + + diff --git a/welcome_runner.go b/welcome_runner.go new file mode 100644 index 0000000..fe6dd09 --- /dev/null +++ b/welcome_runner.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "os" + "time" +) + +func RunWelcomePage() { + baseURL := os.Getenv("BASE_URL") + welcomeAPIToken := os.Getenv("API_TOKEN") + generateTicker := time.NewTicker(time.Minute) + + for { + select { + case <-generateTicker.C: + beers := rand.Intn(24) + status := "OK" + switch { + case beers < 6: + status = "Critical" + break + case beers < 12: + status = "Warning" + break + } + + beer := DashboardMetric{ + Title: "Amount of beer in the fridge", + Description: fmt.Sprintf("Currently there are %d bottles of beer in the fridge", beers), + Status: status, + Expires: 86400, + Freshness: 120, + } + + body, err := json.Marshal(beer) + if err != nil { + log.Println(err) + } + url := fmt.Sprintf("%s/welcome/beer_available", baseURL) + req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(body)) + req.Header.Add("Authorization", welcomeAPIToken) + http.DefaultClient.Do(req) + } + } + +}