Files
snake-python/templates/side/dashboard.htm
T
daniel156161 f14d780f29
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
feat: add live replays and PostgreSQL maintenance tools
- Stream compact live replay updates across local and clustered dashboards.
- Render responsive snake bodies as SVG paths with aligned custom icons.
- Add cache-busted assets, replay fallback routes, and live-follow playback.
- Support PostgreSQL benchmark sampling and idempotent SQLite migration.
- Add dry-run cleanup for old low-quality PostgreSQL replay payloads.
- Reward safe perimeter lanes and bump Prism to version 1.5.0.
- Add backend, migration, dashboard, and perimeter regression coverage.
2026-08-02 00:50:46 +02:00

226 lines
8.7 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Dashboard</title>
<link rel="stylesheet" href="{{ static_url('css/root.css') }}">
<link rel="stylesheet" href="{{ static_url('css/styles.css') }}">
</head>
<body>
<div class="page">
<header class="topbar">
<div>
<h1 class="title">Battlesnake Replay Dashboard</h1>
<p class="subtitle">Full-screen replay with turn-by-turn move reasoning and snake state.</p>
</div>
<div class="stats" id="stats"></div>
</header>
<main class="main">
<section class="panel">
<div class="panel-header">
<h2>Games</h2>
<p>Pick a game to inspect the match, move timeline, and why your snake chose each turn.</p>
</div>
<div class="games">
<table>
<thead>
<tr>
<th>Game</th>
<th>Status</th>
<th>W/L</th>
<th>Turns</th>
</tr>
</thead>
<tbody id="games-body"></tbody>
</table>
</div>
</section>
<section class="panel right">
<div class="controls">
<button id="prev-btn">Prev</button>
<button id="play-btn" class="primary" title="Play" aria-label="Play"></button>
<button id="next-btn">Next</button>
<label>
<span>Speed</span>
<select id="speed">
<option value="900">0.75x</option>
<option value="650" selected>1x</option>
<option value="400">1.5x</option>
<option value="250">2x</option>
<option value="160">2.5x</option>
<option value="120">3x</option>
</select>
</label>
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
<strong class="turn-badge" id="turn-label">Turn -</strong>
</div>
<div class="content">
<div class="board-wrap">
<div class="board" id="board"></div>
</div>
<div class="thinking" id="thinking"></div>
</div>
</section>
</main>
</div>
<script src="{{ static_url('js/Utils.js') }}"></script>
<script src="{{ static_url('js/Snake.js') }}"></script>
<script src="{{ static_url('js/MoveTable.js') }}"></script>
<script src="{{ static_url('js/SnakeTable.js') }}"></script>
<script src="{{ static_url('js/GameBoard.js') }}"></script>
<script src="{{ static_url('js/OverallStats.js') }}"></script>
<script src="{{ static_url('js/GamesTable.js') }}"></script>
<script src="{{ static_url('js/Thinking.js') }}"></script>
<script src="{{ static_url('js/DashboardWebSocket.js') }}"></script>
<script src="{{ static_url('js/GameState.js') }}"></script>
<script>
const initialGameId = {{ initial_game_id|tojson }};
const initialSummary = {{ initial_summary|tojson }};
const initialGamesPayload = {{ initial_games|tojson }};
let dashboardSummary = (initialSummary && typeof initialSummary === "object") ? initialSummary : {};
let dashboardGamesPayload = (initialGamesPayload && typeof initialGamesPayload === "object") ? initialGamesPayload : { games: [] };
const overallStats = new OverallStats(document.getElementById("stats"));
const gameBoard = new GameBoard(document.getElementById("board"));
const thinkingPanel = new ThinkingPanel(document.getElementById("thinking"));
const sliderEl = document.getElementById("turn-slider");
const turnLabelEl = document.getElementById("turn-label");
const gameState = new GameState({ gameBoard, thinkingPanel, sliderEl, turnLabelEl });
gameState.activeGameId = String(initialGameId || "");
const gamesTable = new GamesTable(
document.getElementById("games-body"),
"{{ battlesnake_url }}",
(gameId) => gameState.loadReplay(gameId),
);
gameState._gamesTable = gamesTable;
const dashboardWS = new DashboardWebSocket({
onReplayUpdate: (payload) => {
const games = Array.isArray(dashboardGamesPayload.games)
? dashboardGamesPayload.games
: [];
const listedGame = games.find(
(game) => String(game.game_id) === String(payload.game_id),
);
if (listedGame && payload.game) {
listedGame.final_turn = payload.game.final_turn;
listedGame.status = payload.game.status;
gamesTable.render(games, gameState.activeGameId);
}
gameState.applyLiveTurn(payload.game_id, payload.game, payload.turn);
},
onGamesUpdate: (payload) => {
if (payload.summary) {
dashboardSummary = payload.summary;
overallStats.render(dashboardSummary);
}
if (payload.games) {
dashboardGamesPayload = payload.games;
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
gamesTable.render(games, gameState.activeGameId);
const activeGame = games.find((game) => String(game.game_id) === gameState.activeGameId);
if (activeGame && gameState.replay && gameState.replay.game) {
gameState.replay.game.status = activeGame.status;
gameState.replay.game.final_turn = activeGame.final_turn;
}
}
},
});
gameState.setWebSocket(dashboardWS);
document.getElementById("play-btn").addEventListener("click", () => {
if (gameState.isPlaying) gameState.stopPlayback();
else gameState.startPlayback();
});
document.getElementById("prev-btn").addEventListener("click", () => {
gameState.stepBackward();
});
document.getElementById("next-btn").addEventListener("click", () => {
gameState.stepForward();
});
window.addEventListener("keydown", (event) => {
const target = event.target;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (event.key === "ArrowLeft") { event.preventDefault(); gameState.stepBackward(); return; }
if (event.key === "ArrowRight") { event.preventDefault(); gameState.stepForward(); return; }
if (event.key === "ArrowUp") { event.preventDefault(); gameState.adjustSpeed(1); return; }
if (event.key === "ArrowDown") { event.preventDefault(); gameState.adjustSpeed(-1); return; }
if (event.key === " " || event.key === "Spacebar") {
event.preventDefault();
if (gameState.isPlaying) gameState.stopPlayback();
else gameState.startPlayback();
}
});
sliderEl.addEventListener("input", () => {
gameState.stopPlayback();
gameState.turnIndex = Number(sliderEl.value || 0);
gameState.renderTurn();
});
document.getElementById("speed").addEventListener("change", () => {
if (gameState.isPlaying) gameState.startPlayback();
});
document.getElementById("thinking").addEventListener("click", (event) => {
const row = event.target.closest(".snake-row");
if (!row) return;
const section = row.closest(".snakes-section");
if (!section) return;
const clickedId = row.dataset.snakeId || null;
if (row.classList.contains("highlighted")) {
row.classList.remove("highlighted");
section.classList.remove("has-highlight");
gameState.setSelectedSnakeId(null);
} else {
section.querySelectorAll(".snake-row.highlighted").forEach((r) => r.classList.remove("highlighted"));
row.classList.add("highlighted");
section.classList.add("has-highlight");
gameState.setSelectedSnakeId(clickedId);
}
const game = gameState.replay && gameState.replay.game ? gameState.replay.game : {};
const turns = gameState.replay && Array.isArray(gameState.replay.turns) ? gameState.replay.turns : [];
if (turns[gameState.turnIndex]) {
gameBoard.paintBoard(turns[gameState.turnIndex], game.width, game.height, gameState.selectedSnakeId, gameState.replay);
}
});
window.addEventListener("resize", () => {
thinkingPanel.syncMonoOffset();
});
window.addEventListener("beforeunload", () => {
dashboardWS.shutdown();
});
async function boot() {
thinkingPanel.render(null, null);
overallStats.render(dashboardSummary);
dashboardWS.connect();
await dashboardWS.waitForOpen(2500);
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
gamesTable.render(games, gameState.activeGameId);
if (gameState.activeGameId) {
await gameState.loadReplay(gameState.activeGameId);
} else if (games.length > 0) {
await gameState.loadReplay(games[0].game_id);
}
thinkingPanel.syncMonoOffset();
}
boot();
</script>
</body>
</html>