This commit is contained in:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-fast
|
||||
env:
|
||||
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
|
||||
DOCKER_LATEST: latest
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with: # replace it with your local IP
|
||||
config-inline: |
|
||||
[registry."${{ vars.DOCKER_REGISTRY }}"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# -------- Dockerfile (Multi-Stage Build) --------
|
||||
# 1. Builder-Stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /bin/vfdashboard
|
||||
|
||||
# 2. Runtime-Stage
|
||||
FROM alpine:3.22
|
||||
|
||||
# HTTPS-Callouts in Alpine brauchen ca-certificates
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN mkdir /data
|
||||
RUN mkdir /static
|
||||
#RUN mkdir /dynamicsrc
|
||||
#RUN mkdir /tempsrc
|
||||
COPY --from=builder /bin/vfdashboard /bin/vfdashboard
|
||||
COPY ./static /static
|
||||
# COPY ./static /tempsrc/static
|
||||
#COPY ./dynamicsrc /dynamicsrc
|
||||
|
||||
# Default listens on :8080 – siehe main.go
|
||||
EXPOSE 8080
|
||||
|
||||
# Environment defaults; können per compose überschrieben werden
|
||||
ENV HTTP_PORT=8080 \
|
||||
HTTP_TLS=false \
|
||||
HTTP_TLS_CERTIFICATE=./server-cert.pem \
|
||||
HTTP_TLS_PRIVATEKEY=./server-key.pem \
|
||||
DB_PATH=/data/guild_config.db
|
||||
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["/bin/vfdashboard"]
|
18
go.mod
Normal file
18
go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module git.send.nrw/sendnrw/vocalforge-dashboard
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require modernc.org/sqlite v1.38.2
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
23
go.sum
Normal file
23
go.sum
Normal file
@@ -0,0 +1,23 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
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>`))
|
6
static/css/bootstrap.min.css
vendored
Normal file
6
static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
static/css/tom-select.default.min.css
vendored
Normal file
2
static/css/tom-select.default.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5021
static/js/tom-select.complete.min.js
vendored
Normal file
5021
static/js/tom-select.complete.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user