monzero/cmd/monfront/main.go
Gibheer 73c7353082 monfront - sort result list
The result list should be ordered for the state when unhandled entries
should be returned. Else it is getting weird to figure out, which issue
is the most important to fix.
2019-01-10 07:10:02 +01:00

729 lines
22 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
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
States []int
Output string
Inserted time.Time
Sent time.Time
}
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("/group", showGroup)
http.HandleFunc("/node", showNode)
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"
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`
}
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
}
if strings.HasPrefix(r.URL.Path, "/unhandled") {
query += ` order by maxstate desc, g.name, n.name`
} else {
query += ` order by g.name, n.name`
}
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
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)
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
}
// TODO load the last couple notifications
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 showGroup(w http.ResponseWriter, r *http.Request) {
// TODO implement showing all nodes only from one group and its message?
return
}
func showNode(w http.ResponseWriter, r *http.Request) {
// TODO implement showing all checks from one node and its message?
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": `<!doctype html>
<html>
<head>
<title>{{ .Title }}</title>
<link rel="shortcut icon" href="/static/favicon" />
<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 code { font-size: 75%; }
table td.disabled { text-decoration: line-through; }
.icon {
display: inline-block;
height: 1em;
margin: 0;
width: 1em;
vertical-align: bottom;
margin-right: 0.5em;
background-size: contain;
}
.mute { background-image: url(/static/icon-mute); }
.detail > div { display: grid; grid-template-columns: 25% auto; }
.detail > div:hover { background: #dfdfdf; }
/* state background colors */
{{ range $mapId, $mapping := .Mappings -}}
{{ range $target, $val := $mapping -}}
.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>
{{ if .Error }}<div class="error">{{ .Error }}</div>{{ end }}`,
"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 }}<span class="icon mute"></span>{{ end }}<a href="/check?check_id={{ .CheckID }}">{{ .CommandName }}</a> - {{ (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><code>{{ .Msg }}</code></td>
</tr>
{{ end }}
</tbody>
</table>
</content>
</form>
{{ template "footer" . }}`,
"checkformheader": `<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>`,
"checkformfooter": `</section></form>`,
"grouplist": `{{ template "header" . }}
{{ template "checkformheader" . }}
<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>
{{ template "checkformfooter" . }}
{{ template "footer" . }}`,
"check": `{{ template "header" . }}
{{ template "checkformheader" . }}
<content class="details">
{{ $mapping := .Mappings }}
{{ with .CheckDetails }}
<input type="hidden" name="check_id" value="{{ .Id }}" />
<article class="detail">
<h1>check</h1>
<div><span class="label">current state</span><span class="value state-{{ .MappingId }}-{{ index .States 0 }}">{{ (index $mapping .MappingId (index .States 0) ).Title }}</span></div>
<div><span class="label">current notice</span><span class="value">{{ if .Notice }}{{ .Notice.String }}{{ end }}</span></div>
<div><span class="label">Message</span><span class="value">{{ .Message }}</span></div>
<div><span class="label">enabled</span><span class="value">{{ .Enabled }}</span></div>
<div><span class="label">updated</span><span class="value">{{ .Updated.Format "2006.01.02 15:04:05" }}</span></div>
<div><span class="label">next check</span><span class="value">{{ .NextTime.Format "2006.01.02 15:04:05" }}</span></div>
<div><span class="label">last refresh</span><span class="value">{{ .LastRefresh.Format "2006.01.02 15:04:05" }}</span></div>
<div><span class="label">mapping</span><span class="value">{{ .MappingId }}</span></div>
</article>
<article class="detail">
<h1>node {{ .NodeName }}</h1>
<div><span class="label">Message</span><span class="value">{{ .NodeMessage }}</span></div>
</article>
<article class="detail">
<h1>command {{ .CommandName }}</h1>
<div><span class="label">Message</span><span class="value">{{ .CommandMessage }}</span></div>
<div><span class="label">command line</span><span class="value"><code>{{ join .CommandLine " " }}</code></span></div>
</article>
States []int64
Notifiers []notifier
Notifications []notification
{{ end }}
</content>
{{ template "checkformfooter" . }}
{{ template "footer" . }}`,
}
Static = map[string]string{
"icon-mute": `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" 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>`,
"error": `{{ template "header" . }}{{ template "footer" . }}`,
}
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() },
"join": func(args []string, c string) string { return strings.Join(args, c) },
}
)