All checks were successful
release-tag / release-image (push) Successful in 2m28s
291 lines
9.0 KiB
Go
291 lines
9.0 KiB
Go
// VocalForge Minimal Status Dashboard (Bootstrap)
|
|
// Run: DB_PATH=./guild_config.db ADDR=:8080 go run vocalforge_status_dashboard.go
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
var (
|
|
dbDsn = GetENV("DB_PATH", "/data/guild_config.db")
|
|
HTTP_PORT string = "8080"
|
|
HTTP_TLS bool = false
|
|
HTTP_TLS_PRIVATEKEY string = ""
|
|
HTTP_TLS_CERTIFICATE string = ""
|
|
)
|
|
|
|
type Status struct {
|
|
StartedAt time.Time
|
|
Now time.Time
|
|
DBPath string
|
|
GuildCount int
|
|
RecentEvents []EventRow
|
|
HasEventTable bool
|
|
}
|
|
|
|
type EventRow struct {
|
|
TS int64 `json:"ts"`
|
|
GuildID string `json:"guild_id"`
|
|
Type string `json:"type"`
|
|
ChannelID string `json:"channel_id,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
Extra string `json:"extra,omitempty"`
|
|
}
|
|
|
|
var (
|
|
db *sql.DB
|
|
startedAt = time.Now()
|
|
)
|
|
|
|
func GetENV(k, d string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
|
|
func Enabled(k string, def bool) bool {
|
|
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return b
|
|
}
|
|
|
|
func main() {
|
|
var err error
|
|
db, err = sql.Open("sqlite", dbDsn+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout=5000&_pragma=synchronous=NORMAL")
|
|
if err != nil {
|
|
log.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
HTTP_PORT = GetENV("HTTP_PORT", "8080")
|
|
HTTP_TLS = Enabled("HTTP_TLS", false)
|
|
HTTP_TLS_CERTIFICATE = GetENV("HTTP_TLS_CERTIFICATE", "./server-cert.pem")
|
|
HTTP_TLS_PRIVATEKEY = GetENV("HTTP_TLS_PRIVATEKEY", "./server-key.pem")
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", handleIndex)
|
|
mux.HandleFunc("/api/status", handleAPIStatus)
|
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("/data/static"))))
|
|
|
|
log.Printf("VocalForge status dashboard listening on %s (DB=%s)", HTTP_PORT, dbDsn)
|
|
|
|
if HTTP_TLS {
|
|
log.Printf("WoL server listening on %s (DB: %s)", ":"+HTTP_PORT, dbDsn)
|
|
if err := http.ListenAndServeTLS(":"+HTTP_PORT, HTTP_TLS_CERTIFICATE, HTTP_TLS_PRIVATEKEY, withSecurity(mux)); err != nil {
|
|
log.Fatalf("http server error: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("WoL server listening on %s (DB: %s)", ":"+HTTP_PORT, dbDsn)
|
|
if err := http.ListenAndServe(":"+HTTP_PORT, withSecurity(mux)); err != nil {
|
|
log.Fatalf("http server error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
st := collectStatus()
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := indexTmpl.Execute(w, st); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func handleAPIStatus(w http.ResponseWriter, r *http.Request) {
|
|
st := collectStatus()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(st)
|
|
}
|
|
|
|
func collectStatus() Status {
|
|
st := Status{
|
|
StartedAt: startedAt,
|
|
Now: time.Now(),
|
|
DBPath: dbDsn,
|
|
}
|
|
|
|
// guild count (from guild_config)
|
|
if row := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='guild_config'`); row != nil {
|
|
var exists int
|
|
_ = row.Scan(&exists)
|
|
if exists == 1 {
|
|
row2 := db.QueryRow(`SELECT COUNT(*) FROM guild_config`)
|
|
_ = row2.Scan(&st.GuildCount)
|
|
}
|
|
}
|
|
|
|
// recent events if table exists
|
|
st.HasEventTable = tableExists("eventlog")
|
|
if st.HasEventTable {
|
|
rows, err := db.Query(`SELECT ts, guild_id, type, COALESCE(channel_id,''), COALESCE(user_id,''), COALESCE(extra,'')
|
|
FROM eventlog ORDER BY ts DESC LIMIT 25`)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var e EventRow
|
|
if err := rows.Scan(&e.TS, &e.GuildID, &e.Type, &e.ChannelID, &e.UserID, &e.Extra); err == nil {
|
|
st.RecentEvents = append(st.RecentEvents, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return st
|
|
}
|
|
|
|
func tableExists(name string) bool {
|
|
row := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name)
|
|
var n int
|
|
if err := row.Scan(&n); err != nil {
|
|
return false
|
|
}
|
|
return n > 0
|
|
}
|
|
|
|
func withSecurity(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Simple security headers
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
var indexTmpl = template.Must(template.New("index").Parse(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>VocalForge · Status</title>
|
|
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
|
</head>
|
|
<body class="bg-light">
|
|
<nav class="navbar navbar-dark bg-dark">
|
|
<div class="container-fluid">
|
|
<span class="navbar-brand mb-0 h1">VocalForge · Status</span>
|
|
<span class="navbar-text text-white-50">DB: {{ .DBPath }}</span>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="container my-4">
|
|
<div class="row g-3">
|
|
<div class="col-12 col-md-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Uptime</h5>
|
|
<p class="card-text"><span id="uptime" data-start="{{ .StartedAt.Unix }}"></span></p>
|
|
<small class="text-muted">Started: {{ .StartedAt.Format "2006-01-02 15:04:05" }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Guilds</h5>
|
|
<p class="display-6 mb-0">{{ .GuildCount }}</p>
|
|
<small class="text-muted">from guild_config</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Event Log</h5>
|
|
{{ if .HasEventTable }}
|
|
<p class="mb-0">{{ len .RecentEvents }} recent</p>
|
|
<small class="text-muted">from eventlog</small>
|
|
{{ else }}
|
|
<p class="mb-0">N/A</p>
|
|
<small class="text-muted">create table eventlog to enable</small>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3 mt-1">
|
|
<div class="col-12">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<h5 class="card-title mb-3">Recent Events</h5>
|
|
{{ if .HasEventTable }}
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-striped align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 160px;">Time</th>
|
|
<th>Guild</th>
|
|
<th>Type</th>
|
|
<th>Channel</th>
|
|
<th>User</th>
|
|
<th>Extra</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{ range .RecentEvents }}
|
|
<tr>
|
|
<td><time class="time" data-ts="{{ .TS }}"></time></td>
|
|
<td><code>{{ .GuildID }}</code></td>
|
|
<td><span class="badge text-bg-primary">{{ .Type }}</span></td>
|
|
<td><code>{{ .ChannelID }}</code></td>
|
|
<td><code>{{ .UserID }}</code></td>
|
|
<td class="small text-break">{{ .Extra }}</td>
|
|
</tr>
|
|
{{ end }}
|
|
{{ if not .RecentEvents }}<tr><td colspan="6" class="text-muted">No events yet.</td></tr>{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ else }}
|
|
<p class="text-muted mb-0">Event table not present.</p>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
// show uptime ticking
|
|
const upEl = document.getElementById('uptime');
|
|
function fmtDur(sec) {
|
|
const d = Math.floor(sec/86400); sec%=86400;
|
|
const h = Math.floor(sec/3600); sec%=3600;
|
|
const m = Math.floor(sec/60); const s = sec%60;
|
|
const parts = [];
|
|
if (d) parts.push(d+'d'); if (h) parts.push(h+'h'); if (m) parts.push(m+'m'); parts.push(s+'s');
|
|
return parts.join(' ');
|
|
}
|
|
function tick() {
|
|
if (upEl) {
|
|
const start = parseInt(upEl.dataset.start, 10) * 1000;
|
|
const now = Date.now();
|
|
upEl.textContent = fmtDur(Math.floor((now - start)/1000));
|
|
}
|
|
// render timestamps
|
|
document.querySelectorAll('time.time').forEach(t => {
|
|
const ts = parseInt(t.dataset.ts, 10) * 1000;
|
|
if (!isNaN(ts)) t.textContent = new Date(ts).toLocaleString();
|
|
});
|
|
}
|
|
tick(); setInterval(tick, 1000);
|
|
</script>
|
|
</body>
|
|
</html>`))
|