From dba2971f1ff5ab4604eb96d67b6f37bdfc2c2c4d Mon Sep 17 00:00:00 2001 From: Pavle012 Date: Thu, 9 Jul 2026 12:24:15 +0200 Subject: [PATCH 1/2] Added a stats panel --- server.py | 44 +++++++ website/index.html | 56 +++++++++ website/script.js | 303 +++++++++++++++++++++++++++++++++++++++++++++ website/styles.css | 181 ++++++++++++++++++++++++++- 4 files changed, 583 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index d3129ec..bf455d4 100644 --- a/server.py +++ b/server.py @@ -16,6 +16,13 @@ os.chdir(website_dir) PORT = 8000 +ANALYTICS_TABLES = { + "daily_player_peaks", + "global_player_peaks", + "player_count_samples", + "player_stats", +} + class LiveChatHandler(http.server.SimpleHTTPRequestHandler): def do_GET(self): @@ -23,6 +30,15 @@ def do_GET(self): self.serve_chat_proxy() return + request_path = self.path.split("?", 1)[0] + if request_path.startswith("/api/"): + table_name = request_path[len("/api/"):] + if table_name in ANALYTICS_TABLES: + self.serve_analytics_proxy(table_name) + return + self.send_error(404, "Unknown API table") + return + self.serve_static_file() def serve_static_file(self): @@ -79,6 +95,34 @@ def serve_chat_proxy(self): except (BrokenPipeError, ConnectionResetError): return + def serve_analytics_proxy(self, table_name): + try: + req = urllib.request.Request( + f"https://raspi.kubabin.dev/api/{table_name}", + headers={"User-Agent": "Mozilla/5.0"}, + ) + with urllib.request.urlopen(req, timeout=15) as response: + body = response.read() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + return + except Exception as exc: + error_body = str(exc).encode("utf-8") + try: + self.send_response(502) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(error_body))) + self.end_headers() + self.wfile.write(error_body) + except (BrokenPipeError, ConnectionResetError): + return + print(f"Serving from: {website_dir}") print(f"Starting server at http://localhost:{PORT}") diff --git a/website/index.html b/website/index.html index 486bd0e..2122366 100644 --- a/website/index.html +++ b/website/index.html @@ -37,6 +37,13 @@ Chat + + @@ -70,10 +77,59 @@

Create: Assembly Line SMP

