init
All checks were successful
release-tag / release-image (push) Successful in 2m28s

This commit is contained in:
2025-08-13 22:00:34 +02:00
parent e05640b36e
commit dbdee7d440
10 changed files with 5455 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

File diff suppressed because it is too large Load Diff