Files
vocalforge-dashboard/main.go
jbergner dbdee7d440
All checks were successful
release-tag / release-image (push) Successful in 2m28s
init
2025-08-13 22:00:34 +02:00

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>`))