diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e5ed0de --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm add:*)", + "Bash(node -e:*)", + "Bash(npx tsc:*)", + "Bash(pnpm run build:*)", + "Bash(pnpm exec tsc:*)", + "Bash(git add:*)", + "Bash(git commit -m \"$\\(cat <<''EOF''\nAdd interactive first-time setup wizard for backend\n\nAdds an interactive CLI setup that runs automatically when no .env file\nexists, guiding through MongoDB URI, port, ping interval, HTTPS, and\nadmin account creation with input validation, colored output, and\npassword masking. Supports --skip-setup flag for CI/Docker environments.\n\nCo-Authored-By: Claude Opus 4.6 \nEOF\n\\)\")", + "Bash(npx next build:*)", + "Bash(git commit -m \"$\\(cat <<''EOF''\nRedesign chart: smooth lines, move legend to table, fix Now button zoom reset\n\n- Add tension \\(0.3\\) to chart lines for smoother curves\n- Remove built-in Chart.js legend, replace with clickable color dots\n in the server table''s new \"Chart\" column to toggle server visibility\n- Sort table rows by player count descending\n- Fix \"Now\" button to also reset chart zoom via forwardRef/useImperativeHandle\n- Remove redundant \"Reset to live\" button\n\nCo-Authored-By: Claude Opus 4.6 \nEOF\n\\)\")" + ] + } +} diff --git a/.github/workflows/android-snapshot-dev.yml b/.github/workflows/android-snapshot-dev.yml new file mode 100644 index 0000000..4688888 --- /dev/null +++ b/.github/workflows/android-snapshot-dev.yml @@ -0,0 +1,146 @@ +name: Android Snapshot (dev) + +on: + push: + branches: ["dev"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: dev + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "21" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build web assets + run: pnpm build + + - name: Sync Android + run: pnpm sync:android + + - name: Build APK + run: | + chmod +x android/gradlew + cd android + ./gradlew assembleDebug + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: app/android/app/build/outputs/apk/debug/app-debug.apk + + release: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: dev + + - name: Download APK artifact + uses: actions/download-artifact@v4 + with: + name: android-apk + + - name: Update latest snapshot tag + run: | + git tag -f latest-snapshot-dev "${GITHUB_SHA}" + git push -f origin latest-snapshot-dev + + - name: Prepare release + id: release + uses: actions/github-script@v7 + with: + script: | + const tag = "latest-snapshot-dev"; + const notes = `Snapshot build from ${context.sha.substring(0, 7)} (${new Date().toISOString().slice(0, 10)})`; + let release; + try { + release = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + if (!release) { + release = await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + name: "Latest Snapshot (dev)", + body: notes, + prerelease: true + }); + } else { + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.data.id + }); + for (const asset of assets) { + await github.rest.repos.deleteReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + asset_id: asset.id + }); + } + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.data.id, + name: "Latest Snapshot (dev)", + body: notes, + prerelease: true + }); + } + + core.setOutput("upload_url", release.data.upload_url); + + - name: Upload release asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.release.outputs.upload_url }} + asset_path: app-debug.apk + asset_name: app-debug.apk + asset_content_type: application/vnd.android.package-archive diff --git a/.github/workflows/android-snapshot.yml b/.github/workflows/android-snapshot.yml new file mode 100644 index 0000000..497fd34 --- /dev/null +++ b/.github/workflows/android-snapshot.yml @@ -0,0 +1,144 @@ +name: Android Snapshot + +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "21" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build web assets + run: pnpm build + + - name: Sync Android + run: pnpm sync:android + + - name: Build APK + run: | + chmod +x android/gradlew + cd android + ./gradlew assembleDebug + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: app/android/app/build/outputs/apk/debug/app-debug.apk + + release: + runs-on: ubuntu-latest + needs: build + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download APK artifact + uses: actions/download-artifact@v4 + with: + name: android-apk + + - name: Update latest snapshot tag + run: | + git tag -f latest-snapshot "${GITHUB_SHA}" + git push -f origin latest-snapshot + + - name: Prepare release + id: release + uses: actions/github-script@v7 + with: + script: | + const tag = "latest-snapshot"; + const notes = `Snapshot build from ${context.sha.substring(0, 7)} (${new Date().toISOString().slice(0, 10)})`; + let release; + try { + release = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + if (!release) { + release = await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + name: "Latest Snapshot", + body: notes, + prerelease: true + }); + } else { + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.data.id + }); + for (const asset of assets) { + await github.rest.repos.deleteReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + asset_id: asset.id + }); + } + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.data.id, + name: "Latest Snapshot", + body: notes, + prerelease: true + }); + } + + core.setOutput("upload_url", release.data.upload_url); + + - name: Upload release asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.release.outputs.upload_url }} + asset_path: app-debug.apk + asset_name: app-debug.apk + asset_content_type: application/vnd.android.package-archive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aced64b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.next/ \ No newline at end of file diff --git a/app/components/CursorGlow.tsx b/app/components/CursorGlow.tsx new file mode 100644 index 0000000..9dcefb9 --- /dev/null +++ b/app/components/CursorGlow.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useCallback } from "react"; + +export function CursorGlow() { + const glowRef = useRef(null); + const pulseRef = useRef(null); + const targetRef = useRef({ x: 0, y: 0 }); + const currentRef = useRef({ x: 0, y: 0 }); + const rafRef = useRef(0); + const visibleRef = useRef(false); + const animatingRef = useRef(false); + + const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + + const tick = useCallback(() => { + const el = glowRef.current; + if (!el) return; + + const dx = targetRef.current.x - currentRef.current.x; + const dy = targetRef.current.y - currentRef.current.y; + + // Stop animating when close enough + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { + currentRef.current = { ...targetRef.current }; + el.style.transform = `translate(${currentRef.current.x}px, ${currentRef.current.y}px)`; + animatingRef.current = false; + return; + } + + currentRef.current.x = lerp(currentRef.current.x, targetRef.current.x, 0.04); + currentRef.current.y = lerp(currentRef.current.y, targetRef.current.y, 0.04); + el.style.transform = `translate(${currentRef.current.x}px, ${currentRef.current.y}px)`; + + rafRef.current = requestAnimationFrame(tick); + }, []); + + const startAnimation = useCallback(() => { + if (!animatingRef.current) { + animatingRef.current = true; + rafRef.current = requestAnimationFrame(tick); + } + }, [tick]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + targetRef.current = { x: e.clientX, y: e.clientY }; + + if (!visibleRef.current && glowRef.current) { + // Jump to position on first appearance (no lerp lag) + currentRef.current = { x: e.clientX, y: e.clientY }; + glowRef.current.style.opacity = "1"; + visibleRef.current = true; + } + + startAnimation(); + }; + + const handleMouseLeave = () => { + if (glowRef.current) { + glowRef.current.style.opacity = "0"; + visibleRef.current = false; + } + }; + + const handleClick = (e: MouseEvent) => { + const el = pulseRef.current; + if (!el) return; + + el.style.left = `${e.clientX}px`; + el.style.top = `${e.clientY}px`; + + el.classList.remove("cursor-pulse-animate"); + void el.offsetWidth; + el.classList.add("cursor-pulse-animate"); + }; + + window.addEventListener("mousemove", handleMouseMove, { passive: true }); + document.documentElement.addEventListener("mouseleave", handleMouseLeave); + window.addEventListener("click", handleClick, { passive: true }); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + document.documentElement.removeEventListener("mouseleave", handleMouseLeave); + window.removeEventListener("click", handleClick); + cancelAnimationFrame(rafRef.current); + }; + }, [startAnimation]); + + return ( + <> +
+
+ + ); +} diff --git a/app/components/charts/OnlinePlayersChart.tsx b/app/components/charts/OnlinePlayersChart.tsx index f8790f5..35d95bf 100644 --- a/app/components/charts/OnlinePlayersChart.tsx +++ b/app/components/charts/OnlinePlayersChart.tsx @@ -40,22 +40,41 @@ export const OnlinePlayersChart = forwardRef { + const buckets = new Map(); + for (const point of pings) { + const timestamp = Number(point.timestamp); + const count = Number(point.count); + if (!Number.isFinite(timestamp) || !Number.isFinite(count)) continue; + const bucketKey = Math.floor(timestamp / 15000); + const bucket = buckets.get(bucketKey); + if (bucket) { + bucket.sum += count; + bucket.count += 1; + } else { + buckets.set(bucketKey, { sum: count, count: 1 }); + } + } + return Array.from(buckets.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([bucketKey, bucket]) => ({ + x: bucketKey * 15000, + y: Math.round(bucket.sum / bucket.count), + })); + }; for (const server in data.data) { const serverData = data.data[server]; const label = serverData.name || server; datasets.push({ label, - data: serverData.pings.map((point: any) => ({ - x: point.timestamp, - y: point.count - })), + data: toQuarterMinuteAverages(serverData.pings || []), fill: false, pointRadius: 0, pointHoverRadius: 0, backgroundColor: serverData.color, borderColor: serverData.color, - borderWidth: 2, + borderWidth: 3, tension: 0.3, hidden: hiddenServers ? hiddenServers.has(label) : false, }); @@ -118,6 +137,38 @@ export const OnlinePlayersChart = forwardRef { + const first = items?.[0]; + if (!first) return ""; + const timestamp = first.parsed?.x; + if (!timestamp) return ""; + const date = new Date(timestamp); + return date.toLocaleString(); + }, + label: (item: any) => `${item.dataset?.label || "Server"}: ${item.parsed?.y ?? 0}`, + labelTextColor: (item: any) => { + const chartTooltip = item?.chart?.tooltip; + const caretY = chartTooltip?.caretY ?? null; + if (caretY == null) return "rgba(255,255,255,0.75)"; + let nearest = item; + let nearestDist = Number.POSITIVE_INFINITY; + for (const candidate of chartTooltip?.dataPoints || []) { + const y = candidate?.element?.y; + if (!Number.isFinite(y)) continue; + const dist = Math.abs(y - caretY); + if (dist < nearestDist) { + nearest = candidate; + nearestDist = dist; + } + } + return item.dataset?.label === nearest?.dataset?.label + ? "#ffffff" + : "rgba(255,255,255,0.75)"; + }, + }, itemSort: (a: any, b: any) => b.parsed.y - a.parsed.y, }, zoom: { @@ -142,7 +193,7 @@ export const OnlinePlayersChart = forwardRef(null); + const canvasRef = useRef(null); + const pluginRegisteredRef = useRef(false); + const hasManualViewportRef = useRef(false); + + useEffect(() => { + if (pluginRegisteredRef.current || typeof window === "undefined") return; + Chart.register(require("chartjs-plugin-zoom").default); + pluginRegisteredRef.current = true; + }, []); + + useEffect(() => { + if (!points || points.length === 0) return; + + const dataPoints = points + .filter((point) => Number.isFinite(point.timestamp) && Number.isFinite(point.predictedPlayers)) + .map((point) => ({ x: point.timestamp, y: point.predictedPlayers })); + + if (dataPoints.length === 0) return; + + const minX = Math.min(...dataPoints.map((point) => point.x)); + const maxX = Math.max(...dataPoints.map((point) => point.x)); + + const stepSize = 1; + const maxYValue = Math.max(...dataPoints.map((point) => point.y), 0); + const yMax = Math.max(1, Math.ceil(maxYValue * 1.2)); + + if (chartRef.current) { + const chart = chartRef.current; + chart.data.datasets = [ + { + label: "Predicted players", + data: dataPoints, + borderColor: lineColor, + backgroundColor: lineColor, + borderWidth: 3, + pointRadius: 0, + pointHoverRadius: 4, + }, + ]; + + if (chart.options?.scales?.x && !hasManualViewportRef.current) { + const xScale = chart.options.scales.x as any; + xScale.min = minX; + xScale.max = maxX; + } + + if (chart.options?.scales?.y) { + const yScale = chart.options.scales.y as any; + yScale.min = 0; + yScale.max = yMax; + yScale.ticks.stepSize = stepSize; + yScale.ticks.callback = (value: any) => Math.ceil(Number(value)); + } + + chart.update("none"); + return; + } + + if (!canvasRef.current) return; + + chartRef.current = new Chart(canvasRef.current, { + type: "line", + data: { + datasets: [ + { + label: "Predicted players", + data: dataPoints, + borderColor: lineColor, + backgroundColor: lineColor, + borderWidth: 3, + pointRadius: 0, + pointHoverRadius: 4, + }, + ], + }, + options: { + animation: false, + maintainAspectRatio: false, + responsive: true, + plugins: { + legend: { + display: false, + }, + tooltip: { + backgroundColor: theme.tooltipBg, + borderColor: theme.tooltipBorder, + borderWidth: 1, + titleColor: theme.tooltipText, + bodyColor: theme.tooltipText, + callbacks: { + title: (items: any[]) => { + const first = items?.[0]; + if (!first) return ""; + const timestamp = first.parsed?.x; + if (!timestamp) return ""; + return new Date(timestamp).toLocaleString(); + }, + label: (item: any) => { + const value = Number.isFinite(item.parsed?.y) ? item.parsed.y.toFixed(2) : "0.00"; + return `Predicted players: ${value}`; + }, + }, + }, + zoom: { + zoom: { + drag: { + enabled: true, + threshold: 8, + }, + mode: "x", + onZoomComplete: () => { + hasManualViewportRef.current = true; + }, + }, + }, + }, + interaction: { + mode: "nearest", + intersect: false, + }, + scales: { + y: { + min: 0, + max: yMax, + ticks: { + color: theme.axis, + beginAtZero: true, + stepSize, + callback: (value: any) => { + const rounded = Math.ceil(Number(value)); + return rounded < 0 ? 0 : rounded; + }, + }, + grid: { + borderDash: [3], + borderDashOffset: 3, + drawBorder: false, + color: theme.grid, + }, + }, + x: { + type: "time", + time: { + unit: "hour", + displayFormats: { + hour: "HH:mm", + }, + tooltipFormat: "ll HH:mm", + }, + min: minX, + max: maxX, + ticks: { + autoSkip: true, + color: theme.axis, + callback: (value: any) => { + const date = new Date(value); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + }, + }, + grid: { + display: false, + }, + }, + }, + }, + } as any); + }, [points, lineColor, theme]); + + useEffect(() => { + return () => { + if (chartRef.current) { + chartRef.current.destroy(); + chartRef.current = null; + } + }; + }, []); + + const handleResetZoom = () => { + const chart = chartRef.current; + if (!chart) return; + const dataset = chart.data.datasets?.[0] as any; + const data = Array.isArray(dataset?.data) ? dataset.data : []; + if (data.length === 0) return; + const minX = Math.min(...data.map((point: any) => point.x)); + const maxX = Math.max(...data.map((point: any) => point.x)); + hasManualViewportRef.current = false; + if (chart.options?.scales?.x) { + const xScale = chart.options.scales.x as any; + xScale.min = minX; + xScale.max = maxX; + } + if (chart.options?.scales?.y) { + const yScale = chart.options.scales.y as any; + yScale.min = undefined; + yScale.max = undefined; + } + chart.update(); + }; + + return ( + + ); +} diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index 16ce197..9f1388e 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -22,16 +22,7 @@ import { ModalFooter, Checkbox, } from "@heroui/react"; -import { - ResponsiveContainer, - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Legend, - Tooltip as RechartsTooltip, -} from "recharts"; +import { PredictionChart } from "./PredictionChart"; import { AddServer } from "../server/AddServer"; @@ -69,6 +60,8 @@ export function ServerTable({ onServersChanged, hiddenServers, onToggleServer, + isCached = false, + onToggleAll, }: { url: string | null, token: string, @@ -80,6 +73,8 @@ export function ServerTable({ onServersChanged: () => void; hiddenServers?: Set; onToggleServer?: (serverName: string) => void; + isCached?: boolean; + onToggleAll?: (allServerNames: string[]) => void; }) { const [filterValue, setFilterValue] = React.useState(""); const [visibleColumns, setVisibleColumns] = React.useState(new Set(INITIAL_VISIBLE_COLUMNS)); @@ -102,7 +97,7 @@ export function ServerTable({ const [isPredicting, setIsPredicting] = React.useState(false); const [isDarkMode, setIsDarkMode] = React.useState(true); - const rowsPerPage = 6; + const [rowsPerPage, setRowsPerPage] = React.useState(6); const hasSearchFilter = Boolean(filterValue); @@ -159,6 +154,8 @@ export function ServerTable({ line: "#2563eb", }; }, [isDarkMode]); + const predictionLineColor = "#9f74ca"; + const predictionBackground = "color-mix(in rgb, var(--heroui-content1) 82%, #000000 18%)"; const tableColumns = React.useMemo(() => { const columns = [...baseColumns]; @@ -168,12 +165,16 @@ export function ServerTable({ return columns; }, [canManageServers, canSeePrediction]); + const allServerNames = React.useMemo(() => data.map((s: any) => s.server), [data]); + const anyChartVisible = allServerNames.length > 0 && allServerNames.some((name: string) => !hiddenServers?.has(name)); + const headerColumns = React.useMemo(() => { // @ts-ignore - if (visibleColumns === "all") return tableColumns; + if (visibleColumns === "all") return tableColumns.map(c => ({ ...c })); - return tableColumns.filter((column) => Array.from(visibleColumns).includes(column.uid)); - }, [visibleColumns, tableColumns]); + return tableColumns.filter((column) => Array.from(visibleColumns).includes(column.uid)).map(c => ({ ...c })); + // anyChartVisible is included to force HeroUI to re-render column headers when toggle-all state changes + }, [visibleColumns, tableColumns, anyChartVisible]); const filteredItems = React.useMemo(() => { let filteredData = [...data]; @@ -269,6 +270,9 @@ export function ServerTable({ ) case "playerCount": + if (isCached) { + return --; + } return (
) case "dailyPeak": + if (isCached) { + return --; + } return (
{cellValue} @@ -289,6 +296,9 @@ export function ServerTable({
) case "record": + if (isCached) { + return --; + } return (
{cellValue} @@ -299,18 +309,24 @@ export function ServerTable({ ) case "actions": return ( -
+
{canSeePrediction ? ( - ) : null} {canManageServers ? ( <> - - @@ -361,7 +377,7 @@ export function ServerTable({ return; } - const points = (Array.isArray(json.points) ? json.points : []) + const rawPoints = (Array.isArray(json.points) ? json.points : []) .map((point: any) => ({ timestamp: Number(point.timestamp), count: Number(point.count), @@ -371,6 +387,34 @@ export function ServerTable({ ) .sort((a: { timestamp: number; }, b: { timestamp: number; }) => a.timestamp - b.timestamp); + const minuteBuckets = new Map(); + for (const point of rawPoints) { + const minuteKey = Math.floor(point.timestamp / 60000); + const existingBucket = minuteBuckets.get(minuteKey); + + if (!existingBucket) { + minuteBuckets.set(minuteKey, { + timestamp: point.timestamp, + count: point.count, + n: 1, + }); + } else { + const newN = existingBucket.n + 1; + const newCount = + (existingBucket.count * existingBucket.n + point.count) / newN; + + minuteBuckets.set(minuteKey, { + timestamp: point.timestamp, + count: newCount, + n: newN, + }); + } + } + + const points = Array.from(minuteBuckets.values()) + .map(({ timestamp, count }) => ({ timestamp, count })) + .sort((a, b) => a.timestamp - b.timestamp); + const minimumCoverageMs = 24 * 60 * 60 * 1000; if (points.length < 2) { @@ -387,18 +431,34 @@ export function ServerTable({ } const now = Date.now(); - const recentWindowMs = 6 * 60 * 60 * 1000; + const recentWindowMs = 12 * 60 * 60 * 1000; const recentPoints = points.filter((point: { timestamp: number; }) => point.timestamp >= now - recentWindowMs); - const fallbackRecentAverage = points.reduce((sum: number, point: { count: number; }) => sum + point.count, 0) / points.length; + const overallAverage = points.reduce((sum: number, point: { count: number; }) => sum + point.count, 0) / points.length; const recentAverage = recentPoints.length > 0 ? recentPoints.reduce((sum: number, point: { count: number; }) => sum + point.count, 0) / recentPoints.length - : fallbackRecentAverage; - - const firstRecent = recentPoints[0] || points[Math.max(0, points.length - 2)]; - const lastRecent = recentPoints[recentPoints.length - 1] || points[points.length - 1]; - const recentDurationHours = Math.max(1, (lastRecent.timestamp - firstRecent.timestamp) / (60 * 60 * 1000)); - const trendPerHour = (lastRecent.count - firstRecent.count) / recentDurationHours; + : overallAverage; + + const trendPerHour = (() => { + if (recentPoints.length < 2) return 0; + const start = recentPoints[0].timestamp; + let sumX = 0; + let sumY = 0; + let sumXY = 0; + let sumXX = 0; + for (const point of recentPoints) { + const x = (point.timestamp - start) / (60 * 60 * 1000); + const y = point.count; + sumX += x; + sumY += y; + sumXY += x * y; + sumXX += x * x; + } + const n = recentPoints.length; + const denom = n * sumXX - sumX * sumX; + if (denom === 0) return 0; + return (n * sumXY - sumX * sumY) / denom; + })(); const groupedByHour = points.reduce((acc: Record, point: { timestamp: number; count: number; }) => { const hour = new Date(point.timestamp).getHours(); @@ -452,23 +512,50 @@ export function ServerTable({ const futureDay = futureDate.getDay(); const futureHour = futureDate.getHours(); const dayHourKey = `${futureDay}-${futureHour}`; + const dayHourValues = groupedByDayHour[dayHourKey] || []; const dayHourBaseline = dayHourAverage[dayHourKey]; - const hourBaseline = hourlyAverage[futureHour] ?? recentAverage; - const baseline = dayHourBaseline ?? hourBaseline; + const hourBaseline = hourlyAverage[futureHour]; + const dayHourWeight = Math.min(0.7, dayHourValues.length / 10); + const hourWeight = 0.2; + const overallWeight = 1 - dayHourWeight - hourWeight; + const baseline = + (dayHourBaseline ?? hourBaseline ?? overallAverage) * dayHourWeight + + (hourBaseline ?? overallAverage) * hourWeight + + overallAverage * overallWeight; const trendComponent = trendPerHour * (hourOffset + 1); - const blended = (baseline * 0.8) + ((recentAverage + trendComponent) * 0.2); + const blended = (baseline * 0.75) + ((recentAverage + trendComponent) * 0.25); predictions.push({ timestamp: futureTimestamp, label: futureDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), - predictedPlayers: Math.max(0, Math.round(blended)), + predictedPlayers: Math.max(0, blended), }); } - setPredictionSeries(predictions); + const detailedPredictions: PredictionPoint[] = []; + const stepMs = 15 * 60 * 1000; + for (let i = 0; i < predictions.length; i++) { + const current = predictions[i]; + const next = predictions[i + 1]; + detailedPredictions.push(current); + if (!next) continue; + const delta = next.predictedPlayers - current.predictedPlayers; + for (let step = 1; step <= 3; step++) { + const timestamp = current.timestamp + step * stepMs; + const interpolated = current.predictedPlayers + (delta * step) / 4; + detailedPredictions.push({ + timestamp, + label: new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + predictedPlayers: Math.max(0, interpolated), + }); + } + } + + setPredictionSeries(detailedPredictions); setIsPredicting(false); }; + const handleDelete = async (serverId: string) => { if (!url) return; const shouldDelete = window.confirm("Are you sure you want to delete this server?"); @@ -534,12 +621,13 @@ export function ServerTable({ const topContent = React.useMemo(() => { return (
-
+
} + size="sm" value={filterValue} onClear={() => onClear()} onValueChange={onSearchChange} @@ -596,9 +684,26 @@ export function ServerTable({ total={pages} onChange={setPage} /> +
); - }, [items.length, page, pages, hasSearchFilter]); + }, [items.length, page, pages, hasSearchFilter, rowsPerPage]); return ( <> @@ -608,7 +713,7 @@ export function ServerTable({ bottomContent={bottomContent} bottomContentPlacement="outside" classNames={{ - wrapper: "max-h-[382px]", + wrapper: "max-h-[382px] border border-default-200/60 bg-content1/60 shadow-sm", }} // @ts-ignore sortDescriptor={sortDescriptor} @@ -622,9 +727,29 @@ export function ServerTable({ - {column.name} + {column.uid === "chart" ? ( + + ) : column.name} )} @@ -649,7 +774,7 @@ export function ServerTable({ {(onClose) => ( <> - Prediction for {predictionTarget?.name}Next 24 full hours (hourly) + Prediction for {predictionTarget?.name}Next 24 hours (15-min steps) {isPredicting ?

Calculating prediction...

: null} {!isPredicting && predictionError ?

{predictionError}

: null} @@ -657,44 +782,23 @@ export function ServerTable({

Experimental feature: this chart is only an estimate and may differ from actual player counts.

) : null} {!isPredicting && !predictionError && predictionSeries.length > 0 ? ( -
- - - - - - [String(value), "Predicted players"]} - labelFormatter={(_, payload: any[]) => - payload?.[0]?.payload?.timestamp - ? new Date(payload[0].payload.timestamp).toLocaleString() - : "" - } - /> - - - - +
+
) : null} - + )} diff --git a/app/components/server/AddServer.tsx b/app/components/server/AddServer.tsx index acf963e..8c6bf47 100644 --- a/app/components/server/AddServer.tsx +++ b/app/components/server/AddServer.tsx @@ -136,40 +136,50 @@ export function AddServer({ <> Add server + Create a new server entry for monitoring. - + {error ?

{error}

: null} - setServerName(e.target.value)} - value={serverName} - /> - setServerIP(e.target.value)} - value={serverIP} - /> - setServerPort(e.target.value)} - value={serverPort} - /> - - Bedrock server (disable for Java) - - setServerColor(e.target.value)} - value={serverColor} - /> +
+ setServerName(e.target.value)} + value={serverName} + /> + setServerIP(e.target.value)} + value={serverIP} + /> + setServerPort(e.target.value)} + value={serverPort} + description="Default Bedrock: 19132, Java: 25565" + /> +
+ + Bedrock server (disable for Java) + + setServerColor(e.target.value)} + value={serverColor} + /> +
+
+ + + +
+ ); + } + if (!backendReachable) { return (
- +

Connection lost

The backend is currently unreachable.

@@ -675,17 +959,24 @@ export default function Dashboard() { } if (!token) { - return (
Loading - server...
) + return ( +
+ + + Loading server... + + +
+ ); } return ( <> -
-
+
+
- Signed in as - {currentUser?.name || "Loading user..."} + Signed in as + {currentUser?.name || "Loading user..."}
{canChangeOwnPassword ? ( @@ -704,18 +995,43 @@ export default function Dashboard() {
- - -

+ + +
+

Currently connected players -

+

+

Live view of online players and range controls.

+
- - + + {isChartLoading ? ( +
+
+
+
+ {Array.from({ length: 24 }).map((_, index) => ( +
+ ))} +
+
+
+
+ ) : Object.values(data?.data || {}).some((server: any) => (server?.pings || []).length > 0) ? ( + + ) : ( +
+ No data to display yet. +
+ )} - -
-
+ +
+
@@ -726,23 +1042,29 @@ export default function Dashboard() { Now {!isLiveRange ? ( - Custom range + Custom range ) : ( - Live range + Live range )}
@@ -764,6 +1086,7 @@ export default function Dashboard() { const parsedTo = parseDateTimeLocal(customToInput); if (Number.isFinite(parsedTo) && parsed >= parsedTo) return; } + setIsCustomRangeEditing(true); setCustomFromInput(value); }} /> @@ -781,6 +1104,7 @@ export default function Dashboard() { if (!Number.isFinite(parsed) || parsed > Date.now()) return; const parsedFrom = parseDateTimeLocal(customFromInput); if (Number.isFinite(parsedFrom) && parsed <= parsedFrom) return; + setIsCustomRangeEditing(true); setCustomToInput(value); }} /> @@ -795,25 +1119,61 @@ export default function Dashboard() {
- - - { - if (url && token && canManageServers) { - loadServerDetails(url, token); - } - reloadData(); - }} - hiddenServers={hiddenServers} - onToggleServer={handleToggleServer} - /> + + +
+
+

Servers

+

Search, filter, and manage visibility.

+
+
+ {isTableCached ? ( + + Cached data shown + + ) : null} + {isTableSlow && !isTableLoading ? ( + + Fetching latest… + + ) : null} +
+
+
+ + {isTableLoading ? ( +
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : sortedTableData.length > 0 ? ( + { + if (url && token && canManageServers) { + const serverIndex = parseInt(router.query.server as string) || 0; + loadServerDetails(url, token, serverIndex); + } + reloadData(); + }} + hiddenServers={hiddenServers} + onToggleServer={handleToggleServer} + onToggleAll={handleToggleAll} + /> + ) : ( +
+ No data to display yet. +
+ )}
@@ -830,13 +1190,17 @@ export default function Dashboard() { {(onClose) => ( <> - Change password - + + Change password + Update your account credentials. + + {accountError ?

{accountError}

: null} setCurrentPassword(e.target.value)} /> @@ -844,6 +1208,7 @@ export default function Dashboard() { label="New password" type="password" variant="bordered" + size="sm" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> @@ -851,6 +1216,7 @@ export default function Dashboard() { label="Confirm new password" type="password" variant="bordered" + size="sm" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} /> @@ -888,6 +1254,7 @@ export default function Dashboard() { setNewUserName(e.target.value)} /> @@ -895,10 +1262,12 @@ export default function Dashboard() { label="Temporary password" variant="bordered" type="password" + size="sm" value={newUserPassword} onChange={(e) => setNewUserPassword(e.target.value)} /> -
+
+ Permissions @@ -946,7 +1315,8 @@ export default function Dashboard() {
-
+
+ Users can be edited or reset below. @@ -1016,8 +1386,9 @@ export default function Dashboard() { <> Edit permissions for {userPermissionTarget?.name} + Adjust access levels and visibility. - + @@ -1087,12 +1458,14 @@ export default function Dashboard() { <> Reset password for {userPasswordTarget?.name} + Set a temporary password and share it securely. - + setUserPassword(e.target.value)} /> diff --git a/app/pages/index.tsx b/app/pages/index.tsx index 990157f..8783a8b 100644 --- a/app/pages/index.tsx +++ b/app/pages/index.tsx @@ -169,50 +169,77 @@ export default function Home() { }; return ( -
+
{ page === 0 ? ( - + logo -

RedTrack

+
+

RedTrack

+

Monitor players and manage servers in one place.

+
- -

+ +

Welcome to RedTrack. To get started, please select or add a new server below.

-
- { - servers.map((server: any, index: any) => ( -
- - -
- )) - } -
+ ))} +
+ )} - -
- diff --git a/app/styles/globals.css b/app/styles/globals.css index c766860..2943cb2 100644 --- a/app/styles/globals.css +++ b/app/styles/globals.css @@ -1,3 +1,5 @@ +@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"); + @tailwind base; @tailwind components; @tailwind utilities; @@ -17,7 +19,104 @@ body { color: var(--foreground); background: var(--background); - font-family: Arial, Helvetica, sans-serif; + font-family: "Poppins", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + accent-color: #78cf30; + color-scheme: dark; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-drag: none; +} + +.app-shell { + min-height: 100vh; + background: + radial-gradient(1200px 600px at 12% -10%, rgba(120, 207, 48, 0.16), transparent 60%), + radial-gradient(900px 500px at 90% 0%, rgba(176, 100, 79, 0.18), transparent 55%), + radial-gradient(800px 400px at 70% 120%, rgba(120, 207, 48, 0.12), transparent 60%), + linear-gradient(180deg, rgba(11, 13, 15, 0.95), rgba(8, 10, 12, 0.98)); +} + +/* ── Cursor-following glow ── */ +.cursor-glow { + position: fixed; + top: 0; + left: 0; + width: 0; + height: 0; + pointer-events: none; + z-index: 0; + will-change: transform, opacity; + transition: opacity 0.25s ease; +} + +.cursor-glow::before { + content: ""; + position: absolute; + width: 256px; + height: 256px; + top: -128px; + left: -128px; + border-radius: 50%; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.035) 0%, + rgba(255, 255, 255, 0.012) 40%, + transparent 70% + ); +} + +/* ── Click pulse ring ── */ +.cursor-pulse { + position: fixed; + width: 0; + height: 0; + pointer-events: none; + z-index: 0; +} + +.cursor-pulse::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0); + transform: translate(-50%, -50%) scale(1); + opacity: 0; +} + +.cursor-pulse-animate::before { + animation: cursor-pulse-ring 0.6s ease-out forwards; +} + +@keyframes cursor-pulse-ring { + 0% { + width: 0; + height: 0; + border-color: rgba(255, 255, 255, 0.2); + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 100% { + width: 60px; + height: 60px; + border-color: rgba(255, 255, 255, 0); + opacity: 0; + transform: translate(-50%, -50%) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .cursor-glow, + .cursor-pulse { + display: none; + } } .page-card { @@ -26,6 +125,9 @@ body { height: auto; max-height: min(92vh, 760px); border-radius: 1rem; + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.35), + 0 2px 12px rgba(0, 0, 0, 0.25); } @layer base { @@ -42,6 +144,12 @@ body { animation: 3s blink ease infinite; } +@media (prefers-reduced-motion: reduce) { + .blinking { + animation: none; + } +} + @keyframes blink { from, to { opacity: 0; @@ -100,4 +208,4 @@ body { 70% { opacity: 1; } -} \ No newline at end of file +} diff --git a/backend/src/api/routes/Stats.ts b/backend/src/api/routes/Stats.ts index ff969ca..f726e4f 100644 --- a/backend/src/api/routes/Stats.ts +++ b/backend/src/api/routes/Stats.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { requiresAuth } from "../ApiServer"; import Pings from "../../models/Pings"; +import LatestStats from "../../models/LatestStats"; import Server from "../../models/Server"; import Permissions from "../../utils/Permissions"; @@ -151,78 +152,21 @@ router.get('/latest', requiresAuth, async (req: Request, res: Response) => { }); const currentMillis = Date.now(); + const latestStats = await LatestStats.find().lean(); - const serverStats = await Pings.aggregate([ - { - $match: { - timestamp: { $gte: currentMillis - 24 * 60 * 60 * 1000 } // Last 24 hours - } - }, - { - $project: { - serverData: { $objectToArray: "$data" }, // Convert "data" object to an array - timestamp: 1 - } - }, - { $unwind: "$serverData" }, // Flatten the array (each key-value pair becomes a document) - { - $group: { - _id: "$serverData.k", // Group by serverId - highestEntry: { - $max: { count: "$serverData.v", timestamp: "$timestamp" } // Find highest count + timestamp - }, - latestEntry: { - $last: { count: "$serverData.v", timestamp: "$timestamp" } // Keep the latest entry - } - } - }, - { - $project: { - highestCount: "$highestEntry.count", - highestTimestamp: "$highestEntry.timestamp", - latestCount: "$latestEntry.count", - latestTimestamp: "$latestEntry.timestamp" - } - } - ]); - - let data = [] as any; - - for (let singleData of serverStats) { - const result = await Pings.aggregate([ - { - $match: { - [`data.${singleData._id}`]: { $exists: true } // Ensure the field exists - } - }, - { - $project: { - serverId: `$data.${singleData._id}`, - timestamp: 1 - } - }, - { - $sort: { serverId: -1 } - }, - { - $limit: 1 - } - ]); - - const record = result.length > 0 ? result[0] : null; - - if(serverNames[singleData._id] == null) continue; - + const data = [] as any; + for (const entry of latestStats) { + if (serverNames[entry.serverId] == null) continue; data.push({ - internalId: singleData._id, - server: serverNames[singleData._id] || singleData._id, - playerCount: singleData.latestCount, - dailyPeak: singleData.highestCount, - dailyPeakTimestamp: singleData.highestTimestamp, - record: record ? record.serverId : null, - recordTimestamp: record ? record.timestamp : null, - outdated: (currentMillis - singleData.latestTimestamp) > (parseInt(process.env.ping_rate as string) * 2) - }) + internalId: entry.serverId, + server: serverNames[entry.serverId] || entry.serverId, + playerCount: entry.latestCount, + dailyPeak: entry.dailyPeak, + dailyPeakTimestamp: entry.dailyPeakTimestamp, + record: entry.record, + recordTimestamp: entry.recordTimestamp, + outdated: (currentMillis - entry.latestTimestamp) > (parseInt(process.env.ping_rate as string) * 2) + }); } res.json(data); diff --git a/backend/src/jobs/PingServerJob.ts b/backend/src/jobs/PingServerJob.ts index 83e465d..266d4f1 100644 --- a/backend/src/jobs/PingServerJob.ts +++ b/backend/src/jobs/PingServerJob.ts @@ -2,6 +2,7 @@ import { ping as pingBedrock } from "bedrock-protocol"; import { ServerData } from "../../../types/ServerData"; import Server from '../models/Server'; import Pings from "../models/Pings"; +import LatestStats from "../models/LatestStats"; async function pingServer(data: ServerData, isBedrockServer: boolean): Promise { if (isBedrockServer) { @@ -42,10 +43,177 @@ async function pingAll() { } catch (e) { } } + const now = Date.now(); await new Pings({ - timestamp: Date.now(), + timestamp: now, data: data }).save(); + + const dayKey = new Date(now).toISOString().slice(0, 10); + const serverIds = Object.keys(data); + if (serverIds.length === 0) return; + + const existing = await LatestStats.find({ serverId: { $in: serverIds } }).lean(); + const existingById = new Map(existing.map((entry) => [entry.serverId, entry])); + const rollingWindowStart = now - 24 * 60 * 60 * 1000; + + const fetchRollingPeak = async (serverId: string) => { + const peakRows = await Pings.aggregate([ + { + $match: { + timestamp: { $gte: rollingWindowStart }, + [`data.${serverId}`]: { $exists: true } + } + }, + { + $project: { + timestamp: 1, + count: `$data.${serverId}` + } + }, + { $sort: { count: -1, timestamp: -1 } }, + { $limit: 1 } + ]); + if (peakRows.length === 0) { + return { peak: null as number | null, timestamp: null as number | null }; + } + return { peak: peakRows[0].count as number, timestamp: peakRows[0].timestamp as number }; + }; + + const fetchRecord = async (serverId: string) => { + const recordRows = await Pings.aggregate([ + { + $match: { + [`data.${serverId}`]: { $exists: true } + } + }, + { + $project: { + timestamp: 1, + count: `$data.${serverId}` + } + }, + { $sort: { count: -1, timestamp: -1 } }, + { $limit: 1 } + ]); + if (recordRows.length === 0) { + return { record: null as number | null, timestamp: null as number | null }; + } + return { record: recordRows[0].count as number, timestamp: recordRows[0].timestamp as number }; + }; + + const peakBackfillIds = serverIds.filter((serverId) => { + const current = existingById.get(serverId); + return !current || typeof current.dailyPeakTimestamp !== "number" || current.dailyPeakTimestamp < rollingWindowStart; + }); + const recordBackfillIds = serverIds.filter((serverId) => { + const current = existingById.get(serverId); + return !current || typeof current.record !== "number" || typeof current.recordTimestamp !== "number"; + }); + + const runWithConcurrencyLimit = async ( + ids: string[], + limit: number, + fn: (id: string) => Promise + ): Promise> => { + const results = new Map(); + if (ids.length === 0) { + return results; + } + + let index = 0; + + const worker = async () => { + while (index < ids.length) { + const currentIndex = index++; + const id = ids[currentIndex]; + const value = await fn(id); + results.set(id, value); + } + }; + + const workers: Promise[] = []; + const workerCount = Math.min(limit, ids.length); + for (let i = 0; i < workerCount; i++) { + workers.push(worker()); + } + + await Promise.all(workers); + return results; + }; + + const peakBackfills = await runWithConcurrencyLimit( + peakBackfillIds, + 10, + fetchRollingPeak + ); + + const recordBackfills = await runWithConcurrencyLimit( + recordBackfillIds, + 10, + fetchRecord + ); + const updates = serverIds.map((serverId) => { + const count = data[serverId]; + const current = existingById.get(serverId); + + let dailyPeak = count; + let dailyPeakTimestamp = now; + if (current && typeof current.dailyPeak === "number" && typeof current.dailyPeakTimestamp === "number") { + if (current.dailyPeakTimestamp >= rollingWindowStart && current.dailyPeak >= count) { + dailyPeak = current.dailyPeak; + dailyPeakTimestamp = current.dailyPeakTimestamp; + } + } + const peakBackfill = peakBackfills.get(serverId); + if (peakBackfill?.peak != null && peakBackfill.timestamp != null && peakBackfill.peak > dailyPeak) { + dailyPeak = peakBackfill.peak; + dailyPeakTimestamp = peakBackfill.timestamp; + } + if (count > dailyPeak) { + dailyPeak = count; + dailyPeakTimestamp = now; + } + + let record = count; + let recordTimestamp = now; + if (current && typeof current.record === "number" && typeof current.recordTimestamp === "number") { + record = current.record; + recordTimestamp = current.recordTimestamp; + } + const recordBackfill = recordBackfills.get(serverId); + if (recordBackfill?.record != null && recordBackfill.timestamp != null) { + if (recordBackfill.record >= record) { + record = recordBackfill.record; + recordTimestamp = recordBackfill.timestamp; + } + } + if (count > record) { + record = count; + recordTimestamp = now; + } + + return { + updateOne: { + filter: { serverId }, + update: { + $set: { + serverId, + latestCount: count, + latestTimestamp: now, + dayKey, + dailyPeak, + dailyPeakTimestamp, + record, + recordTimestamp, + } + }, + upsert: true + } + }; + }); + + await LatestStats.bulkWrite(updates); } export { pingServer, pingAll } diff --git a/backend/src/models/LatestStats.ts b/backend/src/models/LatestStats.ts new file mode 100644 index 0000000..82a4593 --- /dev/null +++ b/backend/src/models/LatestStats.ts @@ -0,0 +1,37 @@ +import { model, Schema } from "mongoose"; + +export default model("latest_stats", new Schema({ + serverId: { + type: String, + required: true, + unique: true + }, + latestCount: { + type: Number, + required: true + }, + latestTimestamp: { + type: Number, + required: true + }, + dayKey: { + type: String, + required: true + }, + dailyPeak: { + type: Number, + required: true + }, + dailyPeakTimestamp: { + type: Number, + required: true + }, + record: { + type: Number, + required: true + }, + recordTimestamp: { + type: Number, + required: true + } +}).index({ serverId: 1 })); diff --git a/backend/src/models/Pings.ts b/backend/src/models/Pings.ts index 091e5ba..fa438ff 100644 --- a/backend/src/models/Pings.ts +++ b/backend/src/models/Pings.ts @@ -11,4 +11,4 @@ export default model("pings_new", new Schema({ default: {} //serverId : playerCount } -})); \ No newline at end of file +}).index({ timestamp: 1 }));