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