Development #24

Merged
kyle merged 35 commits from development into main 2026-08-28 10:40:10 -05:00
46 changed files with 2824 additions and 645 deletions
Showing only changes of commit a52872bea8 - Show all commits
+183
View File
@@ -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,
+3 -48
View File
@@ -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>
);
}
+15 -2
View File
@@ -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;
@@ -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;
+1 -1
View File
@@ -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">
&mdash;
</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}>
&mdash;
</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;
+8
View File
@@ -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";
+64
View File
@@ -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;
@@ -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>
</>
);
};