Browse Source

GUI ranking and events tabs

Daniel Garcia Costa 1 month ago
parent
commit
463ef973e2

+ 203 - 33
GUI/index.html

@@ -40,6 +40,14 @@
       </div>
     </div>
     <div class="col-md-2">
+      <div class="card card-kpi text-bg-warning">
+        <div class="card-body text-center">
+          <h6>Actives</h6>
+          <h2 id="kpiActiveShards">–</h2>
+        </div>
+      </div>
+    </div>
+    <div class="col-md-2">
       <div class="card card-kpi text-bg-success">
         <div class="card-body text-center">
           <h6>Players</h6>
@@ -55,14 +63,6 @@
         </div>
       </div>
     </div>
-    <div class="col-md-2">
-      <div class="card card-kpi text-bg-warning">
-        <div class="card-body text-center">
-          <h6>Active</h6>
-          <h2 id="kpiActiveShards">–</h2>
-        </div>
-      </div>
-    </div>
   </div>
 
 
@@ -156,22 +156,26 @@
     <!-- TAB: PLAYERS -->
     <!-- ===================================================== -->
     <div class="tab-pane fade" id="tabPlayers">
+        <div class="d-flex justify-content-between align-items-center mb-2">
+            <h2 class="section-title mb-0">Players (Global)</h2>
+            <button
+                class="btn btn-sm btn-outline-secondary"
+                onclick="loadPlayersGlobal()">
+                🔄 Refresh
+            </button>
+        </div>
 
-
-      <h2 class="section-title">Players (Global)</h2>
-
-
-      <table class="table table-striped">
+        <table class="table table-striped">
         <thead class="table-dark">
-          <tr>
-            <th>Player</th>
-            <th>Global Score</th>
-            <th>Shards</th>
-            <th>Actions</th>
-          </tr>
+            <tr>
+                <th>Player</th>
+                <th>Global Score</th>
+                <th>Shards</th>
+                <th>Actions</th>
+            </tr>
         </thead>
         <tbody id="tablePlayers"></tbody>
-      </table>
+        </table>
 
 
     </div>
@@ -182,6 +186,10 @@
     <!-- ===================================================== -->
     <div class="tab-pane fade" id="tabRankings">
 
+      <div class="d-flex justify-content-between align-items-center mb-2">
+            <h2 class="section-title mb-0">Rankings by</h2>
+            <button class="btn btn-sm btn-outline-secondary" onclick="refreshRankings()">🔄 Refresh</button>
+      </div> 
 
       <ul class="nav nav-pills mb-3">
         <li class="nav-item">
@@ -192,13 +200,36 @@
         </li>
       </ul>
 
-
       <div class="tab-content">
         <div class="tab-pane fade show active" id="rankGlobal">
           Ranking global de jugadores (agregación multi‑shard)
+
+            <table class="table table-sm table-striped">
+                <thead class="table-dark">
+                    <tr>
+                        <th>#</th>
+                        <th>Player</th>
+                        <th>Score</th>
+                    </tr>
+                </thead>
+                <tbody id="tableRankingGlobal"></tbody>
+            </table>
+          
         </div>
         <div class="tab-pane fade" id="rankShard">
           Ranking por shard usando <code>shard_player_stats</code>
+
+            <table class="table table-sm table-striped">
+                <thead class="table-dark">
+                    <tr>
+                        <th>#</th>
+                        <th>Player</th>
+                        <th>Score</th>
+                    </tr>
+                </thead>
+                <tbody id="tableRankingShard"></tbody>
+            </table>
+
         </div>
       </div>
 
@@ -220,27 +251,53 @@
       </div>
 
 
-      <table class="table table-sm table-hover">
-        <thead>
-          <tr>
-            <th>Time</th>
-            <th>Shard</th>
-            <th>Player</th>
-            <th>Action</th>
-            <th>Δ</th>
-            <th>Winner</th>
-          </tr>
+      <div class="d-flex gap-2 mb-2">
+        <input type="text" id="filterShard" class="form-control form-control-sm" placeholder="Shard ID">
+
+        <input type="text" id="filterPlayer" class="form-control form-control-sm" placeholder="Player ID">
+
+        <select id="filterAction" class="form-select form-select-sm">
+            <option value="">All actions</option>
+            <option value="EXPLORE">EXPLORE</option>
+            <option value="GATHER">GATHER</option>
+            <option value="FIGHT">FIGHT</option>
+            <option value="REST">REST</option>
+        </select>
+
+        <button class="btn btn-sm btn-outline-primary" onclick="loadEvents(0)">
+            Apply
+        </button>
+    </div>
+
+    <table class="table table-sm table-striped table-hover">
+        <thead class="table-dark">
+            <tr>
+                <th>Time</th>
+                <th>Shard</th>
+                <th>Player</th>
+                <th>Action</th>
+                <th>Δ Score</th>
+            </tr>
         </thead>
         <tbody id="tableEvents"></tbody>
