Gibheer
7c541189fe
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.
545 lines
16 KiB
Go
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() },
|
|
}
|
|
)
|