fetch statistics from the server
This commit is contained in:
@@ -9,7 +9,8 @@ import {
|
|||||||
type EventConfig,
|
type EventConfig,
|
||||||
type EventRules,
|
type EventRules,
|
||||||
type ServerSettings,
|
type ServerSettings,
|
||||||
type StateHistory
|
type StateHistory,
|
||||||
|
type StateHistoryStats
|
||||||
} from '$models/config';
|
} from '$models/config';
|
||||||
import type { Server } from '$models/server';
|
import type { Server } from '$models/server';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
@@ -49,6 +50,18 @@ export const getStateHistory = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getStateHistoryStats = async (
|
||||||
|
event: RequestEvent,
|
||||||
|
serverId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
): Promise<StateHistoryStats> => {
|
||||||
|
return fetchAPIEvent(
|
||||||
|
event,
|
||||||
|
`/server/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getEventFile = async (event: RequestEvent, serverId: string): Promise<EventConfig> => {
|
export const getEventFile = async (event: RequestEvent, serverId: string): Promise<EventConfig> => {
|
||||||
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.event}`);
|
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.event}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
import { compareAsc, isSameDay } from 'date-fns';
|
import { compareAsc, isSameDay } from 'date-fns';
|
||||||
import { formatInTimeZone } from 'date-fns-tz';
|
import { formatInTimeZone } from 'date-fns-tz';
|
||||||
import 'chartjs-adapter-date-fns';
|
import 'chartjs-adapter-date-fns';
|
||||||
import type { StateHistory } from '$models/config';
|
import type { StateHistoryStats } from '$models/config';
|
||||||
import { flatMap } from 'lodash-es';
|
import { flatMap } from 'lodash-es';
|
||||||
|
|
||||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
interface Props {
|
interface Props {
|
||||||
// Props
|
// Props
|
||||||
stateHistory?: Array<StateHistory>;
|
statistics: StateHistoryStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { stateHistory = [] }: Props = $props();
|
let { statistics }: Props = $props();
|
||||||
|
|
||||||
// Chart instances
|
// Chart instances
|
||||||
let playerCountChart: Chart | null = null;
|
let playerCountChart: Chart | null = null;
|
||||||
@@ -25,21 +25,8 @@
|
|||||||
let sessionTypeCanvas: HTMLCanvasElement | undefined = $state();
|
let sessionTypeCanvas: HTMLCanvasElement | undefined = $state();
|
||||||
let dailyActivityCanvas: HTMLCanvasElement | undefined = $state();
|
let dailyActivityCanvas: HTMLCanvasElement | undefined = $state();
|
||||||
|
|
||||||
let totalSessions = $state(0);
|
|
||||||
let averagePlayerCount = $state(0);
|
|
||||||
let peakPlayerCount = $state(0);
|
|
||||||
let totalPlaytime = $state(0);
|
|
||||||
let dailyActivityData = $state<{
|
|
||||||
labels: string[];
|
|
||||||
data: {
|
|
||||||
count: number;
|
|
||||||
sessions: StateHistory[];
|
|
||||||
}[];
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Initialize date range (last 30 days by default)
|
// Initialize date range (last 30 days by default)
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
processData();
|
|
||||||
createCharts();
|
createCharts();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,43 +37,19 @@
|
|||||||
if (dailyActivityChart) dailyActivityChart.destroy();
|
if (dailyActivityChart) dailyActivityChart.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
function processData() {
|
|
||||||
calculateSummaryStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateSummaryStats() {
|
|
||||||
totalSessions = stateHistory.length;
|
|
||||||
|
|
||||||
if (stateHistory.length > 0) {
|
|
||||||
const playerCounts = stateHistory.map((item) => item.playerCount);
|
|
||||||
averagePlayerCount = Math.round(
|
|
||||||
playerCounts.reduce((a, b) => a + b, 0) / playerCounts.length
|
|
||||||
);
|
|
||||||
peakPlayerCount = Math.max(...playerCounts);
|
|
||||||
totalPlaytime = stateHistory.reduce(
|
|
||||||
(total, session) => total + session.sessionDurationMinutes,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
averagePlayerCount = 0;
|
|
||||||
peakPlayerCount = 0;
|
|
||||||
totalPlaytime = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCharts() {
|
function createCharts() {
|
||||||
if (!playerCountCanvas || !sessionTypeCanvas || !dailyActivityCanvas) return;
|
if (!statistics || !playerCountCanvas || !sessionTypeCanvas || !dailyActivityCanvas) return;
|
||||||
|
|
||||||
// Player Count Over Time Chart
|
|
||||||
const playerCountData = preparePlayerCountData();
|
|
||||||
playerCountChart = new Chart(playerCountCanvas, {
|
playerCountChart = new Chart(playerCountCanvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: playerCountData.map(({ x }) => x),
|
labels: statistics.playerCountOverTime.map(({ timestamp }) =>
|
||||||
|
formatDate(timestamp, 'MMM dd kk:mm')
|
||||||
|
),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Player Count',
|
label: 'Player Count',
|
||||||
data: playerCountData.map(({ y }) => y),
|
data: statistics.playerCountOverTime.map(({ count }) => count),
|
||||||
borderColor: '#10b981',
|
borderColor: '#10b981',
|
||||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||||
fill: true,
|
fill: true,
|
||||||
@@ -107,15 +70,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session Types Pie Chart
|
|
||||||
const sessionTypeData = prepareSessionTypeData();
|
|
||||||
sessionTypeChart = new Chart(sessionTypeCanvas, {
|
sessionTypeChart = new Chart(sessionTypeCanvas, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: sessionTypeData.labels,
|
labels: statistics.sessionTypes.map(({ name }) => name),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: sessionTypeData.data,
|
data: statistics.sessionTypes.map(({ count }) => count),
|
||||||
backgroundColor: ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
|
backgroundColor: ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -135,16 +96,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Daily Activity Bar Chart
|
|
||||||
dailyActivityData = prepareDailyActivityData();
|
|
||||||
dailyActivityChart = new Chart(dailyActivityCanvas, {
|
dailyActivityChart = new Chart(dailyActivityCanvas, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: dailyActivityData.labels,
|
labels: statistics.dailyActivity.map(({ date }) => date),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Sessions',
|
label: 'Sessions',
|
||||||
data: dailyActivityData.data.map(({ count }) => count),
|
data: statistics.dailyActivity.map(({ sessionsCount }) => sessionsCount),
|
||||||
backgroundColor: '#10b981',
|
backgroundColor: '#10b981',
|
||||||
borderColor: '#059669',
|
borderColor: '#059669',
|
||||||
borderWidth: 1
|
borderWidth: 1
|
||||||
@@ -184,93 +143,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function preparePlayerCountData() {
|
|
||||||
// Group by date and get average player count per day
|
|
||||||
const dailyData = stateHistory.reduce(
|
|
||||||
(acc, item) => {
|
|
||||||
const date = formatDate(item.dateCreated, 'MMM dd kk:mm');
|
|
||||||
if (!acc[date]) {
|
|
||||||
acc[date] = { total: 0, count: 0 };
|
|
||||||
}
|
|
||||||
acc[date].total = acc[date].total > item.playerCount ? acc[date].total : item.playerCount;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, { total: number; count: number }>
|
|
||||||
);
|
|
||||||
|
|
||||||
return Object.entries(dailyData)
|
|
||||||
.map(([date, data]) => ({
|
|
||||||
x: date,
|
|
||||||
y: data.total
|
|
||||||
}))
|
|
||||||
.sort((a, b) => compareAsc(a.x, b.x));
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareSessionTypeData() {
|
|
||||||
const sessionCounts = stateHistory.reduce(
|
|
||||||
(acc, session) => {
|
|
||||||
acc[session.session] = (acc[session.session] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: Object.keys(sessionCounts),
|
|
||||||
data: Object.values(sessionCounts)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareDailyActivityData() {
|
|
||||||
const dailyActivity = stateHistory.reduce(
|
|
||||||
(acc, session, index) => {
|
|
||||||
const date = formatDate(session.dateCreated, 'yyyy-MM-dd');
|
|
||||||
|
|
||||||
// Initialize counter for this date if not exists
|
|
||||||
if (!acc[date]) {
|
|
||||||
acc[date] = {
|
|
||||||
count: 0,
|
|
||||||
sessions: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this session is part of a sequence
|
|
||||||
if (index > 0) {
|
|
||||||
const prevSession = stateHistory[index - 1];
|
|
||||||
|
|
||||||
if (
|
|
||||||
isSameDay(session.dateCreated, prevSession.dateCreated) &&
|
|
||||||
prevSession.session === session.session
|
|
||||||
) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment counter for non-sequential sessions
|
|
||||||
acc[date].count++;
|
|
||||||
acc[date].sessions.push(session);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
count: number;
|
|
||||||
sessions: StateHistory[];
|
|
||||||
}
|
|
||||||
>
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortedEntries = Object.entries(dailyActivity).sort(
|
|
||||||
([a], [b]) => new Date(a).getTime() - new Date(b).getTime()
|
|
||||||
);
|
|
||||||
console.log(sortedEntries);
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: sortedEntries.map(([date]) => formatDate(date, 'MMM dd')),
|
|
||||||
data: sortedEntries.map(([, count]) => count)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string, formatString: string) {
|
function formatDate(dateString: string, formatString: string) {
|
||||||
return formatInTimeZone(dateString, localTimeZone, formatString, {
|
return formatInTimeZone(dateString, localTimeZone, formatString, {
|
||||||
timeZone: 'utc'
|
timeZone: 'utc'
|
||||||
@@ -306,7 +178,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-400">Average Players</p>
|
<p class="text-sm font-medium text-gray-400">Average Players</p>
|
||||||
<p class="text-2xl font-bold text-white">{averagePlayerCount}</p>
|
<p class="text-2xl font-bold text-white">{Math.round(statistics.averagePlayers)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,7 +203,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-400">Peak Players</p>
|
<p class="text-sm font-medium text-gray-400">Peak Players</p>
|
||||||
<p class="text-2xl font-bold text-white">{peakPlayerCount}</p>
|
<p class="text-2xl font-bold text-white">{statistics.peakPlayers}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-400">Total Sessions</p>
|
<p class="text-sm font-medium text-gray-400">Total Sessions</p>
|
||||||
<p class="text-2xl font-bold text-white">{totalSessions}</p>
|
<p class="text-2xl font-bold text-white">{statistics.totalSessions}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +253,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-400">Total Playtime</p>
|
<p class="text-sm font-medium text-gray-400">Total Playtime</p>
|
||||||
<p class="text-2xl font-bold text-white">{formatDuration(totalPlaytime)}</p>
|
<p class="text-2xl font-bold text-white">{formatDuration(statistics.totalPlaytime)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,32 +311,32 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||||
{#each flatMap(dailyActivityData?.data, ({ sessions }) => sessions) as session}
|
{#each statistics.recentSessions as session}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||||
{formatDate(session.dateCreated, 'MMM dd kk:mm')}
|
{formatDate(session.date, 'MMM dd kk:mm')}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
class={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
||||||
session.session === 'Race'
|
session.type === 'Race'
|
||||||
? 'bg-red-100 text-red-800'
|
? 'bg-red-100 text-red-800'
|
||||||
: session.session === 'Qualifying'
|
: session.type === 'Qualifying'
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
: 'bg-green-100 text-green-800'
|
: 'bg-green-100 text-green-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{session.session}
|
{session.type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||||
{session.track}
|
{session.track}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||||
{formatDuration(session.sessionDurationMinutes)}
|
{formatDuration(session.duration)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||||
{session.playerCount}
|
{session.players}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export enum configFile {
|
|||||||
}
|
}
|
||||||
export enum serverTab {
|
export enum serverTab {
|
||||||
statistics = 'statistics',
|
statistics = 'statistics',
|
||||||
|
statistics2 = 'statistics2',
|
||||||
configuration = 'configuration',
|
configuration = 'configuration',
|
||||||
assistRules = 'assistRules',
|
assistRules = 'assistRules',
|
||||||
event = 'event',
|
event = 'event',
|
||||||
@@ -31,6 +32,41 @@ export interface StateHistory {
|
|||||||
session: string;
|
session: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionCount {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyActivity {
|
||||||
|
date: string; // ISO 8601 date string
|
||||||
|
sessionsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerCountPoint {
|
||||||
|
timestamp: string; // ISO 8601 datetime string
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentSession {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
type: string;
|
||||||
|
track: string;
|
||||||
|
duration: number;
|
||||||
|
players: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateHistoryStats {
|
||||||
|
averagePlayers: number;
|
||||||
|
peakPlayers: number;
|
||||||
|
totalSessions: number;
|
||||||
|
totalPlaytime: number; // in minutes
|
||||||
|
playerCountOverTime: PlayerCountPoint[];
|
||||||
|
sessionTypes: SessionCount[];
|
||||||
|
dailyActivity: DailyActivity[];
|
||||||
|
recentSessions: RecentSession[];
|
||||||
|
}
|
||||||
|
|
||||||
export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings;
|
export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings;
|
||||||
export type ConfigFile =
|
export type ConfigFile =
|
||||||
| configFile.configuration
|
| configFile.configuration
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { updateConfig, getConfigFiles, getServerById, getStateHistory } from '$api/serverService';
|
import {
|
||||||
|
updateConfig,
|
||||||
|
getConfigFiles,
|
||||||
|
getServerById,
|
||||||
|
getStateHistory,
|
||||||
|
getStateHistoryStats
|
||||||
|
} from '$api/serverService';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import { checkAuth } from '$api/authService';
|
import { checkAuth } from '$api/authService';
|
||||||
import { getTracks } from '$api/lookupService';
|
import { getTracks } from '$api/lookupService';
|
||||||
@@ -17,18 +23,18 @@ export const load = async (event: RequestEvent) => {
|
|||||||
const endDate = formatISO(today);
|
const endDate = formatISO(today);
|
||||||
const startDate = formatISO(subDays(today, 30));
|
const startDate = formatISO(subDays(today, 30));
|
||||||
|
|
||||||
const [server, configs, tracks, stateHistory] = await Promise.all([
|
const [server, configs, tracks, statistics] = await Promise.all([
|
||||||
getServerById(event, event.params.id),
|
getServerById(event, event.params.id),
|
||||||
getConfigFiles(event, event.params.id),
|
getConfigFiles(event, event.params.id),
|
||||||
getTracks(event),
|
getTracks(event),
|
||||||
getStateHistory(event, event.params.id, startDate, endDate)
|
getStateHistoryStats(event, event.params.id, startDate, endDate)
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
id: event.params.id,
|
id: event.params.id,
|
||||||
configs,
|
configs,
|
||||||
tracks,
|
tracks,
|
||||||
server,
|
server,
|
||||||
stateHistory
|
statistics
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
const tracks = data.tracks;
|
const tracks = data.tracks;
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const server = data.server;
|
const server = data.server;
|
||||||
const stateHistory = data.stateHistory;
|
const statistics = data.statistics;
|
||||||
let tab = $state(serverTab.statistics);
|
let tab = $state(serverTab.statistics);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
{#if tab === serverTab.statistics}
|
{#if tab === serverTab.statistics}
|
||||||
<Statistics {stateHistory} />
|
<Statistics {statistics} />
|
||||||
{:else if tab === serverTab.event}
|
{:else if tab === serverTab.event}
|
||||||
<EditorEvent config={configs.event} {tracks} {id} />
|
<EditorEvent config={configs.event} {tracks} {id} />
|
||||||
{:else if tab === serverTab.configuration}
|
{:else if tab === serverTab.configuration}
|
||||||
|
|||||||
Reference in New Issue
Block a user