-      </table>
+    </table>
+
+    <div class="d-flex justify-content-between align-items-center">
+        <button class="btn btn-sm btn-outline-secondary" onclick="prevEventsPage()">
+            ◀ Prev
+        </button>
 
+        <span id="eventsPageInfo"></span>
 
+        <button class="btn btn-sm btn-outline-secondary" onclick="nextEventsPage()">
+            Next ▶
+        </button>
     </div>
 
+    </div>
 
   </div>
 
-
 </div>
 
 
@@ -260,6 +317,8 @@
     let selectedShardId = null;
     let snapshotChart = null;
     let eventsChart = null;
+    let eventsPage = 0;
+    let eventsTotalPages = 0;
 
     async function refreshShardsTab() {
         try {
@@ -441,6 +500,106 @@
         });
     }
 
+    /* FUNCIONES PARA ACTUALIZAR PESTAÑA RANKINGS */
+    async function loadRankingGlobal() {
+        const res = await fetch(`${API_BASE}/master/rankings/global`);
+        const ranking = await res.json();
+
+        const tbody = document.getElementById("tableRankingGlobal");
+        tbody.innerHTML = "";
+
+        ranking.forEach((p, index) => {
+            const tr = document.createElement("tr");
+            tr.innerHTML = `
+                <td>${index + 1}</td>
+                <td>${p.playerId}</td>
+                <td>${p.score}</td>
+            `;
+            tbody.appendChild(tr);
+        });
+    }
+
+    async function loadRankingShard() {
+        if (!selectedShardId) return;
+
+        const res = await fetch(
+            `${API_BASE}/master/rankings/shards/${selectedShardId}`
+        );
+        const ranking = await res.json();
+
+        const tbody = document.getElementById("tableRankingShard");
+        tbody.innerHTML = "";
+
+        ranking.forEach((p, index) => {
+            const tr = document.createElement("tr");
+            tr.innerHTML = `
+                <td>${index + 1}</td>
+                <td>${p.playerId}</td>
+                <td>${p.score}</td>
+            `;
+            tbody.appendChild(tr);
+        });
+    }
+
+    function refreshRankings(){
+        loadRankingGlobal();
+        loadRankingShard();
+    }
+
+    /* FUNCIONES PARA ACTUALIZAR LA PESTAÑA EVENTS */
+    async function loadEvents(page) {
+
+        const shard = document.getElementById("filterShard").value;
+        const player = document.getElementById("filterPlayer").value;
+        const action = document.getElementById("filterAction").value;
+
+        const params = new URLSearchParams({
+            page: page,
+            size: 15,
+            shard: shard,
+            player: player,
+            action: action
+        });
+
+        const res = await fetch(
+            `${API_BASE}/master/events?${params}`
+        );
+
+        const data = await res.json();
+
+        eventsPage = data.number;
+        eventsTotalPages = data.totalPages;
+
+        const tbody = document.getElementById("tableEvents");
+        tbody.innerHTML = "";
+
+        data.content.forEach(e => {
+            const tr = document.createElement("tr");
+            tr.innerHTML = `
+                <td>${new Date(e.timestamp).toLocaleTimeString()}</td>
+                <td>${e.shardId}</td>
+                <td>${e.playerId}</td>
+                <td>${e.action}</td>
+                <td>${e.pointsDelta}</td>
+            `;
+            tbody.appendChild(tr);
+        });
+
+        document.getElementById("eventsPageInfo").textContent =
+            `Page ${eventsPage + 1} / ${eventsTotalPages}`;
+    }
+
+    function nextEventsPage() {
+        if (eventsPage + 1 < eventsTotalPages) {
+            loadEvents(eventsPage + 1);
+        }
+    }
+
+    function prevEventsPage() {
+        if (eventsPage > 0) {
+            loadEvents(eventsPage - 1);
+        }
+    }
 
     /* FUNCIONES AUXILIARES */
     function heartbeatColor(lastHeartbeat) {
@@ -479,6 +638,17 @@
                 loadPlayersGlobal();
             });
 
