create_aio
This commit is contained in:
146
internal/admin/admin.go
Normal file
146
internal/admin/admin.go
Normal 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)
|
||||
})
|
||||
}
|
||||
39
internal/admin/tpl/layout.html
Normal file
39
internal/admin/tpl/layout.html
Normal 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>
|
||||
57
internal/admin/tpl/partials_items.html
Normal file
57
internal/admin/tpl/partials_items.html
Normal 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>
|
||||
23
internal/admin/tpl/partials_peers.html
Normal file
23
internal/admin/tpl/partials_peers.html
Normal 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>
|
||||
Reference in New Issue
Block a user