monzero/cmd/monfront/main.go
Gibheer 7c541189fe monfront - fix actions on checks
This is related to #6. The muting, enable and disable were not working
properly, because the date was not set when the check was updated.

With this comes also the first icon to represent the muted status.
2019-01-03 19:48:17 +01:00

545 lines
16 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"html"
"html/template"
"io/ioutil"
"log"
"net/http"
"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
Mappings map[int]map[int]MapEntry
Checks []check
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
}
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("/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) {
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"
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:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "unknown action %s", action)
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`)
}
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 ")
}
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 {
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
}
// showCheck loads shows the notifications for a specific check.
func showCheck(w http.ResponseWriter, r *http.Request) {
}
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`
}
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
}
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
}
var (
SQLShowMappings = `select mapping_id, target, title, color
from mapping_level`
)
var (
Templates = map[string]string{
"header": `<!doctype html>
<html>
<head>
<title>{{ .Title }}</title>
<style type="text/css">
* { font-size: 100%; }
body { display: flex; flex-direction: column; padding: 0; margin: 0; }
#mainmenu { background: #3a5f78; }
#mainmenu ul {
padding: 0;
display: flex;
flex-direction: row;
align-items: stretch;
align-content: center; }
#mainmenu .submenu { border-left: 0.1em solid black; }
#mainmenu li { list-style-type: none; }
.submenu .header {
text-align: center;
font-weight: bold;
color: #ff9000;
padding: 0.5em 0.5em;
display: block; }
#mainmenu a, #mainmenu a:visited, #mainmenu a:active, #mainmenu a:hover {
color: #ff9000;
padding: 0.25em 0.5em;
display: block; }
#mainmenu a:hover, #mainmenu a:active { color: #eeeeee; }
#mainmenu ul ul a { margin-left: 0.5em; }
#mainmenu form * { display: block; margin: 0.25em 0.5em; }
#mainmenu form input { }
#mainmenu form button { }
form section {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
}
form nav { order: 1; }
form content { order: 2; flex-grow: 1; border-left: 0.15em solid #dddddd; }
form nav { display: flex; flex-direction: column; }
form nav > * { margin: 0.5em; }
table { width: 100%; }
table tr:nth-child(odd) { background: #eeeeee; }
table tr:hover { background: #dfdfdf; }
table th { background: #cccccc; color: #3a5f78; }
table td, table th { text-align: center; }
table pre { font-size: 75%; }
table td.disabled { text-decoration: line-through; }
.icon { height: 1em; margin: 0; width: 1em; vertical-align: bottom; margin-right: 0.5em;}
{{ range $mapId, $mapping := .Mappings }}
{{ range $target, $val := $mapping }}
td.state-{{ $mapId }}-{{ $target }} { background: {{ $val.Color }}; }
{{ end }}
{{ end }}
</style>
<script>
setTimeout(function() { if (document.activeElement.tagName == "BODY") { location.reload(true) } }, 30000)
</script>
</head>
<body>
<nav id="mainmenu">
<ul>
<li><a href="/">home</a></li>
<li class="submenu">
<span class="header">all</span>
<ul>
<li><a href="/checks">checks</a></li>
<li><a href="/groups">groups</a></li>
</ul>
</li>
<li class="submenu">
<span class="header">unhandled</span>
<ul>
<li><a href="/unhandled/checks">checks</a></li>
<li><a href="/unhandled/groups">groups</a></li>
</ul>
</li>
<li class="submenu">
<form action="/checks" method="get">
<input name="search" placeholder="search" />
<button type="submit">search</button>
</form>
</li>
<li class="submenu"><span class="header">{{ now.Format "2006.01.02 15:04:05" }}</span></li>
</ul>
</nav>`,
"footer": `</body></html>`,
"checklist": `{{ template "header" . }}
<form method="post" action="/action">
<section>
<nav>
<div class="option">
<label for="action">Action</label>
<select name="action">
<option value="reschedule">run now</option>
<option value="mute">mute</option>
<option value="unmute">unmute</option>
<option value="ack">acknowledge</option>
<option value="disable">disable</option>
<option value="enable">enable</option>
<option value="comment">comment</option>
</select>
</div>
<div class="option">
<label for="comment">comment</label>
<input name="comment" />
</div>
<button type="submit">submit</button>
</nav>
<content>
<table>
<thead><tr><th></th><th>host</th><th>status</th><th>next check</th><th>message</th></tr></thead>
<tbody>
{{ $current := "" }}
{{ $mapping := .Mappings }}
{{ range .Checks }}
<tr>
<td><input type="checkbox" name="checks" value="{{ .CheckID }}" /></td>
<td>{{ if ne $current .NodeName }}{{ $current = .NodeName }}<a href="/checks?node_id={{ .NodeId }}">{{ .NodeName }}</a>{{ end }}</td>
<td class="state-{{ .MappingId }}-{{ .State }}">{{ if ne .Notify true }}{{ template "icon-mute" . }}{{ end }}{{ .CommandName }} - {{ (index $mapping .MappingId .State).Title }}</td>
<td {{ if ne .Enabled true }}title="This check is disabled." class="disabled"{{ end }}>{{ .NextTime.Format "2006.01.02 15:04:05" }} - in {{ in .NextTime }}</td>
<td><pre>{{ .Msg }}</pre></td>
</tr>
{{ end }}
</tbody>
</table>
</content>
</form>
{{ template "footer" . }}`,
"grouplist": `{{ template "header" . }}
<form method="post" action="/action">
<section>
<nav>
<div class="option">
<label for="action">Action</label>
<select name="action">
<option value="reschedule">run now</option>
<option value="mute">mute</option>
<option value="unmute">unmute</option>
<option value="ack">acknowledge</option>
<option value="disable">disable</option>
<option value="enable">enable</option>
<option value="comment">comment</option>
</select>
</div>
<div class="option">
<label for="comment">comment</label>
<input name="comment" />
</div>
<button type="submit">submit</button>
</nav>
<content>
<table>
<thead><tr><th></th><th>group</th><th>host</th><th>worst state</th></tr></thead>
<tbody>
{{ $current := "" }}
{{ $mapping := .Mappings }}
{{ range .Groups }}
<tr>
<td><input type="checkbox" name="nodes" value="{{ .NodeId }}" /></td>
<td>{{ if ne $current .Name }}{{ $current = .Name }}{{ .Name }}{{ end }}</td>
<td><a href="/checks?node_id={{ .NodeId }}">{{ .NodeName }}</a></td>
<td class="state-{{ .MappingId }}-{{ .State }}">{{ (index $mapping .MappingId .State).Title }}</td>
</tr>
{{ end }}
</tbody>
</table>
</content>
</form>
{{ template "footer" . }}`,
"icon-mute": `<svg class="icon" width="100" height="100" viewBox="0 0 35.3 35.3" version="1.1"><title>Check is muted</title><style>.s0{fill:#191919;}</style><g transform="translate(0,-261.72223)"><path d="m17.6 261.7v35.3L5.3 284.7H0v-10.6l5.3 0zM30.2 273.1l-3.7 3.7-3.7-3.7-2.5 2.5 3.7 3.7-3.7 3.7 2.5 2.5 3.7-3.7 3.7 3.7 2.5-2.5-3.7-3.7 3.7-3.7z" fill="#191919"/></g></svg>`,
}
TmplUnhandledGroups = `TODO`
Funcs = template.FuncMap{
"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() },
}
)