+ + + diff --git a/website/script.js b/website/script.js index 3722816..6867fcf 100644 --- a/website/script.js +++ b/website/script.js @@ -173,6 +173,303 @@ async function loadLatestChat() { } } +/* ---------- Analytics ---------- */ +const analyticsApiBase = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" + ? "/api" + : "https://raspi.kubabin.dev/api"; + +let analyticsLoaded = false; +const chartInstances = {}; + +function formatDateTime(value) { + if (!value) return "—"; + const d = new Date(value); + if (isNaN(d.getTime())) return String(value); + return d.toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + }); +} + +function formatDate(value) { + if (!value) return "—"; + const d = new Date(value); + if (isNaN(d.getTime())) return String(value); + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +function getThemeColor(varName) { + return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); +} + +function showAnalyticsError(wrapEl) { + wrapEl.innerHTML = ""; + const errEl = document.createElement("p"); + errEl.className = "analytics__status"; + errEl.textContent = "Couldn't load this data right now."; + wrapEl.appendChild(errEl); +} + +async function fetchAnalyticsTable(tableName) { + const response = await fetch(`${analyticsApiBase}/${tableName}`); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + const data = await response.json(); + return Array.isArray(data) ? data : [data]; +} + +/* --- Stat card: All-Time Peak --- */ +async function loadGlobalPeak() { + const wrapEl = document.querySelector('[data-stat-wrap="global_player_peaks"]'); + if (!wrapEl) return; + + try { + const rows = await fetchAnalyticsTable("global_player_peaks"); + const row = rows[0]; + if (!row) { + showAnalyticsError(wrapEl); + return; + } + + wrapEl.innerHTML = ""; + const card = document.createElement("div"); + card.className = "analytics__stat-card"; + + const icon = document.createElement("div"); + icon.className = "analytics__stat-icon"; + icon.innerHTML = 'groups'; + + const textWrap = document.createElement("div"); + const value = document.createElement("p"); + value.className = "analytics__stat-value"; + value.textContent = row.peak_online_players ?? "—"; + + const label = document.createElement("p"); + label.className = "analytics__stat-label"; + label.textContent = `Reached ${formatDateTime(row.peak_recorded_at)}`; + + textWrap.append(value, label); + card.append(icon, textWrap); + wrapEl.appendChild(card); + } catch (error) { + console.warn("Unable to load global_player_peaks:", error); + showAnalyticsError(wrapEl); + } +} + +/* --- Bar chart: Daily Peaks --- */ +async function loadDailyPeaksChart() { + const wrapEl = document.querySelector('[data-chart-wrap="daily_player_peaks"]'); + const canvas = document.querySelector('[data-chart-canvas="daily_player_peaks"]'); + if (!wrapEl || !canvas) return; + + try { + let rows = await fetchAnalyticsTable("daily_player_peaks"); + rows.sort((a, b) => new Date(a.peak_date) - new Date(b.peak_date)); + rows = rows.slice(-60); // last 60 days + + if (!rows.length) { + showAnalyticsError(wrapEl); + return; + } + + wrapEl.querySelector(".analytics__status")?.remove(); + canvas.hidden = false; + + const primary = getThemeColor("--md-sys-color-primary"); + const onSurfaceVariant = getThemeColor("--md-sys-color-on-surface-variant"); + const gridColor = getThemeColor("--md-sys-color-outline-variant"); + + chartInstances.daily_player_peaks?.destroy(); + chartInstances.daily_player_peaks = new Chart(canvas, { + type: "bar", + data: { + labels: rows.map((r) => formatDate(r.peak_date)), + datasets: [{ + label: "Peak players", + data: rows.map((r) => r.peak_online_players), + backgroundColor: primary, + borderRadius: 6, + maxBarThickness: 28, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + padding: 10, + cornerRadius: 12, + titleFont: { family: "Google Sans Flex" }, + bodyFont: { family: "Google Sans Flex" }, + }, + }, + scales: { + x: { + ticks: { color: onSurfaceVariant, maxRotation: 0, autoSkip: true, font: { family: "Google Sans Flex", size: 11 } }, + grid: { display: false }, + }, + y: { + beginAtZero: true, + ticks: { color: onSurfaceVariant, precision: 0, font: { family: "Google Sans Flex", size: 11 } }, + grid: { color: gridColor }, + }, + }, + }, + }); + } catch (error) { + console.warn("Unable to load daily_player_peaks:", error); + showAnalyticsError(wrapEl); + } +} + +/* --- Line chart: Live Player Count Samples --- */ +async function loadPlayerCountChart() { + const wrapEl = document.querySelector('[data-chart-wrap="player_count_samples"]'); + const canvas = document.querySelector('[data-chart-canvas="player_count_samples"]'); + if (!wrapEl || !canvas) return; + + try { + let rows = await fetchAnalyticsTable("player_count_samples"); + rows.sort((a, b) => new Date(a.sampled_at) - new Date(b.sampled_at)); + rows = rows.slice(-150); // most recent samples + + if (!rows.length) { + showAnalyticsError(wrapEl); + return; + } + + wrapEl.querySelector(".analytics__status")?.remove(); + canvas.hidden = false; + + const primary = getThemeColor("--md-sys-color-primary"); + const secondaryContainer = getThemeColor("--md-sys-color-secondary-container"); + const onSurfaceVariant = getThemeColor("--md-sys-color-on-surface-variant"); + const gridColor = getThemeColor("--md-sys-color-outline-variant"); + + chartInstances.player_count_samples?.destroy(); + chartInstances.player_count_samples = new Chart(canvas, { + type: "line", + data: { + labels: rows.map((r) => formatDateTime(r.sampled_at)), + datasets: [{ + label: "Online players", + data: rows.map((r) => r.online_players), + borderColor: primary, + backgroundColor: secondaryContainer, + fill: true, + tension: 0.35, + pointRadius: 0, + pointHitRadius: 8, + borderWidth: 2, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + padding: 10, + cornerRadius: 12, + titleFont: { family: "Google Sans Flex" }, + bodyFont: { family: "Google Sans Flex" }, + }, + }, + scales: { + x: { + ticks: { display: false }, + grid: { display: false }, + }, + y: { + beginAtZero: true, + ticks: { color: onSurfaceVariant, precision: 0, font: { family: "Google Sans Flex", size: 11 } }, + grid: { color: gridColor }, + }, + }, + }, + }); + } catch (error) { + console.warn("Unable to load player_count_samples:", error); + showAnalyticsError(wrapEl); + } +} + +/* --- Table: Player Stats --- */ +function renderPlayerStatsTable(wrapEl, rows) { + wrapEl.innerHTML = ""; + + if (!rows.length) { + const empty = document.createElement("p"); + empty.className = "analytics__status"; + empty.textContent = "No data available yet."; + wrapEl.appendChild(empty); + return; + } + + const columns = [ + { key: "player_name", label: "Player" }, + { key: "first_joined_at", label: "First joined", type: "datetime" }, + { key: "last_seen_at", label: "Last seen", type: "datetime" }, + ]; + + const scrollDiv = document.createElement("div"); + scrollDiv.className = "analytics__table-scroll"; + + const table = document.createElement("table"); + table.className = "analytics__table"; + + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + columns.forEach((col) => { + const th = document.createElement("th"); + th.textContent = col.label; + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + rows.forEach((row) => { + const tr = document.createElement("tr"); + columns.forEach((col) => { + const td = document.createElement("td"); + const value = row[col.key]; + td.textContent = col.type === "datetime" ? formatDateTime(value) : (value ?? "—"); + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + + scrollDiv.appendChild(table); + wrapEl.appendChild(scrollDiv); +} + +async function loadPlayerStatsTable() { + const wrapEl = document.querySelector('[data-table-wrap="player_stats"]'); + if (!wrapEl) return; + + try { + let rows = await fetchAnalyticsTable("player_stats"); + rows.sort((a, b) => new Date(b.last_seen_at) - new Date(a.last_seen_at)); + rows = rows.slice(0, 150); + renderPlayerStatsTable(wrapEl, rows); + } catch (error) { + console.warn("Unable to load player_stats:", error); + showAnalyticsError(wrapEl); + } +} + +function loadAllAnalytics() { + loadGlobalPeak(); + loadDailyPeaksChart(); + loadPlayerCountChart(); + loadPlayerStatsTable(); +} + function updateSeasonTabVisibility() { const seasonNavItem = document.querySelector('.nav-item[data-view="season"]'); const seasonPanel = document.querySelector('.view-panel[data-view-panel="season"]'); @@ -209,10 +506,16 @@ function activateView(view) { }); } + if (view === "analytics" && !analyticsLoaded) { + analyticsLoaded = true; + loadAllAnalytics(); + } + const titles = { map: "Create: Assembly Line SMP", chat: "Create: Assembly Line SMP", season: "Create: Assembly Line SMP", + analytics: "Create: Assembly Line SMP", }; pageTitle.textContent = titles[view] || "Create: Assembly Line SMP"; } diff --git a/website/styles.css b/website/styles.css index b0a6f5d..44a8fce 100644 --- a/website/styles.css +++ b/website/styles.css @@ -276,6 +276,185 @@ html, body { font-weight: 500; } +/* ---------- Analytics panel ---------- */ +.analytics { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; + padding-right: 4px; +} + +.analytics__block { + background: var(--md-sys-color-surface-container-lowest); + border-radius: var(--md-corner-extra-large); + padding: 20px 20px 8px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.analytics__block-head { + padding: 0 4px; +} + +.analytics__title { + font-family: var(--font-display); + font-weight: 600; + font-variation-settings: "opsz" 20, "ROND" 100; + font-size: 20px; + line-height: 26px; + margin: 0 0 2px; + color: var(--md-sys-color-on-surface); +} + +.analytics__subtitle { + font-family: var(--font-body); + font-size: 13px; + line-height: 18px; + margin: 0; + color: var(--md-sys-color-on-surface-variant); +} + +.analytics__status { + font-family: var(--font-body); + font-size: 14px; + color: var(--md-sys-color-on-surface-variant); + padding: 16px 4px 20px; + margin: 0; +} + +/* Stat card (All-Time Peak) */ +.analytics__stat-wrap { + display: flex; + align-items: center; + gap: 20px; + padding: 8px 4px 20px; +} + +.analytics__stat-card { + display: flex; + align-items: center; + gap: 20px; + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + border-radius: 20px; + padding: 20px 28px; + flex: 1; +} + +.analytics__stat-icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--md-sys-color-surface-container-lowest); + color: var(--md-sys-color-primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.analytics__stat-value { + font-family: var(--font-display); + font-weight: 700; + font-variation-settings: "opsz" 40, "ROND" 100; + font-size: 40px; + line-height: 1.1; + margin: 0; +} + +.analytics__stat-label { + font-family: var(--font-body); + font-size: 13px; + margin: 4px 0 0; + color: var(--md-sys-color-on-secondary-container); + opacity: 0.85; +} + +/* Chart blocks */ +.analytics__chart-wrap { + position: relative; + padding: 8px 4px 16px; + height: 260px; +} + +.analytics__chart-wrap canvas { + max-height: 100%; +} + +@media (max-width: 768px) { + .analytics__stat-card { + padding: 16px 20px; + } + .analytics__stat-value { + font-size: 30px; + } + .analytics__chart-wrap { + height: 220px; + } +} + +.analytics__table-wrap { + overflow-x: auto; + border-radius: 16px; + border: 1px solid var(--md-sys-color-outline-variant); +} + +.analytics__table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-body); + font-size: 13px; + white-space: nowrap; +} + +.analytics__table thead th { + position: sticky; + top: 0; + background: var(--md-sys-color-surface-container-high); + color: var(--md-sys-color-on-surface-variant); + font-weight: 600; + text-align: left; + padding: 10px 14px; + border-bottom: 1px solid var(--md-sys-color-outline-variant); +} + +.analytics__table tbody td { + padding: 9px 14px; + color: var(--md-sys-color-on-surface); + border-bottom: 1px solid var(--md-sys-color-outline-variant); +} + +.analytics__table tbody tr:last-child td { + border-bottom: none; +} + +.analytics__table tbody tr:nth-child(even) { + background: var(--md-sys-color-surface-container); +} + +.analytics__table-scroll { + max-height: 360px; + overflow-y: auto; +} + +@media (max-width: 768px) { + .analytics__block { + padding: 16px 16px 8px; + border-radius: 20px; + } + .analytics__table { + font-size: 12px; + } + .analytics__table thead th, + .analytics__table tbody td { + padding: 8px 10px; + } +} + /* ---------- Conversation panel ---------- */ .conversation { flex: 1; @@ -527,4 +706,4 @@ html, body { /* When a nav-item is selected, turn its icon FILL on */ .nav-item[data-selected="true"] .material-symbols-outlined { font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; -} \ No newline at end of file +} From e0ad6a2d9d89aaa709d77aa4adf2e9459bd494f5 Mon Sep 17 00:00:00 2001 From: kubabin Date: Fri, 17 Jul 2026 21:45:27 +0200 Subject: [PATCH 2/2] Declare independence from external servers --- mod/build.gradle | 6 +++++ mod/gradle.properties | 2 +- .../java/dev/kubabin/livechat/Livechat.java | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/mod/build.gradle b/mod/build.gradle index 9148150..d11c99a 100644 --- a/mod/build.gradle +++ b/mod/build.gradle @@ -89,6 +89,12 @@ neoForge { sourceSet(sourceSets.main) } } + tasks.named('processResources', ProcessResources) { + from ('../website') { + + into 'assets/livechat/web' + } + } } // Include resources generated by data generators. diff --git a/mod/gradle.properties b/mod/gradle.properties index 0d0be3e..7e7cfa3 100644 --- a/mod/gradle.properties +++ b/mod/gradle.properties @@ -29,7 +29,7 @@ mod_name=LiveChat # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=GNU AGPL 3.0 # The mod version. See https://semver.org/ -mod_version=1.0 +mod_version=1.1 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/mod/src/main/java/dev/kubabin/livechat/Livechat.java b/mod/src/main/java/dev/kubabin/livechat/Livechat.java index f46a940..3f73fe5 100644 --- a/mod/src/main/java/dev/kubabin/livechat/Livechat.java +++ b/mod/src/main/java/dev/kubabin/livechat/Livechat.java @@ -14,6 +14,7 @@ import net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerLoggedOutEvent; import org.slf4j.Logger; +import java.io.InputStream; import java.time.Clock; import java.util.ArrayDeque; @@ -56,6 +57,29 @@ public void onServerStarting(ServerStartingEvent event) { exchange.getResponseBody().write(response.getBytes()); exchange.close(); }); + httpServer.createContext("/api/chat", exchange -> { + String response = gson.toJson(messageQueue); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.getBytes().length); + exchange.getResponseBody().write(response.getBytes()); + exchange.close(); + }); + // Serve other static files if needed + httpServer.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + InputStream is = Livechat.class.getResourceAsStream("/assets/livechat/web" + path); + LOGGER.info("Serving static file: {}", path); + if (is == null) { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + return; + } + byte[] response = is.readAllBytes(); + //exchange.getResponseHeaders().add("Content-Type", "text/plain"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); httpServer.start(); LOGGER.info("Server socket started on port {}", Config.socketPort); } catch (Exception e) {