From a52872bea82fba457ee0bf855722bec0c40b228c Mon Sep 17 00:00:00 2001 From: yohlo Date: Tue, 25 Aug 2026 21:54:21 -0700 Subject: [PATCH] feat(admin): dashboard home, telemetry section, enrollment ops screen - mission-control dashboard: now strip, tournament quick actions, usage sparklines, error feed, live activity, tools list - /admin/telemetry with functions/events/errors/players/usage child routes, hand-rolled svg chart primitives, decomposed activities table - error browser with grouping and mark-resolved - consolidated per-tournament enrollment screen; hub simplified with run tournament promoted - /admin/activities redirects to /admin/telemetry/functions --- src/app/routeTree.gen.ts | 183 +++++++ src/app/routes/_authed/admin/activities.tsx | 51 +- src/app/routes/_authed/admin/index.tsx | 17 +- src/app/routes/_authed/admin/telemetry.tsx | 64 +++ .../routes/_authed/admin/telemetry/errors.tsx | 27 + .../routes/_authed/admin/telemetry/events.tsx | 28 + .../_authed/admin/telemetry/functions.tsx | 28 + .../routes/_authed/admin/telemetry/index.tsx | 7 + .../_authed/admin/telemetry/players.tsx | 25 + .../routes/_authed/admin/telemetry/usage.tsx | 19 + .../admin/tournaments/$id/enrollment.tsx | 56 ++ .../components/activities-table.tsx | 504 ++++++------------ .../components/dashboard/activity-feed.tsx | 87 +++ .../components/dashboard/errors-card.tsx | 62 +++ .../admin/components/dashboard/index.tsx | 57 ++ .../admin/components/dashboard/now-strip.tsx | 61 +++ .../components/dashboard/quiet-boundary.tsx | 35 ++ .../tools-list.tsx} | 12 +- .../components/dashboard/tournament-card.tsx | 76 +++ .../admin/components/dashboard/usage-card.tsx | 55 ++ src/features/admin/index.ts | 2 +- .../telemetry/components/charts/bar-list.tsx | 87 +++ .../components/charts/chart-path.test.ts | 64 +++ .../telemetry/components/charts/chart-path.ts | 61 +++ .../telemetry/components/charts/index.ts | 5 + .../telemetry/components/charts/sparkline.tsx | 77 +++ .../telemetry/components/charts/stat-tile.tsx | 53 ++ .../components/client-events-table.tsx | 158 ++++++ .../error-browser/error-browser.tsx | 74 +++ .../error-browser/error-group-row.tsx | 107 ++++ .../components/error-browser/index.ts | 2 + .../components/shared/detail-sheet.tsx | 38 ++ .../telemetry/components/shared/index.ts | 7 + .../components/shared/paginated-list.tsx | 46 ++ .../components/shared/relative-time.tsx | 21 + .../components/shared/search-filter-bar.tsx | 134 +++++ .../components/shared/telemetry-list-item.tsx | 60 +++ .../telemetry/components/usage-screen.tsx | 281 ++++++++++ src/features/telemetry/index.ts | 8 + src/features/telemetry/range.ts | 64 +++ .../components/edit-enrolled-teams.tsx | 184 ------- .../enrollment/enrollment-stats.tsx | 48 ++ .../free-agent-section.tsx} | 63 ++- .../components/enrollment/index.tsx | 17 + .../components/enrollment/team-section.tsx | 283 ++++++++++ .../components/manage-tournament.tsx | 71 +-- 46 files changed, 2824 insertions(+), 645 deletions(-) create mode 100644 src/app/routes/_authed/admin/telemetry.tsx create mode 100644 src/app/routes/_authed/admin/telemetry/errors.tsx create mode 100644 src/app/routes/_authed/admin/telemetry/events.tsx create mode 100644 src/app/routes/_authed/admin/telemetry/functions.tsx create mode 100644 src/app/routes/_authed/admin/telemetry/index.tsx create mode 100644 src/app/routes/_authed/admin/telemetry/players.tsx create mode 100644 src/app/routes/_authed/admin/telemetry/usage.tsx create mode 100644 src/app/routes/_authed/admin/tournaments/$id/enrollment.tsx create mode 100644 src/features/admin/components/dashboard/activity-feed.tsx create mode 100644 src/features/admin/components/dashboard/errors-card.tsx create mode 100644 src/features/admin/components/dashboard/index.tsx create mode 100644 src/features/admin/components/dashboard/now-strip.tsx create mode 100644 src/features/admin/components/dashboard/quiet-boundary.tsx rename src/features/admin/components/{admin-page.tsx => dashboard/tools-list.tsx} (94%) create mode 100644 src/features/admin/components/dashboard/tournament-card.tsx create mode 100644 src/features/admin/components/dashboard/usage-card.tsx create mode 100644 src/features/telemetry/components/charts/bar-list.tsx create mode 100644 src/features/telemetry/components/charts/chart-path.test.ts create mode 100644 src/features/telemetry/components/charts/chart-path.ts create mode 100644 src/features/telemetry/components/charts/index.ts create mode 100644 src/features/telemetry/components/charts/sparkline.tsx create mode 100644 src/features/telemetry/components/charts/stat-tile.tsx create mode 100644 src/features/telemetry/components/client-events-table.tsx create mode 100644 src/features/telemetry/components/error-browser/error-browser.tsx create mode 100644 src/features/telemetry/components/error-browser/error-group-row.tsx create mode 100644 src/features/telemetry/components/error-browser/index.ts create mode 100644 src/features/telemetry/components/shared/detail-sheet.tsx create mode 100644 src/features/telemetry/components/shared/index.ts create mode 100644 src/features/telemetry/components/shared/paginated-list.tsx create mode 100644 src/features/telemetry/components/shared/relative-time.tsx create mode 100644 src/features/telemetry/components/shared/search-filter-bar.tsx create mode 100644 src/features/telemetry/components/shared/telemetry-list-item.tsx create mode 100644 src/features/telemetry/components/usage-screen.tsx create mode 100644 src/features/telemetry/index.ts create mode 100644 src/features/telemetry/range.ts delete mode 100644 src/features/tournaments/components/edit-enrolled-teams.tsx create mode 100644 src/features/tournaments/components/enrollment/enrollment-stats.tsx rename src/features/tournaments/components/{edit-enrolled-players.tsx => enrollment/free-agent-section.tsx} (73%) create mode 100644 src/features/tournaments/components/enrollment/index.tsx create mode 100644 src/features/tournaments/components/enrollment/team-section.tsx diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 0b8d5e6..99fca68 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -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, diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx index a62fc78..c450417 100644 --- a/src/app/routes/_authed/admin/activities.tsx +++ b/src/app/routes/_authed/admin/activities.tsx @@ -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("server-functions"); - - return ( - - - Server Functions - Player Activity - - - - }> - - - - - - }> - - - - - ); -} diff --git a/src/app/routes/_authed/admin/index.tsx b/src/app/routes/_authed/admin/index.tsx index 64934ec..a36a1db 100644 --- a/src/app/routes/_authed/admin/index.tsx +++ b/src/app/routes/_authed/admin/index.tsx @@ -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: () => , + component: () => , }); diff --git a/src/app/routes/_authed/admin/telemetry.tsx b/src/app/routes/_authed/admin/telemetry.tsx new file mode 100644 index 0000000..6ba2abe --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry.tsx @@ -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 ( + { + if (value && value in TAB_ROUTES) { + navigate({ to: TAB_ROUTES[value as TabValue] }); + } + }} + > + + + Functions + + + Events + + + Errors + + + Players + + + Usage + + + + + + ); +} diff --git a/src/app/routes/_authed/admin/telemetry/errors.tsx b/src/app/routes/_authed/admin/telemetry/errors.tsx new file mode 100644 index 0000000..ab553a6 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/errors.tsx @@ -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 ( + }> + + + ); +} diff --git a/src/app/routes/_authed/admin/telemetry/events.tsx b/src/app/routes/_authed/admin/telemetry/events.tsx new file mode 100644 index 0000000..cc030b6 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/events.tsx @@ -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 ( + }> + + + ); +} diff --git a/src/app/routes/_authed/admin/telemetry/functions.tsx b/src/app/routes/_authed/admin/telemetry/functions.tsx new file mode 100644 index 0000000..76e6e09 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/functions.tsx @@ -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 ( + }> + + + ); +} diff --git a/src/app/routes/_authed/admin/telemetry/index.tsx b/src/app/routes/_authed/admin/telemetry/index.tsx new file mode 100644 index 0000000..e9553e9 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/index.tsx @@ -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" }); + }, +}); diff --git a/src/app/routes/_authed/admin/telemetry/players.tsx b/src/app/routes/_authed/admin/telemetry/players.tsx new file mode 100644 index 0000000..eb1b734 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/players.tsx @@ -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 ( + }> + + + ); +} diff --git a/src/app/routes/_authed/admin/telemetry/usage.tsx b/src/app/routes/_authed/admin/telemetry/usage.tsx new file mode 100644 index 0000000..124caf9 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/usage.tsx @@ -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 ; +} diff --git a/src/app/routes/_authed/admin/tournaments/$id/enrollment.tsx b/src/app/routes/_authed/admin/tournaments/$id/enrollment.tsx new file mode 100644 index 0000000..5090bce --- /dev/null +++ b/src/app/routes/_authed/admin/tournaments/$id/enrollment.tsx @@ -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 ( + + + + + + + + + ); +} + +function RouteComponent() { + const { tournament } = Route.useRouteContext(); + return ; +} diff --git a/src/features/activities/components/activities-table.tsx b/src/features/activities/components/activities-table.tsx index 4bf97b7..2a37fa7 100644 --- a/src/features/activities/components/activities-table.tsx +++ b/src/features/activities/components/activities-table.tsx @@ -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 ( - - - - - - {activity.name} - - {activity.success ? ( - - ) : ( - - )} - - - - {playerName} - - - {activity.duration}ms - - - {formatDate(activity.created)} - - - {activity.error && ( - - {activity.error} - - )} - - - - ); -}); - -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 ( - - - - - Function Name - - {activity.name} - - - - - Status - - - {activity.success ? ( - <> - - - Success - - - ) : ( - <> - - - Failed - - - )} - - - - - - Player - - {playerName} - - - - - Duration - - {activity.duration}ms - - - - - Created - - {formatDate(activity.created)} - - - {activity.user_agent && ( - - - User Agent - - - {activity.user_agent} - - - )} - - {activity.error && ( - - - Error Message - - - - {activity.error} - - - - )} - - {activity.arguments && ( - - - Arguments - - - {JSON.stringify(activity.arguments, null, 2)} - - - )} - - - ); -}); - -const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => { - const { data: result } = useActivities(searchParams); - return ( - <> - - {result.items.map((activity: Activity, index: number) => ( - - onActivityClick(activity)} - /> - {index < result.items.length - 1 && } - - ))} - - - {result.items.length === 0 && ( - - - - - - <Trans>No Activities Found</Trans> - - - )} - - {result.totalPages > 1 && ( - - - - )} - - ); -}; +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(null); + const [playerFilter, setPlayerFilter] = useState(null); + const [slowOnly, setSlowOnly] = useState(false); const [sortBy, setSortBy] = useState("-created"); const [selectedActivity, setSelectedActivity] = useState(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 ; - if (sortBy === `-${field}`) return ; - return null; - }; - const handleActivityClick = (activity: Activity) => { setSelectedActivity(activity); openDetails(); @@ -304,90 +73,163 @@ export const ActivitiesTable = () => { return ( - - { - setSearch(e.currentTarget.value); - setPage(1); - }} - leftSection={} - size="md" - /> - - - + ))} + + )} + + {chips && chips.length > 0 && ( + + {chips.map((chip, index) => ( + + {chip.label} + + ))} + + )} + + + {(summary || (sortFields && sortFields.length > 0)) && ( + + + {summary} + + {sortFields && sortFields.length > 0 && onSort && ( + + + Sort: + + {sortFields.map((sortField, index) => ( + + {index > 0 && ( + + • + + )} + onSort(sortField.field)} + style={{ display: "flex", alignItems: "center", gap: 4 }} + > + + {sortField.label} + + {getSortIcon(sortField.field)} + + + ))} + + )} + + )} + + ); +}; + +export default SearchFilterBar; diff --git a/src/features/telemetry/components/shared/telemetry-list-item.tsx b/src/features/telemetry/components/shared/telemetry-list-item.tsx new file mode 100644 index 0000000..fdee4ba --- /dev/null +++ b/src/features/telemetry/components/shared/telemetry-list-item.tsx @@ -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) => ( + + + + + + {title} + + {icon} + + {meta && meta.length > 0 && ( + + {meta.map((item, index) => ( + + {item} + + ))} + + )} + {error && ( + + {error} + + )} + + {rightSection && {rightSection}} + + + ) +); + +export default TelemetryListItem; diff --git a/src/features/telemetry/components/usage-screen.tsx b/src/features/telemetry/components/usage-screen.tsx new file mode 100644 index 0000000..3448453 --- /dev/null +++ b/src/features/telemetry/components/usage-screen.tsx @@ -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 = { + 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 ( + + {rating === "good" ? ( + Good + ) : rating === "ok" ? ( + Needs improvement + ) : ( + Poor + )} + + ); +}; + +const formatVital = (name: VitalName, value: number) => + name === "CLS" ? value.toFixed(2) : `${Math.round(value)}ms`; + +const topItems = (totals: Map, 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 ( + + + + + + + + + p50 + + + + + + + + p95 + + + + + ); +}; + +const UsageSections = ({ from, to }: { from: string; to: string }) => { + const { t } = useLingui(); + const [selectedFn, setSelectedFn] = useState(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 ( + + + + <Trans>Engagement</Trans> + + Daily active players} + value={(dauSeries[dauSeries.length - 1] ?? 0).toLocaleString()} + spark={dauSeries} + /> + + WAU} + value={(latestValue(rows, "wau", "") ?? 0).toLocaleString()} + /> + MAU} + value={(latestValue(rows, "mau", "") ?? 0).toLocaleString()} + /> + Sessions} value={sessions.toLocaleString()} /> + + + + + Page views by route + + + + + + + + + <Trans>Server functions</Trans> + + + + + Top functions by call count + + + + + + + + + <Trans>Errors</Trans> + + Client errors per day} + value={errorSeries.reduce((sum, value) => sum + value, 0).toLocaleString()} + spark={errorSeries} + /> + + + + + <Trans>Vitals</Trans> + + + {vitals.map(({ name, value }) => ( + : undefined} + /> + ))} + + + + Duration p50 / p95, + value: , + }, + ] + : [] + } + /> + + ); +}; + +export const UsageScreen = () => { + const { t } = useLingui(); + const [rangeDays, setRangeDays] = useState<"7" | "30">("7"); + const range = useMemo(() => lastNDays(Number(rangeDays)), [rangeDays]); + + return ( + + setRangeDays(value as "7" | "30")} + data={[ + { value: "7", label: t`7d` }, + { value: "30", label: t`30d` }, + ]} + fullWidth + /> + + + + + + + + + + + + } + > + + + + ); +}; + +export default UsageScreen; diff --git a/src/features/telemetry/index.ts b/src/features/telemetry/index.ts new file mode 100644 index 0000000..65ab60b --- /dev/null +++ b/src/features/telemetry/index.ts @@ -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"; diff --git a/src/features/telemetry/range.ts b/src/features/telemetry/range.ts new file mode 100644 index 0000000..d6f9179 --- /dev/null +++ b/src/features/telemetry/range.ts @@ -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(); + 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 => { + const totals = new Map(); + 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; +}; diff --git a/src/features/tournaments/components/edit-enrolled-teams.tsx b/src/features/tournaments/components/edit-enrolled-teams.tsx deleted file mode 100644 index 40e5821..0000000 --- a/src/features/tournaments/components/edit-enrolled-teams.tsx +++ /dev/null @@ -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 ( - - - - - {team.name} - - {playerNames && ( - - {playerNames} - - )} - - onUnenroll(team.id)} - disabled={disabled} - size="sm" - > - - - - ); -}); - -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[]> => { - 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) => { - enrollTeam({ tournamentId, teamId: option.data.id }); - }, - [enrollTeam, tournamentId] - ); - - const handleUnenrollTeam = useCallback( - (teamId: string) => { - unenrollTeam({ tournamentId, teamId }); - }, - [unenrollTeam, tournamentId] - ); - - const renderTeamOption = (option: TypeaheadOption) => { - const team = option.data; - return ( - - - - {team.name} - - - ); - }; - - const formatTeam = (option: TypeaheadOption) => { - return option.data.name; - }; - - const isLoading = tournamentLoading || unenrolledLoading; - const enrolledTeams = tournament?.teams || []; - const hasEnrolledTeams = enrolledTeams.length > 0; - - return ( - - - - Add Team - - - - - - - - Enrolled Teams - - - - - - - {isLoading ? ( - - - - ) : !hasEnrolledTeams ? ( - - No teams enrolled yet - - ) : ( - - {enrolledTeams.map((team: TeamInfo) => ( - - ))} - - )} - - - ); -}; - -export default EditEnrolledTeams; diff --git a/src/features/tournaments/components/enrollment/enrollment-stats.tsx b/src/features/tournaments/components/enrollment/enrollment-stats.tsx new file mode 100644 index 0000000..5fa382f --- /dev/null +++ b/src/features/tournaments/components/enrollment/enrollment-stats.tsx @@ -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 ( + + + + {props.count} + + + {props.regional ? ( + + ) : ( + Enrolled Teams + )} + + + + {props.regional && !hasEnoughPlayers && ( + }> + Need at least 2 players to create teams + + )} + + {props.regional && hasOddPlayers && ( + }> + Cannot create teams with an odd number of players. Please have one player unenroll. + + )} + + ); +}; + +export default EnrollmentStats; diff --git a/src/features/tournaments/components/edit-enrolled-players.tsx b/src/features/tournaments/components/enrollment/free-agent-section.tsx similarity index 73% rename from src/features/tournaments/components/edit-enrolled-players.tsx rename to src/features/tournaments/components/enrollment/free-agent-section.tsx index 8dd8f1e..24ffa6e 100644 --- a/src/features/tournaments/components/edit-enrolled-players.tsx +++ b/src/features/tournaments/components/enrollment/free-agent-section.tsx @@ -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 ( - - - Add Player - - - + + + + + + Add Player + + + + + + @@ -161,4 +184,4 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => { ); }; -export default EditEnrolledPlayers; +export default FreeAgentSection; diff --git a/src/features/tournaments/components/enrollment/index.tsx b/src/features/tournaments/components/enrollment/index.tsx new file mode 100644 index 0000000..ddd6687 --- /dev/null +++ b/src/features/tournaments/components/enrollment/index.tsx @@ -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 ? ( + + ) : ( + + ); +}; + +export default Enrollment; diff --git a/src/features/tournaments/components/enrollment/team-section.tsx b/src/features/tournaments/components/enrollment/team-section.tsx new file mode 100644 index 0000000..c2b8bc0 --- /dev/null +++ b/src/features/tournaments/components/enrollment/team-section.tsx @@ -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 ( + + + + + {team.name} + + {playerNames && ( + + {playerNames} + + )} + + onEdit(team.id)} + size="sm" + > + + + onUnenroll(team.id)} + disabled={disabled} + size="sm" + > + + + + ); +}); + +interface TeamEditSheetProps { + teamId: string; + isOpen: boolean; + onClose: () => void; +} + +const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => { + const { data: team } = useTeam(teamId); + const { t } = useLingui(); + + return ( + + {team && ( + p.id) : [], + logo: typeof team.logo === "string" ? undefined : team.logo, + }} + close={onClose} + /> + )} + + ); +}; + +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(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[]> => { + 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) => { + enrollTeam({ tournamentId, teamId: option.data.id }); + }, + [enrollTeam, tournamentId] + ); + + const handleUnenrollTeam = useCallback( + (teamId: string) => { + unenrollTeam({ tournamentId, teamId }); + }, + [unenrollTeam, tournamentId] + ); + + const renderTeamOption = (option: TypeaheadOption) => { + const team = option.data; + return ( + + + + {team.name} + + + ); + }; + + const formatTeam = (option: TypeaheadOption) => { + return option.data.name; + }; + + const isLoading = tournamentLoading || unenrolledLoading; + const enrolledTeams = tournament?.teams || []; + const hasEnrolledTeams = enrolledTeams.length > 0; + + return ( + <> + + + + + + + Add Team + + + + + + } + onClick={() => + navigate({ to: `/admin/tournaments/${tournamentId}/teams` }) + } + > + Manage Team Songs/Logos + + + + + + Enrolled Teams + + + + + + + {isLoading ? ( + + + + ) : !hasEnrolledTeams ? ( + + No teams enrolled yet + + ) : ( + + {enrolledTeams.map((team: TeamInfo) => ( + + ))} + + )} + + + + {selectedTeamId && ( + + )} + + ); +}; + +export default TeamSection; diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx index 4e08c46..a0ce96e 100644 --- a/src/features/tournaments/components/manage-tournament.tsx +++ b/src/features/tournaments/components/manage-tournament.tsx @@ -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 ( <> + + + + + { Icon={PencilLineIcon} onClick={openEditRules} /> - - {tournament.regional && ( - - )} - {!tournament.regional && ( - - )} - { > - - - {tournament.regional === true ? ( - - ) : ( - - )} - ); };