admin update

master
Denis Ranneft 1 month ago
parent 94d8a0cda8
commit 907c192577

@ -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 `<div class="card" style="margin-top:10px"><h4>Путь, город, отдых</h4>${rows.join("")}${pollNote}</div>`;
}
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 `
<div class="card" style="margin-top:10px">
<h4>Live snapshot (WebSocket)</h4>
<div class="kv"><kbd>status</kbd><div>${e(status)}${heroId ? " (hero " + e(heroId) + ")" : ""}</div></div>
<div class="kv"><kbd>lastUpdate</kbd><div>${e(last)}</div></div>
${err ? `<div class="status-err">${e(err)}</div>` : ""}
<button class="btn" onclick="connectHeroLiveWS()">Connect live</button>
<button class="btn" onclick="stopHeroLiveWS()">Disconnect</button>
<p class="muted" style="margin-top:6px">Endpoint: <kbd>/admin-ws/hero/{heroId}</kbd> (BasicAuth from saved credentials).</p>
</div>`;
}
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 @@
<div class="kv"><kbd>Name</kbd><div>${e(h.name)}</div></div>
<div class="kv"><kbd>State</kbd><div>${e(h.state)}</div></div>
${heroMovementDetailHtml(h)}
${heroLiveWsCardHtml()}
<div class="kv"><kbd>Telegram ID</kbd><div>${e(h.telegramId)}</div></div>
<div class="kv"><kbd>Level</kbd><div>${e(h.level)}</div></div>
<div class="kv"><kbd>HP</kbd><div>${e(h.hp)}/${e(h.maxHp)}</div></div>
@ -1476,6 +1564,8 @@
window.openConfirm = openConfirm;
window.closeConfirm = closeConfirm;
window.confirmProceed = confirmProceed;
window.connectHeroLiveWS = connectHeroLiveWS;
window.stopHeroLiveWS = stopHeroLiveWS;
render();
</script>
</body>

@ -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;
}
}

@ -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)
}

Loading…
Cancel
Save