add statistics
This commit is contained in:
105
package-lock.json
generated
105
package-lock.json
generated
@@ -7,6 +7,13 @@
|
||||
"": {
|
||||
"name": "acc-server-manager-web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
@@ -18,6 +25,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.4",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@@ -64,6 +72,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
|
||||
"integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
@@ -798,6 +812,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1662,6 +1682,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
@@ -1693,6 +1730,18 @@
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz",
|
||||
"integrity": "sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
@@ -2057,6 +2106,28 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.4.9",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
|
||||
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2165,6 +2236,25 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-tz": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"date-fns": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
@@ -2602,9 +2692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz",
|
||||
"integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==",
|
||||
"version": "6.4.5",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz",
|
||||
"integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -4326,6 +4416,15 @@
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.4",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@@ -41,5 +42,12 @@
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"uuid": "^11.0.5",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
type Configurations,
|
||||
type EventConfig,
|
||||
type EventRules,
|
||||
type ServerSettings
|
||||
type ServerSettings,
|
||||
type StateHistory
|
||||
} from '$models/config';
|
||||
import type { Server } from '$models/server';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
@@ -36,6 +37,18 @@ export const getConfigFile = async (
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${file}`);
|
||||
};
|
||||
|
||||
export const getStateHistory = async (
|
||||
event: RequestEvent,
|
||||
serverId: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<Array<StateHistory>> => {
|
||||
return fetchAPIEvent(
|
||||
event,
|
||||
`/server/${serverId}/state-history?start_date=${startDate}&end_date=${endDate}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getEventFile = async (event: RequestEvent, serverId: string): Promise<EventConfig> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.event}`);
|
||||
};
|
||||
|
||||
471
src/components/Statistics.svelte
Normal file
471
src/components/Statistics.svelte
Normal file
@@ -0,0 +1,471 @@
|
||||
<script lang="ts">
|
||||
import Chart from 'chart.js/auto';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { compareAsc } from 'date-fns';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import type { StateHistory } from '$models/config';
|
||||
import { flatMap } from 'lodash-es';
|
||||
|
||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
interface Props {
|
||||
// Props
|
||||
stateHistory?: Array<StateHistory>;
|
||||
}
|
||||
|
||||
let { stateHistory = [] }: Props = $props();
|
||||
|
||||
// Chart instances
|
||||
let playerCountChart: Chart | null = null;
|
||||
let sessionTypeChart: Chart | null = null;
|
||||
let dailyActivityChart: Chart | null = null;
|
||||
|
||||
// Chart canvas elements
|
||||
let playerCountCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let sessionTypeCanvas: 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)
|
||||
onMount(() => {
|
||||
processData();
|
||||
createCharts();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Cleanup charts
|
||||
if (playerCountChart) playerCountChart.destroy();
|
||||
if (sessionTypeChart) sessionTypeChart.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() {
|
||||
if (!playerCountCanvas || !sessionTypeCanvas || !dailyActivityCanvas) return;
|
||||
|
||||
// Player Count Over Time Chart
|
||||
const playerCountData = preparePlayerCountData();
|
||||
playerCountChart = new Chart(playerCountCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: playerCountData.map(({ x }) => x),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Player Count',
|
||||
data: playerCountData.map(({ y }) => y),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Session Types Pie Chart
|
||||
const sessionTypeData = prepareSessionTypeData();
|
||||
sessionTypeChart = new Chart(sessionTypeCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sessionTypeData.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: sessionTypeData.data,
|
||||
backgroundColor: ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
padding: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Daily Activity Bar Chart
|
||||
dailyActivityData = prepareDailyActivityData();
|
||||
dailyActivityChart = new Chart(dailyActivityCanvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: dailyActivityData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Sessions',
|
||||
data: dailyActivityData.data.map(({ count }) => count),
|
||||
backgroundColor: '#10b981',
|
||||
borderColor: '#059669',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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: [session]
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this session is part of a sequence
|
||||
if (index > 0) {
|
||||
const prevSession = stateHistory[index - 1];
|
||||
const prevDate = formatDate(prevSession.dateCreated, 'yyyy-MM-dd');
|
||||
|
||||
if (date === prevDate && 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()
|
||||
);
|
||||
|
||||
return {
|
||||
labels: sortedEntries.map(([date]) => formatDate(date, 'MMM dd')),
|
||||
data: sortedEntries.map(([, count]) => count)
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(dateString: string, formatString: string) {
|
||||
return formatInTimeZone(dateString, localTimeZone, formatString, {
|
||||
timeZone: 'utc'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4">
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-green-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Average Players</p>
|
||||
<p class="text-2xl font-bold text-white">{averagePlayerCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-yellow-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Peak Players</p>
|
||||
<p class="text-2xl font-bold text-white">{peakPlayerCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-blue-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Total Sessions</p>
|
||||
<p class="text-2xl font-bold text-white">{totalSessions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-purple-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Total Playtime</p>
|
||||
<p class="text-2xl font-bold text-white">{formatDuration(totalPlaytime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Player Count Over Time -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Player Count Over Time</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={playerCountCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Types Pie Chart -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Session Types</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={sessionTypeCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Activity Bar Chart -->
|
||||
<div class="mb-8 rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Daily Activity</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={dailyActivityCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session History Table -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Recent Sessions</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Date</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Type</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Track</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Duration</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Players</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||
{#each flatMap(dailyActivityData?.data, ({ sessions }) => sessions) as session}
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDate(session.dateCreated, 'MMM dd kk:mm')}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
||||
session.session === 'Race'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: session.session === 'Qualifying'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}
|
||||
>
|
||||
{session.session}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{session.track}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDuration(session.sessionDurationMinutes)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{session.playerCount}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,6 +13,23 @@ export enum configFile {
|
||||
eventRules = 'eventRules.json',
|
||||
settings = 'settings.json'
|
||||
}
|
||||
export enum serverTab {
|
||||
statistics = 'statistics',
|
||||
configuration = 'configuration',
|
||||
assistRules = 'assistRules',
|
||||
event = 'event',
|
||||
eventRules = 'eventRules',
|
||||
settings = 'settings'
|
||||
}
|
||||
|
||||
export interface StateHistory {
|
||||
dateCreated: string;
|
||||
sessionStart: string;
|
||||
playerCount: number;
|
||||
track: string;
|
||||
sessionDurationMinutes: number;
|
||||
session: string;
|
||||
}
|
||||
|
||||
export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings;
|
||||
export type ConfigFile =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { updateConfig, getConfigFiles, getServerById } from '$api/serverService';
|
||||
import { updateConfig, getConfigFiles, getServerById, getStateHistory } from '$api/serverService';
|
||||
import type { Actions } from './$types';
|
||||
import { checkAuth } from '$api/authService';
|
||||
import { getTracks } from '$api/lookupService';
|
||||
@@ -6,21 +6,30 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { configFile, type Config, type Session } from '$models/config';
|
||||
import { set } from 'lodash-es';
|
||||
import { format } from 'date-fns-tz';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
export const load = async (event: RequestEvent) => {
|
||||
const isAuth = await checkAuth(event);
|
||||
if (!isAuth) return redirect(308, '/login');
|
||||
if (!event.params.id) return redirect(308, '/dashboard');
|
||||
const [server, configs, tracks] = await Promise.all([
|
||||
const today = format(new Date(), 'yyyy-MM-ddTHH:mm:ssZ');
|
||||
const thirtyDaysAgo = format(subDays(new Date(), 30), 'yyyy-MM-ddTHH:mm:ssZ');
|
||||
|
||||
const endDate = today;
|
||||
const startDate = thirtyDaysAgo;
|
||||
const [server, configs, tracks, stateHistory] = await Promise.all([
|
||||
getServerById(event, event.params.id),
|
||||
getConfigFiles(event, event.params.id),
|
||||
getTracks(event)
|
||||
getTracks(event),
|
||||
getStateHistory(event, event.params.id, startDate, endDate)
|
||||
]);
|
||||
return {
|
||||
id: event.params.id,
|
||||
configs,
|
||||
tracks,
|
||||
server
|
||||
server,
|
||||
stateHistory
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
import EditorEvent from '$components/EditorEvent.svelte';
|
||||
import EditorEventRules from '$components/EditorEventRules.svelte';
|
||||
import EditorSettings from '$components/EditorSettings.svelte';
|
||||
import Statistics from '$components/Statistics.svelte';
|
||||
import { getStatusColor, serviceStatusToString } from '$lib/types/serviceStatus.js';
|
||||
import { configFile } from '$models/config.js';
|
||||
import { serverTab } from '$models/config.js';
|
||||
|
||||
let { data } = $props();
|
||||
const configs = data.configs;
|
||||
const tracks = data.tracks;
|
||||
const id = data.id;
|
||||
const server = data.server;
|
||||
let tab = $state(configFile.event);
|
||||
const stateHistory = data.stateHistory;
|
||||
let tab = $state(serverTab.statistics);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -37,27 +39,50 @@
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<button
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === configFile.event ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === configFile.event || !configs.event}
|
||||
onclick={() => (tab = configFile.event)}
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.event ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === serverTab.event || !configs.event}
|
||||
onclick={() => (tab = serverTab.event)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
Statistics
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.event ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === serverTab.event || !configs.event}
|
||||
onclick={() => (tab = serverTab.event)}
|
||||
>
|
||||
Event
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === configFile.configuration ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === configFile.configuration || !configs.configuration}
|
||||
onclick={() => (tab = configFile.configuration)}
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.configuration ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === serverTab.configuration || !configs.configuration}
|
||||
onclick={() => (tab = serverTab.configuration)}
|
||||
>
|
||||
Configuration
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === configFile.settings ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === configFile.settings || !configs.settings}
|
||||
onclick={() => (tab = configFile.settings)}
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.settings ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === serverTab.settings || !configs.settings}
|
||||
onclick={() => (tab = serverTab.settings)}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
@@ -65,9 +90,9 @@
|
||||
{#if configs.assistRules}
|
||||
<li>
|
||||
<button
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === configFile.assistRules ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === configFile.assistRules || !configs.assistRules}
|
||||
onclick={() => (tab = configFile.assistRules)}
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.assistRules ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === serverTab.assistRules || !configs.assistRules}
|
||||
onclick={() => (tab = serverTab.assistRules)}
|
||||
>
|
||||
Assist Rules
|
||||
</button>
|
||||
@@ -76,9 +101,9 @@
|
||||
{#if configs.eventRules}
|
||||
<li>
|
||||
<button
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === configFile.eventRules ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === configFile.eventRules || !configs.eventRules}
|
||||
onclick={() => (tab = configFile.eventRules)}
|
||||
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.eventRules ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
|
||||
disabled={tab === serverTab.eventRules || !configs.eventRules}
|
||||
onclick={() => (tab = serverTab.eventRules)}
|
||||
>
|
||||
Event Rules
|
||||
</button>
|
||||
@@ -91,15 +116,17 @@
|
||||
<main class="flex-1 overflow-auto">
|
||||
<header class="flex items-center justify-between bg-gray-800 p-4 shadow-md">
|
||||
<h1 class="text-xl font-semibold">
|
||||
{#if tab === configFile.event}
|
||||
{#if tab === serverTab.statistics}
|
||||
Statistics
|
||||
{:else if tab === serverTab.event}
|
||||
Event
|
||||
{:else if tab === configFile.configuration}
|
||||
{:else if tab === serverTab.configuration}
|
||||
Configuration
|
||||
{:else if tab === configFile.settings}
|
||||
{:else if tab === serverTab.settings}
|
||||
Settings
|
||||
{:else if tab === configFile.assistRules}
|
||||
{:else if tab === serverTab.assistRules}
|
||||
Assist Rules
|
||||
{:else if tab === configFile.eventRules}
|
||||
{:else if tab === serverTab.eventRules}
|
||||
Event Rules
|
||||
{/if}
|
||||
</h1>
|
||||
@@ -147,15 +174,17 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-6">
|
||||
{#if tab === configFile.event}
|
||||
{#if tab === serverTab.statistics}
|
||||
<Statistics {stateHistory} />
|
||||
{:else if tab === serverTab.event}
|
||||
<EditorEvent config={configs.event} {tracks} {id} />
|
||||
{:else if tab === configFile.configuration}
|
||||
{:else if tab === serverTab.configuration}
|
||||
<EditorConfiguration config={configs.configuration} {id} />
|
||||
{:else if tab === configFile.settings}
|
||||
{:else if tab === serverTab.settings}
|
||||
<EditorSettings config={configs.settings} {id} />
|
||||
{:else if tab === configFile.assistRules}
|
||||
{:else if tab === serverTab.assistRules}
|
||||
<EditorAssistRules config={configs.assistRules} {id} />
|
||||
{:else if tab === configFile.eventRules}
|
||||
{:else if tab === serverTab.eventRules}
|
||||
<EditorEventRules config={configs.eventRules} {id} />
|
||||
{:else}
|
||||
<div class="rounded-lg bg-gray-800 p-6 text-center">
|
||||
|
||||
Reference in New Issue
Block a user