From bda5eb3002a87a3648969998927b12c68689d82c Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 08:08:54 +0100 Subject: [PATCH 01/24] add slider for range selection --- app/pages/dashboard/index.tsx | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/app/pages/dashboard/index.tsx b/app/pages/dashboard/index.tsx index 9ac706e..43f3bc3 100644 --- a/app/pages/dashboard/index.tsx +++ b/app/pages/dashboard/index.tsx @@ -93,8 +93,8 @@ export default function Dashboard() { base: "my-2 sm:my-8", }; - const liveRangeOptions = [1, 2, 4, 8, 12, 24] as const; - const [liveRangeHours, setLiveRangeHours] = useState(1); + const liveRangeOptions = [1, 2, 4, 8, 12, 24]; + const [liveRangeHours, setLiveRangeHours] = useState(1); const liveRangeMs = liveRangeHours * 60 * 60 * 1000; const rangeShiftMs = 2 * 60 * 60 * 1000; const pingRate = 10000; @@ -240,12 +240,16 @@ export default function Dashboard() { await fetchChartRange(url, token, liveFrom, liveTo); }; - const handleLiveRangeChange = async (hours: typeof liveRangeOptions[number]) => { - setLiveRangeHours(hours); + const handleLiveRangeChange = async (hours: number) => { + const snapped = liveRangeOptions.reduce((closest, option) => { + if (Math.abs(option - hours) < Math.abs(closest - hours)) return option; + return closest; + }, liveRangeOptions[0]); + setLiveRangeHours(snapped); if (!url || !token || dateOverriddenRef.current) return; const rangeNow = Date.now(); - const liveFrom = rangeNow - hours * 60 * 60 * 1000; + const liveFrom = rangeNow - snapped * 60 * 60 * 1000; const liveTo = rangeNow; setFromDate(liveFrom); setToDate(liveTo); @@ -727,17 +731,22 @@ export default function Dashboard() { {!isLiveRange ? ( Custom range From f2b0c0cfe4b0478aae3127fc23245eb08cd558ff Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 08:48:40 +0100 Subject: [PATCH 02/24] imrpove ui/ux in general --- app/components/charts/OnlinePlayersChart.tsx | 63 +++++++++-- app/components/charts/ServerTable.tsx | 19 +++- app/components/server/AddServer.tsx | 74 +++++++------ app/pages/_app.tsx | 2 +- app/pages/_document.tsx | 2 +- app/pages/dashboard/index.tsx | 98 +++++++++++------ app/pages/index.tsx | 106 ++++++++++++------- app/styles/globals.css | 28 ++++- 8 files changed, 278 insertions(+), 114 deletions(-) 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 ({ timestamp: Number(point.timestamp), count: Number(point.count), @@ -371,6 +371,16 @@ 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); + minuteBuckets.set(minuteKey, point); + } + + const points = Array.from(minuteBuckets.values()).sort( + (a, b) => a.timestamp - b.timestamp + ); + const minimumCoverageMs = 24 * 60 * 60 * 1000; if (points.length < 2) { @@ -534,12 +544,13 @@ export function ServerTable({ const topContent = React.useMemo(() => { return (
-
+
} + size="sm" value={filterValue} onClear={() => onClear()} onValueChange={onSearchChange} @@ -608,7 +619,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} 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} + /> +
+
@@ -730,9 +746,10 @@ export default function Dashboard() { Now {!isLiveRange ? ( - Custom range + Custom range ) : ( - Live range + Live range )}
@@ -773,6 +790,7 @@ export default function Dashboard() { const parsedTo = parseDateTimeLocal(customToInput); if (Number.isFinite(parsedTo) && parsed >= parsedTo) return; } + setIsCustomRangeEditing(true); setCustomFromInput(value); }} /> @@ -790,6 +808,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); }} /> @@ -804,8 +823,14 @@ export default function Dashboard() {
- - + + +
+

Servers

+

Search, filter, and manage visibility.

+
+
+ {(onClose) => ( <> - Change password - + + Change password + Update your account credentials. + + {accountError ?

{accountError}

: null} setCurrentPassword(e.target.value)} /> @@ -853,6 +882,7 @@ export default function Dashboard() { label="New password" type="password" variant="bordered" + size="sm" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> @@ -860,6 +890,7 @@ export default function Dashboard() { label="Confirm new password" type="password" variant="bordered" + size="sm" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} /> @@ -897,6 +928,7 @@ export default function Dashboard() { setNewUserName(e.target.value)} /> @@ -904,10 +936,12 @@ export default function Dashboard() { label="Temporary password" variant="bordered" type="password" + size="sm" value={newUserPassword} onChange={(e) => setNewUserPassword(e.target.value)} /> -
+
+ Permissions @@ -955,7 +989,8 @@ export default function Dashboard() {
-
+
+ Users can be edited or reset below. @@ -1025,8 +1060,9 @@ export default function Dashboard() { <> Edit permissions for {userPermissionTarget?.name} + Adjust access levels and visibility. - + @@ -1096,12 +1132,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..82cc04d 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) => ( + {servers.length === 0 ? ( +
+ No servers saved yet. Add one to start tracking live players. +
+ ) : ( +
+ {servers.map((server: any, index: any) => (
- - -
- )) - } -
+ ))} +
+ )}
-
- diff --git a/app/styles/globals.css b/app/styles/globals.css index c766860..a7fd579 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,20 @@ 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; +} + +.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)); } .page-card { @@ -26,6 +41,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 +60,12 @@ body { animation: 3s blink ease infinite; } +@media (prefers-reduced-motion: reduce) { + .blinking { + animation: none; + } +} + @keyframes blink { from, to { opacity: 0; @@ -100,4 +124,4 @@ body { 70% { opacity: 1; } -} \ No newline at end of file +} From 7297e881f3f2fd5a51362529d8fb4040edd4e03c Mon Sep 17 00:00:00 2001 From: PleaseInsertNameHere <73995049+PleaseInsertNameHere@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:43:05 +0100 Subject: [PATCH 03/24] Add row selection, fix positioning of backend selection buttons, add ability to toggle all charts, align actions right --- .claude/settings.local.json | 15 +++++++ .gitignore | 0 app/components/charts/ServerTable.tsx | 61 +++++++++++++++++++++++---- app/pages/dashboard/index.tsx | 12 ++++++ app/pages/index.tsx | 18 ++++---- 5 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore 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/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index 88c47b4..dcd67e8 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -69,6 +69,7 @@ export function ServerTable({ onServersChanged, hiddenServers, onToggleServer, + onToggleAll, }: { url: string | null, token: string, @@ -80,6 +81,7 @@ export function ServerTable({ onServersChanged: () => void; hiddenServers?: Set; onToggleServer?: (serverName: string) => void; + onToggleAll?: (allServerNames: string[]) => void; }) { const [filterValue, setFilterValue] = React.useState(""); const [visibleColumns, setVisibleColumns] = React.useState(new Set(INITIAL_VISIBLE_COLUMNS)); @@ -102,7 +104,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); @@ -168,12 +170,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]; @@ -299,7 +305,7 @@ export function ServerTable({ ) case "actions": return ( -
+
{canSeePrediction ? (
); - }, [items.length, page, pages, hasSearchFilter]); + }, [items.length, page, pages, hasSearchFilter, rowsPerPage]); return ( <> @@ -633,9 +656,29 @@ export function ServerTable({ - {column.name} + {column.uid === "chart" ? ( +
{ + e.stopPropagation(); + onToggleAll?.(allServerNames); + }} + > +
+
+ ) : column.name} )} @@ -705,7 +748,7 @@ export function ServerTable({ ) : null} - + )} diff --git a/app/pages/dashboard/index.tsx b/app/pages/dashboard/index.tsx index 844e228..3e91f88 100644 --- a/app/pages/dashboard/index.tsx +++ b/app/pages/dashboard/index.tsx @@ -170,6 +170,17 @@ export default function Dashboard() { }); }; + const handleToggleAll = (allServerNames: string[]) => { + setHiddenServers(prev => { + const anyVisible = allServerNames.some(name => !prev.has(name)); + if (anyVisible) { + return new Set(allServerNames); + } else { + return new Set(); + } + }); + }; + const sortedTableData = React.useMemo(() => { return [...tableData].sort((a, b) => b.playerCount - a.playerCount); }, [tableData]); @@ -847,6 +858,7 @@ export default function Dashboard() { }} hiddenServers={hiddenServers} onToggleServer={handleToggleServer} + onToggleAll={handleToggleAll} /> diff --git a/app/pages/index.tsx b/app/pages/index.tsx index 82cc04d..8783a8b 100644 --- a/app/pages/index.tsx +++ b/app/pages/index.tsx @@ -193,7 +193,7 @@ export default function Home() { ) : (
{servers.map((server: any, index: any) => ( -
+
)} - -
+ +
@@ -247,7 +247,7 @@ export default function Home() { href="https://discord.gg/cTNTrQsJSx" target="_blank" rel="noopener noreferrer" - className="w-12" + className="min-w-10" variant='ghost' color='default' isIconOnly @@ -260,7 +260,7 @@ export default function Home() { href="https://github.com/RedstoneCloud/RedTrack" target="_blank" rel="noopener noreferrer" - className="w-12" + className="min-w-10" variant='ghost' color='default' isIconOnly From d1fb98fe9557c7c3299eab0ca8e5fb39711244b3 Mon Sep 17 00:00:00 2001 From: PleaseInsertNameHere <73995049+PleaseInsertNameHere@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:52:28 +0100 Subject: [PATCH 04/24] add .next/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e69de29..aced64b 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +.next/ \ No newline at end of file From 4b9b376511f342fa02065ce228ec5c4d0e71e2ef Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 20:36:03 +0100 Subject: [PATCH 05/24] merge conflicts --- app/pages/dashboard/index.tsx | 124 +++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 25 deletions(-) diff --git a/app/pages/dashboard/index.tsx b/app/pages/dashboard/index.tsx index 3e91f88..e4ec602 100644 --- a/app/pages/dashboard/index.tsx +++ b/app/pages/dashboard/index.tsx @@ -51,6 +51,7 @@ export default function Dashboard() { const [hiddenServers, setHiddenServers] = useState>(new Set()); let [backendReachable, setBackendReachable] = useState(true); let [backendError, setBackendError] = useState(""); + const [authError, setAuthError] = useState(""); let [fromDate, setFromDate] = useState(new Date().getTime() - 60 * 1000 * 60 * 6) let [toDate, setToDate] = useState(new Date().getTime()); let [dateOverridden, setDateOverridden] = useState(false); @@ -129,6 +130,10 @@ export default function Dashboard() { const [customToInput, setCustomToInput] = useState(formatDateTimeLocal(toDate)); const [rangeError, setRangeError] = useState(""); const [isCustomRangeEditing, setIsCustomRangeEditing] = useState(false); + const [isChartLoading, setIsChartLoading] = useState(true); + const [isTableLoading, setIsTableLoading] = useState(true); + const hasLoadedChartRef = useRef(false); + const hasLoadedTableRef = useRef(false); useEffect(() => { fromDateRef.current = fromDate; @@ -192,8 +197,17 @@ export default function Dashboard() { const maxSelectableDateTime = formatDateTimeLocal(now); const fromInputMax = customToInput && customToInput < maxSelectableDateTime ? customToInput : maxSelectableDateTime; - const fetchChartRange = async (baseUrl: string, sessionToken: string, rangeFrom: number, rangeTo: number) => { + const fetchChartRange = async ( + baseUrl: string, + sessionToken: string, + rangeFrom: number, + rangeTo: number, + options: { showLoading?: boolean } = {} + ) => { const clamped = getClampedRange(rangeFrom, rangeTo); + if (options.showLoading) { + setIsChartLoading(true); + } const response = await requestBackend(baseUrl, sessionToken, '/api/stats/range?from=' + clamped.from + '&to=' + clamped.to, { method: 'GET', headers: { @@ -203,6 +217,8 @@ export default function Dashboard() { const dat = await response.json(); setData((prev: any) => ({ type: prev.type, from: clamped.from, to: clamped.to, ...dat })); + hasLoadedChartRef.current = true; + setIsChartLoading(false); }; const handleRangeShift = async (direction: "prev" | "next") => { @@ -236,7 +252,7 @@ export default function Dashboard() { setToDate(nextTo); fromDateRef.current = nextFrom; toDateRef.current = nextTo; - await fetchChartRange(url, token, nextFrom, nextTo); + await fetchChartRange(url, token, nextFrom, nextTo, { showLoading: true }); }; const handleRangeReset = async () => { @@ -252,7 +268,7 @@ export default function Dashboard() { fromDateRef.current = liveFrom; toDateRef.current = liveTo; chartRef.current?.resetZoom(); - await fetchChartRange(url, token, liveFrom, liveTo); + await fetchChartRange(url, token, liveFrom, liveTo, { showLoading: true }); }; const handleLiveRangeChange = async (hours: number) => { @@ -271,7 +287,7 @@ export default function Dashboard() { setToDate(liveTo); fromDateRef.current = liveFrom; toDateRef.current = liveTo; - await fetchChartRange(url, token, liveFrom, liveTo); + await fetchChartRange(url, token, liveFrom, liveTo, { showLoading: true }); }; const handleApplyCustomRange = async () => { @@ -302,7 +318,7 @@ export default function Dashboard() { setToDate(parsedTo); fromDateRef.current = parsedFrom; toDateRef.current = parsedTo; - await fetchChartRange(url, token, parsedFrom, parsedTo); + await fetchChartRange(url, token, parsedFrom, parsedTo, { showLoading: true }); }; const requestBackend = async (activeUrl: string, activeToken: string, path: string, init?: RequestInit) => { @@ -316,6 +332,11 @@ export default function Dashboard() { }); setBackendReachable(true); setBackendError(""); + if (response.status === 401 || response.status === 403) { + setAuthError("Authentication failed for this server. Please re-authenticate."); + } else if (response.ok) { + setAuthError(""); + } return response; } catch (error) { setBackendReachable(false); @@ -577,6 +598,7 @@ export default function Dashboard() { setToken(tok) setUrl(ur) + setAuthError(""); if (tok != null && ur != null) { const now = Date.now(); @@ -588,12 +610,18 @@ export default function Dashboard() { setToDate(effectiveTo); } + if (!hasLoadedTableRef.current) { + setIsTableLoading(true); + } const response = await requestBackend(ur, tok, "/api/stats/latest", { method: 'GET', headers: { 'Content-Type': 'application/json', } }); + if (response.status === 401 || response.status === 403) { + return; + } const dat = await response.json(); setTableData((prevTableData) => { const tableDataMap = prevTableData && prevTableData.length > 0 ? new Map(prevTableData.map((item) => [item.internalId, item])) : null; @@ -618,9 +646,11 @@ export default function Dashboard() { return updatedData; }) + hasLoadedTableRef.current = true; + setIsTableLoading(false); if (!dateOverriddenRef.current) { - await fetchChartRange(ur, tok, effectiveFrom, effectiveTo); + await fetchChartRange(ur, tok, effectiveFrom, effectiveTo, { showLoading: !hasLoadedChartRef.current }); } } } @@ -674,6 +704,30 @@ export default function Dashboard() { }, [currentUser, token, url]); + if (authError) { + return ( +
+ + +

Authentication failed

+

{authError}

+
+ +

Return to the server list and re-add or update credentials.

+
+ + + + +
+
+ ); + } + if (!backendReachable) { return (
@@ -742,7 +796,17 @@ export default function Dashboard() {
- + {isChartLoading ? ( +
+ Loading chart... +
+ ) : Object.values(data?.data || {}).some((server: any) => (server?.pings || []).length > 0) ? ( + + ) : ( +
+ No data to display yet. +
+ )}
@@ -842,24 +906,34 @@ export default function Dashboard() {
- { - if (url && token && canManageServers) { - loadServerDetails(url, token); - } - reloadData(); - }} - hiddenServers={hiddenServers} - onToggleServer={handleToggleServer} - onToggleAll={handleToggleAll} - /> + {isTableLoading ? ( +
+ Loading table... +
+ ) : sortedTableData.length > 0 ? ( + { + if (url && token && canManageServers) { + loadServerDetails(url, token); + } + reloadData(); + }} + hiddenServers={hiddenServers} + onToggleServer={handleToggleServer} + onToggleAll={handleToggleAll} + /> + ) : ( +
+ No data to display yet. +
+ )}
From 21d4749905a17d714be27405f35715fc2fec0c3d Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 09:31:14 +0100 Subject: [PATCH 06/24] small prediction change --- app/components/charts/ServerTable.tsx | 45 ++++++++++++++++++++------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index dcd67e8..cfc3ec3 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -403,18 +403,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(); @@ -468,11 +484,18 @@ 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, From 373d834c822282a2386ffcacd108732fff3daee9 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 09:35:21 +0100 Subject: [PATCH 07/24] add loading skeleton --- app/pages/dashboard/index.tsx | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/app/pages/dashboard/index.tsx b/app/pages/dashboard/index.tsx index e4ec602..88b7010 100644 --- a/app/pages/dashboard/index.tsx +++ b/app/pages/dashboard/index.tsx @@ -797,8 +797,20 @@ export default function Dashboard() { {isChartLoading ? ( -
- Loading chart... +
+
+
+
+ {Array.from({ length: 24 }).map((_, index) => ( +
+ ))} +
+
+
) : Object.values(data?.data || {}).some((server: any) => (server?.pings || []).length > 0) ? ( @@ -907,8 +919,11 @@ export default function Dashboard() { {isTableLoading ? ( -
- Loading table... +
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))}
) : sortedTableData.length > 0 ? ( Date: Thu, 12 Feb 2026 18:47:42 +0100 Subject: [PATCH 08/24] Changes --- app/components/charts/ServerTable.tsx | 23 +- app/pages/dashboard/index.tsx | 291 +++++++++++++++++++------- backend/src/api/routes/Stats.ts | 84 ++------ backend/src/jobs/PingServerJob.ts | 53 +++++ backend/src/models/LatestStats.ts | 37 ++++ backend/src/models/Pings.ts | 2 +- 6 files changed, 345 insertions(+), 145 deletions(-) create mode 100644 backend/src/models/LatestStats.ts diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index cfc3ec3..8cd27a6 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -69,6 +69,7 @@ export function ServerTable({ onServersChanged, hiddenServers, onToggleServer, + isCached = false, onToggleAll, }: { url: string | null, @@ -81,6 +82,7 @@ export function ServerTable({ onServersChanged: () => void; hiddenServers?: Set; onToggleServer?: (serverName: string) => void; + isCached?: boolean; onToggleAll?: (allServerNames: string[]) => void; }) { const [filterValue, setFilterValue] = React.useState(""); @@ -275,6 +277,9 @@ export function ServerTable({ ) case "playerCount": + if (isCached) { + return --; + } return (
) case "dailyPeak": + if (isCached) { + return --; + } return (
{cellValue} @@ -295,6 +303,9 @@ export function ServerTable({
) case "record": + if (isCached) { + return --; + } return (
{cellValue} @@ -307,16 +318,22 @@ export function ServerTable({ return (
{canSeePrediction ? ( - ) : null} {canManageServers ? ( <> - - diff --git a/app/pages/dashboard/index.tsx b/app/pages/dashboard/index.tsx index 88b7010..8b1032d 100644 --- a/app/pages/dashboard/index.tsx +++ b/app/pages/dashboard/index.tsx @@ -39,8 +39,8 @@ type TableRow = { }; export default function Dashboard() { - let [token, setToken] = useState(null); - let [url, setUrl] = useState(null); + let [token, setToken] = useState(null); + let [url, setUrl] = useState(null); let router = useRouter(); let [data, setData] = useState({ @@ -52,6 +52,8 @@ export default function Dashboard() { let [backendReachable, setBackendReachable] = useState(true); let [backendError, setBackendError] = useState(""); const [authError, setAuthError] = useState(""); + const [serverConfig, setServerConfig] = useState<{ token: string; url: string } | null>(null); + const [activeServerIndex, setActiveServerIndex] = useState(0); let [fromDate, setFromDate] = useState(new Date().getTime() - 60 * 1000 * 60 * 6) let [toDate, setToDate] = useState(new Date().getTime()); let [dateOverridden, setDateOverridden] = useState(false); @@ -132,6 +134,8 @@ export default function Dashboard() { const [isCustomRangeEditing, setIsCustomRangeEditing] = useState(false); const [isChartLoading, setIsChartLoading] = useState(true); const [isTableLoading, setIsTableLoading] = useState(true); + const [isTableCached, setIsTableCached] = useState(false); + const [isTableSlow, setIsTableSlow] = useState(false); const hasLoadedChartRef = useRef(false); const hasLoadedTableRef = useRef(false); @@ -163,6 +167,21 @@ export default function Dashboard() { const canChangeOwnPassword = currentUser ? !hasPermission(currentUser.permissions, Permissions.CANNOT_CHANGE_PASSWORD) : false; const canSeePrediction = currentUser ? hasPermission(currentUser.permissions, Permissions.CAN_SEE_PREDICTION) : false; + const buildPlaceholderRows = React.useCallback((details: Record) => { + return Object.entries(details).map(([id, detail]) => ({ + internalId: id, + server: detail.name, + playerCount: 0, + playerCountDevelopment: "stagnant", + dailyPeak: 0, + dailyPeakTimestamp: 0, + record: 0, + recordTimestamp: 0, + invalidPings: false, + outdated: false, + })) as TableRow[]; + }, []); + const handleToggleServer = (serverName: string) => { setHiddenServers(prev => { const next = new Set(prev); @@ -175,6 +194,14 @@ export default function Dashboard() { }); }; + useEffect(() => { + Preferences.set({ + key: `hiddenServers:${activeServerIndex}`, + value: JSON.stringify(Array.from(hiddenServers)), + }).catch(() => { + }); + }, [hiddenServers, activeServerIndex]); + const handleToggleAll = (allServerNames: string[]) => { setHiddenServers(prev => { const anyVisible = allServerNames.some(name => !prev.has(name)); @@ -318,6 +345,11 @@ export default function Dashboard() { setToDate(parsedTo); fromDateRef.current = parsedFrom; toDateRef.current = parsedTo; + Preferences.set({ + key: `customRangeMs:${activeServerIndex}`, + value: String(parsedTo - parsedFrom), + }).catch(() => { + }); await fetchChartRange(url, token, parsedFrom, parsedTo, { showLoading: true }); }; @@ -365,7 +397,7 @@ export default function Dashboard() { }); }; - const loadServerDetails = async (activeUrl: string, activeToken: string) => { + const loadServerDetails = async (activeUrl: string, activeToken: string, serverIndex?: number) => { const response = await requestBackend(activeUrl, activeToken, "/api/servermanage/list", { method: "GET", headers: { @@ -387,6 +419,10 @@ export default function Dashboard() { return acc; }, {}); setServerDetails(details); + if (typeof serverIndex === "number") { + Preferences.set({ key: `serverDetails:${serverIndex}`, value: JSON.stringify(details) }).catch(() => { + }); + } }; const loadUsers = async (activeUrl: string, activeToken: string) => { @@ -588,73 +624,94 @@ export default function Dashboard() { }; async function reloadData() { - await Preferences.get({ key: 'servers' }).then(async (dat) => { - let servers = await JSON.parse(dat.value || "[]") - let id = parseInt(router.query.server as string) || 0; - let server = servers[id]; - if (server) { - let tok = servers[id].token - let ur = servers[id].url - - setToken(tok) - setUrl(ur) - setAuthError(""); - - if (tok != null && ur != null) { - const now = Date.now(); - const effectiveFrom = dateOverriddenRef.current ? fromDateRef.current : now - liveRangeMsRef.current; - const effectiveTo = dateOverriddenRef.current ? toDateRef.current : now; + const config = serverConfig; + if (!config?.token || !config?.url) return; + const tok = config.token as any; + const ur = config.url as any; + + setToken(tok); + setUrl(ur); + setAuthError(""); + + const now = Date.now(); + const effectiveFrom = dateOverriddenRef.current ? fromDateRef.current : now - liveRangeMsRef.current; + const effectiveTo = dateOverriddenRef.current ? toDateRef.current : now; + + if (!dateOverriddenRef.current) { + setFromDate(effectiveFrom); + setToDate(effectiveTo); + } - if (!dateOverriddenRef.current) { - setFromDate(effectiveFrom); - setToDate(effectiveTo); - } + if (!hasLoadedTableRef.current) { + setIsTableLoading(true); + setIsTableSlow(false); + } - if (!hasLoadedTableRef.current) { - setIsTableLoading(true); - } - const response = await requestBackend(ur, tok, "/api/stats/latest", { - method: 'GET', - headers: { - 'Content-Type': 'application/json', + const latestPromise = requestBackend(ur, tok, "/api/stats/latest", { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }).then(async (response) => { + if (response.status === 401 || response.status === 403) return; + const dat = await response.json(); + setTableData((prevTableData) => { + const tableDataMap = prevTableData && prevTableData.length > 0 ? new Map(prevTableData.map((item) => [item.internalId, item])) : null; + + const updatedData = dat.map((item: TableRow) => { + const previousData = tableDataMap ? tableDataMap.get(item.internalId) : null; + + let playerCountDevelopment = "stagnant"; + if (previousData) { + if (item.playerCount > previousData.playerCount) { + playerCountDevelopment = "increasing"; + } else if (item.playerCount < previousData.playerCount) { + playerCountDevelopment = "decreasing"; } - }); - if (response.status === 401 || response.status === 403) { - return; } - const dat = await response.json(); - setTableData((prevTableData) => { - const tableDataMap = prevTableData && prevTableData.length > 0 ? new Map(prevTableData.map((item) => [item.internalId, item])) : null; - - const updatedData = dat.map((item: TableRow) => { - const previousData = tableDataMap ? tableDataMap.get(item.internalId) : null; - - let playerCountDevelopment = "stagnant"; - if (previousData) { - if (item.playerCount > previousData.playerCount) { - playerCountDevelopment = "increasing"; - } else if (item.playerCount < previousData.playerCount) { - playerCountDevelopment = "decreasing"; - } - } - return { - ...item, - playerCountDevelopment, - }; - }); + return { + ...item, + playerCountDevelopment, + }; + }); - return updatedData; - }) - hasLoadedTableRef.current = true; - setIsTableLoading(false); + return updatedData; + }); + hasLoadedTableRef.current = true; + setIsTableLoading(false); + setIsTableCached(false); + const serverIndex = parseInt(router.query.server as string) || 0; + Preferences.set({ key: `latestStats:${serverIndex}`, value: JSON.stringify(dat) }).catch(() => { + }); + setIsTableSlow(false); + }).catch(() => { + }); - if (!dateOverriddenRef.current) { - await fetchChartRange(ur, tok, effectiveFrom, effectiveTo, { showLoading: !hasLoadedChartRef.current }); - } + const latestTimeoutMs = 5000; + const timeoutPromise = new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), latestTimeoutMs); + }); + + const chartPromise = !dateOverriddenRef.current + ? fetchChartRange(ur, tok, effectiveFrom, effectiveTo, { showLoading: !hasLoadedChartRef.current }) + : Promise.resolve(); + + const latestResult = await Promise.race([latestPromise.then(() => "ok" as const), timeoutPromise]); + if (latestResult === "timeout") { + setIsTableLoading(false); + setIsTableSlow(true); + if (!hasLoadedTableRef.current && Object.keys(serverDetails).length > 0) { + const placeholderRows = buildPlaceholderRows(serverDetails); + if (placeholderRows.length > 0) { + setTableData(placeholderRows); + setIsTableCached(true); + hasLoadedTableRef.current = true; } } - }); + } + + await Promise.allSettled([latestPromise, chartPromise]); } useEffect(() => { @@ -669,7 +726,7 @@ export default function Dashboard() { }); return () => clearInterval(intervalId); - }, [router.query, router]); + }, [router.query, router, serverConfig]); useEffect(() => { router.beforePopState(() => { @@ -691,17 +748,93 @@ export default function Dashboard() { }); }, [token, url]); + useEffect(() => { + let active = true; + Preferences.get({ key: "servers" }).then((dat) => { + if (!active) return; + const servers = JSON.parse(dat.value || "[]"); + const id = parseInt(router.query.server as string) || 0; + const server = servers[id]; + if (server?.token && server?.url) { + setActiveServerIndex(id); + setServerConfig({ token: server.token, url: server.url }); + Preferences.get({ key: `latestStats:${id}` }).then((cached) => { + if (!active || !cached.value) return; + try { + const parsed = JSON.parse(cached.value) as TableRow[]; + if (Array.isArray(parsed) && parsed.length > 0) { + setTableData(parsed.map((item) => ({ + ...item, + playerCountDevelopment: "stagnant", + }))); + hasLoadedTableRef.current = true; + setIsTableLoading(false); + setIsTableCached(true); + } + } catch { + } + }); + Preferences.get({ key: `serverDetails:${id}` }).then((cached) => { + if (!active || !cached.value) return; + try { + const parsed = JSON.parse(cached.value); + if (parsed && typeof parsed === "object") { + setServerDetails(parsed); + if (!hasLoadedTableRef.current) { + const placeholderRows = buildPlaceholderRows(parsed); + if (placeholderRows.length > 0) { + setTableData(placeholderRows); + setIsTableCached(true); + hasLoadedTableRef.current = true; + setIsTableLoading(false); + } + } + } + } catch { + } + }); + Preferences.get({ key: `hiddenServers:${id}` }).then((cached) => { + if (!active || !cached.value) return; + try { + const parsed = JSON.parse(cached.value) as string[]; + if (Array.isArray(parsed)) { + setHiddenServers(new Set(parsed)); + } + } catch { + } + }); + Preferences.get({ key: `customRangeMs:${id}` }).then((cached) => { + if (!active || !cached.value) return; + try { + const parsed = Number(cached.value); + if (Number.isFinite(parsed) && parsed > 0) { + const now = Date.now(); + setCustomFromInput(formatDateTimeLocal(now - parsed)); + setCustomToInput(formatDateTimeLocal(now)); + } + } catch { + } + }); + } + }).catch(() => { + }); + return () => { + active = false; + }; + }, [router.query.server]); + useEffect(() => { if (!token || !url || !currentUser) return; if (hasPermission(currentUser.permissions, Permissions.SERVER_MANAGEMENT)) { - loadServerDetails(url, token).catch(() => { + const serverIndex = parseInt(router.query.server as string) || 0; + loadServerDetails(url, token, serverIndex).catch(() => { }); } if (hasPermission(currentUser.permissions, Permissions.USER_MANAGEMENT)) { loadUsers(url, token).catch(() => { }); } - }, [currentUser, token, url]); + }, [currentUser, token, url, router.query.server]); if (authError) { @@ -912,9 +1045,23 @@ export default function Dashboard() {
-
-

Servers

-

Search, filter, and manage visibility.

+
+
+

Servers

+

Search, filter, and manage visibility.

+
+
+ {isTableCached ? ( + + Showing cached data + + ) : null} + {isTableSlow && !isTableLoading ? ( + + Fetching latest… + + ) : null} +
@@ -934,12 +1081,14 @@ export default function Dashboard() { canManageServers={canManageServers} canSeePrediction={canSeePrediction} serverDetails={serverDetails} + isCached={isTableCached} onServersChanged={() => { if (url && token && canManageServers) { - loadServerDetails(url, token); - } - reloadData(); - }} + const serverIndex = parseInt(router.query.server as string) || 0; + loadServerDetails(url, token, serverIndex); + } + reloadData(); + }} hiddenServers={hiddenServers} onToggleServer={handleToggleServer} onToggleAll={handleToggleAll} 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..a26597b 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) { @@ -46,6 +47,58 @@ async function pingAll() { timestamp: Date.now(), data: data }).save(); + + const now = Date.now(); + 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 updates = serverIds.map((serverId) => { + const count = data[serverId]; + const current = existingById.get(serverId); + + let dailyPeak = count; + let dailyPeakTimestamp = now; + if (current && current.dayKey === dayKey) { + if (typeof current.dailyPeak === "number" && current.dailyPeak >= count) { + dailyPeak = current.dailyPeak; + dailyPeakTimestamp = current.dailyPeakTimestamp; + } + } + + let record = count; + let recordTimestamp = now; + if (current && typeof current.record === "number") { + if (current.record >= count) { + record = current.record; + recordTimestamp = current.recordTimestamp; + } + } + + 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 })); From 6d4edb74f5285f70809a932121cbb250ae9acf4b Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 21:05:28 +0100 Subject: [PATCH 09/24] fix preferences --- app/pages/dashboard/index.tsx | 130 +++++++++++++++++++++++++--------- 1 file changed, 98 insertions(+), 32 deletions(-) diff --git a/app/pages/dashboard/index.tsx b/app/pages/dashboard/index.tsx index 8b1032d..dc69255 100644 --- a/app/pages/dashboard/index.tsx +++ b/app/pages/dashboard/index.tsx @@ -138,6 +138,7 @@ export default function Dashboard() { const [isTableSlow, setIsTableSlow] = useState(false); const hasLoadedChartRef = useRef(false); const hasLoadedTableRef = useRef(false); + const hasLoadedServerPrefsRef = useRef(false); useEffect(() => { fromDateRef.current = fromDate; @@ -195,6 +196,7 @@ export default function Dashboard() { }; useEffect(() => { + if (!hasLoadedServerPrefsRef.current) return; Preferences.set({ key: `hiddenServers:${activeServerIndex}`, value: JSON.stringify(Array.from(hiddenServers)), @@ -202,6 +204,15 @@ export default function Dashboard() { }); }, [hiddenServers, activeServerIndex]); + useEffect(() => { + if (!hasLoadedServerPrefsRef.current) return; + Preferences.set({ + key: `liveRangeHours:${activeServerIndex}`, + value: String(liveRangeHours), + }).catch(() => { + }); + }, [liveRangeHours, activeServerIndex]); + const handleToggleAll = (allServerNames: string[]) => { setHiddenServers(prev => { const anyVisible = allServerNames.some(name => !prev.has(name)); @@ -623,11 +634,31 @@ export default function Dashboard() { await loadUsers(url, token); }; + const applyCachedTableData = async (serverIndex: number) => { + try { + const cached = await Preferences.get({ key: `latestStats:${serverIndex}` }); + if (!cached.value) return false; + const parsed = JSON.parse(cached.value) as TableRow[]; + if (!Array.isArray(parsed) || parsed.length === 0) return false; + setTableData(parsed.map((item) => ({ + ...item, + playerCountDevelopment: "stagnant", + }))); + hasLoadedTableRef.current = true; + setIsTableLoading(false); + setIsTableCached(true); + return true; + } catch { + return false; + } + }; + async function reloadData() { const config = serverConfig; if (!config?.token || !config?.url) return; const tok = config.token as any; const ur = config.url as any; + const serverIndex = parseInt(router.query.server as string) || 0; setToken(tok); setUrl(ur); @@ -685,7 +716,8 @@ export default function Dashboard() { Preferences.set({ key: `latestStats:${serverIndex}`, value: JSON.stringify(dat) }).catch(() => { }); setIsTableSlow(false); - }).catch(() => { + }).catch(async () => { + await applyCachedTableData(serverIndex); }); const latestTimeoutMs = 5000; @@ -701,7 +733,8 @@ export default function Dashboard() { if (latestResult === "timeout") { setIsTableLoading(false); setIsTableSlow(true); - if (!hasLoadedTableRef.current && Object.keys(serverDetails).length > 0) { + const appliedCached = await applyCachedTableData(serverIndex); + if (!appliedCached && !hasLoadedTableRef.current && Object.keys(serverDetails).length > 0) { const placeholderRows = buildPlaceholderRows(serverDetails); if (placeholderRows.length > 0) { setTableData(placeholderRows); @@ -750,18 +783,36 @@ export default function Dashboard() { useEffect(() => { let active = true; - Preferences.get({ key: "servers" }).then((dat) => { - if (!active) return; - const servers = JSON.parse(dat.value || "[]"); - const id = parseInt(router.query.server as string) || 0; - const server = servers[id]; - if (server?.token && server?.url) { + hasLoadedServerPrefsRef.current = false; + + const loadPrefs = async () => { + try { + const dat = await Preferences.get({ key: "servers" }); + if (!active) return; + const servers = JSON.parse(dat.value || "[]"); + const id = parseInt(router.query.server as string) || 0; + const server = servers[id]; + if (!server?.token || !server?.url) { + hasLoadedServerPrefsRef.current = true; + return; + } + setActiveServerIndex(id); setServerConfig({ token: server.token, url: server.url }); - Preferences.get({ key: `latestStats:${id}` }).then((cached) => { - if (!active || !cached.value) return; + + const [latestStats, serverDetailsCache, hiddenServersCache, customRangeCache, liveRangeCache] = await Promise.all([ + Preferences.get({ key: `latestStats:${id}` }), + Preferences.get({ key: `serverDetails:${id}` }), + Preferences.get({ key: `hiddenServers:${id}` }), + Preferences.get({ key: `customRangeMs:${id}` }), + Preferences.get({ key: `liveRangeHours:${id}` }), + ]); + + if (!active) return; + + if (latestStats.value) { try { - const parsed = JSON.parse(cached.value) as TableRow[]; + const parsed = JSON.parse(latestStats.value) as TableRow[]; if (Array.isArray(parsed) && parsed.length > 0) { setTableData(parsed.map((item) => ({ ...item, @@ -773,11 +824,11 @@ export default function Dashboard() { } } catch { } - }); - Preferences.get({ key: `serverDetails:${id}` }).then((cached) => { - if (!active || !cached.value) return; + } + + if (serverDetailsCache.value) { try { - const parsed = JSON.parse(cached.value); + const parsed = JSON.parse(serverDetailsCache.value); if (parsed && typeof parsed === "object") { setServerDetails(parsed); if (!hasLoadedTableRef.current) { @@ -792,21 +843,21 @@ export default function Dashboard() { } } catch { } - }); - Preferences.get({ key: `hiddenServers:${id}` }).then((cached) => { - if (!active || !cached.value) return; + } + + if (hiddenServersCache.value) { try { - const parsed = JSON.parse(cached.value) as string[]; + const parsed = JSON.parse(hiddenServersCache.value) as string[]; if (Array.isArray(parsed)) { setHiddenServers(new Set(parsed)); } } catch { } - }); - Preferences.get({ key: `customRangeMs:${id}` }).then((cached) => { - if (!active || !cached.value) return; + } + + if (customRangeCache.value) { try { - const parsed = Number(cached.value); + const parsed = Number(customRangeCache.value); if (Number.isFinite(parsed) && parsed > 0) { const now = Date.now(); setCustomFromInput(formatDateTimeLocal(now - parsed)); @@ -814,14 +865,29 @@ export default function Dashboard() { } } catch { } - }); + } + + if (liveRangeCache.value) { + const parsed = Number(liveRangeCache.value); + if (Number.isFinite(parsed) && parsed > 0) { + setLiveRangeHours(parsed); + } + } + + hasLoadedServerPrefsRef.current = true; + } catch { + if (active) { + hasLoadedServerPrefsRef.current = true; + } } - }).catch(() => { - }); + }; + + loadPrefs(); + return () => { active = false; }; - }, [router.query.server]); + }, [router.query.server, buildPlaceholderRows]); useEffect(() => { if (!token || !url || !currentUser) return; @@ -1052,8 +1118,8 @@ export default function Dashboard() {
{isTableCached ? ( - - Showing cached data + + Cached data shown ) : null} {isTableSlow && !isTableLoading ? ( @@ -1086,9 +1152,9 @@ export default function Dashboard() { if (url && token && canManageServers) { const serverIndex = parseInt(router.query.server as string) || 0; loadServerDetails(url, token, serverIndex); - } - reloadData(); - }} + } + reloadData(); + }} hiddenServers={hiddenServers} onToggleServer={handleToggleServer} onToggleAll={handleToggleAll} From 8a25249232ea04680d80a2a3013a3a4bb78d670e Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 21:14:40 +0100 Subject: [PATCH 10/24] Improve prediction chart zoom and styling --- app/components/charts/PredictionChart.tsx | 224 ++++++++++++++++++++++ app/components/charts/ServerTable.tsx | 78 ++++---- 2 files changed, 257 insertions(+), 45 deletions(-) create mode 100644 app/components/charts/PredictionChart.tsx diff --git a/app/components/charts/PredictionChart.tsx b/app/components/charts/PredictionChart.tsx new file mode 100644 index 0000000..28679c3 --- /dev/null +++ b/app/components/charts/PredictionChart.tsx @@ -0,0 +1,224 @@ +import Chart from "chart.js/auto"; +import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; +import React, { useEffect, useRef } from "react"; + +type PredictionPoint = { + timestamp: number; + predictedPlayers: number; +}; + +type PredictionTheme = { + grid: string; + axis: string; + tooltipBg: string; + tooltipBorder: string; + tooltipText: string; +}; + +export function PredictionChart({ + points, + lineColor, + theme, +}: { + points: PredictionPoint[]; + lineColor: string; + theme: PredictionTheme; +}) { + const chartRef = useRef(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 maxYValue = Math.max(...dataPoints.map((point) => point.y), 1); + const stepSize = Math.pow(10, Math.floor(Math.log10(Math.max(1, maxYValue)))); + + 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, + tension: 0.3, + }, + ]; + + 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.ticks.stepSize = stepSize; + } + + 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, + tension: 0.3, + }, + ], + }, + 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) => `Predicted players: ${item.parsed?.y ?? 0}`, + }, + }, + zoom: { + zoom: { + drag: { + enabled: true, + threshold: 8, + }, + mode: "x", + onZoomComplete: () => { + hasManualViewportRef.current = true; + }, + }, + }, + }, + interaction: { + mode: "nearest", + intersect: false, + }, + scales: { + y: { + ticks: { + color: theme.axis, + beginAtZero: true, + stepSize, + callback: (value: any) => (value < 0 ? 0 : value), + }, + 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 8cd27a6..9c421e2 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"; @@ -163,6 +154,7 @@ export function ServerTable({ line: "#2563eb", }; }, [isDarkMode]); + const predictionLineColor = "#9f74ca"; const tableColumns = React.useMemo(() => { const columns = [...baseColumns]; @@ -521,10 +513,30 @@ export function ServerTable({ }); } - 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, Math.round(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?"); @@ -743,7 +755,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} @@ -751,39 +763,15 @@ 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} From 55719612e83e45c39a7e09b52b6185e8840f56c9 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 21:38:38 +0100 Subject: [PATCH 11/24] change prediction ch --- app/components/charts/PredictionChart.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/charts/PredictionChart.tsx b/app/components/charts/PredictionChart.tsx index 28679c3..bef4c58 100644 --- a/app/components/charts/PredictionChart.tsx +++ b/app/components/charts/PredictionChart.tsx @@ -46,6 +46,7 @@ export function PredictionChart({ const minX = Math.min(...dataPoints.map((point) => point.x)); const maxX = Math.max(...dataPoints.map((point) => point.x)); + const tension = 0.6; const maxYValue = Math.max(...dataPoints.map((point) => point.y), 1); const stepSize = Math.pow(10, Math.floor(Math.log10(Math.max(1, maxYValue)))); @@ -61,7 +62,7 @@ export function PredictionChart({ borderWidth: 3, pointRadius: 0, pointHoverRadius: 4, - tension: 0.3, + tension, }, ]; @@ -94,7 +95,7 @@ export function PredictionChart({ borderWidth: 3, pointRadius: 0, pointHoverRadius: 4, - tension: 0.3, + tension, }, ], }, From 3d144a9b452ca16c8948cd159c5a2e69e9e6c55a Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 21:42:29 +0100 Subject: [PATCH 12/24] Address PR feedback on stats and UI --- app/components/charts/PredictionChart.tsx | 3 - app/components/charts/ServerTable.tsx | 10 +-- app/pages/dashboard/index.tsx | 6 +- backend/src/jobs/PingServerJob.ts | 99 +++++++++++++++++++++-- 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/app/components/charts/PredictionChart.tsx b/app/components/charts/PredictionChart.tsx index bef4c58..decfa7b 100644 --- a/app/components/charts/PredictionChart.tsx +++ b/app/components/charts/PredictionChart.tsx @@ -46,7 +46,6 @@ export function PredictionChart({ const minX = Math.min(...dataPoints.map((point) => point.x)); const maxX = Math.max(...dataPoints.map((point) => point.x)); - const tension = 0.6; const maxYValue = Math.max(...dataPoints.map((point) => point.y), 1); const stepSize = Math.pow(10, Math.floor(Math.log10(Math.max(1, maxYValue)))); @@ -62,7 +61,6 @@ export function PredictionChart({ borderWidth: 3, pointRadius: 0, pointHoverRadius: 4, - tension, }, ]; @@ -95,7 +93,6 @@ export function PredictionChart({ borderWidth: 3, pointRadius: 0, pointHoverRadius: 4, - tension, }, ], }, diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index 9c421e2..595e103 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -711,16 +711,16 @@ export function ServerTable({ {...(column.uid === "chart" ? { className: "w-14" } : column.uid === "actions" ? { className: "text-right" } : {})} > {column.uid === "chart" ? ( -
{ e.stopPropagation(); onToggleAll?.(allServerNames); }} > -
-
+ ) : column.name} )} diff --git a/app/pages/dashboard/index.tsx b/app/pages/dashboard/index.tsx index dc69255..91b64cf 100644 --- a/app/pages/dashboard/index.tsx +++ b/app/pages/dashboard/index.tsx @@ -721,8 +721,9 @@ export default function Dashboard() { }); const latestTimeoutMs = 5000; + let latestTimeoutId: ReturnType | null = null; const timeoutPromise = new Promise<"timeout">((resolve) => { - setTimeout(() => resolve("timeout"), latestTimeoutMs); + latestTimeoutId = setTimeout(() => resolve("timeout"), latestTimeoutMs); }); const chartPromise = !dateOverriddenRef.current @@ -730,6 +731,9 @@ export default function Dashboard() { : Promise.resolve(); const latestResult = await Promise.race([latestPromise.then(() => "ok" as const), timeoutPromise]); + if (latestTimeoutId) { + clearTimeout(latestTimeoutId); + } if (latestResult === "timeout") { setIsTableLoading(false); setIsTableSlow(true); diff --git a/backend/src/jobs/PingServerJob.ts b/backend/src/jobs/PingServerJob.ts index a26597b..fb6ec1a 100644 --- a/backend/src/jobs/PingServerJob.ts +++ b/backend/src/jobs/PingServerJob.ts @@ -43,18 +43,83 @@ async function pingAll() { } catch (e) { } } + const now = Date.now(); await new Pings({ - timestamp: Date.now(), + timestamp: now, data: data }).save(); - const now = Date.now(); 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 peakBackfills = new Map(); + await Promise.all(peakBackfillIds.map(async (serverId) => { + peakBackfills.set(serverId, await fetchRollingPeak(serverId)); + })); + + const recordBackfills = new Map(); + await Promise.all(recordBackfillIds.map(async (serverId) => { + recordBackfills.set(serverId, await fetchRecord(serverId)); + })); const updates = serverIds.map((serverId) => { const count = data[serverId]; @@ -62,21 +127,39 @@ async function pingAll() { let dailyPeak = count; let dailyPeakTimestamp = now; - if (current && current.dayKey === dayKey) { - if (typeof current.dailyPeak === "number" && current.dailyPeak >= count) { + 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) { + 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") { - if (current.record >= count) { - record = current.record; - recordTimestamp = current.recordTimestamp; + 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: { From 74ce68c5b848352ad154eb6f99af5d1cdf5d2b1a Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 21:51:48 +0100 Subject: [PATCH 13/24] change prediction chart to be more smooth --- app/components/charts/PredictionChart.tsx | 22 +++++++++++++++------- app/components/charts/ServerTable.tsx | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/components/charts/PredictionChart.tsx b/app/components/charts/PredictionChart.tsx index decfa7b..563fb9a 100644 --- a/app/components/charts/PredictionChart.tsx +++ b/app/components/charts/PredictionChart.tsx @@ -47,8 +47,9 @@ export function PredictionChart({ const minX = Math.min(...dataPoints.map((point) => point.x)); const maxX = Math.max(...dataPoints.map((point) => point.x)); - const maxYValue = Math.max(...dataPoints.map((point) => point.y), 1); - const stepSize = Math.pow(10, Math.floor(Math.log10(Math.max(1, maxYValue)))); + const stepSize = 1; + const maxYValue = Math.max(...dataPoints.map((point) => point.y), 0); + const yMax = Math.max(1, maxYValue * 1.2); if (chartRef.current) { const chart = chartRef.current; @@ -70,10 +71,12 @@ export function PredictionChart({ xScale.max = maxX; } - if (chart.options?.scales?.y) { - const yScale = chart.options.scales.y as any; - yScale.ticks.stepSize = stepSize; - } + if (chart.options?.scales?.y) { + const yScale = chart.options.scales.y as any; + yScale.min = 0; + yScale.max = yMax; + yScale.ticks.stepSize = stepSize; + } chart.update("none"); return; @@ -118,7 +121,10 @@ export function PredictionChart({ if (!timestamp) return ""; return new Date(timestamp).toLocaleString(); }, - label: (item: any) => `Predicted players: ${item.parsed?.y ?? 0}`, + label: (item: any) => { + const value = Number.isFinite(item.parsed?.y) ? item.parsed.y.toFixed(2) : "0.00"; + return `Predicted players: ${value}`; + }, }, }, zoom: { @@ -140,6 +146,8 @@ export function PredictionChart({ }, scales: { y: { + min: 0, + max: yMax, ticks: { color: theme.axis, beginAtZero: true, diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index 595e103..f0abbf9 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -509,7 +509,7 @@ export function ServerTable({ 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), }); } @@ -527,7 +527,7 @@ export function ServerTable({ detailedPredictions.push({ timestamp, label: new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), - predictedPlayers: Math.max(0, Math.round(interpolated)), + predictedPlayers: Math.max(0, interpolated), }); } } From 13bcee64634c26d540633552fcf74e31eb03d26d Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 21:54:58 +0100 Subject: [PATCH 14/24] fix y axis problems --- app/components/charts/PredictionChart.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/components/charts/PredictionChart.tsx b/app/components/charts/PredictionChart.tsx index 563fb9a..8c8abcc 100644 --- a/app/components/charts/PredictionChart.tsx +++ b/app/components/charts/PredictionChart.tsx @@ -49,7 +49,7 @@ export function PredictionChart({ const stepSize = 1; const maxYValue = Math.max(...dataPoints.map((point) => point.y), 0); - const yMax = Math.max(1, maxYValue * 1.2); + const yMax = Math.max(1, Math.ceil(maxYValue * 1.2)); if (chartRef.current) { const chart = chartRef.current; @@ -76,6 +76,7 @@ export function PredictionChart({ yScale.min = 0; yScale.max = yMax; yScale.ticks.stepSize = stepSize; + yScale.ticks.callback = (value: any) => Math.ceil(Number(value)); } chart.update("none"); @@ -152,7 +153,10 @@ export function PredictionChart({ color: theme.axis, beginAtZero: true, stepSize, - callback: (value: any) => (value < 0 ? 0 : value), + callback: (value: any) => { + const rounded = Math.ceil(Number(value)); + return rounded < 0 ? 0 : rounded; + }, }, grid: { borderDash: [3], From c8d40c49295427bccbb58d455b7430746d331b04 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 21:59:55 +0100 Subject: [PATCH 15/24] fix chart coloring --- app/components/charts/ServerTable.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index f0abbf9..aa4854c 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -155,6 +155,7 @@ export function ServerTable({ }; }, [isDarkMode]); const predictionLineColor = "#9f74ca"; + const predictionBackground = "var(--heroui-content1)"; const tableColumns = React.useMemo(() => { const columns = [...baseColumns]; @@ -764,8 +765,8 @@ export function ServerTable({ ) : null} {!isPredicting && !predictionError && predictionSeries.length > 0 ? (
Date: Thu, 12 Feb 2026 22:13:16 +0100 Subject: [PATCH 16/24] comment fixes --- app/components/charts/ServerTable.tsx | 35 +++++++++++--- app/pages/dashboard/index.tsx | 70 +++++++++++++++------------ backend/src/jobs/PingServerJob.ts | 2 +- 3 files changed, 67 insertions(+), 40 deletions(-) diff --git a/app/components/charts/ServerTable.tsx b/app/components/charts/ServerTable.tsx index aa4854c..9f1388e 100644 --- a/app/components/charts/ServerTable.tsx +++ b/app/components/charts/ServerTable.tsx @@ -155,7 +155,7 @@ export function ServerTable({ }; }, [isDarkMode]); const predictionLineColor = "#9f74ca"; - const predictionBackground = "var(--heroui-content1)"; + const predictionBackground = "color-mix(in rgb, var(--heroui-content1) 82%, #000000 18%)"; const tableColumns = React.useMemo(() => { const columns = [...baseColumns]; @@ -387,15 +387,33 @@ export function ServerTable({ ) .sort((a: { timestamp: number; }, b: { timestamp: number; }) => a.timestamp - b.timestamp); - const minuteBuckets = new Map(); + const minuteBuckets = new Map(); for (const point of rawPoints) { const minuteKey = Math.floor(point.timestamp / 60000); - minuteBuckets.set(minuteKey, point); + 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()).sort( - (a, b) => a.timestamp - b.timestamp - ); + const points = Array.from(minuteBuckets.values()) + .map(({ timestamp, count }) => ({ timestamp, count })) + .sort((a, b) => a.timestamp - b.timestamp); const minimumCoverageMs = 24 * 60 * 60 * 1000; @@ -766,7 +784,10 @@ export function ServerTable({ {!isPredicting && !predictionError && predictionSeries.length > 0 ? (
{ + const getClampedRange = React.useCallback((rangeFrom: number, rangeTo: number) => { const nowTs = Date.now(); const clampedTo = Math.min(rangeTo, nowTs); const clampedFrom = Math.min(rangeFrom, clampedTo - 1); @@ -121,7 +121,7 @@ export default function Dashboard() { from: clampedFrom, to: clampedTo, }; - }; + }, []); const chartRef = useRef(null); const fromDateRef = useRef(fromDate); @@ -235,7 +235,31 @@ export default function Dashboard() { const maxSelectableDateTime = formatDateTimeLocal(now); const fromInputMax = customToInput && customToInput < maxSelectableDateTime ? customToInput : maxSelectableDateTime; - const fetchChartRange = async ( + const requestBackend = React.useCallback(async (activeUrl: string, activeToken: string, path: string, init?: RequestInit) => { + try { + const response = await fetch(activeUrl + path, { + ...init, + headers: { + "authorization": "Bearer " + activeToken, + ...(init?.headers || {}), + } + }); + setBackendReachable(true); + setBackendError(""); + if (response.status === 401 || response.status === 403) { + setAuthError("Authentication failed for this server. Please re-authenticate."); + } else if (response.ok) { + setAuthError(""); + } + return response; + } catch (error) { + setBackendReachable(false); + setBackendError(error instanceof Error ? error.message : "Unable to reach backend."); + throw error; + } + }, []); + + const fetchChartRange = React.useCallback(async ( baseUrl: string, sessionToken: string, rangeFrom: number, @@ -257,7 +281,7 @@ export default function Dashboard() { setData((prev: any) => ({ type: prev.type, from: clamped.from, to: clamped.to, ...dat })); hasLoadedChartRef.current = true; setIsChartLoading(false); - }; + }, [getClampedRange, requestBackend]); const handleRangeShift = async (direction: "prev" | "next") => { if (!url || !token) return; @@ -364,30 +388,6 @@ export default function Dashboard() { await fetchChartRange(url, token, parsedFrom, parsedTo, { showLoading: true }); }; - const requestBackend = async (activeUrl: string, activeToken: string, path: string, init?: RequestInit) => { - try { - const response = await fetch(activeUrl + path, { - ...init, - headers: { - "authorization": "Bearer " + activeToken, - ...(init?.headers || {}), - } - }); - setBackendReachable(true); - setBackendError(""); - if (response.status === 401 || response.status === 403) { - setAuthError("Authentication failed for this server. Please re-authenticate."); - } else if (response.ok) { - setAuthError(""); - } - return response; - } catch (error) { - setBackendReachable(false); - setBackendError(error instanceof Error ? error.message : "Unable to reach backend."); - throw error; - } - }; - const loadCurrentUser = async (activeUrl: string, activeToken: string) => { const response = await requestBackend(activeUrl, activeToken, "/api/usersmanage/me", { method: "GET", @@ -653,7 +653,7 @@ export default function Dashboard() { } }; - async function reloadData() { + const reloadData = React.useCallback(async () => { const config = serverConfig; if (!config?.token || !config?.url) return; const tok = config.token as any; @@ -684,7 +684,13 @@ export default function Dashboard() { 'Content-Type': 'application/json', } }).then(async (response) => { - if (response.status === 401 || response.status === 403) return; + if (response.status === 401 || response.status === 403) { + hasLoadedTableRef.current = true; + setIsTableLoading(false); + setIsTableCached(true); + setIsTableSlow(false); + return; + } const dat = await response.json(); setTableData((prevTableData) => { const tableDataMap = prevTableData && prevTableData.length > 0 ? new Map(prevTableData.map((item) => [item.internalId, item])) : null; @@ -749,7 +755,7 @@ export default function Dashboard() { } await Promise.allSettled([latestPromise, chartPromise]); - } + }, [serverConfig, router.query.server, buildPlaceholderRows, serverDetails, requestBackend, fetchChartRange]); useEffect(() => { const intervalId = setInterval(async () => { @@ -763,7 +769,7 @@ export default function Dashboard() { }); return () => clearInterval(intervalId); - }, [router.query, router, serverConfig]); + }, [router.query, router, serverConfig, reloadData]); useEffect(() => { router.beforePopState(() => { diff --git a/backend/src/jobs/PingServerJob.ts b/backend/src/jobs/PingServerJob.ts index fb6ec1a..6d84b4f 100644 --- a/backend/src/jobs/PingServerJob.ts +++ b/backend/src/jobs/PingServerJob.ts @@ -134,7 +134,7 @@ async function pingAll() { } } const peakBackfill = peakBackfills.get(serverId); - if (peakBackfill?.peak != null && peakBackfill.timestamp != null) { + if (peakBackfill?.peak != null && peakBackfill.timestamp != null && peakBackfill.peak > dailyPeak) { dailyPeak = peakBackfill.peak; dailyPeakTimestamp = peakBackfill.timestamp; } From f41ef23d0524b0c3f45586b56b36f102866746e8 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 22:27:49 +0100 Subject: [PATCH 17/24] Add experimental Android snapshot workflow --- .github/workflows/android-snapshot.yml | 146 +++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 .github/workflows/android-snapshot.yml diff --git a/.github/workflows/android-snapshot.yml b/.github/workflows/android-snapshot.yml new file mode 100644 index 0000000..fabfb4f --- /dev/null +++ b/.github/workflows/android-snapshot.yml @@ -0,0 +1,146 @@ +name: Android Snapshot + +on: + push: + branches: ["main"] + pull_request: + 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: "17" + + - 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 From b4ec1dd48e2b6aa23146a3269a639f851a605655 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 22:33:14 +0100 Subject: [PATCH 18/24] Limit snapshot workflow triggers and use Java 21 --- .github/workflows/android-snapshot.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/android-snapshot.yml b/.github/workflows/android-snapshot.yml index fabfb4f..497fd34 100644 --- a/.github/workflows/android-snapshot.yml +++ b/.github/workflows/android-snapshot.yml @@ -3,8 +3,6 @@ name: Android Snapshot on: push: branches: ["main"] - pull_request: - branches: ["main"] workflow_dispatch: permissions: @@ -36,7 +34,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: "temurin" - java-version: "17" + java-version: "21" - name: Setup Android SDK uses: android-actions/setup-android@v3 From 2e61f0d489b81f64e070584bab30bce8d908d24a Mon Sep 17 00:00:00 2001 From: PleaseInsertNameHere <73995049+PleaseInsertNameHere@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:35:05 +0100 Subject: [PATCH 19/24] add cursor gradient, prevent right click and selection --- app/components/CursorGlow.tsx | 103 ++++++++++++++++++++++++++++++++++ app/pages/_app.tsx | 13 +++++ app/styles/globals.css | 84 +++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 app/components/CursorGlow.tsx 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/pages/_app.tsx b/app/pages/_app.tsx index d78802f..1e7735b 100644 --- a/app/pages/_app.tsx +++ b/app/pages/_app.tsx @@ -1,12 +1,25 @@ import "@/styles/globals.css"; import type { AppProps } from "next/app"; import * as React from "react"; +import { useEffect } from "react"; import { HeroUIProvider } from "@heroui/system"; +import { CursorGlow } from "@/components/CursorGlow"; export default function App({ Component, pageProps }: AppProps) { + useEffect(() => { + const block = (e: Event) => e.preventDefault(); + document.addEventListener("contextmenu", block); + document.addEventListener("dragstart", block); + return () => { + document.removeEventListener("contextmenu", block); + document.removeEventListener("dragstart", block); + }; + }, []); + return (
+
diff --git a/app/styles/globals.css b/app/styles/globals.css index a7fd579..4b00247 100644 --- a/app/styles/globals.css +++ b/app/styles/globals.css @@ -24,6 +24,11 @@ body { 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 { @@ -35,6 +40,85 @@ body { 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.5s ease; +} + +.cursor-glow::before { + content: ""; + position: absolute; + width: 550px; + height: 550px; + top: -275px; + left: -275px; + 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 { width: min(100%, 680px); max-width: 680px; From 0a71e0e0615b428a93018149abee75274c00df76 Mon Sep 17 00:00:00 2001 From: PleaseInsertNameHere <73995049+PleaseInsertNameHere@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:40:34 +0100 Subject: [PATCH 20/24] make cursor gradient smaller and faster --- app/styles/globals.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/styles/globals.css b/app/styles/globals.css index 4b00247..2943cb2 100644 --- a/app/styles/globals.css +++ b/app/styles/globals.css @@ -50,16 +50,16 @@ body { pointer-events: none; z-index: 0; will-change: transform, opacity; - transition: opacity 0.5s ease; + transition: opacity 0.25s ease; } .cursor-glow::before { content: ""; position: absolute; - width: 550px; - height: 550px; - top: -275px; - left: -275px; + width: 256px; + height: 256px; + top: -128px; + left: -128px; border-radius: 50%; background: radial-gradient( circle, From c4dbfc7764e4505dd7f25430aa35f833e3d7e1d3 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 22:40:41 +0100 Subject: [PATCH 21/24] Add nightly dev Android snapshot release --- .github/workflows/android-snapshot.yml | 37 ++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/android-snapshot.yml b/.github/workflows/android-snapshot.yml index 497fd34..702bbd8 100644 --- a/.github/workflows/android-snapshot.yml +++ b/.github/workflows/android-snapshot.yml @@ -3,6 +3,8 @@ name: Android Snapshot on: push: branches: ["main"] + schedule: + - cron: "0 2 * * *" workflow_dispatch: permissions: @@ -16,8 +18,19 @@ jobs: working-directory: app steps: + - name: Resolve target branch + id: branch + run: | + if [ "${{ github.event_name }}" = "schedule" ]; then + echo "ref=dev" >> "$GITHUB_OUTPUT" + else + echo "ref=main" >> "$GITHUB_OUTPUT" + fi + - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ steps.branch.outputs.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -66,10 +79,24 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: + - name: Resolve target branch + id: branch + run: | + if [ "${{ github.event_name }}" = "schedule" ]; then + echo "ref=dev" >> "$GITHUB_OUTPUT" + echo "tag=latest-snapshot-dev" >> "$GITHUB_OUTPUT" + echo "release_name=Latest Snapshot (dev)" >> "$GITHUB_OUTPUT" + else + echo "ref=main" >> "$GITHUB_OUTPUT" + echo "tag=latest-snapshot" >> "$GITHUB_OUTPUT" + echo "release_name=Latest Snapshot" >> "$GITHUB_OUTPUT" + fi + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ steps.branch.outputs.ref }} - name: Download APK artifact uses: actions/download-artifact@v4 @@ -78,15 +105,15 @@ jobs: - name: Update latest snapshot tag run: | - git tag -f latest-snapshot "${GITHUB_SHA}" - git push -f origin latest-snapshot + git tag -f "${{ steps.branch.outputs.tag }}" "${GITHUB_SHA}" + git push -f origin "${{ steps.branch.outputs.tag }}" - name: Prepare release id: release uses: actions/github-script@v7 with: script: | - const tag = "latest-snapshot"; + const tag = "${{ steps.branch.outputs.tag }}"; const notes = `Snapshot build from ${context.sha.substring(0, 7)} (${new Date().toISOString().slice(0, 10)})`; let release; try { @@ -104,7 +131,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, tag_name: tag, - name: "Latest Snapshot", + name: "${{ steps.branch.outputs.release_name }}", body: notes, prerelease: true }); @@ -125,7 +152,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, release_id: release.data.id, - name: "Latest Snapshot", + name: "${{ steps.branch.outputs.release_name }}", body: notes, prerelease: true }); From 9ba35cb931b65c3b2517f51dabd76c7d08a17551 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 22:42:10 +0100 Subject: [PATCH 22/24] Split dev nightly Android snapshot workflow --- .github/workflows/android-snapshot-dev.yml | 146 +++++++++++++++++++++ .github/workflows/android-snapshot.yml | 37 +----- 2 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/android-snapshot-dev.yml diff --git a/.github/workflows/android-snapshot-dev.yml b/.github/workflows/android-snapshot-dev.yml new file mode 100644 index 0000000..968ebc1 --- /dev/null +++ b/.github/workflows/android-snapshot-dev.yml @@ -0,0 +1,146 @@ +name: Android Snapshot (dev) + +on: + schedule: + - cron: "0 2 * * *" + 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 index 702bbd8..497fd34 100644 --- a/.github/workflows/android-snapshot.yml +++ b/.github/workflows/android-snapshot.yml @@ -3,8 +3,6 @@ name: Android Snapshot on: push: branches: ["main"] - schedule: - - cron: "0 2 * * *" workflow_dispatch: permissions: @@ -18,19 +16,8 @@ jobs: working-directory: app steps: - - name: Resolve target branch - id: branch - run: | - if [ "${{ github.event_name }}" = "schedule" ]; then - echo "ref=dev" >> "$GITHUB_OUTPUT" - else - echo "ref=main" >> "$GITHUB_OUTPUT" - fi - - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ steps.branch.outputs.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -79,24 +66,10 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - - name: Resolve target branch - id: branch - run: | - if [ "${{ github.event_name }}" = "schedule" ]; then - echo "ref=dev" >> "$GITHUB_OUTPUT" - echo "tag=latest-snapshot-dev" >> "$GITHUB_OUTPUT" - echo "release_name=Latest Snapshot (dev)" >> "$GITHUB_OUTPUT" - else - echo "ref=main" >> "$GITHUB_OUTPUT" - echo "tag=latest-snapshot" >> "$GITHUB_OUTPUT" - echo "release_name=Latest Snapshot" >> "$GITHUB_OUTPUT" - fi - - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ steps.branch.outputs.ref }} - name: Download APK artifact uses: actions/download-artifact@v4 @@ -105,15 +78,15 @@ jobs: - name: Update latest snapshot tag run: | - git tag -f "${{ steps.branch.outputs.tag }}" "${GITHUB_SHA}" - git push -f origin "${{ steps.branch.outputs.tag }}" + 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 = "${{ steps.branch.outputs.tag }}"; + const tag = "latest-snapshot"; const notes = `Snapshot build from ${context.sha.substring(0, 7)} (${new Date().toISOString().slice(0, 10)})`; let release; try { @@ -131,7 +104,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, tag_name: tag, - name: "${{ steps.branch.outputs.release_name }}", + name: "Latest Snapshot", body: notes, prerelease: true }); @@ -152,7 +125,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, release_id: release.data.id, - name: "${{ steps.branch.outputs.release_name }}", + name: "Latest Snapshot", body: notes, prerelease: true }); From b545be0570d12d3902d7fa31fa685deb89ecaf86 Mon Sep 17 00:00:00 2001 From: tunikakeks Date: Thu, 12 Feb 2026 22:43:00 +0100 Subject: [PATCH 23/24] Run dev snapshot workflow on dev pushes only --- .github/workflows/android-snapshot-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-snapshot-dev.yml b/.github/workflows/android-snapshot-dev.yml index 968ebc1..4688888 100644 --- a/.github/workflows/android-snapshot-dev.yml +++ b/.github/workflows/android-snapshot-dev.yml @@ -1,8 +1,8 @@ name: Android Snapshot (dev) on: - schedule: - - cron: "0 2 * * *" + push: + branches: ["dev"] workflow_dispatch: permissions: From a7b6f5b1d576d201e69ccdc6c55cc0ee3ff5bd66 Mon Sep 17 00:00:00 2001 From: tunikakeks <54219265+tunikakeks@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:54:50 +0100 Subject: [PATCH 24/24] Update backend/src/jobs/PingServerJob.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/src/jobs/PingServerJob.ts | 48 +++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/backend/src/jobs/PingServerJob.ts b/backend/src/jobs/PingServerJob.ts index 6d84b4f..266d4f1 100644 --- a/backend/src/jobs/PingServerJob.ts +++ b/backend/src/jobs/PingServerJob.ts @@ -111,16 +111,48 @@ async function pingAll() { return !current || typeof current.record !== "number" || typeof current.recordTimestamp !== "number"; }); - const peakBackfills = new Map(); - await Promise.all(peakBackfillIds.map(async (serverId) => { - peakBackfills.set(serverId, await fetchRollingPeak(serverId)); - })); + 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()); + } - const recordBackfills = new Map(); - await Promise.all(recordBackfillIds.map(async (serverId) => { - recordBackfills.set(serverId, await fetchRecord(serverId)); - })); + 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);