Development #24
@@ -38,19 +38,27 @@ import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||
import { Route as AuthedAdminTelemetryRouteImport } from './routes/_authed/admin/telemetry'
|
||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||
import { Route as AuthedAdminTelemetryIndexRouteImport } from './routes/_authed/admin/telemetry/index'
|
||||
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
|
||||
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||
import { Route as AuthedAdminTelemetryUsageRouteImport } from './routes/_authed/admin/telemetry/usage'
|
||||
import { Route as AuthedAdminTelemetryPlayersRouteImport } from './routes/_authed/admin/telemetry/players'
|
||||
import { Route as AuthedAdminTelemetryFunctionsRouteImport } from './routes/_authed/admin/telemetry/functions'
|
||||
import { Route as AuthedAdminTelemetryEventsRouteImport } from './routes/_authed/admin/telemetry/events'
|
||||
import { Route as AuthedAdminTelemetryErrorsRouteImport } from './routes/_authed/admin/telemetry/errors'
|
||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
|
||||
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
|
||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||
import { Route as AuthedAdminTournamentsIdEnrollmentRouteImport } from './routes/_authed/admin/tournaments/$id/enrollment'
|
||||
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||
|
||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||
@@ -199,6 +207,11 @@ const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
|
||||
path: '/profile/$playerId',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryRoute = AuthedAdminTelemetryRouteImport.update({
|
||||
id: '/telemetry',
|
||||
path: '/telemetry',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
||||
id: '/preview',
|
||||
path: '/preview',
|
||||
@@ -220,6 +233,12 @@ const AuthedAdminTournamentsIndexRoute =
|
||||
path: '/tournaments/',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryIndexRoute =
|
||||
AuthedAdminTelemetryIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdPredictionsRoute =
|
||||
AuthedTournamentsIdPredictionsRouteImport.update({
|
||||
id: '/tournaments/$id/predictions',
|
||||
@@ -238,6 +257,36 @@ const AuthedTournamentsIdBracketRoute =
|
||||
path: '/tournaments/$id/bracket',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryUsageRoute =
|
||||
AuthedAdminTelemetryUsageRouteImport.update({
|
||||
id: '/usage',
|
||||
path: '/usage',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryPlayersRoute =
|
||||
AuthedAdminTelemetryPlayersRouteImport.update({
|
||||
id: '/players',
|
||||
path: '/players',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryFunctionsRoute =
|
||||
AuthedAdminTelemetryFunctionsRouteImport.update({
|
||||
id: '/functions',
|
||||
path: '/functions',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryEventsRoute =
|
||||
AuthedAdminTelemetryEventsRouteImport.update({
|
||||
id: '/events',
|
||||
path: '/events',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryErrorsRoute =
|
||||
AuthedAdminTelemetryErrorsRouteImport.update({
|
||||
id: '/errors',
|
||||
path: '/errors',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdIndexRoute =
|
||||
AuthedAdminTournamentsIdIndexRouteImport.update({
|
||||
id: '/tournaments/$id/',
|
||||
@@ -274,6 +323,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
|
||||
path: '/tournaments/$id/teams',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdEnrollmentRoute =
|
||||
AuthedAdminTournamentsIdEnrollmentRouteImport.update({
|
||||
id: '/tournaments/$id/enrollment',
|
||||
path: '/tournaments/$id/enrollment',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdAssignPartnersRoute =
|
||||
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
|
||||
id: '/tournaments/$id/assign-partners',
|
||||
@@ -294,6 +349,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||
'/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
|
||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
@@ -313,11 +369,18 @@ export interface FileRoutesByFullPath {
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/admin/': typeof AuthedAdminIndexRoute
|
||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
|
||||
'/admin/telemetry/events': typeof AuthedAdminTelemetryEventsRoute
|
||||
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
|
||||
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
|
||||
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
|
||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
@@ -356,11 +419,18 @@ export interface FileRoutesByTo {
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/admin': typeof AuthedAdminIndexRoute
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
|
||||
'/admin/telemetry/events': typeof AuthedAdminTelemetryEventsRoute
|
||||
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
|
||||
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
|
||||
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/admin/telemetry': typeof AuthedAdminTelemetryIndexRoute
|
||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
@@ -383,6 +453,7 @@ export interface FileRoutesById {
|
||||
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
|
||||
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
||||
'/_authed/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
|
||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
@@ -402,11 +473,18 @@ export interface FileRoutesById {
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/_authed/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
|
||||
'/_authed/admin/telemetry/events': typeof AuthedAdminTelemetryEventsRoute
|
||||
'/_authed/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
|
||||
'/_authed/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
|
||||
'/_authed/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
|
||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/_authed/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
|
||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/_authed/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
@@ -429,6 +507,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/activities'
|
||||
| '/admin/badges'
|
||||
| '/admin/preview'
|
||||
| '/admin/telemetry'
|
||||
| '/profile/$playerId'
|
||||
| '/teams/$teamId'
|
||||
| '/tournaments/$tournamentId'
|
||||
@@ -448,11 +527,18 @@ export interface FileRouteTypes {
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/admin/'
|
||||
| '/tournaments/'
|
||||
| '/admin/telemetry/errors'
|
||||
| '/admin/telemetry/events'
|
||||
| '/admin/telemetry/functions'
|
||||
| '/admin/telemetry/players'
|
||||
| '/admin/telemetry/usage'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/tournaments/$id/predictions'
|
||||
| '/admin/telemetry/'
|
||||
| '/admin/tournaments/'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/enrollment'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/tournaments/$id/predictions/$playerId'
|
||||
@@ -491,11 +577,18 @@ export interface FileRouteTypes {
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/admin'
|
||||
| '/tournaments'
|
||||
| '/admin/telemetry/errors'
|
||||
| '/admin/telemetry/events'
|
||||
| '/admin/telemetry/functions'
|
||||
| '/admin/telemetry/players'
|
||||
| '/admin/telemetry/usage'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/tournaments/$id/predictions'
|
||||
| '/admin/telemetry'
|
||||
| '/admin/tournaments'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/enrollment'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/tournaments/$id/predictions/$playerId'
|
||||
@@ -517,6 +610,7 @@ export interface FileRouteTypes {
|
||||
| '/_authed/admin/activities'
|
||||
| '/_authed/admin/badges'
|
||||
| '/_authed/admin/preview'
|
||||
| '/_authed/admin/telemetry'
|
||||
| '/_authed/profile/$playerId'
|
||||
| '/_authed/teams/$teamId'
|
||||
| '/_authed/tournaments/$tournamentId'
|
||||
@@ -536,11 +630,18 @@ export interface FileRouteTypes {
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/_authed/admin/'
|
||||
| '/_authed/tournaments/'
|
||||
| '/_authed/admin/telemetry/errors'
|
||||
| '/_authed/admin/telemetry/events'
|
||||
| '/_authed/admin/telemetry/functions'
|
||||
| '/_authed/admin/telemetry/players'
|
||||
| '/_authed/admin/telemetry/usage'
|
||||
| '/_authed/tournaments/$id/bracket'
|
||||
| '/_authed/tournaments/$id/groups'
|
||||
| '/_authed/tournaments/$id/predictions'
|
||||
| '/_authed/admin/telemetry/'
|
||||
| '/_authed/admin/tournaments/'
|
||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||
| '/_authed/admin/tournaments/$id/enrollment'
|
||||
| '/_authed/admin/tournaments/$id/teams'
|
||||
| '/_authed/admin/tournaments/run/$id'
|
||||
| '/_authed/tournaments/$id/predictions_/$playerId'
|
||||
@@ -777,6 +878,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/admin/telemetry': {
|
||||
id: '/_authed/admin/telemetry'
|
||||
path: '/telemetry'
|
||||
fullPath: '/admin/telemetry'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/preview': {
|
||||
id: '/_authed/admin/preview'
|
||||
path: '/preview'
|
||||
@@ -805,6 +913,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/': {
|
||||
id: '/_authed/admin/telemetry/'
|
||||
path: '/'
|
||||
fullPath: '/admin/telemetry/'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/predictions': {
|
||||
id: '/_authed/tournaments/$id/predictions'
|
||||
path: '/tournaments/$id/predictions'
|
||||
@@ -826,6 +941,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/usage': {
|
||||
id: '/_authed/admin/telemetry/usage'
|
||||
path: '/usage'
|
||||
fullPath: '/admin/telemetry/usage'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryUsageRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/players': {
|
||||
id: '/_authed/admin/telemetry/players'
|
||||
path: '/players'
|
||||
fullPath: '/admin/telemetry/players'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryPlayersRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/functions': {
|
||||
id: '/_authed/admin/telemetry/functions'
|
||||
path: '/functions'
|
||||
fullPath: '/admin/telemetry/functions'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryFunctionsRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/events': {
|
||||
id: '/_authed/admin/telemetry/events'
|
||||
path: '/events'
|
||||
fullPath: '/admin/telemetry/events'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryEventsRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/errors': {
|
||||
id: '/_authed/admin/telemetry/errors'
|
||||
path: '/errors'
|
||||
fullPath: '/admin/telemetry/errors'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryErrorsRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/$id/': {
|
||||
id: '/_authed/admin/tournaments/$id/'
|
||||
path: '/tournaments/$id'
|
||||
@@ -868,6 +1018,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/$id/enrollment': {
|
||||
id: '/_authed/admin/tournaments/$id/enrollment'
|
||||
path: '/tournaments/$id/enrollment'
|
||||
fullPath: '/admin/tournaments/$id/enrollment'
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIdEnrollmentRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/$id/assign-partners': {
|
||||
id: '/_authed/admin/tournaments/$id/assign-partners'
|
||||
path: '/tournaments/$id/assign-partners'
|
||||
@@ -878,13 +1035,36 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthedAdminTelemetryRouteChildren {
|
||||
AuthedAdminTelemetryErrorsRoute: typeof AuthedAdminTelemetryErrorsRoute
|
||||
AuthedAdminTelemetryEventsRoute: typeof AuthedAdminTelemetryEventsRoute
|
||||
AuthedAdminTelemetryFunctionsRoute: typeof AuthedAdminTelemetryFunctionsRoute
|
||||
AuthedAdminTelemetryPlayersRoute: typeof AuthedAdminTelemetryPlayersRoute
|
||||
AuthedAdminTelemetryUsageRoute: typeof AuthedAdminTelemetryUsageRoute
|
||||
AuthedAdminTelemetryIndexRoute: typeof AuthedAdminTelemetryIndexRoute
|
||||
}
|
||||
|
||||
const AuthedAdminTelemetryRouteChildren: AuthedAdminTelemetryRouteChildren = {
|
||||
AuthedAdminTelemetryErrorsRoute: AuthedAdminTelemetryErrorsRoute,
|
||||
AuthedAdminTelemetryEventsRoute: AuthedAdminTelemetryEventsRoute,
|
||||
AuthedAdminTelemetryFunctionsRoute: AuthedAdminTelemetryFunctionsRoute,
|
||||
AuthedAdminTelemetryPlayersRoute: AuthedAdminTelemetryPlayersRoute,
|
||||
AuthedAdminTelemetryUsageRoute: AuthedAdminTelemetryUsageRoute,
|
||||
AuthedAdminTelemetryIndexRoute: AuthedAdminTelemetryIndexRoute,
|
||||
}
|
||||
|
||||
const AuthedAdminTelemetryRouteWithChildren =
|
||||
AuthedAdminTelemetryRoute._addFileChildren(AuthedAdminTelemetryRouteChildren)
|
||||
|
||||
interface AuthedAdminRouteChildren {
|
||||
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
|
||||
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
|
||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||
AuthedAdminTelemetryRoute: typeof AuthedAdminTelemetryRouteWithChildren
|
||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
AuthedAdminTournamentsIdEnrollmentRoute: typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
||||
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
||||
@@ -894,10 +1074,13 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
|
||||
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||
AuthedAdminTelemetryRoute: AuthedAdminTelemetryRouteWithChildren,
|
||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute:
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute,
|
||||
AuthedAdminTournamentsIdEnrollmentRoute:
|
||||
AuthedAdminTournamentsIdEnrollmentRoute,
|
||||
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
||||
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
||||
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
||||
|
||||
@@ -1,52 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { ActivitiesTable, activityQueries } from "@/features/activities";
|
||||
import { PlayersActivityTable, playerQueries } from "@/features/players";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||
component: Stats,
|
||||
beforeLoad: ({ context }) => {
|
||||
const queryClient = context.queryClient;
|
||||
prefetchServerQuery(queryClient, activityQueries.search());
|
||||
prefetchServerQuery(queryClient, playerQueries.activity());
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/admin/telemetry/functions" });
|
||||
},
|
||||
loader: () => ({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
header: {
|
||||
title: msg`Activities`,
|
||||
withBackButton: true,
|
||||
},
|
||||
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey],
|
||||
}),
|
||||
});
|
||||
|
||||
function Stats() {
|
||||
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List mb='md'>
|
||||
<Tabs.Tab value="server-functions"><Trans>Server Functions</Trans></Tabs.Tab>
|
||||
<Tabs.Tab value="player-activity"><Trans>Player Activity</Trans></Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="server-functions">
|
||||
<Suspense fallback={<ListSkeleton rows={8} withSearch withSubtitle withTrailing="text" />}>
|
||||
<ActivitiesTable />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="player-activity">
|
||||
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
|
||||
<PlayersActivityTable />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AdminPage } from "@/features/admin";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { AdminDashboard } from "@/features/admin";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import { telemetryQueries } from "@/features/telemetry/queries";
|
||||
import { lastNDays } from "@/features/telemetry/range";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/")({
|
||||
beforeLoad: ({ context }) => {
|
||||
prefetchServerQuery(context.queryClient, tournamentQueries.current());
|
||||
prefetchServerQuery(context.queryClient, telemetryQueries.runtimeStatus());
|
||||
const month = lastNDays(30);
|
||||
prefetchServerQuery(
|
||||
context.queryClient,
|
||||
telemetryQueries.errorGroups({ from: month.from, to: month.to })
|
||||
);
|
||||
},
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
@@ -10,5 +23,5 @@ export const Route = createFileRoute("/_authed/admin/")({
|
||||
},
|
||||
withPadding: false,
|
||||
}),
|
||||
component: () => <AdminPage />,
|
||||
component: () => <AdminDashboard />,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Outlet, createFileRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
const TAB_ROUTES = {
|
||||
functions: "/admin/telemetry/functions",
|
||||
events: "/admin/telemetry/events",
|
||||
errors: "/admin/telemetry/errors",
|
||||
players: "/admin/telemetry/players",
|
||||
usage: "/admin/telemetry/usage",
|
||||
} as const;
|
||||
|
||||
type TabValue = keyof typeof TAB_ROUTES;
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/telemetry")({
|
||||
component: TelemetryLayout,
|
||||
loader: () => ({
|
||||
header: {
|
||||
title: msg`Telemetry`,
|
||||
withBackButton: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
function TelemetryLayout() {
|
||||
const navigate = useNavigate();
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const activeTab =
|
||||
(Object.keys(TAB_ROUTES) as TabValue[]).find((tab) =>
|
||||
pathname.endsWith(`/${tab}`)
|
||||
) ?? "functions";
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(value) => {
|
||||
if (value && value in TAB_ROUTES) {
|
||||
navigate({ to: TAB_ROUTES[value as TabValue] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs.List mb="md" style={{ flexWrap: "nowrap", overflowX: "auto", overflowY: "hidden" }}>
|
||||
<Tabs.Tab value="functions">
|
||||
<Trans>Functions</Trans>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events">
|
||||
<Trans>Events</Trans>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="errors">
|
||||
<Trans>Errors</Trans>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="players">
|
||||
<Trans>Players</Trans>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="usage">
|
||||
<Trans>Usage</Trans>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Outlet />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { telemetryQueries, lastNDays, ErrorBrowser } from "@/features/telemetry";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
|
||||
const errorGroupParams = () => ({ ...lastNDays(30), includeResolved: false });
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/telemetry/errors")({
|
||||
component: ErrorsTab,
|
||||
beforeLoad: ({ context }) => {
|
||||
prefetchServerQuery(context.queryClient, telemetryQueries.errorGroups(errorGroupParams()));
|
||||
},
|
||||
loader: () => ({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
refresh: [telemetryQueries.errorGroups(errorGroupParams()).queryKey],
|
||||
}),
|
||||
});
|
||||
|
||||
function ErrorsTab() {
|
||||
return (
|
||||
<Suspense fallback={<ListSkeleton rows={6} withSubtitle />}>
|
||||
<ErrorBrowser />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { telemetryQueries, defaultClientEventParams, ClientEventsTable } from "@/features/telemetry";
|
||||
import { playerQueries } from "@/features/players";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/telemetry/events")({
|
||||
component: EventsTab,
|
||||
beforeLoad: ({ context }) => {
|
||||
const queryClient = context.queryClient;
|
||||
prefetchServerQuery(queryClient, telemetryQueries.events(defaultClientEventParams));
|
||||
prefetchServerQuery(queryClient, playerQueries.list());
|
||||
},
|
||||
loader: () => ({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
refresh: [telemetryQueries.events(defaultClientEventParams).queryKey],
|
||||
}),
|
||||
});
|
||||
|
||||
function EventsTab() {
|
||||
return (
|
||||
<Suspense fallback={<ListSkeleton rows={8} withSearch withSubtitle withTrailing="text" />}>
|
||||
<ClientEventsTable />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { ActivitiesTable, activityQueries } from "@/features/activities";
|
||||
import { playerQueries } from "@/features/players";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/telemetry/functions")({
|
||||
component: FunctionsTab,
|
||||
beforeLoad: ({ context }) => {
|
||||
const queryClient = context.queryClient;
|
||||
prefetchServerQuery(queryClient, activityQueries.search());
|
||||
prefetchServerQuery(queryClient, playerQueries.list());
|
||||
},
|
||||
loader: () => ({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
refresh: [activityQueries.search().queryKey],
|
||||
}),
|
||||
});
|
||||
|
||||
function FunctionsTab() {
|
||||
return (
|
||||
<Suspense fallback={<ListSkeleton rows={8} withSearch withSubtitle withTrailing="text" />}>
|
||||
<ActivitiesTable />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/telemetry/")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/admin/telemetry/functions" });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { PlayersActivityTable, playerQueries } from "@/features/players";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/telemetry/players")({
|
||||
component: PlayersTab,
|
||||
beforeLoad: ({ context }) => {
|
||||
prefetchServerQuery(context.queryClient, playerQueries.activity());
|
||||
},
|
||||
loader: () => ({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
refresh: [playerQueries.activity().queryKey],
|
||||
}),
|
||||
});
|
||||
|
||||
function PlayersTab() {
|
||||
return (
|
||||
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
|
||||
<PlayersActivityTable />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { telemetryQueries, lastNDays, USAGE_METRICS, UsageScreen } from "@/features/telemetry";
|
||||
|
||||
const defaultUsageParams = () => ({ metrics: USAGE_METRICS, ...lastNDays(7) });
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/telemetry/usage")({
|
||||
component: UsageTab,
|
||||
beforeLoad: ({ context }) => {
|
||||
prefetchServerQuery(context.queryClient, telemetryQueries.rollups(defaultUsageParams()));
|
||||
},
|
||||
loader: () => ({
|
||||
refresh: [telemetryQueries.rollups(defaultUsageParams()).queryKey],
|
||||
}),
|
||||
});
|
||||
|
||||
function UsageTab() {
|
||||
return <UsageScreen />;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import Enrollment from "@/features/tournaments/components/enrollment";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||
import { Skeleton, Stack } from "@mantine/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/enrollment")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||
if (tournament.regional) {
|
||||
await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.free_agents(params.id)
|
||||
);
|
||||
} else {
|
||||
await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.unenrolled(params.id)
|
||||
);
|
||||
}
|
||||
return { tournament };
|
||||
},
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: msg`Enrollment`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
pendingComponent: EnrollmentPending,
|
||||
});
|
||||
|
||||
function EnrollmentPending() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<StatTileSkeleton align="row" />
|
||||
<Stack gap="xs">
|
||||
<Skeleton height={14} width={80} radius="sm" />
|
||||
<Skeleton height={42} radius="sm" />
|
||||
</Stack>
|
||||
<ListSkeleton rows={6} withAvatar avatarSize={32} withTrailing padding="xs" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { tournament } = Route.useRouteContext();
|
||||
return <Enrollment tournament={tournament} />;
|
||||
}
|
||||
@@ -1,268 +1,32 @@
|
||||
import { useState, useMemo, memo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
Text,
|
||||
TextInput,
|
||||
Stack,
|
||||
Group,
|
||||
Box,
|
||||
Container,
|
||||
Divider,
|
||||
UnstyledButton,
|
||||
Select,
|
||||
Pagination,
|
||||
Code,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CaretUpIcon,
|
||||
CaretDownIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
ChecksIcon,
|
||||
PulseIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Alert, Code, Container, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckIcon, XIcon, PulseIcon } from "@phosphor-icons/react";
|
||||
import { Activity, ActivitySearchParams } from "../types";
|
||||
import { useActivities } from "../queries";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { usePlayers } from "@/features/players/queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import {
|
||||
DetailSheet,
|
||||
PaginatedList,
|
||||
SearchFilterBar,
|
||||
TelemetryListItem,
|
||||
} from "@/features/telemetry/components/shared";
|
||||
|
||||
interface ActivityListItemProps {
|
||||
activity: Activity;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const playerName = typeof activity.player === "object" && activity.player
|
||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||
: t`System`;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(i18n.locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
p="md"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{activity.name}
|
||||
</Text>
|
||||
{activity.success ? (
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{playerName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>{activity.duration}ms</Trans>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(activity.created)}
|
||||
</Text>
|
||||
</Group>
|
||||
{activity.error && (
|
||||
<Text size="xs" c="red" lineClamp={1}>
|
||||
{activity.error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
});
|
||||
|
||||
interface ActivityDetailsSheetProps {
|
||||
activity: Activity | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
if (!activity) return null;
|
||||
|
||||
const playerName = typeof activity.player === "object" && activity.player
|
||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||
: t`System`;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(i18n.locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
|
||||
<Stack gap="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Function Name</Trans>
|
||||
</Text>
|
||||
<Text size="sm">{activity.name}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Status</Trans>
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{activity.success ? (
|
||||
<>
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
<Text size="sm" c="green">
|
||||
<Trans>Success</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
<Text size="sm" c="red">
|
||||
<Trans>Failed</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Player</Trans>
|
||||
</Text>
|
||||
<Text size="sm">{playerName}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Duration</Trans>
|
||||
</Text>
|
||||
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Created</Trans>
|
||||
</Text>
|
||||
<Text size="sm">{formatDate(activity.created)}</Text>
|
||||
</Stack>
|
||||
|
||||
{activity.user_agent && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>User Agent</Trans>
|
||||
</Text>
|
||||
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
||||
{activity.user_agent}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activity.error && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Error Message</Trans>
|
||||
</Text>
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||
{activity.error}
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activity.arguments && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Arguments</Trans>
|
||||
</Text>
|
||||
<Code block style={{ fontSize: "11px" }}>
|
||||
{JSON.stringify(activity.arguments, null, 2)}
|
||||
</Code>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Sheet>
|
||||
);
|
||||
});
|
||||
|
||||
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
|
||||
const { data: result } = useActivities(searchParams);
|
||||
return (
|
||||
<>
|
||||
<Stack gap={0}>
|
||||
{result.items.map((activity: Activity, index: number) => (
|
||||
<Box key={activity.id}>
|
||||
<ActivityListItem
|
||||
activity={activity}
|
||||
onClick={() => onActivityClick(activity)}
|
||||
/>
|
||||
{index < result.items.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{result.items.length === 0 && (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size="xl" variant="light" radius="md">
|
||||
<PulseIcon size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} c="dimmed">
|
||||
<Trans>No Activities Found</Trans>
|
||||
</Title>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{result.totalPages > 1 && (
|
||||
<Group justify="center" py="md">
|
||||
<Pagination
|
||||
total={result.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const SLOW_THRESHOLD_MS = 1000;
|
||||
|
||||
export const ActivitiesTable = () => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
||||
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
|
||||
const [slowOnly, setSlowOnly] = useState(false);
|
||||
const [sortBy, setSortBy] = useState("-created");
|
||||
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
|
||||
|
||||
const {
|
||||
isOpen: detailsOpened,
|
||||
open: openDetails,
|
||||
close: closeDetails,
|
||||
} = useSheet();
|
||||
const { data: players } = usePlayers();
|
||||
|
||||
const searchParams: ActivitySearchParams = useMemo(
|
||||
() => ({
|
||||
@@ -270,13 +34,24 @@ export const ActivitiesTable = () => {
|
||||
perPage: 100,
|
||||
name: search || undefined,
|
||||
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
|
||||
player: playerFilter || undefined,
|
||||
sortBy,
|
||||
}),
|
||||
[page, search, successFilter, sortBy]
|
||||
[page, search, successFilter, playerFilter, sortBy]
|
||||
);
|
||||
|
||||
const { data: result } = useActivities(searchParams);
|
||||
|
||||
const items = useMemo(
|
||||
() => (slowOnly ? result.items.filter((a) => a.duration > SLOW_THRESHOLD_MS) : result.items),
|
||||
[result.items, slowOnly]
|
||||
);
|
||||
|
||||
const playerName = (activity: Activity) =>
|
||||
typeof activity.player === "object" && activity.player
|
||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||
: t`System`;
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
setSortBy((prev) => {
|
||||
if (prev === field) return `-${field}`;
|
||||
@@ -285,12 +60,6 @@ export const ActivitiesTable = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortBy === field) return <CaretUpIcon size={14} />;
|
||||
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleActivityClick = (activity: Activity) => {
|
||||
setSelectedActivity(activity);
|
||||
openDetails();
|
||||
@@ -304,90 +73,163 @@ export const ActivitiesTable = () => {
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Stack gap="xs">
|
||||
<Stack gap="xs" px="md">
|
||||
<TextInput
|
||||
placeholder={t`serverFn name`}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Select
|
||||
placeholder={t`Status`}
|
||||
value={successFilter}
|
||||
onChange={(value) => {
|
||||
<SearchFilterBar
|
||||
searchValue={search}
|
||||
onSearchChange={(value) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
}}
|
||||
searchPlaceholder={t`serverFn name`}
|
||||
filters={[
|
||||
{
|
||||
placeholder: t`Status`,
|
||||
value: successFilter,
|
||||
onChange: (value) => {
|
||||
setSuccessFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
},
|
||||
data: [
|
||||
{ value: "all", label: t`All` },
|
||||
{ value: "success", label: t`Success` },
|
||||
{ value: "failure", label: t`Failure` },
|
||||
]}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group px="md" justify="space-between" align="center">
|
||||
<Text size="10px" lh={0} c="dimmed">
|
||||
],
|
||||
},
|
||||
{
|
||||
placeholder: t`Player`,
|
||||
value: playerFilter,
|
||||
onChange: (value) => {
|
||||
setPlayerFilter(value);
|
||||
setPage(1);
|
||||
},
|
||||
data: players.map((player) => ({
|
||||
value: player.id,
|
||||
label: `${player.first_name} ${player.last_name}`,
|
||||
})),
|
||||
searchable: true,
|
||||
},
|
||||
]}
|
||||
chips={[
|
||||
{
|
||||
label: t`Slow >1s (this page)`,
|
||||
checked: slowOnly,
|
||||
onChange: setSlowOnly,
|
||||
},
|
||||
]}
|
||||
sortFields={[
|
||||
{ field: "created", label: <Trans>Date</Trans> },
|
||||
{ field: "duration", label: <Trans>Duration</Trans> },
|
||||
]}
|
||||
sortBy={sortBy}
|
||||
onSort={handleSort}
|
||||
summary={
|
||||
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Sort:</Trans>
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => handleSort("created")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("created") ? 600 : 400}
|
||||
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
|
||||
>
|
||||
<Trans>Date</Trans>
|
||||
</Text>
|
||||
{getSortIcon("created")}
|
||||
</UnstyledButton>
|
||||
<Text size="xs" c="dimmed">
|
||||
•
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => handleSort("duration")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("duration") ? 600 : 400}
|
||||
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
|
||||
>
|
||||
<Trans>Duration</Trans>
|
||||
</Text>
|
||||
{getSortIcon("duration")}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActivitiesResults
|
||||
searchParams={searchParams}
|
||||
<PaginatedList
|
||||
items={items}
|
||||
keyOf={(activity) => activity.id}
|
||||
empty={{ icon: <PulseIcon size={32} />, title: t`No Activities Found` }}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
onActivityClick={handleActivityClick}
|
||||
totalPages={result.totalPages}
|
||||
onPageChange={setPage}
|
||||
renderItem={(activity) => (
|
||||
<TelemetryListItem
|
||||
title={activity.name}
|
||||
icon={
|
||||
activity.success ? (
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
)
|
||||
}
|
||||
meta={[
|
||||
playerName(activity),
|
||||
<Trans>{activity.duration}ms</Trans>,
|
||||
new Date(activity.created).toLocaleString(i18n.locale),
|
||||
]}
|
||||
error={activity.error}
|
||||
onClick={() => handleActivityClick(activity)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<ActivityDetailsSheet
|
||||
activity={selectedActivity}
|
||||
isOpen={detailsOpened}
|
||||
<DetailSheet
|
||||
title={t`Activity Details`}
|
||||
opened={detailsOpened}
|
||||
onClose={handleCloseDetails}
|
||||
fields={
|
||||
selectedActivity
|
||||
? [
|
||||
{ label: <Trans>Function Name</Trans>, value: selectedActivity.name },
|
||||
{
|
||||
label: <Trans>Status</Trans>,
|
||||
value: (
|
||||
<Group gap="xs">
|
||||
{selectedActivity.success ? (
|
||||
<>
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
<Text size="sm" c="green">
|
||||
<Trans>Success</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
<Text size="sm" c="red">
|
||||
<Trans>Failed</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{ label: <Trans>Player</Trans>, value: playerName(selectedActivity) },
|
||||
{
|
||||
label: <Trans>Duration</Trans>,
|
||||
value: (
|
||||
<Text size="sm">
|
||||
<Trans>{selectedActivity.duration}ms</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: <Trans>Created</Trans>,
|
||||
value: new Date(selectedActivity.created).toLocaleString(i18n.locale),
|
||||
},
|
||||
...(selectedActivity.user_agent
|
||||
? [{ label: <Trans>User Agent</Trans>, value: selectedActivity.user_agent }]
|
||||
: []),
|
||||
...(selectedActivity.error
|
||||
? [
|
||||
{
|
||||
label: <Trans>Error Message</Trans>,
|
||||
value: (
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||
{selectedActivity.error}
|
||||
</Text>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(selectedActivity.arguments
|
||||
? [
|
||||
{
|
||||
label: <Trans>Arguments</Trans>,
|
||||
value: (
|
||||
<Code block style={{ fontSize: "11px" }}>
|
||||
{JSON.stringify(selectedActivity.arguments, null, 2)}
|
||||
</Code>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { searchActivities } from "@/features/activities/server";
|
||||
import { activityKeys } from "@/features/activities/queries";
|
||||
import { RelativeTime } from "@/features/telemetry/components/shared";
|
||||
import type { PlayerInfo } from "@/features/players/types";
|
||||
|
||||
const FEED_PARAMS = { perPage: 10 } as const;
|
||||
|
||||
const playerName = (player?: string | PlayerInfo): string | null => {
|
||||
if (!player || typeof player === "string") return null;
|
||||
return [player.first_name, player.last_name].filter(Boolean).join(" ") || null;
|
||||
};
|
||||
|
||||
export const ActivityFeed = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useServerQuery({
|
||||
queryKey: activityKeys.search(FEED_PARAMS),
|
||||
queryFn: () => searchActivities({ data: FEED_PARAMS }),
|
||||
options: {
|
||||
refetchInterval: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Recent activity</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{data?.items.map((activity) => {
|
||||
const name = playerName(activity.player);
|
||||
return (
|
||||
<Group
|
||||
key={activity.id}
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
onClick={() => navigate({ to: "/admin/telemetry/functions" })}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{activity.success ? (
|
||||
<CheckCircleIcon
|
||||
size={14}
|
||||
color="var(--mantine-color-green-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<XCircleIcon
|
||||
size={14}
|
||||
color="var(--mantine-color-red-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" truncate style={{ flex: 1, minWidth: 0 }}>
|
||||
{activity.name}
|
||||
{name && (
|
||||
<Text component="span" size="xs" c="dimmed">
|
||||
{" "}
|
||||
· {name}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
<RelativeTime date={activity.created} />
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{data && data.items.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>No activity yet</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityFeed;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Badge, Card, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { useClientErrorGroups } from "@/features/telemetry/queries";
|
||||
import { RelativeTime } from "@/features/telemetry/components/shared";
|
||||
import { lastNDays } from "@/features/telemetry/range";
|
||||
|
||||
export const ErrorsCard = () => {
|
||||
const navigate = useNavigate();
|
||||
const range = lastNDays(30);
|
||||
|
||||
const { data: groups } = useClientErrorGroups({ from: range.from, to: range.to });
|
||||
|
||||
return (
|
||||
<UnstyledButton w="100%" onClick={() => navigate({ to: "/admin/telemetry/errors" })}>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Client errors</Trans>
|
||||
</Text>
|
||||
{groups && groups.length > 0 ? (
|
||||
<Badge color="red" variant="light">
|
||||
{groups.length}
|
||||
</Badge>
|
||||
) : (
|
||||
<CheckCircleIcon size={18} color="var(--mantine-color-green-6)" weight="fill" />
|
||||
)}
|
||||
</Group>
|
||||
{groups && groups.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
{groups.slice(0, 3).map((group) => (
|
||||
<Group key={group.group_hash} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<WarningCircleIcon
|
||||
size={14}
|
||||
color="var(--mantine-color-red-6)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" lineClamp={1}>
|
||||
{group.sample.message}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<RelativeTime date={group.lastSeen} />
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>No unresolved errors</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorsCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Suspense } from "react";
|
||||
import { Box, SimpleGrid, Stack } from "@mantine/core";
|
||||
import { CardSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||
import QuietBoundary from "./quiet-boundary";
|
||||
import NowStrip from "./now-strip";
|
||||
import TournamentCard from "./tournament-card";
|
||||
import UsageCard from "./usage-card";
|
||||
import ErrorsCard from "./errors-card";
|
||||
import ActivityFeed from "./activity-feed";
|
||||
import ToolsList from "./tools-list";
|
||||
|
||||
const NowStripSkeleton = () => (
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<StatTileSkeleton />
|
||||
<StatTileSkeleton />
|
||||
<StatTileSkeleton />
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
export const AdminDashboard = () => (
|
||||
<Stack gap="md" pb="md">
|
||||
<Stack gap="md" px="md" pt="md">
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<NowStripSkeleton />}>
|
||||
<NowStrip />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<TournamentCard />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ErrorsCard />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageCard />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
<QuietBoundary>
|
||||
<ActivityFeed />
|
||||
</QuietBoundary>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<Box>
|
||||
<ToolsList />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from "react";
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
useClientErrorGroups,
|
||||
useRollupRange,
|
||||
useTelemetryRuntimeStatus,
|
||||
} from "@/features/telemetry/queries";
|
||||
import { StatTile } from "@/features/telemetry/components/charts";
|
||||
import { isoDay, lastNDays, listDays } from "@/features/telemetry/range";
|
||||
|
||||
export const NowStrip = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const range = useMemo(() => lastNDays(7), []);
|
||||
|
||||
const { data: status } = useTelemetryRuntimeStatus();
|
||||
const { data: errorGroups } = useClientErrorGroups({
|
||||
from: lastNDays(30).from,
|
||||
to: range.to,
|
||||
});
|
||||
const { data: rollups } = useRollupRange({
|
||||
metrics: ["server_fn.count"],
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
const { callsToday, callsSeries } = useMemo(() => {
|
||||
const byDate = new Map<string, number>();
|
||||
for (const row of rollups ?? []) {
|
||||
byDate.set(row.date, (byDate.get(row.date) ?? 0) + row.value);
|
||||
}
|
||||
return {
|
||||
callsToday: byDate.get(isoDay(new Date())) ?? 0,
|
||||
callsSeries: listDays(range).map((day) => byDate.get(day) ?? 0),
|
||||
};
|
||||
}, [rollups, range]);
|
||||
|
||||
const unresolved = errorGroups?.length ?? 0;
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<StatTile label={t`Online now`} value={status?.activeSseConnections ?? 0} />
|
||||
<StatTile
|
||||
label={t`Open errors`}
|
||||
value={unresolved}
|
||||
caption={undefined}
|
||||
onClick={() => navigate({ to: "/admin/telemetry/errors" })}
|
||||
/>
|
||||
<StatTile
|
||||
label={t`Calls today`}
|
||||
value={callsToday.toLocaleString()}
|
||||
spark={callsSeries}
|
||||
onClick={() => navigate({ to: "/admin/telemetry/usage" })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default NowStrip;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, ReactNode } from "react";
|
||||
import { Card, Text } from "@mantine/core";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface QuietBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface QuietBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
// One failing telemetry card must not blank the whole dashboard.
|
||||
export class QuietBoundary extends Component<QuietBoundaryProps, QuietBoundaryState> {
|
||||
state: QuietBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): QuietBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>Unavailable right now</Trans>
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default QuietBoundary;
|
||||
+3
-9
@@ -1,20 +1,19 @@
|
||||
import { List, Text } from "@mantine/core";
|
||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||
import ListLink from "@/components/list-link";
|
||||
import {
|
||||
DatabaseIcon,
|
||||
TreeStructureIcon,
|
||||
TrophyIcon,
|
||||
MedalIcon,
|
||||
CrownIcon,
|
||||
ListIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import ListLink from "@/components/list-link";
|
||||
import ListButton from "@/components/list-button";
|
||||
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import toast from "@/lib/sonner";
|
||||
|
||||
const AdminPage = () => {
|
||||
export const ToolsList = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
|
||||
@@ -86,11 +85,6 @@ const AdminPage = () => {
|
||||
<Trans>Badge recalculation failed. Check the server logs.</Trans>
|
||||
</Text>
|
||||
)}
|
||||
<ListLink
|
||||
label={t`Activities`}
|
||||
Icon={ListIcon}
|
||||
to="/admin/activities"
|
||||
/>
|
||||
<ListButton
|
||||
label={t`Open Pocketbase`}
|
||||
Icon={DatabaseIcon}
|
||||
@@ -107,4 +101,4 @@ const AdminPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
export default ToolsList;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { PlayIcon, TrophyIcon, UsersThreeIcon, GearIcon } from "@phosphor-icons/react";
|
||||
import { useCurrentTournament } from "@/features/tournaments/queries";
|
||||
import { Countdown } from "@/components/countdown";
|
||||
|
||||
export const TournamentCard = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const { data: tournament } = useCurrentTournament();
|
||||
|
||||
if (!tournament) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>No tournaments yet</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<TrophyIcon size={16} />}
|
||||
onClick={() => navigate({ to: "/admin/tournaments" })}
|
||||
>
|
||||
<Trans>Manage Tournaments</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const upcoming = !!tournament.start_time && Date.parse(tournament.start_time) > Date.now();
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} truncate>
|
||||
{tournament.name}
|
||||
</Text>
|
||||
{upcoming && (
|
||||
<Countdown date={new Date(tournament.start_time)} label={t`Starts in`} />
|
||||
)}
|
||||
</Stack>
|
||||
<TrophyIcon size={20} />
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<PlayIcon size={16} weight="fill" />}
|
||||
fullWidth
|
||||
onClick={() => navigate({ to: `/admin/tournaments/run/${tournament.id}` })}
|
||||
>
|
||||
<Trans>Run Tournament</Trans>
|
||||
</Button>
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<UsersThreeIcon size={16} />}
|
||||
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}/enrollment` })}
|
||||
>
|
||||
<Trans>Enrollment</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<GearIcon size={16} />}
|
||||
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}` })}
|
||||
>
|
||||
<Trans>Manage</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TournamentCard;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useRollupRange } from "@/features/telemetry/queries";
|
||||
import { Sparkline } from "@/features/telemetry/components/charts";
|
||||
import { fillDailySeries, lastNDays, listDays } from "@/features/telemetry/range";
|
||||
|
||||
export const UsageCard = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const range = useMemo(() => lastNDays(7), []);
|
||||
|
||||
const { data: rollups } = useRollupRange({
|
||||
metrics: ["dau", "page_view.count"],
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
const { dauSeries, pageViewSeries } = useMemo(() => {
|
||||
const days = listDays(range);
|
||||
return {
|
||||
dauSeries: fillDailySeries(rollups ?? [], "dau", "", days),
|
||||
pageViewSeries: fillDailySeries(rollups ?? [], "page_view.count", "", days),
|
||||
};
|
||||
}, [rollups, range]);
|
||||
|
||||
return (
|
||||
<UnstyledButton w="100%" onClick={() => navigate({ to: "/admin/telemetry/usage" })}>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Usage, 7 days</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Active players</Trans>
|
||||
</Text>
|
||||
<Sparkline data={dauSeries} height={36} showArea label={t`Daily active players`} />
|
||||
</Stack>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Page views</Trans>
|
||||
</Text>
|
||||
<Sparkline data={pageViewSeries} height={36} showArea label={t`Daily page views`} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageCard;
|
||||
@@ -2,4 +2,4 @@ import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger("Admin");
|
||||
|
||||
export { default as AdminPage } from "./components/admin-page";
|
||||
export { default as AdminDashboard } from "./components/dashboard";
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
export interface BarListItem {
|
||||
label: ReactNode;
|
||||
value: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface BarListProps {
|
||||
items: BarListItem[];
|
||||
formatValue?: (value: number) => string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const BarRow = ({
|
||||
item,
|
||||
max,
|
||||
formatValue,
|
||||
color,
|
||||
}: {
|
||||
item: BarListItem;
|
||||
max: number;
|
||||
formatValue: (value: number) => string;
|
||||
color: string;
|
||||
}) => {
|
||||
const content = (
|
||||
<Stack gap={4} w="100%">
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Text size="sm" truncate style={{ minWidth: 0 }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatValue(item.value)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
h={8}
|
||||
w={`${max > 0 ? Math.max((item.value / max) * 100, 1) : 0}%`}
|
||||
bg={color}
|
||||
style={{ borderRadius: "0 4px 4px 0" }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!item.onClick) {
|
||||
return <Box py={6}>{content}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton w="100%" py={6} onClick={item.onClick}>
|
||||
{content}
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const BarList = ({
|
||||
items,
|
||||
formatValue = (value) => value.toLocaleString(),
|
||||
color = "var(--mantine-color-anchor)",
|
||||
}: BarListProps) => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...items.map((item) => item.value));
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{items.map((item, index) => (
|
||||
<BarRow
|
||||
key={index}
|
||||
item={item}
|
||||
max={max}
|
||||
formatValue={formatValue}
|
||||
color={color}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarList;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { niceExtent, pointsToPath } from "./chart-path";
|
||||
|
||||
describe("pointsToPath", () => {
|
||||
it("returns an empty string for an empty series", () => {
|
||||
expect(pointsToPath([], 100, 40)).toBe("");
|
||||
});
|
||||
|
||||
it("renders a single point as a horizontal segment without NaN", () => {
|
||||
const path = pointsToPath([5], 100, 40, 2);
|
||||
expect(path).not.toContain("NaN");
|
||||
expect(path).toBe("M2,20 L98,20");
|
||||
});
|
||||
|
||||
it("centers a flat series vertically without NaN", () => {
|
||||
const path = pointsToPath([3, 3, 3], 100, 40);
|
||||
expect(path).not.toContain("NaN");
|
||||
expect(path).toBe("M0,20 L50,20 L100,20");
|
||||
});
|
||||
|
||||
it("maps min to the bottom and max to the top", () => {
|
||||
const path = pointsToPath([0, 10], 100, 40);
|
||||
expect(path).toBe("M0,40 L100,0");
|
||||
});
|
||||
|
||||
it("respects padding", () => {
|
||||
const path = pointsToPath([0, 10], 100, 40, 4);
|
||||
expect(path).toBe("M4,36 L96,4");
|
||||
});
|
||||
|
||||
it("uses a shared extent when provided", () => {
|
||||
const path = pointsToPath([0, 5], 100, 40, 0, [0, 10]);
|
||||
expect(path).toBe("M0,40 L100,20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("niceExtent", () => {
|
||||
it("handles an empty series", () => {
|
||||
expect(niceExtent([])).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("handles an all-zero series", () => {
|
||||
expect(niceExtent([0, 0, 0])).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("spreads a flat non-zero series", () => {
|
||||
const [lo, hi] = niceExtent([10, 10]);
|
||||
expect(lo).toBeLessThan(10);
|
||||
expect(hi).toBeGreaterThan(10);
|
||||
expect(Number.isNaN(lo)).toBe(false);
|
||||
expect(Number.isNaN(hi)).toBe(false);
|
||||
});
|
||||
|
||||
it("covers the data with rounded bounds", () => {
|
||||
const [lo, hi] = niceExtent([3, 47, 12]);
|
||||
expect(lo).toBeLessThanOrEqual(3);
|
||||
expect(hi).toBeGreaterThanOrEqual(47);
|
||||
});
|
||||
|
||||
it("never dips below zero for non-negative data", () => {
|
||||
const [lo] = niceExtent([1, 2, 3]);
|
||||
expect(lo).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export function pointsToPath(
|
||||
values: number[],
|
||||
width: number,
|
||||
height: number,
|
||||
pad = 0,
|
||||
extent?: [number, number]
|
||||
): string {
|
||||
if (values.length === 0) return "";
|
||||
|
||||
const [lo, hi] = extent ?? [Math.min(...values), Math.max(...values)];
|
||||
const span = hi - lo;
|
||||
const innerWidth = Math.max(width - pad * 2, 0);
|
||||
const innerHeight = Math.max(height - pad * 2, 0);
|
||||
|
||||
const yFor = (value: number) =>
|
||||
span === 0
|
||||
? pad + innerHeight / 2
|
||||
: pad + innerHeight - ((value - lo) / span) * innerHeight;
|
||||
|
||||
if (values.length === 1) {
|
||||
const y = yFor(values[0]);
|
||||
return `M${pad},${y} L${pad + innerWidth},${y}`;
|
||||
}
|
||||
|
||||
const step = innerWidth / (values.length - 1);
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const command = index === 0 ? "M" : "L";
|
||||
return `${command}${pad + step * index},${yFor(value)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function niceExtent(values: number[]): [number, number] {
|
||||
if (values.length === 0) return [0, 1];
|
||||
|
||||
let min = Math.min(...values);
|
||||
let max = Math.max(...values);
|
||||
|
||||
if (min === max) {
|
||||
if (min === 0) return [0, 1];
|
||||
const spread = Math.abs(min) / 2;
|
||||
min -= spread;
|
||||
max += spread;
|
||||
}
|
||||
|
||||
const step = niceStep((max - min) / 4);
|
||||
let lo = Math.floor(min / step) * step;
|
||||
const hi = Math.ceil(max / step) * step;
|
||||
if (min >= 0 && lo < 0) lo = 0;
|
||||
return [lo, hi];
|
||||
}
|
||||
|
||||
function niceStep(rough: number): number {
|
||||
const power = 10 ** Math.floor(Math.log10(rough));
|
||||
const fraction = rough / power;
|
||||
if (fraction <= 1) return power;
|
||||
if (fraction <= 2) return 2 * power;
|
||||
if (fraction <= 5) return 5 * power;
|
||||
return 10 * power;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { pointsToPath, niceExtent } from "./chart-path";
|
||||
export { Sparkline } from "./sparkline";
|
||||
export { BarList } from "./bar-list";
|
||||
export type { BarListItem } from "./bar-list";
|
||||
export { StatTile } from "./stat-tile";
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { niceExtent, pointsToPath } from "./chart-path";
|
||||
|
||||
const VIEW_WIDTH = 240;
|
||||
const PAD = 3;
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
series2?: number[];
|
||||
height?: number;
|
||||
showArea?: boolean;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const Sparkline = ({
|
||||
data,
|
||||
series2,
|
||||
height = 48,
|
||||
showArea = false,
|
||||
label,
|
||||
color = "var(--mantine-color-anchor)",
|
||||
}: SparklineProps) => {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm" aria-label={label}>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const extent = niceExtent(series2?.length ? [...data, ...series2] : data);
|
||||
const path = pointsToPath(data, VIEW_WIDTH, height, PAD, extent);
|
||||
const secondPath = series2?.length
|
||||
? pointsToPath(series2, VIEW_WIDTH, height, PAD, extent)
|
||||
: null;
|
||||
const areaPath = showArea
|
||||
? `${path} L${VIEW_WIDTH - PAD},${height - PAD} L${PAD},${height - PAD} Z`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
width="100%"
|
||||
height={height}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
{areaPath && <path d={areaPath} fill={color} fillOpacity={0.1} stroke="none" />}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{secondPath && (
|
||||
<path
|
||||
d={secondPath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sparkline;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Box, Card, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import Sparkline from "./sparkline";
|
||||
|
||||
interface StatTileProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
delta?: number;
|
||||
spark?: number[];
|
||||
caption?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const StatTile = ({ label, value, delta, spark, caption, onClick }: StatTileProps) => {
|
||||
const content = (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline" wrap="nowrap">
|
||||
<Text fz={24} fw={600} lh={1.2}>
|
||||
{value}
|
||||
</Text>
|
||||
{delta !== undefined && delta !== 0 && (
|
||||
<Text size="xs" c={delta > 0 ? "green" : "red"}>
|
||||
{delta > 0 ? "+" : ""}
|
||||
{delta.toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{caption && <Box>{caption}</Box>}
|
||||
{spark && spark.length > 0 && <Sparkline data={spark} height={28} />}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<UnstyledButton onClick={onClick} w="100%">
|
||||
<Card withBorder radius="md" p="md">
|
||||
{content}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatTile;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||
import { Code, Container, Stack } from "@mantine/core";
|
||||
import { CursorClickIcon } from "@phosphor-icons/react";
|
||||
import { usePlayers } from "@/features/players/queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { useClientEvents } from "../queries";
|
||||
import type { ClientEvent, ClientEventSearchParams } from "../types";
|
||||
import {
|
||||
DetailSheet,
|
||||
PaginatedList,
|
||||
RelativeTime,
|
||||
SearchFilterBar,
|
||||
TelemetryListItem,
|
||||
} from "./shared";
|
||||
|
||||
export const defaultClientEventParams: ClientEventSearchParams = {
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
sortBy: "-created",
|
||||
};
|
||||
|
||||
export const ClientEventsTable = () => {
|
||||
const { t, i18n } = useLingui();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState<ClientEvent | null>(null);
|
||||
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
|
||||
|
||||
const { data: players } = usePlayers();
|
||||
|
||||
const searchParams: ClientEventSearchParams = useMemo(
|
||||
() => ({
|
||||
page,
|
||||
perPage: 50,
|
||||
name: search || undefined,
|
||||
player: playerFilter || undefined,
|
||||
sortBy: "-created",
|
||||
}),
|
||||
[page, search, playerFilter]
|
||||
);
|
||||
|
||||
const { data: result } = useClientEvents(searchParams);
|
||||
|
||||
const playerName = (event: ClientEvent) =>
|
||||
typeof event.player === "object" && event.player
|
||||
? `${event.player.first_name} ${event.player.last_name}`
|
||||
: t`Anonymous`;
|
||||
|
||||
const handleEventClick = (event: ClientEvent) => {
|
||||
setSelectedEvent(event);
|
||||
openDetails();
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
setSelectedEvent(null);
|
||||
closeDetails();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Stack gap="xs">
|
||||
<SearchFilterBar
|
||||
searchValue={search}
|
||||
onSearchChange={(value) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
}}
|
||||
searchPlaceholder={t`Event name`}
|
||||
filters={[
|
||||
{
|
||||
placeholder: t`Player`,
|
||||
value: playerFilter,
|
||||
onChange: (value) => {
|
||||
setPlayerFilter(value);
|
||||
setPage(1);
|
||||
},
|
||||
data: players.map((player) => ({
|
||||
value: player.id,
|
||||
label: `${player.first_name} ${player.last_name}`,
|
||||
})),
|
||||
searchable: true,
|
||||
},
|
||||
]}
|
||||
summary={
|
||||
<Plural value={result.totalItems} one="# total event" other="# total events" />
|
||||
}
|
||||
/>
|
||||
|
||||
<PaginatedList
|
||||
items={result.items}
|
||||
keyOf={(event) => event.id}
|
||||
empty={{ icon: <CursorClickIcon size={32} />, title: t`No Events Found` }}
|
||||
page={page}
|
||||
totalPages={result.totalPages}
|
||||
onPageChange={setPage}
|
||||
renderItem={(event) => (
|
||||
<TelemetryListItem
|
||||
title={event.name}
|
||||
meta={[
|
||||
playerName(event),
|
||||
event.route_id || event.path || "",
|
||||
<RelativeTime date={event.created} />,
|
||||
]}
|
||||
onClick={() => handleEventClick(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<DetailSheet
|
||||
title={t`Event Details`}
|
||||
opened={detailsOpened}
|
||||
onClose={handleCloseDetails}
|
||||
fields={
|
||||
selectedEvent
|
||||
? [
|
||||
{ label: <Trans>Name</Trans>, value: selectedEvent.name },
|
||||
{ label: <Trans>Player</Trans>, value: playerName(selectedEvent) },
|
||||
...(selectedEvent.path
|
||||
? [{ label: <Trans>Path</Trans>, value: selectedEvent.path }]
|
||||
: []),
|
||||
...(selectedEvent.route_id
|
||||
? [{ label: <Trans>Route</Trans>, value: selectedEvent.route_id }]
|
||||
: []),
|
||||
...(selectedEvent.session_id
|
||||
? [{ label: <Trans>Session</Trans>, value: selectedEvent.session_id }]
|
||||
: []),
|
||||
...(selectedEvent.value !== undefined && selectedEvent.value !== null
|
||||
? [{ label: <Trans>Value</Trans>, value: selectedEvent.value }]
|
||||
: []),
|
||||
...(selectedEvent.props
|
||||
? [
|
||||
{
|
||||
label: <Trans>Props</Trans>,
|
||||
value: (
|
||||
<Code block style={{ fontSize: "11px" }}>
|
||||
{JSON.stringify(selectedEvent.props, null, 2)}
|
||||
</Code>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(selectedEvent.user_agent
|
||||
? [{ label: <Trans>User Agent</Trans>, value: selectedEvent.user_agent }]
|
||||
: []),
|
||||
{
|
||||
label: <Trans>Created</Trans>,
|
||||
value: new Date(selectedEvent.created).toLocaleString(i18n.locale),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { Container, Divider, Group, Stack, Switch } from "@mantine/core";
|
||||
import { ChecksIcon } from "@phosphor-icons/react";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
import EmptyState from "@/components/empty-state";
|
||||
import { useClientErrorGroups } from "../../queries";
|
||||
import { lastNDays } from "../../range";
|
||||
import { ErrorGroupRow } from "./error-group-row";
|
||||
|
||||
const ErrorGroupList = ({
|
||||
from,
|
||||
to,
|
||||
includeResolved,
|
||||
}: {
|
||||
from: string;
|
||||
to: string;
|
||||
includeResolved: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const { data: groups } = useClientErrorGroups({ from, to, includeResolved });
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ChecksIcon size={32} />}
|
||||
title={t`No unresolved errors`}
|
||||
description={t`Nothing has broken in the last 30 days. Keep it up.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{groups.map((group, index) => (
|
||||
<div key={group.group_hash}>
|
||||
<ErrorGroupRow group={group} />
|
||||
{index < groups.length - 1 && <Divider />}
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorBrowser = () => {
|
||||
const { t } = useLingui();
|
||||
const [includeResolved, setIncludeResolved] = useState(false);
|
||||
const range = useMemo(() => lastNDays(30), []);
|
||||
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Stack gap="xs">
|
||||
<Group px="md" justify="flex-end">
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t`Include resolved`}
|
||||
checked={includeResolved}
|
||||
onChange={(event) => setIncludeResolved(event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Suspense fallback={<ListSkeleton rows={6} withSubtitle />}>
|
||||
<ErrorGroupList
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
includeResolved={includeResolved}
|
||||
/>
|
||||
</Suspense>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorBrowser;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { resolveClientErrorGroup } from "../../server";
|
||||
import { telemetryKeys } from "../../queries";
|
||||
import type { ClientErrorGroup } from "../../types";
|
||||
import { RelativeTime } from "../shared";
|
||||
|
||||
export const ErrorGroupRow = ({ group }: { group: ClientErrorGroup }) => {
|
||||
const { t } = useLingui();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const resolveMutation = useServerMutation({
|
||||
mutationFn: (variables: { groupHash: string; resolved: boolean }) =>
|
||||
resolveClientErrorGroup({ data: variables }),
|
||||
successMessage: group.resolved ? t`Error group reopened` : t`Error group resolved`,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: telemetryKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
p="md"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
style={{ borderRadius: 0 }}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge color={group.resolved ? "gray" : "red"} variant="light" size="sm">
|
||||
{group.count.toLocaleString()}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} lineClamp={2} style={{ minWidth: 0 }}>
|
||||
{group.sample.message}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{group.sample.source && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{group.sample.source}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
<RelativeTime date={group.lastSeen} />
|
||||
</Text>
|
||||
{group.resolved && (
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Resolved</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse in={expanded} transitionDuration={200}>
|
||||
<Stack gap="sm" px="md" pb="md">
|
||||
{group.sample.path && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{group.sample.path}
|
||||
</Text>
|
||||
)}
|
||||
{group.sample.stack && (
|
||||
<Code
|
||||
block
|
||||
style={{ fontSize: "11px", maxHeight: 240, overflow: "auto" }}
|
||||
>
|
||||
{group.sample.stack}
|
||||
</Code>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={group.resolved ? "yellow" : "green"}
|
||||
loading={resolveMutation.isPending}
|
||||
onClick={() =>
|
||||
resolveMutation.mutate({
|
||||
groupHash: group.group_hash,
|
||||
resolved: !group.resolved,
|
||||
})
|
||||
}
|
||||
>
|
||||
{group.resolved ? <Trans>Reopen</Trans> : <Trans>Mark resolved</Trans>}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorGroupRow;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ErrorBrowser } from "./error-browser";
|
||||
export { ErrorGroupRow } from "./error-group-row";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ReactNode, memo } from "react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
|
||||
export interface DetailField {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
interface DetailSheetProps {
|
||||
title: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
fields: DetailField[];
|
||||
}
|
||||
|
||||
export const DetailSheet = memo(({ title, opened, onClose, fields }: DetailSheetProps) => (
|
||||
<Sheet title={title} opened={opened} onChange={onClose}>
|
||||
<Stack gap="md" p="md">
|
||||
{fields.map((field, index) => (
|
||||
<Stack gap="xs" key={index}>
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
{field.label}
|
||||
</Text>
|
||||
{typeof field.value === "string" || typeof field.value === "number" ? (
|
||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||
{field.value}
|
||||
</Text>
|
||||
) : (
|
||||
field.value
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Sheet>
|
||||
));
|
||||
|
||||
export default DetailSheet;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { TelemetryListItem } from "./telemetry-list-item";
|
||||
export { DetailSheet } from "./detail-sheet";
|
||||
export type { DetailField } from "./detail-sheet";
|
||||
export { SearchFilterBar } from "./search-filter-bar";
|
||||
export type { FilterSelectConfig, FilterChipConfig, SortFieldConfig } from "./search-filter-bar";
|
||||
export { PaginatedList } from "./paginated-list";
|
||||
export { RelativeTime } from "./relative-time";
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Box, Divider, Group, Pagination, Stack } from "@mantine/core";
|
||||
import EmptyState from "@/components/empty-state";
|
||||
|
||||
interface PaginatedListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T) => ReactNode;
|
||||
keyOf: (item: T) => string;
|
||||
empty: { icon: ReactNode; title: string; description?: string };
|
||||
page?: number;
|
||||
totalPages?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
|
||||
export const PaginatedList = <T,>({
|
||||
items,
|
||||
renderItem,
|
||||
keyOf,
|
||||
empty,
|
||||
page = 1,
|
||||
totalPages = 1,
|
||||
onPageChange,
|
||||
}: PaginatedListProps<T>) => (
|
||||
<>
|
||||
<Stack gap={0}>
|
||||
{items.map((item, index) => (
|
||||
<Box key={keyOf(item)}>
|
||||
{renderItem(item)}
|
||||
{index < items.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{items.length === 0 && (
|
||||
<EmptyState icon={empty.icon} title={empty.title} description={empty.description} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && onPageChange && (
|
||||
<Group justify="center" py="md">
|
||||
<Pagination total={totalPages} value={page} onChange={onPageChange} size="sm" />
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default PaginatedList;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
export const RelativeTime = ({ date }: { date?: string }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
if (!date) return <>{t`Never`}</>;
|
||||
|
||||
const then = new Date(date);
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return <>{t`Just now`}</>;
|
||||
if (diffMins < 60) return <>{t`${diffMins}m ago`}</>;
|
||||
if (diffHours < 24) return <>{t`${diffHours}h ago`}</>;
|
||||
if (diffDays < 30) return <>{t`${diffDays}d ago`}</>;
|
||||
return <>{then.toLocaleString(i18n.locale)}</>;
|
||||
};
|
||||
|
||||
export default RelativeTime;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Chip, Group, Select, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { CaretDownIcon, CaretUpIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
export interface FilterSelectConfig {
|
||||
placeholder: string;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
data: { value: string; label: string }[];
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterChipConfig {
|
||||
label: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export interface SortFieldConfig {
|
||||
field: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SearchFilterBarProps {
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
filters?: FilterSelectConfig[];
|
||||
chips?: FilterChipConfig[];
|
||||
sortFields?: SortFieldConfig[];
|
||||
sortBy?: string;
|
||||
onSort?: (field: string) => void;
|
||||
summary?: ReactNode;
|
||||
}
|
||||
|
||||
export const SearchFilterBar = ({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
filters,
|
||||
chips,
|
||||
sortFields,
|
||||
sortBy = "",
|
||||
onSort,
|
||||
summary,
|
||||
}: SearchFilterBarProps) => {
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortBy === field) return <CaretUpIcon size={14} />;
|
||||
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="xs" px="md">
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{filters && filters.length > 0 && (
|
||||
<Group>
|
||||
{filters.map((filter, index) => (
|
||||
<Select
|
||||
key={index}
|
||||
placeholder={filter.placeholder}
|
||||
value={filter.value}
|
||||
onChange={filter.onChange}
|
||||
data={filter.data}
|
||||
searchable={filter.searchable}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{chips && chips.length > 0 && (
|
||||
<Group gap="xs">
|
||||
{chips.map((chip, index) => (
|
||||
<Chip key={index} size="xs" checked={chip.checked} onChange={chip.onChange}>
|
||||
{chip.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{(summary || (sortFields && sortFields.length > 0)) && (
|
||||
<Group px="md" justify="space-between" align="center">
|
||||
<Text size="10px" lh={0} c="dimmed" component="div">
|
||||
{summary}
|
||||
</Text>
|
||||
{sortFields && sortFields.length > 0 && onSort && (
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Sort:</Trans>
|
||||
</Text>
|
||||
{sortFields.map((sortField, index) => (
|
||||
<Group gap="xs" key={sortField.field}>
|
||||
{index > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<UnstyledButton
|
||||
onClick={() => onSort(sortField.field)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes(sortField.field) ? 600 : 400}
|
||||
c={sortBy.includes(sortField.field) ? "var(--mantine-color-text)" : "dimmed"}
|
||||
>
|
||||
{sortField.label}
|
||||
</Text>
|
||||
{getSortIcon(sortField.field)}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFilterBar;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Fragment, ReactNode, memo } from "react";
|
||||
import { Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
interface TelemetryListItemProps {
|
||||
title: ReactNode;
|
||||
icon?: ReactNode;
|
||||
meta?: ReactNode[];
|
||||
error?: ReactNode;
|
||||
rightSection?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const TelemetryListItem = memo(
|
||||
({ title, icon, meta, error, rightSection, onClick }: TelemetryListItemProps) => (
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
p="md"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--mantine-color-gray-0)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%" wrap="nowrap">
|
||||
<Stack gap={4} flex={1} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{title}
|
||||
</Text>
|
||||
{icon}
|
||||
</Group>
|
||||
{meta && meta.length > 0 && (
|
||||
<Group gap="md">
|
||||
{meta.map((item, index) => (
|
||||
<Text key={index} size="xs" c="dimmed" component="span">
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{error && (
|
||||
<Text size="xs" c="red" lineClamp={1}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{rightSection && <Fragment>{rightSection}</Fragment>}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)
|
||||
);
|
||||
|
||||
export default TelemetryListItem;
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CardSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||
import { useRollupRange } from "../queries";
|
||||
import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range";
|
||||
import { BarList, Sparkline, StatTile } from "./charts";
|
||||
import { DetailSheet } from "./shared";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
|
||||
export const USAGE_METRICS = [
|
||||
"dau",
|
||||
"wau",
|
||||
"mau",
|
||||
"page_view.count",
|
||||
"page_view.sessions",
|
||||
"server_fn.count",
|
||||
"client_error.count",
|
||||
"vital.p75",
|
||||
];
|
||||
|
||||
const TOP_LIST_SIZE = 8;
|
||||
|
||||
type VitalName = "LCP" | "INP" | "CLS";
|
||||
|
||||
const VITAL_THRESHOLDS: Record<VitalName, { good: number; poor: number }> = {
|
||||
LCP: { good: 2500, poor: 4000 },
|
||||
INP: { good: 200, poor: 500 },
|
||||
CLS: { good: 0.1, poor: 0.25 },
|
||||
};
|
||||
|
||||
const vitalRating = (name: VitalName, value: number): "good" | "ok" | "poor" => {
|
||||
const { good, poor } = VITAL_THRESHOLDS[name];
|
||||
if (value <= good) return "good";
|
||||
if (value > poor) return "poor";
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const VitalBadge = ({ name, value }: { name: VitalName; value: number }) => {
|
||||
const rating = vitalRating(name, value);
|
||||
const color = rating === "good" ? "green" : rating === "ok" ? "yellow" : "red";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm">
|
||||
{rating === "good" ? (
|
||||
<Trans>Good</Trans>
|
||||
) : rating === "ok" ? (
|
||||
<Trans>Needs improvement</Trans>
|
||||
) : (
|
||||
<Trans>Poor</Trans>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatVital = (name: VitalName, value: number) =>
|
||||
name === "CLS" ? value.toFixed(2) : `${Math.round(value)}ms`;
|
||||
|
||||
const topItems = (totals: Map<string, number>, onClick?: (dim: string) => void) =>
|
||||
[...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_LIST_SIZE)
|
||||
.map(([dim, value]) => ({
|
||||
label: dim,
|
||||
value,
|
||||
onClick: onClick ? () => onClick(dim) : undefined,
|
||||
}));
|
||||
|
||||
const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: string }) => {
|
||||
const { data: rows } = useRollupRange({
|
||||
metrics: ["server_fn.duration.p50", "server_fn.duration.p95"],
|
||||
from,
|
||||
to,
|
||||
dim: fn,
|
||||
});
|
||||
const days = listDays({ from, to });
|
||||
const p50 = fillDailySeries(rows, "server_fn.duration.p50", fn, days);
|
||||
const p95 = fillDailySeries(rows, "server_fn.duration.p95", fn, days);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Sparkline data={p50} series2={p95} height={64} showArea />
|
||||
<Group gap="md">
|
||||
<Group gap={6}>
|
||||
<svg width={18} height={8} aria-hidden>
|
||||
<line x1={0} y1={4} x2={18} y2={4} stroke="var(--mantine-color-anchor)" strokeWidth={2} />
|
||||
</svg>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>p50</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<svg width={18} height={8} aria-hidden>
|
||||
<line
|
||||
x1={0}
|
||||
y1={4}
|
||||
x2={18}
|
||||
y2={4}
|
||||
stroke="var(--mantine-color-anchor)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
</svg>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>p95</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||
const { t } = useLingui();
|
||||
const [selectedFn, setSelectedFn] = useState<string | null>(null);
|
||||
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
|
||||
|
||||
const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to });
|
||||
|
||||
const days = useMemo(() => listDays({ from, to }), [from, to]);
|
||||
|
||||
const dauSeries = fillDailySeries(rows, "dau", "", days);
|
||||
const errorSeries = fillDailySeries(rows, "client_error.count", "", days);
|
||||
const sessions = fillDailySeries(rows, "page_view.sessions", "", days).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0
|
||||
);
|
||||
const pageViewItems = topItems(sumByDim(rows, "page_view.count"));
|
||||
const serverFnItems = topItems(sumByDim(rows, "server_fn.count"), (dim) => {
|
||||
setSelectedFn(dim);
|
||||
openFnDetail();
|
||||
});
|
||||
|
||||
const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({
|
||||
name,
|
||||
value: latestValue(rows, "vital.p75", name),
|
||||
}));
|
||||
|
||||
const handleCloseFnDetail = () => {
|
||||
setSelectedFn(null);
|
||||
closeFnDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Engagement</Trans>
|
||||
</Title>
|
||||
<StatTile
|
||||
label={<Trans>Daily active players</Trans>}
|
||||
value={(dauSeries[dauSeries.length - 1] ?? 0).toLocaleString()}
|
||||
spark={dauSeries}
|
||||
/>
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
<StatTile
|
||||
label={<Trans>WAU</Trans>}
|
||||
value={(latestValue(rows, "wau", "") ?? 0).toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={<Trans>MAU</Trans>}
|
||||
value={(latestValue(rows, "mau", "") ?? 0).toLocaleString()}
|
||||
/>
|
||||
<StatTile label={<Trans>Sessions</Trans>} value={sessions.toLocaleString()} />
|
||||
</SimpleGrid>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Page views by route</Trans>
|
||||
</Text>
|
||||
<BarList items={pageViewItems} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Server functions</Trans>
|
||||
</Title>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Top functions by call count</Trans>
|
||||
</Text>
|
||||
<BarList items={serverFnItems} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Errors</Trans>
|
||||
</Title>
|
||||
<StatTile
|
||||
label={<Trans>Client errors per day</Trans>}
|
||||
value={errorSeries.reduce((sum, value) => sum + value, 0).toLocaleString()}
|
||||
spark={errorSeries}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Vitals</Trans>
|
||||
</Title>
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
{vitals.map(({ name, value }) => (
|
||||
<StatTile
|
||||
key={name}
|
||||
label={name}
|
||||
value={value !== undefined ? formatVital(name, value) : "—"}
|
||||
caption={value !== undefined ? <VitalBadge name={name} value={value} /> : undefined}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<DetailSheet
|
||||
title={selectedFn ?? t`Function`}
|
||||
opened={fnDetailOpened}
|
||||
onClose={handleCloseFnDetail}
|
||||
fields={
|
||||
selectedFn
|
||||
? [
|
||||
{
|
||||
label: <Trans>Duration p50 / p95</Trans>,
|
||||
value: <FnDurationDetail fn={selectedFn} from={from} to={to} />,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsageScreen = () => {
|
||||
const { t } = useLingui();
|
||||
const [rangeDays, setRangeDays] = useState<"7" | "30">("7");
|
||||
const range = useMemo(() => lastNDays(Number(rangeDays)), [rangeDays]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
value={rangeDays}
|
||||
onChange={(value) => setRangeDays(value as "7" | "30")}
|
||||
data={[
|
||||
{ value: "7", label: t`7d` },
|
||||
{ value: "30", label: t`30d` },
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<Stack gap="lg">
|
||||
<StatTileSkeleton withCard align="row" />
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
<StatTileSkeleton withCard />
|
||||
<StatTileSkeleton withCard />
|
||||
<StatTileSkeleton withCard />
|
||||
</SimpleGrid>
|
||||
<CardSkeleton withAvatar={false} withBadge={false} />
|
||||
<CardSkeleton withAvatar={false} withBadge={false} />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<UsageSections from={range.from} to={range.to} />
|
||||
</Suspense>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageScreen;
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./types";
|
||||
export * from "./queries";
|
||||
export * from "./range";
|
||||
export * from "./components/charts";
|
||||
export * from "./components/shared";
|
||||
export { ClientEventsTable, defaultClientEventParams } from "./components/client-events-table";
|
||||
export { ErrorBrowser } from "./components/error-browser";
|
||||
export { UsageScreen, USAGE_METRICS } from "./components/usage-screen";
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RollupRow } from "./types";
|
||||
|
||||
export const isoDay = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
export interface DayRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const lastNDays = (days: number): DayRange => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
from: isoDay(new Date(now - (days - 1) * 86400000)),
|
||||
to: isoDay(new Date(now)),
|
||||
};
|
||||
};
|
||||
|
||||
export const listDays = ({ from, to }: DayRange): string[] => {
|
||||
const start = Date.parse(`${from}T00:00:00Z`);
|
||||
const end = Date.parse(`${to}T00:00:00Z`);
|
||||
const days: string[] = [];
|
||||
for (let ts = start; ts <= end; ts += 86400000) {
|
||||
days.push(isoDay(new Date(ts)));
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
export const fillDailySeries = (
|
||||
rows: RollupRow[],
|
||||
metric: string,
|
||||
dim: string,
|
||||
days: string[]
|
||||
): number[] => {
|
||||
const byDate = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row.metric === metric && row.dim === dim) {
|
||||
byDate.set(row.date, (byDate.get(row.date) ?? 0) + row.value);
|
||||
}
|
||||
}
|
||||
return days.map((day) => byDate.get(day) ?? 0);
|
||||
};
|
||||
|
||||
export const sumByDim = (rows: RollupRow[], metric: string): Map<string, number> => {
|
||||
const totals = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row.metric === metric && row.dim !== "") {
|
||||
totals.set(row.dim, (totals.get(row.dim) ?? 0) + row.value);
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
};
|
||||
|
||||
export const latestValue = (
|
||||
rows: RollupRow[],
|
||||
metric: string,
|
||||
dim: string
|
||||
): number | undefined => {
|
||||
let latest: RollupRow | undefined;
|
||||
for (const row of rows) {
|
||||
if (row.metric !== metric || row.dim !== dim) continue;
|
||||
if (!latest || row.date > latest.date) latest = row;
|
||||
}
|
||||
return latest?.value;
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
import {
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Text,
|
||||
Group,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||
import { TrashIcon } from "@phosphor-icons/react";
|
||||
import { useState, useCallback, useMemo, memo } from "react";
|
||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||
import { useTournament, useUnenrolledTeams } from "../queries";
|
||||
import useEnrollTeam from "../hooks/use-enroll-team";
|
||||
import useUnenrollTeam from "../hooks/use-unenroll-team";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
|
||||
interface EditEnrolledTeamsProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
interface TeamItemProps {
|
||||
team: TeamInfo;
|
||||
onUnenroll: (teamId: string) => void;
|
||||
disabled: boolean;
|
||||
isRegional?: boolean;
|
||||
}
|
||||
|
||||
const TeamItem = memo(({ team, onUnenroll, disabled, isRegional }: TeamItemProps) => {
|
||||
const playerNames = useMemo(
|
||||
() =>
|
||||
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
|
||||
"",
|
||||
[team.players]
|
||||
);
|
||||
|
||||
return (
|
||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||
<TeamAvatar
|
||||
team={team}
|
||||
size={32}
|
||||
radius="sm"
|
||||
isRegional={isRegional}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} truncate>
|
||||
{team.name}
|
||||
</Text>
|
||||
{playerNames && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{playerNames}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onUnenroll(team.id)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
const { t } = useLingui();
|
||||
const { data: tournament, isLoading: tournamentLoading } =
|
||||
useTournament(tournamentId);
|
||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||
useUnenrolledTeams(tournamentId);
|
||||
|
||||
const isRegional = tournament?.regional;
|
||||
|
||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||
|
||||
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const filtered = unenrolledTeams.filter((team: Team) =>
|
||||
team.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return filtered.map((team: Team) => ({
|
||||
id: team.id,
|
||||
data: team
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEnrollTeam = useCallback(
|
||||
(option: TypeaheadOption<Team>) => {
|
||||
enrollTeam({ tournamentId, teamId: option.data.id });
|
||||
},
|
||||
[enrollTeam, tournamentId]
|
||||
);
|
||||
|
||||
const handleUnenrollTeam = useCallback(
|
||||
(teamId: string) => {
|
||||
unenrollTeam({ tournamentId, teamId });
|
||||
},
|
||||
[unenrollTeam, tournamentId]
|
||||
);
|
||||
|
||||
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
||||
const team = option.data;
|
||||
return (
|
||||
<Group py="xs" px="sm" gap="sm" align="center">
|
||||
<TeamAvatar
|
||||
team={team as any}
|
||||
size={32}
|
||||
radius="sm"
|
||||
isRegional={isRegional}
|
||||
/>
|
||||
<Text fw={500} truncate>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTeam = (option: TypeaheadOption<Team>) => {
|
||||
return option.data.name;
|
||||
};
|
||||
|
||||
const isLoading = tournamentLoading || unenrolledLoading;
|
||||
const enrolledTeams = tournament?.teams || [];
|
||||
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="lg" w="100%">
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Add Team</Trans>
|
||||
</Text>
|
||||
<Typeahead
|
||||
placeholder={t`Search for teams to enroll...`}
|
||||
onSelect={handleEnrollTeam}
|
||||
searchFn={searchTeams}
|
||||
renderOption={renderTeamOption}
|
||||
format={formatTeam}
|
||||
disabled={isEnrolling || unenrolledLoading}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Enrolled Teams</Trans>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Plural value={enrolledTeams.length} one="# team" other="# teams" />
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : !hasEnrolledTeams ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
<Trans>No teams enrolled yet</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs" w="100%">
|
||||
{enrolledTeams.map((team: TeamInfo) => (
|
||||
<TeamItem
|
||||
key={team.id}
|
||||
team={team}
|
||||
onUnenroll={handleUnenrollTeam}
|
||||
disabled={isUnenrolling}
|
||||
isRegional={isRegional}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditEnrolledTeams;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Alert, Group, Stack, Text } from "@mantine/core";
|
||||
import { WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { Trans, Plural } from "@lingui/react/macro";
|
||||
|
||||
interface EnrollmentStatsProps {
|
||||
count: number;
|
||||
regional?: boolean;
|
||||
}
|
||||
|
||||
const EnrollmentStats = (props: EnrollmentStatsProps) => {
|
||||
const hasOddPlayers = props.count % 2 !== 0;
|
||||
const hasEnoughPlayers = props.count >= 2;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="xl" fw={700}>
|
||||
{props.count}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{props.regional ? (
|
||||
<Plural
|
||||
value={props.count}
|
||||
one="player enrolled"
|
||||
other="players enrolled"
|
||||
/>
|
||||
) : (
|
||||
<Trans>Enrolled Teams</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{props.regional && !hasEnoughPlayers && (
|
||||
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
|
||||
<Trans>Need at least 2 players to create teams</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{props.regional && hasOddPlayers && (
|
||||
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
|
||||
<Trans>Cannot create teams with an odd number of players. Please have one player unenroll.</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnrollmentStats;
|
||||
+43
-20
@@ -4,19 +4,23 @@ import {
|
||||
Text,
|
||||
Group,
|
||||
Loader,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
import { TrashIcon } from "@phosphor-icons/react";
|
||||
import { TrashIcon, ShuffleIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, memo } from "react";
|
||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||
import { useFreeAgents } from "../queries";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Button from "@/components/button";
|
||||
import { useFreeAgents } from "../../queries";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
import { PlayerInfo, Player } from "@/features/players/types";
|
||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||
import { usePlayers } from "@/features/players/queries";
|
||||
import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
|
||||
import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
|
||||
import useAdminEnrollPlayer from "../../hooks/use-admin-enroll-player";
|
||||
import useAdminUnenrollPlayer from "../../hooks/use-admin-unenroll-player";
|
||||
import EnrollmentStats from "./enrollment-stats";
|
||||
|
||||
interface EditEnrolledPlayersProps {
|
||||
interface FreeAgentSectionProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
@@ -51,8 +55,9 @@ const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
|
||||
const FreeAgentSection = ({ tournamentId }: FreeAgentSectionProps) => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
|
||||
const { data: allPlayers = [] } = usePlayers();
|
||||
|
||||
@@ -112,19 +117,37 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg" w="100%">
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Add Player</Trans>
|
||||
</Text>
|
||||
<Typeahead
|
||||
placeholder={t`Search for players to enroll...`}
|
||||
onSelect={handleEnrollPlayer}
|
||||
searchFn={searchPlayers}
|
||||
renderOption={renderPlayerOption}
|
||||
format={formatPlayer}
|
||||
disabled={isEnrolling}
|
||||
/>
|
||||
</Stack>
|
||||
<EnrollmentStats count={enrolledPlayers.length} regional />
|
||||
|
||||
<Box
|
||||
pos="sticky"
|
||||
top={0}
|
||||
py="xs"
|
||||
style={{ zIndex: 5, backgroundColor: "var(--mantine-color-body)" }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Add Player</Trans>
|
||||
</Text>
|
||||
<Typeahead
|
||||
placeholder={t`Search for players to enroll...`}
|
||||
onSelect={handleEnrollPlayer}
|
||||
searchFn={searchPlayers}
|
||||
renderOption={renderPlayerOption}
|
||||
format={formatPlayer}
|
||||
disabled={isEnrolling}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
leftSection={<ShuffleIcon size={18} />}
|
||||
onClick={() =>
|
||||
navigate({ to: `/admin/tournaments/${tournamentId}/assign-partners` })
|
||||
}
|
||||
>
|
||||
<Trans>Assign Partners</Trans>
|
||||
</Button>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
@@ -161,4 +184,4 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EditEnrolledPlayers;
|
||||
export default FreeAgentSection;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Tournament } from "../../types";
|
||||
import FreeAgentSection from "./free-agent-section";
|
||||
import TeamSection from "./team-section";
|
||||
|
||||
interface EnrollmentProps {
|
||||
tournament: Tournament;
|
||||
}
|
||||
|
||||
const Enrollment = ({ tournament }: EnrollmentProps) => {
|
||||
return tournament.regional === true ? (
|
||||
<FreeAgentSection tournamentId={tournament.id} />
|
||||
) : (
|
||||
<TeamSection tournamentId={tournament.id} />
|
||||
);
|
||||
};
|
||||
|
||||
export default Enrollment;
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Text,
|
||||
Group,
|
||||
Loader,
|
||||
Box,
|
||||
Button as MantineButton,
|
||||
} from "@mantine/core";
|
||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||
import { TrashIcon, PencilIcon, MusicNotesIcon } from "@phosphor-icons/react";
|
||||
import { useState, useCallback, useMemo, memo } from "react";
|
||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useTournament, useUnenrolledTeams } from "../../queries";
|
||||
import useEnrollTeam from "../../hooks/use-enroll-team";
|
||||
import useUnenrollTeam from "../../hooks/use-unenroll-team";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import TeamForm from "@/features/teams/components/team-form";
|
||||
import { useTeam } from "@/features/teams/queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import EnrollmentStats from "./enrollment-stats";
|
||||
|
||||
interface TeamSectionProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
interface TeamItemProps {
|
||||
team: TeamInfo;
|
||||
onUnenroll: (teamId: string) => void;
|
||||
onEdit: (teamId: string) => void;
|
||||
disabled: boolean;
|
||||
isRegional?: boolean;
|
||||
}
|
||||
|
||||
const TeamItem = memo(({ team, onUnenroll, onEdit, disabled, isRegional }: TeamItemProps) => {
|
||||
const playerNames = useMemo(
|
||||
() =>
|
||||
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
|
||||
"",
|
||||
[team.players]
|
||||
);
|
||||
|
||||
return (
|
||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||
<TeamAvatar
|
||||
team={team}
|
||||
size={32}
|
||||
radius="sm"
|
||||
isRegional={isRegional}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} truncate>
|
||||
{team.name}
|
||||
</Text>
|
||||
{playerNames && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{playerNames}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => onEdit(team.id)}
|
||||
size="sm"
|
||||
>
|
||||
<PencilIcon size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onUnenroll(team.id)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
interface TeamEditSheetProps {
|
||||
teamId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
|
||||
const { data: team } = useTeam(teamId);
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={team ? t`Edit ${team.name}` : t`Edit Team`}
|
||||
opened={isOpen}
|
||||
onChange={onClose}
|
||||
>
|
||||
{team && (
|
||||
<TeamForm
|
||||
teamId={team.id}
|
||||
initialValues={{
|
||||
...team,
|
||||
players: team.players ? team.players.map((p) => p.id) : [],
|
||||
logo: typeof team.logo === "string" ? undefined : team.logo,
|
||||
}}
|
||||
close={onClose}
|
||||
/>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamSection = ({ tournamentId }: TeamSectionProps) => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const { data: tournament, isLoading: tournamentLoading } =
|
||||
useTournament(tournamentId);
|
||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||
useUnenrolledTeams(tournamentId);
|
||||
|
||||
const isRegional = tournament?.regional;
|
||||
|
||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const {
|
||||
isOpen: editTeamOpened,
|
||||
open: openEditTeam,
|
||||
close: closeEditTeam,
|
||||
} = useSheet();
|
||||
|
||||
const handleEditTeam = useCallback(
|
||||
(teamId: string) => {
|
||||
setSelectedTeamId(teamId);
|
||||
openEditTeam();
|
||||
},
|
||||
[openEditTeam]
|
||||
);
|
||||
|
||||
const handleCloseEditTeam = useCallback(() => {
|
||||
setSelectedTeamId(null);
|
||||
closeEditTeam();
|
||||
}, [closeEditTeam]);
|
||||
|
||||
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const filtered = unenrolledTeams.filter((team: Team) =>
|
||||
team.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return filtered.map((team: Team) => ({
|
||||
id: team.id,
|
||||
data: team
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEnrollTeam = useCallback(
|
||||
(option: TypeaheadOption<Team>) => {
|
||||
enrollTeam({ tournamentId, teamId: option.data.id });
|
||||
},
|
||||
[enrollTeam, tournamentId]
|
||||
);
|
||||
|
||||
const handleUnenrollTeam = useCallback(
|
||||
(teamId: string) => {
|
||||
unenrollTeam({ tournamentId, teamId });
|
||||
},
|
||||
[unenrollTeam, tournamentId]
|
||||
);
|
||||
|
||||
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
||||
const team = option.data;
|
||||
return (
|
||||
<Group py="xs" px="sm" gap="sm" align="center">
|
||||
<TeamAvatar
|
||||
team={team as any}
|
||||
size={32}
|
||||
radius="sm"
|
||||
isRegional={isRegional}
|
||||
/>
|
||||
<Text fw={500} truncate>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTeam = (option: TypeaheadOption<Team>) => {
|
||||
return option.data.name;
|
||||
};
|
||||
|
||||
const isLoading = tournamentLoading || unenrolledLoading;
|
||||
const enrolledTeams = tournament?.teams || [];
|
||||
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="lg" w="100%">
|
||||
<EnrollmentStats count={enrolledTeams.length} />
|
||||
|
||||
<Box
|
||||
pos="sticky"
|
||||
top={0}
|
||||
py="xs"
|
||||
style={{ zIndex: 5, backgroundColor: "var(--mantine-color-body)" }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Add Team</Trans>
|
||||
</Text>
|
||||
<Typeahead
|
||||
placeholder={t`Search for teams to enroll...`}
|
||||
onSelect={handleEnrollTeam}
|
||||
searchFn={searchTeams}
|
||||
renderOption={renderTeamOption}
|
||||
format={formatTeam}
|
||||
disabled={isEnrolling || unenrolledLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<MantineButton
|
||||
fullWidth
|
||||
variant="light"
|
||||
leftSection={<MusicNotesIcon size={18} />}
|
||||
onClick={() =>
|
||||
navigate({ to: `/admin/tournaments/${tournamentId}/teams` })
|
||||
}
|
||||
>
|
||||
<Trans>Manage Team Songs/Logos</Trans>
|
||||
</MantineButton>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Enrolled Teams</Trans>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Plural value={enrolledTeams.length} one="# team" other="# teams" />
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : !hasEnrolledTeams ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
<Trans>No teams enrolled yet</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs" w="100%">
|
||||
{enrolledTeams.map((team: TeamInfo) => (
|
||||
<TeamItem
|
||||
key={team.id}
|
||||
team={team}
|
||||
onUnenroll={handleUnenrollTeam}
|
||||
onEdit={handleEditTeam}
|
||||
disabled={isUnenrolling}
|
||||
isRegional={isRegional}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{selectedTeamId && (
|
||||
<TeamEditSheet
|
||||
teamId={selectedTeamId}
|
||||
isOpen={editTeamOpened}
|
||||
onClose={handleCloseEditTeam}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamSection;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTournament } from "../queries";
|
||||
import { List } from "@mantine/core";
|
||||
import { Box, List } from "@mantine/core";
|
||||
import ListButton from "@/components/list-button";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import TournamentForm from "./tournament-form";
|
||||
@@ -8,16 +8,13 @@ import {
|
||||
PencilLineIcon,
|
||||
TreeStructureIcon,
|
||||
UsersThreeIcon,
|
||||
UsersIcon,
|
||||
ShuffleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||
import EditEnrolledPlayers from "./edit-enrolled-players";
|
||||
import ListLink from "@/components/list-link";
|
||||
import React from "react";
|
||||
import EditRules from "./edit-rules";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import Button from "@/components/button";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
interface ManageTournamentProps {
|
||||
tournamentId: string;
|
||||
@@ -25,6 +22,7 @@ interface ManageTournamentProps {
|
||||
|
||||
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const { data: tournament } = useTournament(tournamentId);
|
||||
|
||||
if (!tournament) throw new Error("Tournament not found.");
|
||||
@@ -38,17 +36,26 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
open: openEditRules,
|
||||
close: closeEditRules,
|
||||
} = useSheet();
|
||||
const {
|
||||
isOpen: editTeamsOpened,
|
||||
open: openEditTeams,
|
||||
close: closeEditTeams,
|
||||
} = useSheet();
|
||||
|
||||
const [v, setV] = React.useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box px="md" py="md">
|
||||
<Button
|
||||
leftSection={<TreeStructureIcon weight="bold" size={20} />}
|
||||
onClick={() =>
|
||||
navigate({ to: `/admin/tournaments/run/${tournamentId}` })
|
||||
}
|
||||
>
|
||||
<Trans>Run Tournament</Trans>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<List p="0">
|
||||
<ListLink
|
||||
label={t`Enrollment & Teams`}
|
||||
Icon={UsersThreeIcon}
|
||||
to={`/admin/tournaments/${tournamentId}/enrollment`}
|
||||
/>
|
||||
<ListButton
|
||||
label={t`Edit Tournament`}
|
||||
Icon={HardDrivesIcon}
|
||||
@@ -59,30 +66,6 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
Icon={PencilLineIcon}
|
||||
onClick={openEditRules}
|
||||
/>
|
||||
<ListButton
|
||||
label={t`Edit Enrollments`}
|
||||
Icon={UsersThreeIcon}
|
||||
onClick={openEditTeams}
|
||||
/>
|
||||
{tournament.regional && (
|
||||
<ListLink
|
||||
label={t`Assign Partners`}
|
||||
Icon={ShuffleIcon}
|
||||
to={`/admin/tournaments/${tournamentId}/assign-partners`}
|
||||
/>
|
||||
)}
|
||||
{!tournament.regional && (
|
||||
<ListLink
|
||||
label={t`Manage Team Songs/Logos`}
|
||||
Icon={UsersIcon}
|
||||
to={`/admin/tournaments/${tournamentId}/teams`}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={t`Run Tournament`}
|
||||
Icon={TreeStructureIcon}
|
||||
to={`/admin/tournaments/run/${tournamentId}`}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<Sheet
|
||||
@@ -112,18 +95,6 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
>
|
||||
<EditRules tournamentId={tournamentId} onClose={closeEditRules} />
|
||||
</Sheet>
|
||||
|
||||
<Sheet
|
||||
title={tournament.regional === true ? t`Manage Enrollments` : t`Edit Enrolled Teams`}
|
||||
opened={editTeamsOpened}
|
||||
onChange={closeEditTeams}
|
||||
>
|
||||
{tournament.regional === true ? (
|
||||
<EditEnrolledPlayers tournamentId={tournamentId} />
|
||||
) : (
|
||||
<EditEnrolledTeams tournamentId={tournamentId} />
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user