package main import ( "database/sql" "encoding/json" "flag" "fmt" "html" "html/template" "io/ioutil" "log" "net/http" "os" "strings" "time" "github.com/lib/pq" ) var ( configPath = flag.String("config", "monfront.conf", "path to the config file") DB *sql.DB Tmpl *template.Template ) type ( Config struct { DB string `json:"db"` Listen string `json:"listen"` } Context struct { Title string CurrentPath string Error string Mappings map[int]map[int]MapEntry Checks []check CheckDetails *checkDetails Groups []group } MapEntry struct { Title string Color string } check struct { NodeId int NodeName string CommandName string CheckID int64 MappingId int State int Enabled bool Notify bool Notice sql.NullString NextTime time.Time Msg string } checkDetails struct { Id int64 Message string Enabled bool Updated time.Time LastRefresh time.Time NextTime time.Time MappingId int MappingName string NodeId int NodeName string NodeMessage string CommandId int CommandName string CommandLine []string CommandMessage string States []int64 Notice sql.NullString Notifiers []notifier Notifications []notification } notifier struct { Id int Name string Enabled bool } notification struct { Id int64 State int Output string Inserted time.Time Sent pq.NullTime NotifierName string MappingId int } group struct { GroupId int Name string NodeId int NodeName string State int MappingId int } ) func main() { flag.Parse() raw, err := ioutil.ReadFile(*configPath) if err != nil { log.Fatalf("could not read config: %s", err) } config := Config{} if err := json.Unmarshal(raw, &config); err != nil { log.Fatalf("could not parse config: %s", err) } db, err := sql.Open("postgres", config.DB) if err != nil { log.Fatalf("could not open database connection: %s", err) } DB = db tmpl := template.New("main") tmpl.Funcs(Funcs) for k, val := range Templates { template.Must(tmpl.New(k).Parse(val)) } Tmpl = tmpl http.HandleFunc("/", showChecks) http.HandleFunc("/static/", showStatic) http.HandleFunc("/check", showCheck) http.HandleFunc("/checks", showChecks) http.HandleFunc("/groups", showGroups) http.HandleFunc("/action", checkAction) http.HandleFunc("/unhandled/checks", showChecks) http.HandleFunc("/unhandled/groups", showGroups) http.ListenAndServe(config.Listen, nil) } func checkAction(w http.ResponseWriter, r *http.Request) { con := Context{} if r.Method != "POST" { w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte("method is not supported")) return } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "could not parse parameters: %s", err) return } checks := r.PostForm["checks"] action := r.PostForm["action"] if len(action) == 0 || action[0] == "" || len(checks) == 0 { w.Header()["Location"] = []string{"/"} w.WriteHeader(http.StatusSeeOther) return } setTable := "checks" setClause := "" switch action[0] { case "mute": setTable = "checks_notify" setClause = "enabled = false" case "unmute": setTable = "checks_notify" setClause = "enabled = true" case "enable": setClause = "enabled = true, updated = now()" case "disable": setClause = "enabled = false, updated = now()" case "reschedule": setClause = "next_time = now()" setTable = "active_checks" case "ack": setClause = "acknowledged = true" setTable = "active_checks" hostname, err := os.Hostname() if err != nil { log.Printf("could not resolve hostname: %s", err) con.Error = "could not resolve hostname" returnError(http.StatusInternalServerError, con, w) return } if _, err := DB.Exec(`insert into notifications(check_id, states, output, mapping_id, notifier_id, check_host) select ac.check_id, 0 || states[1:array_length(states, 1)], 'check acknowledged', ac.mapping_id, cn.notifier_id, $2 from checks_notify cn join active_checks ac on cn.check_id = ac.check_id where cn.check_id = any ($1::int[])`, pq.Array(&checks), &hostname); err != nil { log.Printf("could not acknowledge check: %s", err) con.Error = "could not acknowledge check" returnError(http.StatusInternalServerError, con, w) return } case "comment": if len(r.PostForm["comment"]) == 0 { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "no comment sent") return } comment := r.PostForm["comment"][0] _, err := DB.Exec( "update active_checks set notice = $2 where check_id = any ($1::int[]);", pq.Array(&checks), html.EscapeString(comment)) if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "could not store changes") log.Printf("could not adjust checks %#v: %s", checks, err) return } w.Header()["Location"] = []string{"/"} w.WriteHeader(http.StatusSeeOther) return default: con.Error = fmt.Sprintf("requested action '%s' does not exist", action[0]) returnError(http.StatusNotFound, con, w) return } whereColumn := "id" if setTable == "active_checks" || setTable == "checks_notify" { whereColumn = "check_id" } _, err := DB.Exec("update "+setTable+" set "+setClause+" where "+whereColumn+" = any ($1::int[]);", pq.Array(&checks)) if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "could not store changes") log.Printf("could not adjust checks %#v: %s", checks, err) return } ref, found := r.Header["Referrer"] if found { w.Header()["Location"] = ref } else { w.Header()["Location"] = []string{"/"} } w.WriteHeader(http.StatusSeeOther) return } func showChecks(w http.ResponseWriter, r *http.Request) { query := `select c.id, n.id, n.name, co.name, ac.mapping_id, ac.states[1] as state, ac.enabled, ac.notice, ac.next_time, ac.msg, case when cn.check_id is null then false else true end as notify_enabled from active_checks ac join checks c on ac.check_id = c.id join nodes n on c.node_id = n.id join commands co on c.command_id = co.id left join ( select distinct check_id from checks_notify where enabled = true) cn on c.id = cn.check_id` where := []string{} if strings.HasPrefix(r.URL.Path, "/unhandled") { where = append(where, `ac.states[1] > 0 and ac.acknowledged = false and ac.enabled = true`) } idx := 0 params := []interface{}{} if search, found := r.URL.Query()["search"]; found { idx += 1 // Add the search for nodes. As hostnames or FQDNs are really weird, the // string needs to be split up by some characters. The input string needs // to be split up too, so all is done here. // TODO move this into a proper index and add more to search. where = append(where, fmt.Sprintf( `to_tsvector('english', regexp_replace(n.name, '[.-/]', ' ', 'g')) @@ to_tsquery('english', regexp_replace($%d, '[.-/]', ' & ', 'g'))`, idx)) params = append(params, search[0]) } if id, found := r.URL.Query()["node_id"]; found { idx += 1 where = append(where, fmt.Sprintf("n.id = $%d::int", idx)) params = append(params, id[0]) } if id, found := r.URL.Query()["command_id"]; found { idx += 1 where = append(where, fmt.Sprintf("co.id = $%d::int", idx)) params = append(params, id[0]) } if id, found := r.URL.Query()["check_id"]; found { idx += 1 where = append(where, fmt.Sprintf("c.id = $%d::int", idx)) params = append(params, id[0]) } if len(where) > 0 { query += " where " + strings.Join(where, " and ") } if strings.HasPrefix(r.URL.Path, "/unhandled") { query += ` order by ac.states[1] desc, n.name, co.name` } else { query += ` order by n.name, co.name` } rows, err := DB.Query(query, params...) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problems with the database")) log.Printf("could not get check list: %s", err) return } checks := []check{} for rows.Next() { c := check{} err := rows.Scan(&c.CheckID, &c.NodeId, &c.NodeName, &c.CommandName, &c.MappingId, &c.State, &c.Enabled, &c.Notice, &c.NextTime, &c.Msg, &c.Notify) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problems with the database")) log.Printf("could not get check list: %s", err) return } checks = append(checks, c) } con := Context{ Checks: checks, } if err := loadMappings(&con); err != nil { con.Error = "could not load mapping data" w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with the mappings")) log.Printf("could not load mappings: %s", err) return } w.Header()["Content-Type"] = []string{"text/html"} if err := Tmpl.ExecuteTemplate(w, "checklist", con); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with a template")) log.Printf("could not execute template: %s", err) return } return } func showGroups(w http.ResponseWriter, r *http.Request) { query := `select groupid, groupname, nodeid, nodename, mapping_id, state from ( select g.id groupid, g.name groupname, n.id nodeid, n.name nodename, ac.mapping_id, ac.states[1] state, max(ac.states[1]) over (partition by c.node_id) maxstate from groups g join nodes_groups ng on g.id = ng.group_id join nodes n on ng.node_id = n.id join checks c on n.id = c.node_id join active_checks ac on c.id = ac.check_id join mapping_level ml on ac.mapping_id = ml.mapping_id and ac.states[1] = ml.target ) s where state = maxstate` if strings.HasPrefix(r.URL.Path, "/unhandled") { query += ` and state > 0` } if strings.HasPrefix(r.URL.Path, "/unhandled") { query += ` order by state desc, groupname, nodename` } else { query += ` order by groupname, nodename` } rows, err := DB.Query(query) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problems with the database")) log.Printf("could not get check list: %s", err) return } groups := []group{} for rows.Next() { g := group{} err := rows.Scan(&g.GroupId, &g.Name, &g.NodeId, &g.NodeName, &g.MappingId, &g.State) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problems with the database")) log.Printf("could not get check list: %s", err) return } groups = append(groups, g) } con := Context{ Groups: groups, } if err := loadMappings(&con); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with the mappings")) log.Printf("could not load mappings: %s", err) return } w.Header()["Content-Type"] = []string{"text/html"} if err := Tmpl.ExecuteTemplate(w, "grouplist", con); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with a template")) log.Printf("could not execute template: %s", err) return } return } // showCheck loads shows the notifications for a specific check. func showCheck(w http.ResponseWriter, r *http.Request) { cd := checkDetails{} con := Context{CheckDetails: &cd} id, found := r.URL.Query()["check_id"] if !found { con.Error = "no check given to view" returnError(http.StatusNotFound, con, w) return } query := `select c.id, c.message, c.enabled, c.updated, c.last_refresh, m.id, m.name, n.id, n.name, n.message, co.id, co.Name, co.message, ac.cmdline, ac.states, ac.msg, ac.next_time from checks c join active_checks ac on c.id = ac.check_id join nodes n on c.node_id = n.id join commands co on c.command_id = co.id join mappings m on ac.mapping_id = m.id where c.id = $1::bigint` err := DB.QueryRow(query, id[0]).Scan(&cd.Id, &cd.Message, &cd.Enabled, &cd.Updated, &cd.LastRefresh, &cd.MappingId, &cd.MappingName, &cd.NodeId, &cd.NodeName, &cd.NodeMessage, &cd.CommandId, &cd.CommandName, &cd.CommandMessage, pq.Array(&cd.CommandLine), pq.Array(&cd.States), &cd.Notice, &cd.NextTime) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problems with the database")) log.Printf("could not get check details for check id %s: %s", id[0], err) return } query = `select n.id, states[1], output, inserted, sent, no.name, n.mapping_id from notifications n join notifier no on n.notifier_id = no.id where check_id = $1::bigint order by inserted desc limit 500` rows, err := DB.Query(query, cd.Id) if err != nil { log.Printf("could not load notifications: %s", err) con.Error = "could not load notification information" returnError(http.StatusInternalServerError, con, w) return } cd.Notifications = []notification{} for rows.Next() { if err := rows.Err(); err != nil { log.Printf("could not load notifications: %s", err) con.Error = "could not load notification information" returnError(http.StatusInternalServerError, con, w) return } no := notification{} if err := rows.Scan(&no.Id, &no.State, &no.Output, &no.Inserted, &no.Sent, &no.NotifierName, &no.MappingId); err != nil { log.Printf("could not scan notifications: %s", err) con.Error = "could not load notification information" returnError(http.StatusInternalServerError, con, w) return } cd.Notifications = append(cd.Notifications, no) } if err := loadMappings(&con); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with the mappings")) log.Printf("could not load mappings: %s", err) return } w.Header()["Content-Type"] = []string{"text/html"} if err := Tmpl.ExecuteTemplate(w, "check", con); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with a template")) log.Printf("could not execute template: %s", err) return } } func returnError(status int, con Context, w http.ResponseWriter) { w.Header()["Content-Type"] = []string{"text/html"} w.WriteHeader(status) if err := Tmpl.ExecuteTemplate(w, "error", con); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with a template")) log.Printf("could not execute template: %s", err) } } func loadMappings(c *Context) error { c.Mappings = map[int]map[int]MapEntry{} rows, err := DB.Query(SQLShowMappings) if err != nil { return err } for rows.Next() { if rows.Err() != nil { return rows.Err() } var ( mapId int target int title string color string ) if err := rows.Scan(&mapId, &target, &title, &color); err != nil { return err } ma, found := c.Mappings[mapId] if !found { ma = map[int]MapEntry{} c.Mappings[mapId] = ma } ma[target] = MapEntry{Title: title, Color: color} } return nil } func showStatic(w http.ResponseWriter, r *http.Request) { file := strings.TrimPrefix(r.URL.Path, "/static/") raw, found := Static[file] if !found { w.WriteHeader(http.StatusNotFound) w.Write([]byte("file does not exist")) return } w.Header()["Content-Type"] = []string{"image/svg+xml"} w.WriteHeader(http.StatusOK) w.Write([]byte(raw)) return } var ( SQLShowMappings = `select mapping_id, target, title, color from mapping_level` ) var ( Templates = map[string]string{ "header": ` {{ .Title }} {{ if .Error }}
{{ .Error }}
{{ end }}`, "footer": ``, "checklist": `{{ template "header" . }}
{{ $current := "" }} {{ $mapping := .Mappings }} {{ range .Checks }} {{ end }}
hoststatusnext checkmessage
{{ if ne $current .NodeName }}{{ $current = .NodeName }}{{ .NodeName }}{{ end }} {{ if ne .Notify true }}{{ end }}{{ .CommandName }} - {{ (index $mapping .MappingId .State).Title }} {{ .NextTime.Format "2006.01.02 15:04:05" }} - in {{ in .NextTime }} {{ .Msg }}
{{ template "footer" . }}`, "checkformheader": `
`, "checkformfooter": `
`, "grouplist": `{{ template "header" . }} {{ template "checkformheader" . }} {{ $current := "" }} {{ $mapping := .Mappings }} {{ range .Groups }} {{ end }}
grouphostworst state
{{ if ne $current .Name }}{{ $current = .Name }}{{ .Name }}{{ end }} {{ .NodeName }} {{ (index $mapping .MappingId .State).Title }}
{{ template "checkformfooter" . }} {{ template "footer" . }}`, "check": `{{ template "header" . }} {{ template "checkformheader" . }} {{ $mapping := .Mappings }} {{ with .CheckDetails }}

check

current state{{ (index $mapping .MappingId (index .States 0) ).Title }}
current notice{{ if .Notice }}{{ .Notice.String }}{{ end }}
Message{{ .Message }}
enabled{{ .Enabled }}
updated{{ .Updated.Format "2006.01.02 15:04:05" }}
next check{{ .NextTime.Format "2006.01.02 15:04:05" }}
last refresh{{ .LastRefresh.Format "2006.01.02 15:04:05" }}
mapping{{ .MappingId }}

node {{ .NodeName }}

Message{{ .NodeMessage }}

command {{ .CommandName }}

Message{{ .CommandMessage }}
command line{{ join .CommandLine " " }}

notifications

{{ range .Notifications -}} {{ end -}}
notifierstatecreatedsentoutput
{{ .NotifierName }} {{ (index $mapping .MappingId .State).Title }} {{ .Inserted.Format "2006.01.02 15:04:05" }} {{ if .Sent.Valid }}{{ .Sent.Time.Format "2006.01.02 15:04:05" }}{{ end }} {{ .Output }}
{{ end }}
{{ template "checkformfooter" . }} {{ template "footer" . }}`, } Static = map[string]string{ "icon-mute": `Check is muted`, "error": `{{ template "header" . }}{{ template "footer" . }}`, } TmplUnhandledGroups = `TODO` Funcs = template.FuncMap{ "int": func(in int64) int { return int(in) }, "sub": func(base, amount int) int { return base - amount }, "in": func(t time.Time) time.Duration { return t.Sub(time.Now()).Round(1 * time.Second) }, "now": func() time.Time { return time.Now() }, "join": func(args []string, c string) string { return strings.Join(args, c) }, } )