create_aio

This commit is contained in:
2025-09-27 09:33:40 +02:00
parent b5ad30cfbd
commit de22d7c0f2
13 changed files with 1387 additions and 907 deletions

146
internal/admin/admin.go Normal file
View File

@@ -0,0 +1,146 @@
package admin
import (
"context"
_ "embed"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"git.send.nrw/sendnrw/decent-webui/internal/filesvc"
"git.send.nrw/sendnrw/decent-webui/internal/mesh"
)
/*** Templates einbetten ***/
//go:embed tpl/layout.html
var layoutHTML string
//go:embed tpl/partials_items.html
var itemsPartialHTML string
//go:embed tpl/partials_peers.html
var peersPartialHTML string
var (
tplLayout = template.Must(template.New("layout").Parse(layoutHTML))
tplItems = template.Must(template.New("items").Funcs(template.FuncMap{
"timeRFC3339": func(unixNano int64) string {
if unixNano == 0 {
return ""
}
return time.Unix(0, unixNano).UTC().Format(time.RFC3339)
},
}).Parse(itemsPartialHTML))
tplPeers = template.Must(template.New("peers").Parse(peersPartialHTML))
)
type Deps struct {
Store filesvc.MeshStore
Mesh *mesh.Node
}
// Register hängt alle /admin Routen ein.
// Auth liegt optional VOR Register (BasicAuth-Middleware), siehe main.go.
func Register(mux *http.ServeMux, d Deps) {
// Dashboard
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
renderLayout(w, r, "Files", "/admin/items")
})
// Partials
mux.HandleFunc("/admin/items", func(w http.ResponseWriter, r *http.Request) {
// Liste rendern (Pagination optional via ?next=)
nextQ := strings.TrimSpace(r.URL.Query().Get("next"))
var nextID filesvc.ID
if nextQ != "" {
if n, err := strconv.ParseInt(nextQ, 10, 64); err == nil {
nextID = filesvc.ID(n)
}
}
items, nextOut, _ := d.Store.List(r.Context(), nextID, 100)
_ = tplItems.Execute(w, map[string]any{
"Items": items,
"Next": nextOut,
})
})
mux.HandleFunc("/admin/peers", func(w http.ResponseWriter, r *http.Request) {
peers := d.Mesh.PeerList()
_ = tplPeers.Execute(w, map[string]any{
"Peers": peers,
"Now": time.Now(),
})
})
// Actions (HTMX POSTs)
mux.HandleFunc("/admin/items/create", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name != "" {
_, _ = d.Store.Create(r.Context(), name)
_ = d.Mesh.SyncNow(r.Context()) // prompt push (best effort)
}
// Nach Aktion Items partial zurückgeben (HTMX swap)
http.Redirect(w, r, "/admin/items", http.StatusSeeOther)
})
mux.HandleFunc("/admin/items/rename", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
idStr := r.FormValue("id")
newName := strings.TrimSpace(r.FormValue("name"))
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil && newName != "" {
_, _ = d.Store.Rename(r.Context(), filesvc.ID(id), newName)
_ = d.Mesh.SyncNow(r.Context())
}
http.Redirect(w, r, "/admin/items", http.StatusSeeOther)
})
mux.HandleFunc("/admin/items/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if id, err := strconv.ParseInt(r.FormValue("id"), 10, 64); err == nil {
_, _ = d.Store.Delete(r.Context(), filesvc.ID(id))
_ = d.Mesh.SyncNow(r.Context())
}
http.Redirect(w, r, "/admin/items", http.StatusSeeOther)
})
mux.HandleFunc("/admin/mesh/syncnow", func(w http.ResponseWriter, r *http.Request) {
_ = d.Mesh.SyncNow(context.Background())
http.Redirect(w, r, "/admin/peers", http.StatusSeeOther)
})
}
func renderLayout(w http.ResponseWriter, _ *http.Request, active string, initial string) {
_ = tplLayout.Execute(w, map[string]any{
"Active": active,
"Init": initial, // initialer HTMX Swap-Endpunkt
})
}
/*** Optional: einfache BasicAuth (siehe main.go) ***/
func BasicAuth(user, pass string, next http.Handler) http.Handler {
if strings.TrimSpace(user) == "" {
return next // deaktiviert
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != user || p != pass {
w.Header().Set("WWW-Authenticate", `Basic realm="admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,39 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Admin</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
:root { --bg:#0b1220; --card:#121a2b; --muted:#94a3b8; --text:#e5e7eb; --accent:#4f46e5; }
html,body { margin:0; background:var(--bg); color:var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu; }
.wrap { max-width: 980px; margin: 40px auto; padding: 0 16px; }
.nav { display:flex; gap:12px; margin-bottom:16px; }
.btn { background:var(--card); border:1px solid #243044; padding:8px 12px; border-radius:10px; color:var(--text); cursor:pointer; }
.btn:hover { border-color:#3b4a66; }
.btn-primary { background: var(--accent); border-color: var(--accent); color:white; }
.card { background:var(--card); border:1px solid #1f2937; border-radius:16px; padding:16px; box-shadow: 0 6px 30px rgba(0,0,0,.25); }
.row { display:flex; gap:16px; flex-wrap:wrap; }
table { width:100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #1f2937; padding:10px; text-align:left; }
input[type="text"] { background:#0f1626; border:1px solid #263246; color:var(--text); padding:8px 10px; border-radius:10px; width:100%; }
small { color: var(--muted); }
.muted { color: var(--muted); }
.pill { font-size: 12px; padding: 2px 8px; border:1px solid #2b364b; border-radius:999px; background:#0f1626; color:#bcd; }
</style>
</head>
<body>
<div class="wrap">
<h1 style="margin:0 0 10px 0;">Unified Admin</h1>
<div class="nav">
<button class="btn" hx-get="/admin/items" hx-target="#main" hx-swap="innerHTML">Dateien</button>
<button class="btn" hx-get="/admin/peers" hx-target="#main" hx-swap="innerHTML">Mesh</button>
</div>
<div id="main" class="card" hx-get="{{.Init}}" hx-trigger="load" hx-swap="innerHTML">
<div class="muted">Lade…</div>
</div>
<div style="margin-top:14px"><small>© Admin UI</small></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,57 @@
<div class="row">
<div style="flex: 1 1 360px">
<form hx-post="/admin/items/create" hx-target="#items" hx-get="/admin/items" hx-swap="outerHTML">
<label>Neue Datei</label>
<div style="display:flex; gap:8px; margin-top:6px">
<input type="text" name="name" placeholder="z.B. notes.txt" required>
<button class="btn btn-primary" type="submit">Anlegen</button>
</div>
</form>
</div>
</div>
<div id="items" style="margin-top:14px">
<table>
<thead>
<tr>
<th>ID</th><th>Name</th><th>Updated</th><th style="width:200px">Aktionen</th>
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr>
<td>{{ .ID }}</td>
<td>{{ .Name }}</td>
<td><small class="muted">{{ printf "%.19s" (timeRFC3339 .UpdatedAt) }}</small></td>
<td>
<form style="display:inline-flex; gap:6px"
hx-post="/admin/items/rename"
hx-target="#items" hx-get="/admin/items" hx-swap="outerHTML">
<input type="hidden" name="id" value="{{ .ID }}">
<input type="text" name="name" placeholder="Neuer Name">
<button class="btn" type="submit" title="Umbenennen">Rename</button>
</form>
<form style="display:inline"
hx-post="/admin/items/delete"
hx-target="#items" hx-get="/admin/items" hx-swap="outerHTML"
onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="id" value="{{ .ID }}">
<button class="btn" type="submit" title="Löschen">Delete</button>
</form>
</td>
</tr>
{{ else }}
<tr><td colspan="4" class="muted">Keine Dateien vorhanden.</td></tr>
{{ end }}
</tbody>
</table>
{{ if .Next }}
<div style="margin-top:10px">
<button class="btn"
hx-get="/admin/items?next={{ .Next }}"
hx-target="#items" hx-swap="outerHTML">Mehr laden</button>
<span class="pill">next={{ .Next }}</span>
</div>
{{ end }}
</div>

View File

@@ -0,0 +1,23 @@
<div style="display:flex; justify-content:space-between; align-items:center">
<h3 style="margin:0">Mesh Peers</h3>
<form hx-post="/admin/mesh/syncnow" hx-target="#peers" hx-get="/admin/peers" hx-swap="outerHTML">
<button class="btn btn-primary" type="submit">Jetzt synchronisieren</button>
</form>
</div>
<div id="peers" style="margin-top:10px">
<table>
<thead><tr><th>URL</th><th>Self</th><th>Last Seen</th></tr></thead>
<tbody>
{{ range .Peers }}
<tr>
<td>{{ .URL }}</td>
<td>{{ if .Self }}✅{{ else }}—{{ end }}</td>
<td><small class="muted">{{ .LastSeen }}</small></td>
</tr>
{{ else }}
<tr><td colspan="3" class="muted">Keine Peers bekannt.</td></tr>
{{ end }}
</tbody>
</table>
<div class="muted" style="margin-top:8px"><small>Stand: {{ .Now }}</small></div>
</div>