+    
+    document.querySelector('button[data-bs-target="#tabRankings"]')
+            .addEventListener("shown.bs.tab", () => {
+                refreshRankings();
+            });
+
+    document.querySelector('button[data-bs-target="#tabEvents"]')
+            .addEventListener("shown.bs.tab", () => {
+                loadEvents(0);
+            });
+
 
 </script>
 

+ 31 - 0
src/main/java/es/uv/dagarcos/master/controller/EventDashboardController.java

@@ -0,0 +1,31 @@
+package es.uv.dagarcos.master.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import es.uv.dagarcos.master.dto.MasterEventDto;
+import es.uv.dagarcos.master.service.EventDashboardService;
+
+@RestController
+@RequestMapping("/master/events")
+@CrossOrigin
+public class EventDashboardController {
+
+    @Autowired
+    private EventDashboardService eventService;
+
+    @GetMapping
+    public Page<MasterEventDto> getEvents(@RequestParam(defaultValue = "") String shard, 
+                                          @RequestParam(defaultValue = "") String player, 
+                                          @RequestParam(defaultValue = "") String action, 
+                                          Pageable pageable) {
+
+        return eventService.getEvents(shard, player, action, pageable);
+    }
+}

+ 33 - 0
src/main/java/es/uv/dagarcos/master/controller/RankingDashboardController.java

@@ -0,0 +1,33 @@
+package es.uv.dagarcos.master.controller;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import es.uv.dagarcos.master.dto.RankingEntryDto;
+import es.uv.dagarcos.master.service.RankingDashboardService;
+
+@RestController
+@RequestMapping("/master/rankings")
+@CrossOrigin
+public class RankingDashboardController {
+
+    @Autowired
+    private RankingDashboardService rankingService;
+
+    @GetMapping("/global")
+    public List<RankingEntryDto> globalRanking() {
+        return rankingService.getGlobalRanking();
+    }
+
+    @GetMapping("/shards/{shardId}")
+    public List<RankingEntryDto> shardRanking(
+            @PathVariable String shardId) {
+        return rankingService.getShardRanking(shardId);
+    }
+}

+ 46 - 0
src/main/java/es/uv/dagarcos/master/dto/MasterEventDto.java

@@ -0,0 +1,46 @@
+package es.uv.dagarcos.master.dto;
+
+import java.time.Instant;
+
+public class MasterEventDto {
+
+    private Instant timestamp;
+    private String shardId;
+    private String playerId;
+    private String action;
+    private int pointsDelta;
+
+    public MasterEventDto(
+            Instant timestamp,
+            String shardId,
+            String playerId,
+            String action,
+            int pointsDelta) {
+
+        this.timestamp = timestamp;
+        this.shardId = shardId;
+        this.playerId = playerId;
+        this.action = action;
+        this.pointsDelta = pointsDelta;
+    }
+
+    public Instant getTimestamp() {
+        return timestamp;
+    }
+
+    public String getShardId() {
+        return shardId;
+    }
+
+    public String getPlayerId() {
+        return playerId;
+    }
+
+    public String getAction() {
+        return action;
+    }
+
+    public int getPointsDelta() {
+        return pointsDelta;
+    }
+}

+ 31 - 0
src/main/java/es/uv/dagarcos/master/dto/RankingEntryDto.java

@@ -0,0 +1,31 @@
+package es.uv.dagarcos.master.dto;
+
+public class RankingEntryDto {
+
+    private String playerId;
+    private long score;
+
+    public RankingEntryDto(String playerId, long score) {
+        this.playerId = playerId;
+        this.score = score;
+    }
+
+    public RankingEntryDto(){}
+
+    public String getPlayerId() {
+        return playerId;
+    }
+
+    public void setPlayerId(String playerId) {
+        this.playerId = playerId;
+    }
+
+    public long getScore() {
+        return score;
+    }
+
+    public void setScore(long score) {
+        this.score = score;
+    }
+
+}

+ 21 - 0
src/main/java/es/uv/dagarcos/master/repository/MasterEventRepository.java

@@ -3,6 +3,9 @@ package es.uv.dagarcos.master.repository;
 import es.uv.dagarcos.master.domain.MasterEvent;
 import es.uv.dagarcos.master.domain.PlayerGlobal;
 import es.uv.dagarcos.master.domain.Shard;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.repository.query.Param;
