From 907c192577bd83eb1561c1c34e34c92043ca8797 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Tue, 31 Mar 2026 01:34:56 +0300 Subject: [PATCH] admin update --- admin-web/index.html | 92 ++++++++++++++++++++++++- admin-web/nginx.conf | 11 +++ backend/internal/handler/admin.go | 107 ++++++++++++++++++++++++++++-- 3 files changed, 204 insertions(+), 6 deletions(-) diff --git a/admin-web/index.html b/admin-web/index.html index a14ccf2..5ffcabd 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -81,6 +81,11 @@ confirm: { open: false, title: "", message: "" }, _heroPollTimer: null, _heroPollUntil: null, + _heroLiveWs: null, + _heroLiveWsHeroId: null, + _heroLiveWsStatus: "disconnected", + _heroLiveWsError: "", + _heroLiveWsLastAt: null, }; state._confirmAction = null; @@ -568,7 +573,21 @@ state._heroPollTimer = null; state._heroPollUntil = null; } + function stopHeroLiveWS() { + if (state._heroLiveWs) { + try { state._heroLiveWs.close(); } catch (err) {} + } + state._heroLiveWs = null; + state._heroLiveWsHeroId = null; + state._heroLiveWsStatus = "disconnected"; + state._heroLiveWsError = ""; + state._heroLiveWsLastAt = null; + render(); + } function startHeroMovementPoll(durationSec = 55) { + if (state._heroLiveWs && state._heroLiveWs.readyState === WebSocket.OPEN) { + return; + } stopHeroMovementPoll(); state._heroPollUntil = Date.now() + durationSec * 1000; state._heroPollTimer = setInterval(async () => { @@ -586,6 +605,55 @@ }, 1000); render(); } + function connectHeroLiveWS() { + if (!state.selectedHeroId) { setMessage("Select hero first"); return; } + if (!state.auth.username || !state.auth.password) { setMessage("Set admin credentials first"); return; } + stopHeroMovementPoll(); + stopHeroLiveWS(); + const proto = location.protocol === "https:" ? "wss" : "ws"; + const auth = btoa(`${state.auth.username}:${state.auth.password}`); + const url = `${proto}://${location.host}/admin-ws/hero/${state.selectedHeroId}?auth=${encodeURIComponent(auth)}`; + const ws = new WebSocket(url); + state._heroLiveWs = ws; + state._heroLiveWsHeroId = state.selectedHeroId; + state._heroLiveWsStatus = "connecting"; + state._heroLiveWsError = ""; + state._heroLiveWsLastAt = null; + render(); + ws.onopen = () => { + state._heroLiveWsStatus = "connected"; + render(); + }; + ws.onclose = () => { + state._heroLiveWsStatus = "disconnected"; + render(); + }; + ws.onerror = () => { + state._heroLiveWsStatus = "error"; + state._heroLiveWsError = "WebSocket error"; + render(); + }; + ws.onmessage = (evt) => { + try { + const data = JSON.parse(evt.data); + if (data && data.error) { + state._heroLiveWsStatus = "error"; + state._heroLiveWsError = String(data.error); + render(); + return; + } + if (data && data.id && state.selectedHeroId === data.id) { + state.selectedHero = data; + state._heroLiveWsLastAt = Date.now(); + render(); + } + } catch (err) { + state._heroLiveWsStatus = "error"; + state._heroLiveWsError = "Failed to parse WS payload"; + render(); + } + }; + } function formatRemainingMs(ms) { if (ms == null || !Number.isFinite(ms)) return "—"; if (ms <= 0) return "истекло"; @@ -637,9 +705,28 @@ } return `

Путь, город, отдых

${rows.join("")}${pollNote}
`; } + function heroLiveWsCardHtml() { + const status = state._heroLiveWsStatus || "disconnected"; + const last = state._heroLiveWsLastAt ? new Date(state._heroLiveWsLastAt).toLocaleTimeString() : "—"; + const heroId = state._heroLiveWsHeroId; + const err = state._heroLiveWsError; + return ` +
+

Live snapshot (WebSocket)

+
status
${e(status)}${heroId ? " (hero " + e(heroId) + ")" : ""}
+
lastUpdate
${e(last)}
+ ${err ? `
${e(err)}
` : ""} + + +

Endpoint: /admin-ws/hero/{heroId} (BasicAuth from saved credentials).

+
`; + } async function loadHero(heroId) { if (!heroId) return; - if (state.selectedHeroId != null && state.selectedHeroId !== heroId) stopHeroMovementPoll(); + if (state.selectedHeroId != null && state.selectedHeroId !== heroId) { + stopHeroMovementPoll(); + stopHeroLiveWS(); + } state.selectedHeroId = heroId; const [hero, gear, quests] = await Promise.all([api(`heroes/${heroId}`), api(`heroes/${heroId}/gear`), api(`heroes/${heroId}/quests`)]); state.selectedHero = hero; state.gear = gear; state.quests = quests; @@ -1176,6 +1263,7 @@
Name
${e(h.name)}
State
${e(h.state)}
${heroMovementDetailHtml(h)} + ${heroLiveWsCardHtml()}
Telegram ID
${e(h.telegramId)}
Level
${e(h.level)}
HP
${e(h.hp)}/${e(h.maxHp)}
@@ -1476,6 +1564,8 @@ window.openConfirm = openConfirm; window.closeConfirm = closeConfirm; window.confirmProceed = confirmProceed; + window.connectHeroLiveWS = connectHeroLiveWS; + window.stopHeroLiveWS = stopHeroLiveWS; render(); diff --git a/admin-web/nginx.conf b/admin-web/nginx.conf index b525423..41c9757 100644 --- a/admin-web/nginx.conf +++ b/admin-web/nginx.conf @@ -15,4 +15,15 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + + location /admin-ws/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 5c0c05b..56bcb79 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -33,8 +34,8 @@ type AdminHandler struct { hub *Hub pool *pgxpool.Pool logger *slog.Logger - adminUser string - adminPass string + adminUser string + adminPass string } // NewAdminHandler creates a new AdminHandler with all required dependencies. @@ -133,10 +134,18 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON { } func (h *AdminHandler) writeAdminHeroDetail(w http.ResponseWriter, hero *model.Hero) { - if hero == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "nil hero"}) + out, err := h.buildAdminHeroDetail(hero) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } + writeJSON(w, http.StatusOK, out) +} + +func (h *AdminHandler) buildAdminHeroDetail(hero *model.Hero) (adminHeroDetailResponse, error) { + if hero == nil { + return adminHeroDetailResponse{}, fmt.Errorf("nil hero") + } now := time.Now() hero.RefreshDerivedCombatStats(now) out := adminHeroDetailResponse{Hero: *hero, TownPause: hero.TownPause} @@ -146,7 +155,7 @@ func (h *AdminHandler) writeAdminHeroDetail(w http.ResponseWriter, hero *model.H out.TownPause = hm.Hero.TownPause out.AdminLiveMovement = buildAdminLiveMovementSnap(hm) } - writeJSON(w, http.StatusOK, out) + return out, nil } // ListHeroes returns a paginated list of all heroes. @@ -1859,6 +1868,74 @@ func (h *AdminHandler) WSConnections(w http.ResponseWriter, r *http.Request) { }) } +// AdminHeroSnapshotWS streams hero detail + movement snapshot for admin UI. +// GET /admin-ws/hero/{heroId}?auth=BASE64(user:pass) +func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Request) { + if !h.adminWSAuthorized(r) { + w.Header().Set("WWW-Authenticate", `Basic realm="Admin", charset="UTF-8"`) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId"}) + return + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + h.logger.Error("admin ws upgrade failed", "error", err) + return + } + defer conn.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + sendSnapshot := func() error { + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + return err + } + if hero == nil { + return fmt.Errorf("hero not found") + } + snap, err := h.buildAdminHeroDetail(hero) + if err != nil { + return err + } + conn.SetWriteDeadline(time.Now().Add(writeWait)) + return conn.WriteJSON(snap) + } + + if err := sendSnapshot(); err != nil { + _ = conn.WriteJSON(map[string]string{"error": err.Error()}) + return + } + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-r.Context().Done(): + return + case <-ticker.C: + if err := sendSnapshot(); err != nil { + _ = conn.WriteJSON(map[string]string{"error": err.Error()}) + return + } + } + } +} + // ── Server Info ───────────────────────────────────────────────────── // ServerInfo returns general server diagnostics. @@ -2048,6 +2125,26 @@ func parseHeroID(r *http.Request) (int64, error) { return strconv.ParseInt(chi.URLParam(r, "heroId"), 10, 64) } +func (h *AdminHandler) adminWSAuthorized(r *http.Request) bool { + if user, pass, ok := r.BasicAuth(); ok { + if basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass) { + return true + } + } + q := r.URL.Query() + if raw := strings.TrimSpace(q.Get("auth")); raw != "" { + if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 && basicAuthCredentialsMatch(parts[0], parts[1], h.adminUser, h.adminPass) { + return true + } + } + } + user := q.Get("user") + pass := q.Get("pass") + return basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass) +} + func parseQuestID(r *http.Request) (int64, error) { return strconv.ParseInt(chi.URLParam(r, "questId"), 10, 64) }