diff options
Diffstat (limited to 'cmd/monfront')
-rw-r--r-- | cmd/monfront/README.md | 72 | ||||
-rw-r--r-- | cmd/monfront/authenticater.go | 183 | ||||
-rw-r--r-- | cmd/monfront/authorizer.go | 35 | ||||
-rw-r--r-- | cmd/monfront/checks.go | 226 | ||||
-rw-r--r-- | cmd/monfront/filter.go | 96 | ||||
-rw-r--r-- | cmd/monfront/groups.go | 85 | ||||
-rw-r--r-- | cmd/monfront/main.go | 426 | ||||
-rw-r--r-- | cmd/monfront/pw.go | 99 | ||||
-rw-r--r-- | cmd/monfront/server.go | 153 | ||||
-rw-r--r-- | cmd/monfront/templates/check.html | 51 | ||||
-rw-r--r-- | cmd/monfront/templates/checkfilter.html | 49 | ||||
-rw-r--r-- | cmd/monfront/templates/checkformfooter.html | 1 | ||||
-rw-r--r-- | cmd/monfront/templates/checkformheader.html | 43 | ||||
-rw-r--r-- | cmd/monfront/templates/checklist.html | 29 | ||||
-rw-r--r-- | cmd/monfront/templates/error.html | 1 | ||||
-rw-r--r-- | cmd/monfront/templates/footer.html | 76 | ||||
-rw-r--r-- | cmd/monfront/templates/grouplist.html | 21 | ||||
-rw-r--r-- | cmd/monfront/templates/header.html | 101 |
18 files changed, 1747 insertions, 0 deletions
diff --git a/cmd/monfront/README.md b/cmd/monfront/README.md new file mode 100644 index 0000000..ad64845 --- /dev/null +++ b/cmd/monfront/README.md @@ -0,0 +1,72 @@ +monfront +======== + +Monfront is the frontend to manage monzero. Monzero consists of the other two +components moncheck and monwork too. + +requirements +------------ + +runtime requirements: +* PostgreSQL >= 10.0 + +build requirements: +* Go >= 1.11 + +components +---------- + +The following components exist: + +### monfront + +Monfront is a webfrontend to view the current state of all checks, configure +hosts, groups, checks and view current notifications. +It is possible to run multiple instances. + +configuration +------------- + +To get the system working, first install the database. After that, create an +alarm mapping: + +``` +insert into mappings(name, description) values ('default', 'The default mapping'); +insert into mapping_level values (1, 0, 0, 'okay', 'green'); +insert into mapping_level values (1, 1, 1, 'okay', 'orange'); +insert into mapping_level values (1, 2, 2, 'okay', 'red'); +insert into mapping_level values (1, 3, 3, 'okay', 'gray'); +``` + +Next is to create a notifier. This feature doesn't work 100% yet and needs some +work and may look different later: + +``` +insert into notifier(name) values ('default'); +``` + +After that create a check command: + +``` +insert into commands(name, command, message) values ('ping', 'ping -n -c 1 {{ .ip }}', 'Ping a target'); +``` + +This command can contain variables that are set in the check. It will be executed by moncheck and the result stored. + +After that, create a node which will get the checks attached: + +``` +insert into nodes(name, message) values ('localhost', 'My localhost is my castle'); +``` + +With that prepared, create the first check: + +``` +insert into checks(node_id, command_id, notifier_id, message, options) +values (1, 1, 1, 'This is my localhost ping check!', '{"ip": "127.0.0.1"}'); +``` + +Now start the daemons moncheck, monfront and monwork. + +monwork will transform the configured check into an active check, while moncheck +will run the actual checks. Through monfront one can view the current status. diff --git a/cmd/monfront/authenticater.go b/cmd/monfront/authenticater.go new file mode 100644 index 0000000..8453264 --- /dev/null +++ b/cmd/monfront/authenticater.go @@ -0,0 +1,183 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "fmt" + "log" + "net/http" + "strings" + "time" +) + +const ( + BasicAuthPrompt = `Basic realm="auth for monfront"` + SessionCookie = `session` + UserAnonymous = `anonymous` +) + +type ( + // Authenticator is a middleware taking a context and authenticating + // the user. + Authenticator struct { + db *sql.DB + Mode string + Token []byte + AllowAnonymous bool + Header string + List [][]string + ClientCA string + + sessions map[string]*session // maps a session key to a user + } + + session struct { + user string + t time.Time + } +) + +// Handler returns the handler for the authentication configuration. +func (a *Authenticator) Handler() (func(*Context) error, error) { + switch a.Mode { + case "none": + return func(_ *Context) error { return nil }, nil + case "header": + if a.Header == "" { + return nil, fmt.Errorf("authentication mode is 'header' but no header was provided") + } + return func(c *Context) error { + if user := c.r.Header.Get(a.Header); user == "" { + if a.AllowAnonymous { + c.User = UserAnonymous + return nil + } + return a.Unauthorized(c) + } else { + c.User = user + } + return nil + }, nil + case "list": + return func(c *Context) error { + user, pass, ok := c.r.BasicAuth() + if !ok || user == "" || pass == "" { + if a.AllowAnonymous { + c.User = UserAnonymous + return nil + } + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } + var found string + for _, entry := range a.List { + if entry[0] == user { + found = entry[1] + } + } + if found == "" { + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } + p := pwHash{} + if err := p.Parse(found); err != nil { + log.Printf("could not parse hash for user '%s': %s", user, err) + return a.Unauthorized(c) + } + if ok, err := p.compare(pass); err != nil { + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } else if !ok { + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } + c.User = user + return nil + }, nil + case "db": + return func(c *Context) error { + sessCookie := c.GetCookieVal(SessionCookie) + if sessCookie != "" { + ses := a.getSession(sessCookie) + if ses != "" { + // TODO fix time limit to make it variable + c.SetCookie(SessionCookie, sessCookie, time.Now().Add(2*time.Hour)) + c.User = ses + return nil + } + } + return fmt.Errorf("NOT YET IMPLEMENTED") + }, fmt.Errorf("NOT YET IMPLEMENTED") + case "cert": + return func(c *Context) error { + return fmt.Errorf("NOT YET IMPLEMENTED") + }, fmt.Errorf("NOT YET IMPLEMENTED") + default: + return nil, fmt.Errorf("unknown mode '%s' for authentication", a.Mode) + } + return nil, fmt.Errorf("could not create authenticator") +} + +func (a *Authenticator) Unauthorized(c *Context) error { + c.w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(c.w, "unauthorized\n") + return fmt.Errorf("no authentication") +} + +// creates a session for a user +func (a *Authenticator) createSession(user string) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("could not generate new session key") + } + res := a.mac(raw) + ses := fmt.Sprintf( + "%s-%s", + base64.StdEncoding.EncodeToString(raw), + base64.StdEncoding.EncodeToString(res), + ) + a.sessions[ses] = &session{user: user, t: time.Now()} + return ses, nil +} + +func (a *Authenticator) mac(input []byte) []byte { + mac := hmac.New(sha256.New, a.Token) + mac.Write(input) + return mac.Sum(nil) +} + +// getSession returns the username of the current session. +func (a *Authenticator) getSession(session string) string { + if session == "" { + return "" + } + parts := strings.Split(session, "-") + if len(parts) != 2 { + return "" + } + msg, err := base64.StdEncoding.DecodeString(parts[0]) + if err != nil { + return "" + } + mac, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + verify := a.mac(msg) + if !hmac.Equal(mac, verify) { + return "" + } + if ses, found := a.sessions[session]; found { + // TODO make timeout a config option + if time.Now().Sub(ses.t) < 8*time.Hour { + delete(a.sessions, session) + return "" + } + ses.t = time.Now() + return ses.user + } + return "" +} diff --git a/cmd/monfront/authorizer.go b/cmd/monfront/authorizer.go new file mode 100644 index 0000000..7880cd0 --- /dev/null +++ b/cmd/monfront/authorizer.go @@ -0,0 +1,35 @@ +package main + +import ( + "database/sql" + "fmt" +) + +type ( + Authorizer struct { + db *sql.DB + Mode string + List []string + } +) + +func (a *Authorizer) Handler() (func(c *Context) error, error) { + switch a.Mode { + case "none": + return func(_ *Context) error { return nil }, nil + case "list": + return func(c *Context) error { + for _, user := range a.List { + if user == c.User { + c.CanEdit = true + return nil + } + } + return nil + }, nil + case "all": + return func(c *Context) error { c.CanEdit = true; return nil }, nil + default: + return func(_ *Context) error { return nil }, fmt.Errorf("authorization mode '%s' is unsupported", a.Mode) + } +} diff --git a/cmd/monfront/checks.go b/cmd/monfront/checks.go new file mode 100644 index 0000000..d61d337 --- /dev/null +++ b/cmd/monfront/checks.go @@ -0,0 +1,226 @@ +package main + +import ( + "database/sql" + "log" + "net/http" + "time" + + "github.com/lib/pq" +) + +type ( + check struct { + NodeId int + NodeName string + CommandName string + CheckID int64 + CheckName string + MappingId int + State int + Enabled bool + Notify bool + Notice sql.NullString + NextTime time.Time + Msg string + StateSince time.Time + } + + checkDetails struct { + Id int64 + Name string + 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 + CheckerID int + CheckerName string + CheckerMsg string + } + + 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 + } +) + +// showCheck loads shows the notifications for a specific check. +func showCheck(con *Context) { + cd := checkDetails{} + con.CheckDetails = &cd + id, found := con.r.URL.Query()["check_id"] + if !found { + con.Error = "no check given to view" + returnError(http.StatusNotFound, con, con.w) + return + } + query := `select c.id, c.name, 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, ch.id, ch.name, ch.description + 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 + join checkers ch on c.checker_id = ch.id + where c.id = $1::bigint` + err := DB.QueryRow(query, id[0]).Scan(&cd.Id, &cd.Name, &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, + &cd.CheckerID, &cd.CheckerName, &cd.CheckerMsg) + if err != nil && err == sql.ErrNoRows { + con.w.Header()["Location"] = []string{"/"} + con.w.WriteHeader(http.StatusSeeOther) + return + } else if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.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) + defer rows.Close() + if err != nil { + log.Printf("could not load notifications: %s", err) + con.Error = "could not load notification information" + returnError(http.StatusInternalServerError, con, 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, 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, con.w) + return + } + cd.Notifications = append(cd.Notifications, no) + } + + if err := con.loadMappings(); err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problem with the mappings")) + log.Printf("could not load mappings: %s", err) + return + } + + con.w.Header()["Content-Type"] = []string{"text/html"} + con.Render("check") +} + +func showChecks(con *Context) { + query := `select c.id, c.name, 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, + state_since + 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` + filter := newFilter() + con.Filter = filter + if id, found := con.r.URL.Query()["group_id"]; found { + query += ` join nodes_groups ng on n.id = ng.node_id` + filter.Add("ng.group_id", "=", id[0], "int") + } + filter.filterChecks(con) + if search, found := con.r.URL.Query()["search"]; found { + filter.AddSpecial( + `to_tsvector('english', regexp_replace(n.name, '[.-/]', ' ', 'g'))`, + `@@`, + `to_tsquery('english', regexp_replace($%d, '[.-/]', ' & ', 'g') || ':*')`, + search[0]) + } + if id, found := con.r.URL.Query()["node_id"]; found { + filter.Add("n.id", "=", id[0], "int") + } + if id, found := con.r.URL.Query()["check_id"]; found { + filter.Add("c.id", "=", id[0], "int") + } + where, params := filter.Join() + if len(where) > 0 { + query += " where " + where + } + query += ` order by n.name, c.name, co.name` + rows, err := DB.Query(query, params...) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problems with the database")) + log.Printf("could not get check list: %s", err) + return + } + defer rows.Close() + + checks := []check{} + for rows.Next() { + c := check{} + err := rows.Scan(&c.CheckID, &c.CheckName, &c.NodeId, &c.NodeName, &c.CommandName, &c.MappingId, + &c.State, &c.Enabled, &c.Notice, &c.NextTime, &c.Msg, &c.Notify, &c.StateSince) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + returnError(http.StatusInternalServerError, con, con.w) + log.Printf("could not get check list: %s", err) + return + } + checks = append(checks, c) + } + con.Checks = checks + if err := con.loadCommands(); err != nil { + con.Error = "could not load commands" + returnError(http.StatusInternalServerError, con, con.w) + log.Printf("could not get commands: %s", err) + return + } + if err := con.loadMappings(); err != nil { + con.Error = "could not load mapping data" + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problem with the mappings")) + log.Printf("could not load mappings: %s", err) + return + } + con.w.Header()["Content-Type"] = []string{"text/html"} + con.Render("checklist") + return +} diff --git a/cmd/monfront/filter.go b/cmd/monfront/filter.go new file mode 100644 index 0000000..bd82c91 --- /dev/null +++ b/cmd/monfront/filter.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "strings" +) + +type ( + filter struct { + idx int + where []string + params []interface{} + Vals map[string]string + } +) + +func newFilter() *filter { + return &filter{ + idx: 0, + where: []string{}, + params: []interface{}{}, + Vals: map[string]string{}, + } +} + +func (f *filter) filterChecks(c *Context) { + args := c.r.URL.Query() + for name, val := range args { + if !strings.HasPrefix(name, "filter-") { + continue + } + arg := strings.TrimPrefix(name, "filter-") + switch arg { + case "command": + if val[0] == "" { + continue + } + f.Add("co.id", "=", val[0], "int") + f.Vals[arg] = val[0] + case "search": + if val[0] == "" { + continue + } + f.Add(`n.name`, `like`, strings.ReplaceAll(val[0], "*", "%"), "text") + f.Vals[arg] = val[0] + case "state": + if val[0] == "" { + continue + } + f.Add("states[1]", ">=", val[0], "int") + f.Vals[arg] = val[0] + case "ack": + if val[0] == "" { + continue + } + if val[0] != "true" && val[0] != "false" { + continue + } + f.Add("acknowledged", "=", val[0], "boolean") + f.Vals[arg] = val[0] + case "mapping": + if val[0] == "" { + continue + } + f.Add("ac.mapping_id", "=", val[0], "int") + f.Vals[arg] = val[0] + } + } +} + +// Add a new where clause element which will be joined at the end. +func (f *filter) Add(field, op string, arg interface{}, castTo string) { + f.idx += 1 + f.where = append(f.where, fmt.Sprintf("%s %s $%d::%s", field, op, f.idx, castTo)) + f.params = append(f.params, arg) +} + +// AddSpecial lets you add a special where clause comparison where you can +// wrap the argument in whatevery you like. +// +// Your string has to contain %d. This will place the index of the variable +// in the query string. +// +// Example: +// AddSpecial("foo", "=", "to_tsvector('english', $%d), search) +func (f *filter) AddSpecial(field, op, special string, arg interface{}) { + f.idx += 1 + f.where = append(f.where, fmt.Sprintf("%s %s "+special, field, op, f.idx)) + f.params = append(f.params, arg) +} + +// Join takes all where clauses and joins them together with the AND operator. +// The result and all collected parameters are then returned. +func (f *filter) Join() (string, []interface{}) { + return strings.Join(f.where, " and "), f.params +} diff --git a/cmd/monfront/groups.go b/cmd/monfront/groups.go new file mode 100644 index 0000000..0dfa40c --- /dev/null +++ b/cmd/monfront/groups.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "strings" +) + +type ( + group struct { + GroupId int + Name string + NodeId int + NodeName string + State int + MappingId int + } +) + +func showGroups(con *Context) { + query := `select + group_id, + group_name, + node_id, + node_name, + mapping_id, + state +from ( + select + g.id group_id, + g.name group_name, + n.id node_id, + n.name node_name, + ac.states[1] state, + ac.mapping_id, + ac.acknowledged, + row_number() over (partition by c.node_id order by ac.states[1] desc) 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 + %s + order by g.name, n.name +) groups +where maxstate = 1` + if strings.HasPrefix(con.r.URL.Path, "/unhandled") { + query = fmt.Sprintf(query, `where ac.states[1] != 0 and acknowledged = false`) + con.Unhandled = true + } else { + query = fmt.Sprintf(query, "") + } + + rows, err := DB.Query(query) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.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 { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problems with the database")) + log.Printf("could not get check list: %s", err) + return + } + groups = append(groups, g) + } + con.Groups = groups + if err := con.loadMappings(); err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problem with the mappings")) + log.Printf("could not load mappings: %s", err) + return + } + con.w.Header()["Content-Type"] = []string{"text/html"} + con.Render("grouplist") + return +} diff --git a/cmd/monfront/main.go b/cmd/monfront/main.go new file mode 100644 index 0000000..6275b34 --- /dev/null +++ b/cmd/monfront/main.go @@ -0,0 +1,426 @@ +package main + +import ( + "crypto/tls" + "database/sql" + "flag" + "fmt" + "html/template" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/BurntSushi/toml" + "github.com/lib/pq" + "golang.org/x/crypto/ssh/terminal" +) + +var ( + configPath = flag.String("config", "monfront.conf", "path to the config file") + DB *sql.DB + Tmpl *template.Template +) + +type ( + Config struct { + DB string `toml:"db"` + Listen string `toml:"listen"` + TemplatePath string `toml:"template_path"` + SSL struct { + Enable bool `toml:"enable"` + Priv string `toml:"private_key"` + Cert string `toml:"certificate"` + } `toml:"ssl"` + Authentication struct { + Mode string `toml:"mode"` + Token string `toml:"session_token"` + AllowAnonymous bool `toml:"allow_anonymous"` + Header string `toml:"header"` + List [][]string `toml:"list"` + ClientCA string `toml:"cert"` + } `toml:"authentication"` + Authorization struct { + Mode string `toml:"mode"` + List []string `toml:"list"` + } + } + + MapEntry struct { + Name string + Title string + Color string + } +) + +func main() { + flag.Parse() + + if len(flag.Args()) > 0 { + switch flag.Arg(0) { + case "pwgen": + fmt.Printf("enter password: ") + pw, err := terminal.ReadPassword(0) + fmt.Println() + if err != nil { + log.Fatalf("could not read password: %s", err) + } + hash, err := newHash(string(pw)) + if err != nil { + log.Fatalf("could not generate password hash: %s", err) + } + fmt.Printf("generated password hash: %s\n", hash) + os.Exit(0) + default: + log.Fatalf("unknown command '%s'", flag.Arg(0)) + } + } + + if info, err := os.Stat(*configPath); err != nil { + log.Fatalf("could not find config '%s': %s", *configPath, err) + } else if info.Mode() != 0600 && info.Mode() != 0400 { + log.Fatalf("config '%s' is world readable!", *configPath) + } + + raw, err := ioutil.ReadFile(*configPath) + if err != nil { + log.Fatalf("could not read config: %s", err) + } + config := Config{ + Listen: "127.0.0.1:8080", + TemplatePath: "templates", + } + if err := toml.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 + + authenticator := Authenticator{ + db: db, + Mode: config.Authentication.Mode, + Token: []byte(config.Authentication.Token), + AllowAnonymous: config.Authentication.AllowAnonymous, + Header: config.Authentication.Header, + List: config.Authentication.List, + ClientCA: config.Authentication.ClientCA, + } + auth, err := authenticator.Handler() + if err != nil { + log.Fatalf("could not start authenticator") + } + authorizer := Authorizer{ + db: db, + Mode: config.Authorization.Mode, + List: config.Authorization.List, + } + autho, err := authorizer.Handler() + if err != nil { + log.Fatalf("could not start authorizer") + } + + tmpl := template.New("main") + tmpl.Funcs(Funcs) + files, err := ioutil.ReadDir(config.TemplatePath) + if err != nil { + log.Fatalf("could not read directory '%s': %s", config.TemplatePath, err) + } + for _, file := range files { + if !file.Mode().IsRegular() { + continue + } + if !strings.HasSuffix(file.Name(), ".html") { + continue + } + raw, err := ioutil.ReadFile(path.Join(config.TemplatePath, file.Name())) + if err != nil { + log.Fatalf("could not read file '%s': %s", path.Join(config.TemplatePath, file.Name()), err) + } + template.Must(tmpl.New(strings.TrimSuffix(file.Name(), ".html")).Parse(string(raw))) + } + Tmpl = tmpl + + if config.Listen == "" { + config.Listen = "127.0.0.1:8080" + } + l, err := net.Listen("tcp", config.Listen) + if err != nil { + log.Fatalf("could not create listener: %s", err) + } + if config.SSL.Enable { + cert, err := tls.LoadX509KeyPair(config.SSL.Cert, config.SSL.Priv) + if err != nil { + log.Fatalf("could not load certificate: %s", err) + } + tlsConf := &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"h2", "1.1"}, + } + l = tls.NewListener(l, tlsConf) + } + + s := newServer(l, db, tmpl, auth, autho) + s.Handle("/", showChecks) + s.Handle("/check", showCheck) + s.Handle("/checks", showChecks) + s.Handle("/groups", showGroups) + s.Handle("/action", checkAction) + s.HandleStatic("/static/", showStatic) + log.Fatalf("http server stopped: %s", s.ListenAndServe()) +} + +func checkAction(con *Context) { + if con.r.Method != "POST" { + con.w.WriteHeader(http.StatusMethodNotAllowed) + con.w.Write([]byte("method is not supported")) + return + } + if !con.CanEdit { + con.w.WriteHeader(http.StatusForbidden) + con.w.Write([]byte("no permission to change data")) + return + } + if err := con.r.ParseForm(); err != nil { + con.w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(con.w, "could not parse parameters: %s", err) + return + } + ref, found := con.r.Header["Referer"] + if found { + con.w.Header()["Location"] = ref + } else { + con.w.Header()["Location"] = []string{"/"} + } + checks := con.r.PostForm["checks"] + action := con.r.PostForm.Get("action") + if action == "" || len(checks) == 0 { + con.w.WriteHeader(http.StatusSeeOther) + return + } + setTable := "checks" + setClause := "" + + comment := con.r.PostForm.Get("comment") + run_in := con.r.PostForm.Get("run_in") + if action == "comment" && comment == "" && run_in != "" { + action = "reschedule" + } + + switch action { + 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 "delete_check": + if _, err := DB.Exec(`delete from checks where id = any ($1::bigint[])`, pq.Array(checks)); err != nil { + log.Printf("could not delete checks '%s': %s", checks, err) + con.Error = "could not delete checks" + returnError(http.StatusInternalServerError, con, con.w) + return + } + con.w.WriteHeader(http.StatusSeeOther) + return + case "create_check": + + case "reschedule": + setClause = "next_time = now()" + if run_in != "" { + runNum, err := strconv.Atoi(run_in) + if err != nil { + con.Error = "run_in is not a valid number" + returnError(http.StatusBadRequest, con, con.w) + return + } + setClause = fmt.Sprintf("next_time = now() + '%dmin'::interval", runNum) + } + setTable = "active_checks" + case "deack": + setClause = "acknowledged = false" + 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, 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:4], '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::bigint[])`, pq.Array(&checks), &hostname); err != nil { + log.Printf("could not acknowledge check: %s", err) + con.Error = "could not acknowledge check" + returnError(http.StatusInternalServerError, con, con.w) + return + } + case "comment": + if comment == "" { + con.w.WriteHeader(http.StatusSeeOther) + return + } + _, err := DB.Exec( + "update active_checks set notice = $2 where check_id = any ($1::bigint[]);", + pq.Array(&checks), + comment) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(con.w, "could not store changes") + log.Printf("could not adjust checks %#v: %s", checks, err) + return + } + con.w.WriteHeader(http.StatusSeeOther) + return + case "uncomment": + _, err := DB.Exec(`update active_checks set notice = null where check_id = any($1::bigint[]);`, + pq.Array(&checks)) + if err != nil { + con.Error = "could not uncomment checks" + returnError(http.StatusInternalServerError, con, con.w) + log.Printf("could not uncomment checks: %s", err) + return + } + con.w.WriteHeader(http.StatusSeeOther) + return + default: + con.Error = fmt.Sprintf("requested action '%s' does not exist", action[0]) + returnError(http.StatusNotFound, con, 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::bigint[]);", pq.Array(&checks)) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(con.w, "could not store changes") + log.Printf("could not adjust checks %#v: %s", checks, err) + return + } + con.w.WriteHeader(http.StatusSeeOther) + return +} + +func returnError(status int, con interface{}, 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 (c *Context) loadCommands() error { + c.Commands = map[string]int{} + rows, err := DB.Query(`select id, name from commands order by name`) + if err != nil { + return err + } + for rows.Next() { + if rows.Err() != nil { + return rows.Err() + } + var ( + id int + name string + ) + if err := rows.Scan(&id, &name); err != nil { + return err + } + c.Commands[name] = id + } + return nil +} + +func (c *Context) loadMappings() 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 + name string + target int + title string + color string + ) + if err := rows.Scan(&mapId, &name, &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, Name: name} + } + 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, name, target, title, color + from mappings m join mapping_level ml on m.id = ml.mapping_id` +) + +var ( + Templates = map[string]string{} + 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>`, + "icon-notice": `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="36" height="36"><path d="M2.572.19h30.857c1.319 0 2.38 1.356 2.38 3.041v19.98c0 1.685-1.061 3.04-2.38 3.04H15.941L4 35.81v-9.56H2.572C1.252 26.252.19 24.897.19 23.212V3.232C.19 1.545 1.252.19 2.57.19z" stroke="#000" stroke-width=".38" stroke-linejoin="round"/></svg>`, + "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) }, + "since": func(t time.Time) time.Duration { return time.Now().Sub(t).Round(1 * time.Second) }, + "now": func() time.Time { return time.Now() }, + "join": func(args []string, c string) string { return strings.Join(args, c) }, + "mapString": func(mapId, target int) string { return fmt.Sprintf("%d-%d", mapId, target) }, + "itoa": func(i int) string { return strconv.Itoa(i) }, + } +) diff --git a/cmd/monfront/pw.go b/cmd/monfront/pw.go new file mode 100644 index 0000000..d06e9f2 --- /dev/null +++ b/cmd/monfront/pw.go @@ -0,0 +1,99 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/scrypt" +) + +type ( + pwHash struct { + salt []byte + hash []byte + } +) + +// Create a new password hash. +func newHash(pw string) (*pwHash, error) { + hash := pwHash{} + if err := hash.genSalt(); err != nil { + return nil, err + } + h, err := hash.Hash(pw) + if err != nil { + return nil, err + } + hash.hash = h + return &hash, nil +} + +// generate a hash for the given salt and password +func (p *pwHash) Hash(pw string) ([]byte, error) { + if len(p.salt) == 0 { + return []byte{}, fmt.Errorf("salt not initialized") + } + // constants taken from https://godoc.org/golang.org/x/crypto/scrypt + hash, err := scrypt.Key([]byte(pw), p.salt, 32768, 8, 1, 32) + if err != nil { + return []byte{}, fmt.Errorf("could not compute hash: %s", err) + } + return hash, nil +} + +// genSalt generates 8 bytes of salt. +func (p *pwHash) genSalt() error { + salt := make([]byte, 8) + _, err := rand.Read(salt) + p.salt = salt + return err +} + +// compare a hash to a password and return true, when it matches. +func (p *pwHash) compare(pw string) (bool, error) { + hash, err := p.Hash(pw) + if err != nil { + return false, fmt.Errorf("could not check password") + } + if bytes.Compare(p.hash, hash) == 0 { + return true, nil + } + return false, nil +} + +// Encode a hash and salt to a string. +func (p *pwHash) String() string { + return fmt.Sprintf( + "1$%s$%s", + base64.StdEncoding.EncodeToString(p.salt), + base64.StdEncoding.EncodeToString(p.hash), + ) +} + +// Parse a hash from a file or anywhere. +func (p *pwHash) Parse(raw string) error { + if len(raw) == 0 { + return fmt.Errorf("no hash found") + } + parts := strings.Split(raw, "$") + if len(parts) != 3 { + return fmt.Errorf("format error") + } + if parts[0] != "1" { + return fmt.Errorf("unknown hash version") + } + salt, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("could not parse salt: %s", err) + } + hash, err := base64.StdEncoding.DecodeString(parts[2]) + if err != nil { + return fmt.Errorf("could not parse salt: %s", err) + } + p.salt = salt + p.hash = hash + return nil +} diff --git a/cmd/monfront/server.go b/cmd/monfront/server.go new file mode 100644 index 0000000..2ada1d0 --- /dev/null +++ b/cmd/monfront/server.go @@ -0,0 +1,153 @@ +package main + +import ( + "compress/gzip" + "database/sql" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "strings" + "time" +) + +type ( + server struct { + listen net.Listener + db *sql.DB + h *http.ServeMux + tmpl *template.Template + auth func(c *Context) error // authentication + autho func(c *Context) error // authorization + } + + handleFunc func(c *Context) + + Context struct { + // internal maintenance stuff + w http.ResponseWriter + r *http.Request + tmpl *template.Template + db *sql.DB + + User string `json:"-"` + Filter *filter `json:"-"` + CanEdit bool `json:"-"` // has user permission to edit stuff? + + Title string `json:"title,omitempty"` + CurrentPath string `json:"-"` + Error string `json:"error,omitempty"` + Mappings map[int]map[int]MapEntry `json:"mappings,omitempty"` + Commands map[string]int `json:"commands,omitempty"` + Checks []check `json:"checks,omitempty"` + CheckDetails *checkDetails `json:"check_details,omitempty"` + Groups []group `json:"groups,omitempty"` + Unhandled bool `json:"-"` // set this flag when unhandled was called + } +) + +func newServer(l net.Listener, db *sql.DB, tmpl *template.Template, auth func(c *Context) error, autho func(c *Context) error) *server { + s := &server{ + listen: l, + db: db, + tmpl: tmpl, + h: http.NewServeMux(), + auth: auth, + autho: autho, + } + return s +} + +func (s *server) ListenAndServe() error { + server := http.Server{Handler: s.h} + return server.Serve(s.listen) +} + +func (s *server) Handle(path string, fun handleFunc) { + s.h.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + c := &Context{ + w: w, + r: r, + tmpl: s.tmpl, + db: s.db, + } + if err := s.auth(c); err != nil { + return + } + if err := s.autho(c); err != nil { + return + } + fun(c) + return + }) +} + +func (s *server) HandleStatic(path string, h func(w http.ResponseWriter, r *http.Request)) { + s.h.HandleFunc(path, h) +} + +// Render calls the template with the given name to +// render the appropiate content. +// In case of an error, a error message is automatically pushed +// to the client. +func (c *Context) Render(t string) error { + var w io.Writer = c.w + if strings.Contains(c.r.Header.Get("Accept-Encoding"), "gzip") { + gz, err := gzip.NewWriterLevel(w, 5) + if err != nil { + log.Printf("could not create gzip writer: %s", err) + return fmt.Errorf("could not create gzip writer: %s", err) + } + defer gz.Close() + w = gz + c.w.Header().Set("Content-Encoding", "gzip") + } + + if c.r.Header.Get("Accept") == "application/json" { + c.w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", "") // disable indentation to save traffic + if err := enc.Encode(c); err != nil { + c.w.WriteHeader(http.StatusInternalServerError) + c.w.Write([]byte("could not write json output")) + log.Printf("could not write json output: %s", err) + return err + } + return nil + } + + if err := c.tmpl.ExecuteTemplate(w, t, c); err != nil { + c.w.WriteHeader(http.StatusInternalServerError) + c.w.Write([]byte("problem with a template")) + log.Printf("could not execute template: %s", err) + return err + } + return nil +} + +// Get a cookie value. +func (c *Context) GetCookieVal(name string) string { + cook, err := c.r.Cookie(name) + if err == http.ErrNoCookie { + return "" + } + return cook.Value +} + +// Set a new key value cookie with a deadline. +func (c *Context) SetCookie(name, val string, expire time.Time) { + cook := http.Cookie{ + Name: name, + Value: val, + Expires: expire, + Secure: true, + SameSite: http.SameSiteStrictMode, + HttpOnly: true, + Path: "/", + } + http.SetCookie(c.w, &cook) + return +} diff --git a/cmd/monfront/templates/check.html b/cmd/monfront/templates/check.html new file mode 100644 index 0000000..e30f62a --- /dev/null +++ b/cmd/monfront/templates/check.html @@ -0,0 +1,51 @@ + {{ template "header" . }} + <section id="content"> + {{ template "checkformheader" . }} + {{ $mapping := .Mappings }} + {{ with .CheckDetails }} + <input type="hidden" name="checks" value="{{ .Id }}" /> + <article class="detail"> + <h1>check for service {{ .Name }}</h1> + <div><span class="label">current state</span><span class="value state-{{ index .States 0 }}"></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 <a href="/checks?node_id={{ .NodeId }}">{{ .NodeName }}</a></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> + <article class="detail"> + <h1>checker {{ .CheckerName }}</h1> + <div><span class="label">Description</span><span class="value">{{ .CheckerMsg }}</span></div> + </article> + <article> + <h1>notifications</h1> + <table> + <thead><tr><th>notifier</th><th>state</th><th>created</th><th>sent</th><th>output</th></thead> + <tbody> + {{ range .Notifications -}} + <tr> + <td>{{ .NotifierName }}</td> + <td class="state-{{ .MappingId }}-{{ .State }}">{{ (index $mapping .MappingId .State).Title }}</td> + <td>{{ .Inserted.Format "2006.01.02 15:04:05" }}</td> + <td>{{ if .Sent.Valid }}{{ .Sent.Time.Format "2006.01.02 15:04:05" }}{{ end }}</td> + <td>{{ .Output }}</td> + </tr> + {{ end -}} + </tbody> + </table> + </article> + {{ end }} + {{ template "checkformfooter" . }} + </section> + {{ template "footer" . }} diff --git a/cmd/monfront/templates/checkfilter.html b/cmd/monfront/templates/checkfilter.html new file mode 100644 index 0000000..f86ce3c --- /dev/null +++ b/cmd/monfront/templates/checkfilter.html @@ -0,0 +1,49 @@ + <form action="/checks" method="get"> + <aside id="edit"> + <div class="option"> + <select id="filter-state" name="filter-state" class="states"> + <option value="">filter state</option> + {{ $FilterValsState := "" }} + {{ if .Filter.Vals.state }}{{$FilterValsState = .Filter.Vals.state }}{{ end }} + <option value="0" {{ if eq (itoa 0) $FilterValsState }}selected{{ end }}>>= OK</option> + <option value="1" {{ if eq (itoa 1) $FilterValsState }}selected{{ end }}>>= Warning</option> + <option value="2" {{ if eq (itoa 2) $FilterValsState }}selected{{ end }}>>= Error</option> + <option value="3" {{ if eq (itoa 3) $FilterValsState }}selected{{ end }}>>= Unknown</option> + </select> + </div> + <div class="option"> + <select id="filter-ack" name="filter-ack"> + <option value="">filter acknowledged</option> + <option value="false" {{ if eq "false" (index .Filter.Vals "ack") }}selected{{ end }}>unacknowledged</option> + <option value="true" {{ if eq "true" (index .Filter.Vals "ack") }}selected{{ end }}>acknowledged</option> + </select> + </div> + <div class="option"> + <select id="filter-mapping" name="filter-mapping"> + <option value="">filter mapping</option> + {{ $FilterValsMapping := "" }} + {{ if .Filter.Vals.mapping }}{{ $FilterValsMapping = .Filter.Vals.mapping }}{{ end }} + {{ range $mapId, $mapping := .Mappings -}} + <option value="{{ $mapId }}" {{ if eq (itoa $mapId) $FilterValsMapping }}selected{{ end }}>{{ (index $mapping 0).Name }}</option> + {{ end }} + </select> + </div> + <div class="option"> + <select id="filter-command" name="filter-command"> + <option value="">filter command</option> + {{ $FilterValsCommands := "" }} + {{ if .Filter.Vals.command }}{{ $FilterValsCommands = .Filter.Vals.command }}{{ end }} + {{ range $command, $comId := .Commands -}} + <option value="{{ $comId }}" {{ if eq (itoa $comId) $FilterValsCommands }}selected{{ end }}>{{ $command }}</option> + {{ end }} + </select> + </div> + <div class="option"> + <input name="filter-search" placeholder="hostname" value="{{ .Filter.Vals.search }}" /> + </div> + <div class="option"> + <button name="filter" value="1">filter</button> + <button name="reset" value="1">reset</button> + </div> + </aside> + </form> diff --git a/cmd/monfront/templates/checkformfooter.html b/cmd/monfront/templates/checkformfooter.html new file mode 100644 index 0000000..5582354 --- /dev/null +++ b/cmd/monfront/templates/checkformfooter.html @@ -0,0 +1 @@ +</form> diff --git a/cmd/monfront/templates/checkformheader.html b/cmd/monfront/templates/checkformheader.html new file mode 100644 index 0000000..7662670 --- /dev/null +++ b/cmd/monfront/templates/checkformheader.html @@ -0,0 +1,43 @@ + {{ if .CanEdit }} + <form action="/action" method="post"> + <aside id="edit"> + <button class="default_button" name="action" value="comment"></button> + <div class="option input"> + <input type="number" name="run_in" placeholder="run in" title="define the number of minutes after which the check should be run again" /> + <button name="action" value="reschedule">run now</button> + </div> + <div class="option"> + <button name="action" value="deack">deack</button> + <button name="action" value="ack">ack</button> + </div> + <div class="option"> + <button name="action" value="enable">enable</button> + <button name="action" value="disable">disable</button> + </div> + <div class="option"> + <button name="action" value="mute">mute</button> + <button name="action" value="unmute">unmute</button> + </div> + <div class="option input"> + <input size="" name="comment" placeholder="enter comment ..." /> + </div> + <div class="option"> + <button name="action" value="comment">comment</button> + <button name="action" value="uncomment">uncomment</button> + </div> + <div class="option"> + <button type="button" name="create_check">create</button> + <button name="action" value="delete_check">delete</button> + </div> + </aside> + <aside id="create_check" class="hidden"> + <div class="option"> + <label for="host">host</label> + <input name="host" placeholder="hostname" /> + </div> + <div class="option"> + <label for="command">command</label> + <input name="command" placeholder="command" /> + </div> + </aside> + {{ end }} diff --git a/cmd/monfront/templates/checklist.html b/cmd/monfront/templates/checklist.html new file mode 100644 index 0000000..17830ef --- /dev/null +++ b/cmd/monfront/templates/checklist.html @@ -0,0 +1,29 @@ + {{ template "header" . }} + <section id="content"> + {{ template "checkfilter" . }} + {{ template "checkformheader" . }} + <table> + <thead><tr><th><input type="checkbox" title="select all" /></th><th>host</th><th>service</th><th>status</th><th title="shows how long the check is already in that state">for</th><th>next check in</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>{{ .CheckName }}</td> + <td class="state-{{ .State }}"> + {{- if ne .Notify true }}<span class="icon mute"></span>{{ end -}} + {{- if .Notice.Valid }}<span class="icon notice" title="{{ .Notice.String }}"></span>{{ end -}} + <a href="/check?check_id={{ .CheckID }}">{{ .CommandName }}</a> + </td> + <td>{{ since .StateSince }}</td> + <td>{{ in .NextTime }}</td> + <td><code>{{ .Msg }}</code></td> + </tr> + {{ end }} + </tbody> + </table> + {{ template "checkformfooter" . }} + </section> + {{ template "footer" . }} diff --git a/cmd/monfront/templates/error.html b/cmd/monfront/templates/error.html new file mode 100644 index 0000000..efffc4c --- /dev/null +++ b/cmd/monfront/templates/error.html @@ -0,0 +1 @@ +{{ .Error }} diff --git a/cmd/monfront/templates/footer.html b/cmd/monfront/templates/footer.html new file mode 100644 index 0000000..124b585 --- /dev/null +++ b/cmd/monfront/templates/footer.html @@ -0,0 +1,76 @@ + <script type="text/javascript"> + function row_head_click_event(event) { + check = false; + current = event.target; + while (current != null) { + if (current.nodeName == 'TABLE') { + break; + } + if (current.nodeName == 'TR') { + check = !current.children[0].children[0].checked; + current.children[0].children[0].checked = check; + } + current = current.parentNode; + } + lines = current.children[1].children + for (i = 0; i < lines.length; i++) { + select_row(event, lines[i], lines[i].children[0].children[0], check); + } + } + function row_click_event(event) { + if (event.target.nodeName == 'INPUT') { + return; + } + current = event.target; + while (current = current.parentNode) { + if (current.nodeName == 'BODY') { + break; + } + if (current.nodeName != 'TR') { + continue; + } + e = current.children[0].children[0]; + check = !e.checked; + select_row(event, current, e, check); + break; + } + } + function select_row(event, row, input, check) { + if (input != event.target) { + input.checked = check; + } + if (input.checked) { + row.classList.add("selected"); + } else { + row.classList.remove("selected"); + } + input.focus(); + } + + for (selector of ['thead > tr', 'thead input']) { + els = document.querySelectorAll(selector); + for (i = 0; i < els.length; i++) { + els[i].addEventListener('click', {handleEvent: row_head_click_event}); + } + } + for (selector of ['tbody > tr', 'tbody input']) { + els = document.querySelectorAll(selector); + for (i = 0; i < els.length; i++) { + els[i].addEventListener('click', {handleEvent: row_click_event}); + } + } + butt = document.querySelectorAll('button[type=button][name=create_check]'); + for (i = 0; i < butt.length; i++) { + butt[i].addEventListener('click', {handleEvent: function(event){ + cur = document.querySelector('#create_check').style.display; + console.log("meh: " + cur); + if (cur == 'block') { + document.querySelector('#create_check').style.display = 'none'; + } else { + document.querySelector('#create_check').style.display = 'block'; + } + }}); + } + </script> + </body> +</html> diff --git a/cmd/monfront/templates/grouplist.html b/cmd/monfront/templates/grouplist.html new file mode 100644 index 0000000..fa0554c --- /dev/null +++ b/cmd/monfront/templates/grouplist.html @@ -0,0 +1,21 @@ + {{ 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 }}<a href="{{ if $.Unhandled }}/unhandled{{ end }}/checks?group_id={{ .GroupId }}">{{ .Name }}</a>{{ 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" . }} diff --git a/cmd/monfront/templates/header.html b/cmd/monfront/templates/header.html new file mode 100644 index 0000000..1f9040d --- /dev/null +++ b/cmd/monfront/templates/header.html @@ -0,0 +1,101 @@ +<!doctype html> +<html> + <head> + <title>{{ .Title }}</title> + <meta name="referrer" content="same-origin"> + <link rel="shortcut icon" href="/static/favicon" /> + <style type="text/css"> + :root { + --main-bg-color: #3a4149; + --dark-bg-color: #2f353a; + --light-bg-color: #626971; + --main-fg-color: #eeeeee; + + --bg-okay: hsla(125, 50%, 40%, 1); + --bg-warn: hsla(40, 100%, 50%, 1); + --bg-crit: hsla(0, 75%, 50%, 1); + --bg-unkn: gray; + } + * { font-size: 100%; } + body { background: var(--dark-bg-color); padding: 0; margin: 0; color: var(--main-fg-color); } + section#content { padding: 1em; background: var(--main-bg-color); border: 1px solid black; margin: 0.5em; } + #mainmenu, aside { background: var(--main-bg-color); border-bottom: 1px solid black; } + #mainmenu ul, aside { + display: flex; + flex-direction: row; + align-items: center; + margin: 0; + padding: 0; + align-content: center; } + aside { padding: 0.5em; border: 1px solid black; border-bottom: none; } + #mainmenu li { list-style-type: none; } + #mainmenu a, #mainmenu a:visited, #mainmenu a:active, #mainmenu a:hover, #mainmenu span { + text-decoration: none; + color: #e4e7ea; + padding: 0.5em 0.75em; + display: block; } + aside .option { display: grid; grid-template-columns: auto auto; margin: 0em 0.25em; } + input[type="number"] { width: 4em; } + button, select { background: var(--dark-bg-color); color: var(--main-fg-color); border: 1px solid black; padding: 0.25em 0.5em; } + a { color: var(--main-fg-color); } + form article { border: 1px solid black; border-bottom: none; padding: 0.5em; } + table { border-collapse: collapse; border-spacing: 0; width: 100%; } + table tr:nth-child(odd) { background: rgba(0, 0, 0, 0.15); } + table tr:nth-child(even) { background: var(--main-bg-color); } + table tr.selected:nth-child(odd) { background: var(--light-bg-color); } + table tr.selected:nth-child(even) { background: rgba(255, 255, 255, 0.45); } + table tr:hover, table tr:hover a { background: #dfdfdf; color: black; } + table th { background: var(--main-bg-color); color: var(--main-fg-color); font-weigth: 700; } + table td, table th { text-align: center; border: 1px solid black; padding: 0.35em 0.15em; } + 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; + } + .hidden { display: none; } + .default_button { margin: 0; padding: 0; border: 0; height: 0; width: 0; } + .mute { background-image: url(/static/icon-mute); } + .notice { background-image: url(/static/icon-notice); } + .detail > div { display: grid; grid-template-columns: 25% auto; } + .detail > div:hover { background: #dfdfdf; color: black; } + .error { padding: 0.5em; background: #ffc6c6; border: 1px solid red; } + select.states option[value="0"], .state-0 { background-color: var(--bg-okay); } + select.states option[value="1"], .state-1 { background-color: var(--bg-warn); } + select.states option[value="2"], .state-2 { background-color: var(--bg-crit); } + select.states option[value="3"], .state-3 { background-color: var(--bg-unkn); } + .state-0:after { content: 'okay' } + .state-1:after { content: 'warning' } + .state-2:after { content: 'critical' } + .state-3:after { content: 'unknown' } + /* state background colors */ + {{ range $mapId, $mapping := .Mappings -}} + {{ range $target, $val := $mapping -}} + .state-{{ $mapId }}-{{ $target }} { background: {{ $val.Color }}; color: black; } + {{ end -}} + {{ end -}} + </style> + <script> + setTimeout(function() { if (document.activeElement.tagName == "BODY") { location.reload(true) } }, 30000) + </script> + </head> + <body> + <nav id="mainmenu"> + <ul> + <li><span>{{ now.Format "2006.01.02" }}</span></li> + <li><span>{{ now.Format "15:04:05" }}</span></li> + <li><a href="/">home</a></li> + <li><a href="/checks?filter-state=1&filter-ack=false">checks</a></li> + <li><a href="/groups">groups</a></li> + <li class="submenu"> + <form action="/checks" method="get"> + </form> + </li> + </ul> + </nav> + {{ if .Error }}<div class="error">{{ .Error }}</div>{{ end }} |