@@ -44,4 +47,22 @@ public interface MasterEventRepository extends JpaRepository<MasterEvent, Long>
     """)
     List<Object[]> countEventsTimeSeries(@Param("shardId") String shardId);
 
+    @Query(
+        value = """
+            SELECT e
+            FROM MasterEvent e
+            WHERE (:shard IS NULL OR e.shard.externalId = :shard)
+              AND (:player IS NULL OR e.player.externalPlayerId = :player)
+              AND (:action IS NULL OR e.action = :action)
+        """,
+        countQuery = """
+            SELECT COUNT(e)
+            FROM MasterEvent e
+            WHERE (:shard IS NULL OR e.shard.externalId = :shard)
+              AND (:player IS NULL OR e.player.externalPlayerId = :player)
+              AND (:action IS NULL OR e.action = :action)
+        """)
+    Page<MasterEvent> findFiltered(@Param("shard") String shard, @Param("player") String player,
+                                   @Param("action") String action, Pageable pageable);
+
 }

+ 20 - 0
src/main/java/es/uv/dagarcos/master/repository/ShardPlayerStatsRepository.java

@@ -44,4 +44,24 @@ public interface ShardPlayerStatsRepository extends JpaRepository<ShardPlayerSta
     """)
     List<Object[]> aggregateGlobalPlayerStats();
 
+    @Query("""
+        SELECT
+            sps.player.externalPlayerId,
+            SUM(sps.score)
+        FROM ShardPlayerStats sps
+        GROUP BY sps.player.externalPlayerId
+        ORDER BY SUM(sps.score) DESC
+    """)
+    List<Object[]> globalRanking();
+
+    @Query(""" 
+        SELECT
+            sps.player.externalPlayerId,
+            sps.score
+        FROM ShardPlayerStats sps
+        WHERE sps.shard.externalId = :shardId
+        ORDER BY sps.score DESC
+    """)
+    List<Object[]> rankingByShard(@Param("shardId") String shardId);
+
 }

+ 1 - 0
src/main/java/es/uv/dagarcos/master/repository/ShardRepository.java

@@ -1,6 +1,7 @@
 package es.uv.dagarcos.master.repository;
 
 import es.uv.dagarcos.master.domain.Shard;
+
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.repository.query.Param;

+ 32 - 0
src/main/java/es/uv/dagarcos/master/service/EventDashboardService.java

@@ -0,0 +1,32 @@
+package es.uv.dagarcos.master.service;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+
+import es.uv.dagarcos.master.dto.MasterEventDto;
+import es.uv.dagarcos.master.repository.MasterEventRepository;
+
+@Service
+public class EventDashboardService {
+
+    @Autowired
+    private MasterEventRepository eventRepository;
+
+    public Page<MasterEventDto> getEvents(String shard, String player, String action, Pageable pageable) {
+
+        String shardFilter = shard.isBlank() ? null : shard;
+        String playerFilter = player.isBlank() ? null : player;
+        String actionFilter = action.isBlank() ? null : action;
+
+        return eventRepository.findFiltered(shardFilter, playerFilter, actionFilter, pageable)
+                              .map(e -> new MasterEventDto(
+                                    e.getTimestamp(),
+                                    e.getShard().getExternalId(),
+                                    e.getPlayer().getExternalPlayerId(),
+                                    e.getAction(),
+                                    e.getPointsDelta()
+                              ));
+    }
+}

+ 38 - 0
src/main/java/es/uv/dagarcos/master/service/RankingDashboardService.java

@@ -0,0 +1,38 @@
+package es.uv.dagarcos.master.service;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import es.uv.dagarcos.master.dto.RankingEntryDto;
+import es.uv.dagarcos.master.repository.ShardPlayerStatsRepository;
+
+@Service
+public class RankingDashboardService {
+
+    @Autowired
+    private ShardPlayerStatsRepository statsRepository;
+
+    public List<RankingEntryDto> getGlobalRanking() {
+
+        return statsRepository.globalRanking()
+                .stream()
+                .map(row -> new RankingEntryDto(
+                        (String) row[0],
+                        (Long) row[1]
+                ))
+                .toList();
+    }
+
+    public List<RankingEntryDto> getShardRanking(String shardId) {
+
+        return statsRepository.rankingByShard(shardId)
+                .stream()
+                .map(row -> new RankingEntryDto(
+                        (String) row[0],
+                        (Long) row[1]
+                ))
+                .toList();
+    }
+}