This commit is contained in:
290
main.go
Normal file
290
main.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// 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>`))
|
Reference in New Issue
Block a user