diff --git a/bun.lock b/bun.lock index 50968ac..a5f208e 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "twilio": "^5.8.0", "vaul": "^1.1.2", "web-push": "^3.6.7", + "web-vitals": "^6.2.0", "zod": "^4.0.15", }, "devDependencies": { @@ -1590,6 +1591,8 @@ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "web-vitals": ["web-vitals@6.2.0", "", {}, "sha512-i2jhLkq6eB38jgJX5OIjEkyzQKgsG7FR56v7GphJNjzKAySgbRbwAfsFC0dDfHROWmCycf912SJpU2QoYIcMVg=="], + "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], diff --git a/k8s/base/app-deployment.yaml b/k8s/base/app-deployment.yaml index 3a79c08..e4cc7fa 100644 --- a/k8s/base/app-deployment.yaml +++ b/k8s/base/app-deployment.yaml @@ -55,6 +55,12 @@ spec: value: "3000" - name: NODE_ENV value: "production" + - name: TELEMETRY_EXCLUDE_PHONES + valueFrom: + secretKeyRef: + name: flxn-secrets + key: telemetry_exclude_phones + optional: true - name: TWILIO_ACCOUNT_SID valueFrom: diff --git a/package.json b/package.json index c14bbab..c7cb483 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "twilio": "^5.8.0", "vaul": "^1.1.2", "web-push": "^3.6.7", + "web-vitals": "^6.2.0", "zod": "^4.0.15" }, "devDependencies": { diff --git a/pb_migrations/1787961600_activities_indexes.js b/pb_migrations/1787961600_activities_indexes.js new file mode 100644 index 0000000..bcefa6b --- /dev/null +++ b/pb_migrations/1787961600_activities_indexes.js @@ -0,0 +1,23 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1262591861") + + unmarshal({ + "indexes": [ + "CREATE INDEX `idx_activities_created` ON `activities` (`created`)", + "CREATE INDEX `idx_activities_name_created` ON `activities` (`name`, `created`)", + "CREATE INDEX `idx_activities_player_created` ON `activities` (`player`, `created`)", + "CREATE INDEX `idx_activities_success_created` ON `activities` (`success`, `created`)" + ] + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1262591861") + + unmarshal({ + "indexes": [] + }, collection) + + return app.save(collection) +}) diff --git a/pb_migrations/1787961700_created_client_events.js b/pb_migrations/1787961700_created_client_events.js new file mode 100644 index 0000000..972f8d0 --- /dev/null +++ b/pb_migrations/1787961700_created_client_events.js @@ -0,0 +1,167 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_name", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation_ce_player", + "maxSelect": 1, + "minSelect": 0, + "name": "player", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_session_id", + "max": 0, + "min": 0, + "name": "session_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_path", + "max": 0, + "min": 0, + "name": "path", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_route_id", + "max": 0, + "min": 0, + "name": "route_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number_ce_value", + "max": null, + "min": null, + "name": "value", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json_ce_props", + "maxSize": 0, + "name": "props", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_user_agent", + "max": 0, + "min": 0, + "name": "user_agent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_client_events", + "indexes": [ + "CREATE INDEX `idx_client_events_created` ON `client_events` (`created`)", + "CREATE INDEX `idx_client_events_name_created` ON `client_events` (`name`, `created`)", + "CREATE INDEX `idx_client_events_player_created` ON `client_events` (`player`, `created`)", + "CREATE INDEX `idx_client_events_session_id` ON `client_events` (`session_id`)" + ], + "listRule": null, + "name": "client_events", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_client_events"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1787961800_created_client_errors.js b/pb_migrations/1787961800_created_client_errors.js new file mode 100644 index 0000000..2600f24 --- /dev/null +++ b/pb_migrations/1787961800_created_client_errors.js @@ -0,0 +1,207 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_message", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_stack", + "max": 0, + "min": 0, + "name": "stack", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select_cerr_source", + "maxSelect": 1, + "name": "source", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "window", + "unhandledrejection", + "error-boundary", + "sw" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_path", + "max": 0, + "min": 0, + "name": "path", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_route_id", + "max": 0, + "min": 0, + "name": "route_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_group_hash", + "max": 0, + "min": 0, + "name": "group_hash", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool_cerr_resolved", + "name": "resolved", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation_cerr_player", + "maxSelect": 1, + "minSelect": 0, + "name": "player", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_session_id", + "max": 0, + "min": 0, + "name": "session_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_user_agent", + "max": 0, + "min": 0, + "name": "user_agent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json_cerr_props", + "maxSize": 0, + "name": "props", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_client_errors", + "indexes": [ + "CREATE INDEX `idx_client_errors_group_created` ON `client_errors` (`group_hash`, `created`)", + "CREATE INDEX `idx_client_errors_created` ON `client_errors` (`created`)", + "CREATE INDEX `idx_client_errors_resolved_created` ON `client_errors` (`resolved`, `created`)" + ], + "listRule": null, + "name": "client_errors", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_client_errors"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js b/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js new file mode 100644 index 0000000..2e699f7 --- /dev/null +++ b/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js @@ -0,0 +1,240 @@ +/// +migrate((app) => { + const rollups = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_tr_date", + "max": 0, + "min": 0, + "name": "date", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_tr_metric", + "max": 0, + "min": 0, + "name": "metric", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_tr_dim", + "max": 0, + "min": 0, + "name": "dim", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number_tr_value", + "max": null, + "min": null, + "name": "value", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json_tr_meta", + "maxSize": 0, + "name": "meta", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_telemetry_rollups", + "indexes": [ + "CREATE UNIQUE INDEX `idx_rollups_date_metric_dim` ON `telemetry_rollups` (`date`, `metric`, `dim`)", + "CREATE INDEX `idx_rollups_metric_date` ON `telemetry_rollups` (`metric`, `date`)" + ], + "listRule": null, + "name": "telemetry_rollups", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + app.save(rollups); + + const alerts = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ta_kind", + "max": 0, + "min": 0, + "name": "kind", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ta_dim", + "max": 0, + "min": 0, + "name": "dim", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ta_message", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number_ta_value", + "max": null, + "min": null, + "name": "value", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json_ta_meta", + "maxSize": 0, + "name": "meta", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_telemetry_alerts", + "indexes": [ + "CREATE INDEX `idx_alerts_kind_dim_created` ON `telemetry_alerts` (`kind`, `dim`, `created`)" + ], + "listRule": null, + "name": "telemetry_alerts", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(alerts); +}, (app) => { + app.delete(app.findCollectionByNameOrId("pbc_telemetry_alerts")); + return app.delete(app.findCollectionByNameOrId("pbc_telemetry_rollups")); +}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index cdc5fb1..298cff7 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo' +import { Route as ApiTelemetryBeaconRouteImport } from './routes/api/telemetry/beacon' import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo' import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token' import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search' @@ -37,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 AuthedAdminTelemetrySessionsRouteImport } from './routes/_authed/admin/telemetry/sessions' +import { Route as AuthedAdminTelemetryPlayersRouteImport } from './routes/_authed/admin/telemetry/players' +import { Route as AuthedAdminTelemetryFunctionsRouteImport } from './routes/_authed/admin/telemetry/functions' +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({ @@ -117,6 +126,11 @@ const ApiTournamentsUploadLogoRoute = path: '/api/tournaments/upload-logo', getParentRoute: () => rootRouteImport, } as any) +const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({ + id: '/api/telemetry/beacon', + path: '/api/telemetry/beacon', + getParentRoute: () => rootRouteImport, +} as any) const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({ id: '/api/teams/upload-logo', path: '/api/teams/upload-logo', @@ -193,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', @@ -214,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', @@ -232,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 AuthedAdminTelemetrySessionsRoute = + AuthedAdminTelemetrySessionsRouteImport.update({ + id: '/sessions', + path: '/sessions', + 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 AuthedAdminTelemetryErrorsRoute = + AuthedAdminTelemetryErrorsRouteImport.update({ + id: '/errors', + path: '/errors', + getParentRoute: () => AuthedAdminTelemetryRoute, + } as any) const AuthedAdminTournamentsIdIndexRoute = AuthedAdminTournamentsIdIndexRouteImport.update({ id: '/tournaments/$id/', @@ -268,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', @@ -288,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 @@ -303,14 +365,22 @@ export interface FileRoutesByFullPath { '/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute + '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/admin/': typeof AuthedAdminIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute + '/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute + '/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute + '/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute + '/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute + '/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 @@ -345,14 +415,22 @@ export interface FileRoutesByTo { '/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute + '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/admin': typeof AuthedAdminIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute + '/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute + '/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute + '/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute + '/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute + '/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 @@ -375,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 @@ -390,14 +469,22 @@ export interface FileRoutesById { '/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute + '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute + '/_authed/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute + '/_authed/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute + '/_authed/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute + '/_authed/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute + '/_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 @@ -420,6 +507,7 @@ export interface FileRouteTypes { | '/admin/activities' | '/admin/badges' | '/admin/preview' + | '/admin/telemetry' | '/profile/$playerId' | '/teams/$teamId' | '/tournaments/$tournamentId' @@ -435,14 +523,22 @@ export interface FileRouteTypes { | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' + | '/api/telemetry/beacon' | '/api/tournaments/upload-logo' | '/admin/' | '/tournaments/' + | '/admin/telemetry/errors' + | '/admin/telemetry/functions' + | '/admin/telemetry/players' + | '/admin/telemetry/sessions' + | '/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' @@ -477,14 +573,22 @@ export interface FileRouteTypes { | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' + | '/api/telemetry/beacon' | '/api/tournaments/upload-logo' | '/admin' | '/tournaments' + | '/admin/telemetry/errors' + | '/admin/telemetry/functions' + | '/admin/telemetry/players' + | '/admin/telemetry/sessions' + | '/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' @@ -506,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' @@ -521,14 +626,22 @@ export interface FileRouteTypes { | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' + | '/api/telemetry/beacon' | '/api/tournaments/upload-logo' | '/_authed/admin/' | '/_authed/tournaments/' + | '/_authed/admin/telemetry/errors' + | '/_authed/admin/telemetry/functions' + | '/_authed/admin/telemetry/players' + | '/_authed/admin/telemetry/sessions' + | '/_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' @@ -555,6 +668,7 @@ export interface RootRouteChildren { ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute + ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute } @@ -652,6 +766,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport parentRoute: typeof rootRouteImport } + '/api/telemetry/beacon': { + id: '/api/telemetry/beacon' + path: '/api/telemetry/beacon' + fullPath: '/api/telemetry/beacon' + preLoaderRoute: typeof ApiTelemetryBeaconRouteImport + parentRoute: typeof rootRouteImport + } '/api/teams/upload-logo': { id: '/api/teams/upload-logo' path: '/api/teams/upload-logo' @@ -757,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' @@ -785,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' @@ -806,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/sessions': { + id: '/_authed/admin/telemetry/sessions' + path: '/sessions' + fullPath: '/admin/telemetry/sessions' + preLoaderRoute: typeof AuthedAdminTelemetrySessionsRouteImport + 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/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' @@ -848,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' @@ -858,13 +1035,36 @@ declare module '@tanstack/react-router' { } } +interface AuthedAdminTelemetryRouteChildren { + AuthedAdminTelemetryErrorsRoute: typeof AuthedAdminTelemetryErrorsRoute + AuthedAdminTelemetryFunctionsRoute: typeof AuthedAdminTelemetryFunctionsRoute + AuthedAdminTelemetryPlayersRoute: typeof AuthedAdminTelemetryPlayersRoute + AuthedAdminTelemetrySessionsRoute: typeof AuthedAdminTelemetrySessionsRoute + AuthedAdminTelemetryUsageRoute: typeof AuthedAdminTelemetryUsageRoute + AuthedAdminTelemetryIndexRoute: typeof AuthedAdminTelemetryIndexRoute +} + +const AuthedAdminTelemetryRouteChildren: AuthedAdminTelemetryRouteChildren = { + AuthedAdminTelemetryErrorsRoute: AuthedAdminTelemetryErrorsRoute, + AuthedAdminTelemetryFunctionsRoute: AuthedAdminTelemetryFunctionsRoute, + AuthedAdminTelemetryPlayersRoute: AuthedAdminTelemetryPlayersRoute, + AuthedAdminTelemetrySessionsRoute: AuthedAdminTelemetrySessionsRoute, + 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 @@ -874,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, @@ -944,6 +1147,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSpotifySearchRoute: ApiSpotifySearchRoute, ApiSpotifyTokenRoute: ApiSpotifyTokenRoute, ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute, + ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute, ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute, ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute, } @@ -952,10 +1156,11 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' +import type { startInstance } from './start.ts' declare module '@tanstack/react-start' { interface Register { ssr: true router: Awaited> + config: Awaited> } } diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index ba1d90b..a81eb22 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -5,11 +5,13 @@ import { Scripts, createRootRouteWithContext, isRedirect, + useRouter, } from "@tanstack/react-router"; import * as React from "react"; import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary"; import { type QueryClient } from "@tanstack/react-query"; import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; +import { initTelemetry, track } from "@/lib/telemetry/client"; import { AuthContextType } from "@/contexts/auth-context"; import Providers from "@/features/core/components/providers"; import { SessionMonitor } from "@/components/session-monitor"; @@ -175,12 +177,15 @@ export const Route = createRootRouteWithContext<{ }); function RootComponent() { + const router = useRouter(); + React.useEffect(() => { ensureSuperTokensFrontend(); + initTelemetry(router); if (import.meta.env.PROD && 'serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch(() => {}); + navigator.serviceWorker.register('/sw.js').catch(() => track('sw_register_failed')); } - }, []); + }, [router]); return ( @@ -193,7 +198,6 @@ function RootComponent() { ); } -// todo: analytics -> process.env data-website-id function RootDocument({ children }: { children: React.ReactNode }) { return ( { - if (!context.auth?.roles?.includes("Admin")) { + if (!isAdminRole(context.auth?.roles)) { throw redirect({ to: "/" }); } diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx index 0cc963a..c450417 100644 --- a/src/app/routes/_authed/admin/activities.tsx +++ b/src/app/routes/_authed/admin/activities.tsx @@ -1,75 +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 { Box, Divider, Group, Skeleton, Stack, 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 ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) { - return ( - - {withSearch && ( - - - - )} - {Array.from({ length: 8 }).map((_, index) => ( -
- - - - - - - - -
- ))} -
- ); -} - -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..30d57db 100644 --- a/src/app/routes/_authed/admin/index.tsx +++ b/src/app/routes/_authed/admin/index.tsx @@ -1,8 +1,13 @@ 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 { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; export const Route = createFileRoute("/_authed/admin/")({ + beforeLoad: ({ context }) => { + prefetchServerQuery(context.queryClient, tournamentQueries.current()); + }, loader: () => ({ header: { withBackButton: true, @@ -10,5 +15,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..5093635 --- /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 = { + usage: "/admin/telemetry/usage", + players: "/admin/telemetry/players", + functions: "/admin/telemetry/functions", + sessions: "/admin/telemetry/sessions", + errors: "/admin/telemetry/errors", +} 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}`) + ) ?? "usage"; + + return ( + { + if (value && value in TAB_ROUTES) { + navigate({ to: TAB_ROUTES[value as TabValue] }); + } + }} + > + + + Usage + + + Players + + + Functions + + + Sessions + + + Errors + + + + + + ); +} 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/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..67a876c --- /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/usage" }); + }, +}); 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/sessions.tsx b/src/app/routes/_authed/admin/telemetry/sessions.tsx new file mode 100644 index 0000000..ea22cb7 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/sessions.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Suspense } from "react"; +import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; +import { telemetryQueries, SessionsList, defaultSessionParams } from "@/features/telemetry"; +import { ListSkeleton } from "@/components/skeletons"; + +export const Route = createFileRoute("/_authed/admin/telemetry/sessions")({ + component: SessionsTab, + beforeLoad: ({ context }) => { + prefetchServerQuery(context.queryClient, telemetryQueries.sessions(defaultSessionParams)); + }, + loader: () => ({ + withPadding: false, + fullWidth: true, + refresh: [telemetryQueries.sessions(defaultSessionParams).queryKey], + }), +}); + +function SessionsTab() { + 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..e0c0742 --- /dev/null +++ b/src/app/routes/_authed/admin/telemetry/usage.tsx @@ -0,0 +1,21 @@ +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: () => ({ + withPadding: false, + fullWidth: true, + refresh: [telemetryQueries.rollups(defaultUsageParams()).queryKey], + }), +}); + +function UsageTab() { + return ; +} diff --git a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx index f49fb71..3df4c0f 100644 --- a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx +++ b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx @@ -1,6 +1,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import { StatTileSkeleton } from "@/components/skeletons"; import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core"; import { useState } from "react"; import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; @@ -37,10 +38,7 @@ function AssignPartnersPending() { 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/app/routes/_authed/admin/tournaments/$id/index.tsx b/src/app/routes/_authed/admin/tournaments/$id/index.tsx index ba24ea8..ae2db70 100644 --- a/src/app/routes/_authed/admin/tournaments/$id/index.tsx +++ b/src/app/routes/_authed/admin/tournaments/$id/index.tsx @@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { tournamentQueries } from "@/features/tournaments/queries"; import ManageTournament from "@/features/tournaments/components/manage-tournament"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; -import { Divider, Group, Skeleton, Stack } from "@mantine/core"; +import { ListSkeleton } from "@/components/skeletons"; import { msg } from "@lingui/core/macro"; export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ @@ -31,18 +31,14 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ function ManageTournamentPending() { return ( - - {Array.from({ length: 5 }).map((_, index) => ( -
- - - - - - -
- ))} -
+ ); } diff --git a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx index fa96f99..c717617 100644 --- a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx +++ b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx @@ -2,7 +2,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { tournamentQueries } from "@/features/tournaments/queries"; import ManageTeams from "@/features/teams/components/manage-teams"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; -import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core"; +import { ListSkeleton } from "@/components/skeletons"; +import { Box, Skeleton, Stack } from "@mantine/core"; import { msg } from "@lingui/core/macro"; export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ @@ -38,21 +39,7 @@ function ManageTeamsPending() { - - {Array.from({ length: 8 }).map((_, index) => ( -
- - - - - - - - - -
- ))} -
+ ); } diff --git a/src/app/routes/_authed/admin/tournaments/index.tsx b/src/app/routes/_authed/admin/tournaments/index.tsx index 7210102..205cb95 100644 --- a/src/app/routes/_authed/admin/tournaments/index.tsx +++ b/src/app/routes/_authed/admin/tournaments/index.tsx @@ -1,7 +1,7 @@ import ManageTournaments from "@/features/admin/components/manage-tournaments"; import { tournamentQueries } from "@/features/tournaments/queries"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; -import { Divider, Group, Skeleton, Stack } from "@mantine/core"; +import { ListSkeleton } from "@/components/skeletons"; import { createFileRoute } from "@tanstack/react-router"; import { Suspense } from "react"; import { msg } from "@lingui/core/macro"; @@ -22,25 +22,13 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({ component: RouteComponent, }); -function TournamentListSkeleton() { - return ( - - {Array.from({ length: 6 }).map((_, index) => ( -
- - - - - -
- ))} -
- ); -} - function RouteComponent() { return ( - }> + + } + > ); diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index ad68e03..216ce86 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -8,12 +8,10 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import GroupStageView from "@/features/tournaments/components/group-stage-view"; import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core"; -import { useMemo } from "react"; -import { BracketData } from "@/features/bracket/types"; -import { Match } from "@/features/matches/types"; import BracketView from "@/features/bracket/components/bracket-view"; import { SpotifyControlsBar } from "@/features/spotify/components"; -import { useAuth } from "@/contexts/auth-context"; +import { useIsAdmin } from "@/features/core/hooks/use-is-admin"; +import { useTournamentRunState } from "@/features/tournaments/hooks/use-tournament-run-state"; import { Trans } from "@lingui/react/macro"; import { msg } from "@lingui/core/macro"; @@ -83,67 +81,10 @@ function RunTournamentPending() { function RouteComponent() { const { id } = Route.useParams(); const { data: tournament } = useTournament(id); - const { roles } = useAuth(); - const isAdmin = roles?.includes('Admin') || false; + const isAdmin = useIsAdmin(); - const hasGroupStage = useMemo(() => { - return tournament.matches?.some((match) => match.round === -1) || false; - }, [tournament.matches]); - - const hasKnockout = useMemo(() => { - return tournament.matches?.some((match) => match.round !== -1) || false; - }, [tournament.matches]); - - const knockoutBracketPopulated = useMemo(() => { - return tournament.matches?.some((match) => - match.round === 0 && match.lid >= 0 && (match.home || match.away) - ) || false; - }, [tournament.matches]); - - const nextUpMatchId = useMemo(() => { - const ready = (tournament.matches ?? []) - .filter( - (match) => - match.status === "ready" && match.home && match.away && !match.bye - ) - .sort((a, b) => a.order - b.order); - return ready[0]?.id; - }, [tournament.matches]); - - const bracket: BracketData = useMemo(() => { - if (!tournament.matches || tournament.matches.length === 0) { - return { winners: [], losers: [] }; - } - - const winnersMap = new Map(); - const losersMap = new Map(); - - tournament.matches - .filter((match) => match.round !== -1) - .sort((a, b) => a.lid - b.lid) - .forEach((match) => { - if (!match.is_losers_bracket) { - if (!winnersMap.has(match.round)) { - winnersMap.set(match.round, []); - } - winnersMap.get(match.round)!.push(match); - } else { - if (!losersMap.has(match.round)) { - losersMap.set(match.round, []); - } - losersMap.get(match.round)!.push(match); - } - }); - - const winners = Array.from(winnersMap.entries()) - .sort(([a], [b]) => a - b) - .map(([, matches]) => matches); - - const losers = Array.from(losersMap.entries()) - .sort(([a], [b]) => a - b) - .map(([, matches]) => matches); - return { winners, losers }; - }, [tournament.matches]); + const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } = + useTournamentRunState(tournament.matches); return ( diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 2d8c0e0..acbb806 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { playerQueries } from "@/features/players/queries"; import PlayerStatsTable from "@/features/players/components/player-stats-table"; -import { Suspense, useState, useDeferredValue, useEffect } from "react"; +import { Suspense, useState, useDeferredValue, useEffect, useRef } from "react"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; @@ -9,6 +9,7 @@ import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/cor import { useQueryClient } from "@tanstack/react-query"; import { Trans } from "@lingui/react/macro"; import { msg } from "@lingui/core/macro"; +import { track } from "@/lib/telemetry/client"; export const Route = createFileRoute("/_authed/stats")({ component: Stats, @@ -32,6 +33,14 @@ function Stats() { const isStale = viewType !== deferredViewType; const queryClient = useQueryClient(); + const viewTypeRef = useRef(viewType); + const changeViewType = (next: typeof viewType) => { + if (viewTypeRef.current === next) return; + viewTypeRef.current = next; + track("stats_filtered", { value: next }); + setViewType(next); + }; + useEffect(() => { const timeout = window.setTimeout(() => { prefetchServerQuery(queryClient, playerQueries.allStats('mainline')); @@ -54,21 +63,21 @@ function Stats() { diff --git a/src/app/routes/_authed/tournaments/$tournamentId.tsx b/src/app/routes/_authed/tournaments/$tournamentId.tsx index 9ed811c..bf42b78 100644 --- a/src/app/routes/_authed/tournaments/$tournamentId.tsx +++ b/src/app/routes/_authed/tournaments/$tournamentId.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { tournamentQueries } from '@/features/tournaments/queries'; import Profile from '@/features/tournaments/components/profile'; +import { isAdminRole } from '@/features/core/utils/roles'; import { z } from "zod"; import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; import { Suspense } from 'react'; @@ -20,7 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ header: { collapsed: true, withBackButton: true, - settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined + settingsLink: isAdminRole(context.auth.roles) ? `/admin/tournaments/${params.tournamentId}` : undefined }, refresh: [tournamentQueries.details(params.tournamentId).queryKey], withPadding: false diff --git a/src/app/routes/api/auth.$.ts b/src/app/routes/api/auth.$.ts index 3b617e2..0c560c7 100644 --- a/src/app/routes/api/auth.$.ts +++ b/src/app/routes/api/auth.$.ts @@ -8,8 +8,6 @@ ensureSuperTokensBackend(); // forwards all supertokens api methods to our API const superTokensHandler = handleAuthAPIRequest(); const handleRequest = async ({ request }: {request: Request}) => { - console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); - console.log("Handling auth request:", request.method, request.url); return superTokensHandler(request); }; export const Route = createFileRoute('/api/auth/$')({ diff --git a/src/app/routes/api/events.$.ts b/src/app/routes/api/events.$.ts index ff488fa..1ced59f 100644 --- a/src/app/routes/api/events.$.ts +++ b/src/app/routes/api/events.$.ts @@ -1,9 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; -import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter"; +import { + serverEvents, + EVENT_TYPES, + incrementConnections, + decrementConnections, + type ServerEvent, +} from "@/lib/events/emitter"; import { logger } from "@/lib/logger"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; -let activeConnections = 0; const encoder = new TextEncoder(); export const Route = createFileRoute("/api/events/$")({ @@ -11,9 +16,9 @@ export const Route = createFileRoute("/api/events/$")({ middleware: [superTokensRequestMiddleware], handlers: { GET: ({ request }) => { - activeConnections++; + const active = incrementConnections(); const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; - logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`); + logger.info(`ServerEvents | New connection ${connectionId}. Active: ${active}`); let cleanedUp = false; let cleanup = () => {}; @@ -34,19 +39,20 @@ export const Route = createFileRoute("/api/events/$")({ serverEvents.on(type, handleEvent); } + // Under the shortest idle timeout observed on proxies/PWA paths. const pingInterval = setInterval(() => { send({ type: "ping", timestamp: Date.now() }); - }, 15000); + }, 8000); cleanup = () => { if (cleanedUp) return; cleanedUp = true; - activeConnections--; + const remaining = decrementConnections(); for (const type of EVENT_TYPES) { serverEvents.off(type, handleEvent); } clearInterval(pingInterval); - logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`); + logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${remaining}`); }; request.signal?.addEventListener("abort", cleanup); diff --git a/src/app/routes/api/health.ts b/src/app/routes/api/health.ts index 38c7e5a..0b78142 100644 --- a/src/app/routes/api/health.ts +++ b/src/app/routes/api/health.ts @@ -1,19 +1,26 @@ import { createFileRoute } from "@tanstack/react-router"; +import { ensureTelemetryScheduler } from "@/lib/telemetry/scheduler.server"; +import { getHealthSnapshot } from "@/lib/telemetry/health.server"; +import { ensureSuperTokensBackend } from "@/lib/supertokens/server"; export const Route = createFileRoute("/api/health")({ server: { handlers: { - GET: () => { + GET: async () => { + ensureSuperTokensBackend(); + ensureTelemetryScheduler(); + const snapshot = await getHealthSnapshot(); + + // Always 200: a dependency outage must not fail the k8s probes. return new Response( JSON.stringify({ - status: "ok", + status: snapshot.status, + checks: snapshot.checks, timestamp: new Date().toISOString(), }), { status: 200, - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, } ); }, diff --git a/src/app/routes/api/push/subscribe.ts b/src/app/routes/api/push/subscribe.ts index 51b77b7..bbcc4b4 100644 --- a/src/app/routes/api/push/subscribe.ts +++ b/src/app/routes/api/push/subscribe.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; @@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) => export const Route = createFileRoute("/api/push/subscribe")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const player = (context as any).player; diff --git a/src/app/routes/api/push/test.ts b/src/app/routes/api/push/test.ts index 3f8f587..7cf43c9 100644 --- a/src/app/routes/api/push/test.ts +++ b/src/app/routes/api/push/test.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { sendPushToPlayer } from "@/lib/push"; import { isPushConfigured } from "@/lib/config"; import { logger } from "@/lib/logger"; @@ -13,7 +14,7 @@ const json = (body: unknown, status = 200) => export const Route = createFileRoute("/api/push/test")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ context }) => { const player = (context as any).player; diff --git a/src/app/routes/api/push/unsubscribe.ts b/src/app/routes/api/push/unsubscribe.ts index 8e3c687..92b3faf 100644 --- a/src/app/routes/api/push/unsubscribe.ts +++ b/src/app/routes/api/push/unsubscribe.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; @@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) => export const Route = createFileRoute("/api/push/unsubscribe")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const player = (context as any).player; diff --git a/src/app/routes/api/teams/upload-logo.ts b/src/app/routes/api/teams/upload-logo.ts index 1ea5723..d2a8f74 100644 --- a/src/app/routes/api/teams/upload-logo.ts +++ b/src/app/routes/api/teams/upload-logo.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; import { localizedFor } from "@/lib/i18n/server-messages"; @@ -12,7 +13,7 @@ const uploadSchema = z.object({ export const Route = createFileRoute("/api/teams/upload-logo")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const i18n = localizedFor(context.metadata); diff --git a/src/app/routes/api/telemetry/beacon.ts b/src/app/routes/api/telemetry/beacon.ts new file mode 100644 index 0000000..4c73e43 --- /dev/null +++ b/src/app/routes/api/telemetry/beacon.ts @@ -0,0 +1,132 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { superTokensOptionalRequestMiddleware } from "@/utils/supertokens"; +import { beaconPayloadSchema, MAX_BEACON_BYTES, type BeaconEvent } from "@/lib/telemetry/beacon-schema"; +import { errorGroupHash } from "@/lib/telemetry/error-hash"; +import { redactArguments } from "@/lib/telemetry/redact-args"; +import { Logger } from "@/lib/logger"; +import type { Player } from "@/features/players/types"; + +const logger = new Logger("Telemetry"); + +const BUCKET_CAPACITY = 120; +const REFILL_PER_MS = BUCKET_CAPACITY / 60_000; +const buckets = new Map(); + +const takeTokens = (key: string, count: number): boolean => { + const now = Date.now(); + const bucket = buckets.get(key) ?? { tokens: BUCKET_CAPACITY, updatedAt: now }; + bucket.tokens = Math.min(BUCKET_CAPACITY, bucket.tokens + (now - bucket.updatedAt) * REFILL_PER_MS); + bucket.updatedAt = now; + if (bucket.tokens < count) { + buckets.set(key, bucket); + return false; + } + bucket.tokens -= count; + buckets.set(key, bucket); + + if (buckets.size > 2000) { + for (const [k, v] of buckets) { + if (now - v.updatedAt > 5 * 60_000) buckets.delete(k); + } + } + return true; +}; + +const clientIp = (request: Request): string => + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + +const writeEvents = async ( + events: BeaconEvent[], + base: { sessionId: string; playerId?: string; userAgent?: string } +) => { + const { isExcludedPlayerId } = await import("@/lib/telemetry/exclusions.server"); + if (base.playerId && (await isExcludedPlayerId(base.playerId))) { + // Excluded players still report errors; their usage stays out. + events = events.filter((event) => event.kind === "error"); + if (events.length === 0) return; + } + + const { pbAdmin } = await import("@/lib/pocketbase/client"); + await pbAdmin.authPromise; + + for (const event of events) { + try { + if (event.kind === "error") { + await pbAdmin.createClientError({ + message: event.message, + stack: event.stack, + source: event.source, + path: event.path, + route_id: event.routeId, + group_hash: errorGroupHash(event), + resolved: false, + player: base.playerId, + session_id: base.sessionId, + user_agent: base.userAgent, + props: redactArguments(event.props), + }); + } else { + await pbAdmin.createClientEvent({ + name: event.kind === "vital" ? `vital.${event.metric}` : event.name, + player: base.playerId, + session_id: base.sessionId, + path: event.path, + route_id: event.routeId, + value: event.value, + props: + event.kind === "vital" + ? event.rating + ? { rating: event.rating } + : undefined + : redactArguments(event.props), + user_agent: base.userAgent, + }); + } + } catch (error) { + logger.error("Failed to persist beacon event", error); + } + } +}; + +export const Route = createFileRoute("/api/telemetry/beacon")({ + server: { + middleware: [superTokensOptionalRequestMiddleware], + handlers: { + POST: async ({ request, context }) => { + const player = (context as unknown as { player?: Player | null }).player ?? undefined; + + const raw = await request.text(); + if (raw.length > MAX_BEACON_BYTES) { + return new Response(null, { status: 413 }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return new Response(null, { status: 400 }); + } + + const result = beaconPayloadSchema.safeParse(parsed); + if (!result.success) { + return new Response(null, { status: 400 }); + } + + const key = player ? `p:${player.id}` : `ip:${clientIp(request)}`; + if (!takeTokens(key, result.data.events.length)) { + return new Response(null, { status: 429 }); + } + + void writeEvents(result.data.events, { + sessionId: result.data.sessionId, + playerId: player?.id, + userAgent: request.headers.get("user-agent") ?? undefined, + }).catch((error) => logger.error("Beacon write batch failed", error)); + + return new Response(null, { status: 204 }); + }, + }, + }, +}); diff --git a/src/app/routes/api/tournaments/upload-logo.ts b/src/app/routes/api/tournaments/upload-logo.ts index 9b7f990..7121c65 100644 --- a/src/app/routes/api/tournaments/upload-logo.ts +++ b/src/app/routes/api/tournaments/upload-logo.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; import { localizedFor } from "@/lib/i18n/server-messages"; @@ -12,7 +13,7 @@ const uploadSchema = z.object({ export const Route = createFileRoute("/api/tournaments/upload-logo")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const i18n = localizedFor(context.metadata); diff --git a/src/app/start.ts b/src/app/start.ts new file mode 100644 index 0000000..fc3ad01 --- /dev/null +++ b/src/app/start.ts @@ -0,0 +1,11 @@ +import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; +import { serverFnLoggingMiddleware } from "@/utils/activities"; + +const csrfMiddleware = createCsrfMiddleware({ + filter: (ctx) => ctx.handlerType === "serverFn", +}); + +export const startInstance = createStart(() => ({ + requestMiddleware: [csrfMiddleware], + functionMiddleware: [serverFnLoggingMiddleware], +})); diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx index 81f4f8a..19cca0a 100644 --- a/src/components/DefaultCatchBoundary.tsx +++ b/src/components/DefaultCatchBoundary.tsx @@ -22,6 +22,7 @@ import { useDisclosure } from '@mantine/hooks' import { useEffect } from 'react' import toast from '@/lib/sonner' import { logger } from '@/lib/logger' +import { captureBoundaryError } from '@/lib/telemetry/client' import { XCircleIcon, WarningIcon } from '@phosphor-icons/react' import { Trans, useLingui } from '@lingui/react/macro' import Button from './button' @@ -41,6 +42,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { useEffect(() => { logger.error('DefaultCatchBoundary | ', error) + captureBoundaryError(error) if (errorMessage.toLowerCase().includes('unauthenticated')) { toast.error(t`You've been logged out`) diff --git a/src/components/countdown.tsx b/src/components/countdown.tsx index c2dae28..de3c5d4 100644 --- a/src/components/countdown.tsx +++ b/src/components/countdown.tsx @@ -53,12 +53,14 @@ export function Countdown({ date, label, color }: CountdownProps) { return ( {label && {label}:} + {/* Server and client render different seconds; the first client tick corrects it. */} {prefix} {pad(timeLeft.seconds)} diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx index e6f17d5..b24ecf7 100644 --- a/src/components/ios-install-prompt.tsx +++ b/src/components/ios-install-prompt.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { track } from '@/lib/telemetry/client' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { Trans, useLingui } from '@lingui/react/macro' @@ -26,13 +27,17 @@ export function IOSInstallPrompt() { if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) { setPlatform(isIOS ? 'ios' : 'android') - const timer = setTimeout(() => setShow(true), 3000) + const timer = setTimeout(() => { + setShow(true) + track('pwa_ios_prompt_shown') + }, 3000) return () => clearTimeout(timer) } }, []) const handleDismiss = () => { localStorage.setItem('pwa-install-prompt-dismissed', 'true') + track('pwa_ios_prompt_dismissed') setShow(false) } diff --git a/src/components/nav-tile/index.tsx b/src/components/nav-tile/index.tsx new file mode 100644 index 0000000..221bf24 --- /dev/null +++ b/src/components/nav-tile/index.tsx @@ -0,0 +1,28 @@ +import { Text, UnstyledButton } from "@mantine/core"; +import { Icon } from "@phosphor-icons/react"; +import classes from "./nav-tile.module.css"; + +interface NavTileProps { + label: string; + Icon: Icon; + onClick?: () => void; + disabled?: boolean; +} + +/** A single square-ish nav tile with icon-over-label and press feedback. */ +export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => ( + + + + + + {label} + + +); + +export default NavTile; diff --git a/src/components/nav-tile/nav-tile.module.css b/src/components/nav-tile/nav-tile.module.css new file mode 100644 index 0000000..028dfed --- /dev/null +++ b/src/components/nav-tile/nav-tile.module.css @@ -0,0 +1,43 @@ +.tile { + -webkit-tap-highlight-color: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 8px; + border-radius: var(--mantine-radius-md); + border: 1px solid var(--mantine-color-default-border); + background-color: var(--mantine-color-body); + text-align: center; +} + +.icon { + color: var(--mantine-primary-color-filled); + display: flex; +} + +@media (hover: hover) { + .tile:hover:not(:disabled) { + background-color: var(--mantine-color-default-hover); + } +} + +.tile:active:not(:disabled) { + background-color: var(--mantine-color-default-hover); +} + +.tile:disabled { + opacity: 0.5; + cursor: default; +} + +@media (prefers-reduced-motion: no-preference) { + .tile { + transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1); + } + + .tile:active:not(:disabled) { + transform: scale(0.97); + } +} diff --git a/src/components/sheet/sheet.tsx b/src/components/sheet/sheet.tsx index 99494c4..96905d3 100644 --- a/src/components/sheet/sheet.tsx +++ b/src/components/sheet/sheet.tsx @@ -9,7 +9,7 @@ import { import { useIsMobile } from "@/hooks/use-is-mobile"; import Drawer from "./drawer"; import Modal from "./modal"; -import { ScrollArea, Flex, Loader } from "@mantine/core"; +import { Box, Flex, Loader } from "@mantine/core"; interface SheetProps extends PropsWithChildren { title?: string; @@ -60,14 +60,19 @@ const Sheet: React.FC = ({ title, children, opened, onChange }) => { }> - {children} - + ); diff --git a/src/components/skeletons/card-skeleton.tsx b/src/components/skeletons/card-skeleton.tsx new file mode 100644 index 0000000..8b6ee7d --- /dev/null +++ b/src/components/skeletons/card-skeleton.tsx @@ -0,0 +1,35 @@ +import { Box, Group, Paper, Skeleton } from "@mantine/core"; +import type { MantineRadius } from "@mantine/core"; + +export interface CardSkeletonProps { + withAvatar?: boolean; + avatarSize?: number; + withBadge?: boolean; + radius?: MantineRadius; + opacity?: number; +} + +export const CardSkeleton = ({ + withAvatar = true, + avatarSize = 32, + withBadge = true, + radius = "md", + opacity = 1, +}: CardSkeletonProps) => { + return ( + + + {withAvatar && ( + + )} + + + + + {withBadge && } + + + ); +}; + +export default CardSkeleton; diff --git a/src/components/skeletons/index.ts b/src/components/skeletons/index.ts new file mode 100644 index 0000000..eaec0ec --- /dev/null +++ b/src/components/skeletons/index.ts @@ -0,0 +1,3 @@ +export * from "./list-skeleton"; +export * from "./card-skeleton"; +export * from "./stat-tile-skeleton"; diff --git a/src/components/skeletons/list-skeleton.tsx b/src/components/skeletons/list-skeleton.tsx new file mode 100644 index 0000000..8344eda --- /dev/null +++ b/src/components/skeletons/list-skeleton.tsx @@ -0,0 +1,76 @@ +import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core"; +import type { MantineSpacing } from "@mantine/core"; + +export interface ListSkeletonProps { + rows?: number; + withSearch?: boolean; + withAvatar?: boolean; + avatarSize?: number; + withSubtitle?: boolean; + withTrailing?: boolean | "double" | "text"; + titleWidth?: string | number; + padding?: MantineSpacing; + fadeStep?: number; + fadeFloor?: number; +} + +export const ListSkeleton = ({ + rows = 6, + withSearch = false, + withAvatar = false, + avatarSize = 40, + withSubtitle = false, + withTrailing = false, + titleWidth = "45%", + padding = "md", + fadeStep = 0.1, + fadeFloor = 0.35, +}: ListSkeletonProps) => { + return ( + + {withSearch && ( + + + + )} + {Array.from({ length: rows }).map((_, index) => ( +
+ + {withAvatar && ( + + )} + {withSubtitle ? ( + + + + + ) : ( + + )} + {withTrailing === "double" ? ( + + + + + ) : withTrailing === "text" ? ( + + ) : withTrailing ? ( + + ) : null} + + +
+ ))} +
+ ); +}; + +export default ListSkeleton; diff --git a/src/components/skeletons/stat-tile-skeleton.tsx b/src/components/skeletons/stat-tile-skeleton.tsx new file mode 100644 index 0000000..23068ff --- /dev/null +++ b/src/components/skeletons/stat-tile-skeleton.tsx @@ -0,0 +1,42 @@ +import { Card, Group, Skeleton, Stack } from "@mantine/core"; + +export interface StatTileSkeletonProps { + withCard?: boolean; + align?: "row" | "column"; + numberWidth?: number; + numberHeight?: number; + labelWidth?: number; + labelHeight?: number; +} + +export const StatTileSkeleton = ({ + withCard = false, + align = "column", + numberWidth = align === "row" ? 36 : 48, + numberHeight = 28, + labelWidth = align === "row" ? 110 : 80, + labelHeight = align === "row" ? 14 : 12, +}: StatTileSkeletonProps) => { + const content = + align === "row" ? ( + + + + + ) : ( + + + + + ); + + if (!withCard) return content; + + return ( + + {content} + + ); +}; + +export default StatTileSkeleton; diff --git a/src/contexts/auth-context.tsx b/src/contexts/auth-context.tsx index a7e0e07..a5073d7 100644 --- a/src/contexts/auth-context.tsx +++ b/src/contexts/auth-context.tsx @@ -18,6 +18,7 @@ interface AuthData { theme?: string; locale?: string; seenThemesHint?: boolean; + telemetryAlerts?: boolean; }; roles: string[]; phone: string; diff --git a/src/contexts/spotify-context.tsx b/src/contexts/spotify-context.tsx index c2ff000..e013aa3 100644 --- a/src/contexts/spotify-context.tsx +++ b/src/contexts/spotify-context.tsx @@ -1,7 +1,7 @@ import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react'; import { useLingui } from '@lingui/react/macro'; import { SpotifyAuth } from '@/lib/spotify/auth'; -import { useAuth } from './auth-context'; +import { useIsAdmin } from '@/features/core/hooks/use-is-admin'; import { useConfig } from '@/hooks/use-config'; import type { SpotifyContextType, @@ -40,8 +40,7 @@ const deepEqual = (a: unknown, b: unknown): boolean => { }; export const SpotifyProvider: React.FC = ({ children }) => { - const { roles } = useAuth(); - const isAdmin = roles?.includes('Admin') || false; + const isAdmin = useIsAdmin(); const config = useConfig(); const { t } = useLingui(); diff --git a/src/features/activities/components/activities-table.tsx b/src/features/activities/components/activities-table.tsx index 4bf97b7..45dd0ab 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 } 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..64dc192 --- /dev/null +++ b/src/features/telemetry/components/usage-screen.tsx @@ -0,0 +1,387 @@ +import { Suspense, useMemo, useState } from "react"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { + Divider, + Group, + SegmentedControl, + SimpleGrid, + Stack, + Text, + Title, +} from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons"; +import { useRollupRange } from "../queries"; +import type { RollupRow } from "../types"; +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", + "event.count", + "event.detail.count", +]; + +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 vitalColor = (name: VitalName, value: number): string | undefined => { + const rating = vitalRating(name, value); + if (rating === "good") return undefined; + return rating === "ok" ? "yellow.8" : "red"; +}; + +const formatVital = (name: VitalName, value: number) => + name === "CLS" ? value.toFixed(2) : `${Math.round(value)}ms`; + +const dayLabel = (i18n: { date: (d: Date, opts?: Intl.DateTimeFormatOptions) => string }, day: string) => + i18n.date(new Date(`${day}T12:00:00Z`), { month: "short", day: "numeric" }); + +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 { i18n } = useLingui(); + 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); + const lastNonZero = (series: number[]) => + [...series].reverse().find((value) => value > 0) ?? 0; + + return ( + + `${Math.round(value)}ms`, + }} + /> + + + + + + + p50 · {Math.round(lastNonZero(p50))}ms + + + + + + + + p95 · {Math.round(lastNonZero(p95))}ms + + + + + ); +}; + +const EventBreakdown = ({ name, rows }: { name: string; rows: RollupRow[] }) => { + const items = useMemo(() => { + const totals = new Map(); + const prefix = `${name}|`; + for (const row of rows) { + if (row.metric !== "event.detail.count" || !row.dim.startsWith(prefix)) continue; + const detail = row.dim.slice(prefix.length); + totals.set(detail, (totals.get(detail) ?? 0) + row.value); + } + return [...totals.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([label, value]) => ({ label, value })); + }, [name, rows]); + + if (items.length === 0) { + return ( + + No breakdown recorded + + ); + } + + return ; +}; + +const UsageSections = ({ from, to }: { from: string; to: string }) => { + const { t, i18n } = useLingui(); + const [selectedFn, setSelectedFn] = useState(null); + const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet(); + const [selectedEvent, setSelectedEvent] = useState(null); + const { isOpen: eventDetailOpened, open: openEventDetail, close: closeEventDetail } = 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 eventItems = topItems(sumByDim(rows, "event.count"), (dim) => { + setSelectedEvent(dim); + openEventDetail(); + }); + + 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} + sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }} + /> + + WAU} + value={(latestValue(rows, "wau", "") ?? 0).toLocaleString()} + /> + MAU} + value={(latestValue(rows, "mau", "") ?? 0).toLocaleString()} + /> + Sessions} value={sessions.toLocaleString()} /> + + + + Page views by route + + + + + + + + + + <Trans>Events</Trans> + + {eventItems.length > 0 ? ( + + + Top interactions, tap for breakdown + + + + ) : ( + + No events recorded in this range + + )} + + + + + + + <Trans>Server functions</Trans> + + + + Top functions by call count + + + + + + + + + + <Trans>Errors</Trans> + + Client errors} + value={errorSeries.reduce((sum, value) => sum + value, 0).toLocaleString()} + spark={errorSeries} + sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }} + /> + + + + + + + <Trans>Vitals</Trans> + + + {vitals.map(({ name, value }) => ( + + ))} + + + + { + setSelectedEvent(null); + closeEventDetail(); + }} + fields={ + selectedEvent + ? [ + { + label: Breakdown, + value: , + }, + ] + : [] + } + /> + + Duration p50 / p95, + value: , + }, + ] + : [] + } + /> + + ); +}; + +export const UsageScreen = () => { + const { t } = useLingui(); + const [preset, setPreset] = useState<"7" | "30" | "180" | "custom">("7"); + const [customRange, setCustomRange] = useState<[string | null, string | null]>([null, null]); + + const range = useMemo(() => { + if (preset !== "custom") return lastNDays(Number(preset)); + const [start, end] = customRange; + if (!start || !end) return null; + return { from: start, to: end }; + }, [preset, customRange]); + + return ( + + setPreset(value as typeof preset)} + data={[ + { value: "7", label: t`7d` }, + { value: "30", label: t`30d` }, + { value: "180", label: t`180d` }, + { value: "custom", label: t`Custom` }, + ]} + fullWidth + /> + + {preset === "custom" && ( + + )} + + {range ? ( + + + + + + + + + + } + > + + + ) : ( + + Pick a date range to see usage + + )} + + ); +}; + +export default UsageScreen; diff --git a/src/features/telemetry/index.ts b/src/features/telemetry/index.ts new file mode 100644 index 0000000..bb7200f --- /dev/null +++ b/src/features/telemetry/index.ts @@ -0,0 +1,9 @@ +export * from "./types"; +export * from "./queries"; +export * from "./range"; +export * from "./components/charts"; +export * from "./components/shared"; +export { SessionsList, defaultSessionParams } from "./components/sessions/sessions-list"; +export { SessionTimelineView } from "./components/sessions/session-timeline"; +export { ErrorBrowser } from "./components/error-browser"; +export { UsageScreen, USAGE_METRICS } from "./components/usage-screen"; diff --git a/src/features/telemetry/queries.ts b/src/features/telemetry/queries.ts new file mode 100644 index 0000000..87f7952 --- /dev/null +++ b/src/features/telemetry/queries.ts @@ -0,0 +1,96 @@ +import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; +import { + getClientErrorGroups, + getRollupRange, + getSessionTimeline, + getTelemetryRuntimeStatus, + listSessions, + listTelemetryAlerts, + searchClientErrors, + searchClientEvents, +} from "./server"; +import type { ClientErrorSearchParams, ClientEventSearchParams } from "./types"; + +interface ErrorGroupParams { + from: string; + to: string; + includeResolved?: boolean; + limit?: number; +} + +interface RollupRangeParams { + metrics: string[]; + from: string; + to: string; + dim?: string; +} + +export const telemetryKeys = { + all: ["telemetry"] as const, + events: (params: ClientEventSearchParams) => ["telemetry", "events", params] as const, + errors: (params: ClientErrorSearchParams) => ["telemetry", "errors", params] as const, + errorGroups: (params: ErrorGroupParams) => ["telemetry", "errorGroups", params] as const, + rollups: (params: RollupRangeParams) => ["telemetry", "rollups", params] as const, + alerts: (page: number) => ["telemetry", "alerts", page] as const, + sessions: (params: { days?: number; page?: number }) => + ["telemetry", "sessions", params] as const, + sessionTimeline: (sessionId: string) => + ["telemetry", "sessionTimeline", sessionId] as const, + runtimeStatus: ["telemetry", "runtimeStatus"] as const, +}; + +export const telemetryQueries = { + events: (params: ClientEventSearchParams = {}) => ({ + queryKey: telemetryKeys.events(params), + queryFn: () => searchClientEvents({ data: params }), + }), + errors: (params: ClientErrorSearchParams = {}) => ({ + queryKey: telemetryKeys.errors(params), + queryFn: () => searchClientErrors({ data: params }), + }), + errorGroups: (params: ErrorGroupParams) => ({ + queryKey: telemetryKeys.errorGroups(params), + queryFn: () => getClientErrorGroups({ data: params }), + }), + rollups: (params: RollupRangeParams) => ({ + queryKey: telemetryKeys.rollups(params), + queryFn: () => getRollupRange({ data: params }), + }), + alerts: (page = 1) => ({ + queryKey: telemetryKeys.alerts(page), + queryFn: () => listTelemetryAlerts({ data: { page } }), + }), + sessions: (params: { days?: number; page?: number } = {}) => ({ + queryKey: telemetryKeys.sessions(params), + queryFn: () => listSessions({ data: params }), + }), + sessionTimeline: (sessionId: string) => ({ + queryKey: telemetryKeys.sessionTimeline(sessionId), + queryFn: () => getSessionTimeline({ data: { sessionId } }), + }), + runtimeStatus: () => ({ + queryKey: telemetryKeys.runtimeStatus, + queryFn: () => getTelemetryRuntimeStatus(), + }), +}; + +export const useClientEvents = (params: ClientEventSearchParams = {}) => + useServerSuspenseQuery(telemetryQueries.events(params)); + +export const useClientErrors = (params: ClientErrorSearchParams = {}) => + useServerSuspenseQuery(telemetryQueries.errors(params)); + +export const useClientErrorGroups = (params: ErrorGroupParams) => + useServerSuspenseQuery(telemetryQueries.errorGroups(params)); + +export const useRollupRange = (params: RollupRangeParams) => + useServerSuspenseQuery(telemetryQueries.rollups(params)); + +export const useSessions = (params: { days?: number; page?: number } = {}) => + useServerSuspenseQuery(telemetryQueries.sessions(params)); + +export const useSessionTimeline = (sessionId: string) => + useServerSuspenseQuery(telemetryQueries.sessionTimeline(sessionId)); + +export const useTelemetryRuntimeStatus = () => + useServerSuspenseQuery(telemetryQueries.runtimeStatus()); 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/telemetry/server.ts b/src/features/telemetry/server.ts new file mode 100644 index 0000000..6a8bc2f --- /dev/null +++ b/src/features/telemetry/server.ts @@ -0,0 +1,440 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens"; +import { pbAdmin } from "@/lib/pocketbase/client"; +import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; +import { + transformClientError, + transformClientEvent, +} from "@/lib/pocketbase/util/transform-types"; +import { ensureTelemetryScheduler, getSchedulerStatus } from "@/lib/telemetry/scheduler.server"; +import { getHealthSnapshot } from "@/lib/telemetry/health.server"; +import { getActiveConnectionCount } from "@/lib/events/emitter"; +import type { + ClientError, + ClientErrorGroup, + ClientEvent, + RollupRow, + SessionSummary, + SessionTimeline, + TelemetryAlert, + TelemetryListResult, + TelemetryRuntimeStatus, + TimelineEntry, +} from "./types"; + +const clientEventSearchSchema = z.object({ + page: z.number().optional(), + perPage: z.number().max(200).optional(), + name: z.string().optional(), + player: z.string().optional(), + sessionId: z.string().optional(), + path: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + sortBy: z.string().optional(), +}); + +export const searchClientEvents = createServerFn() + .validator(clientEventSearchSchema) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult>(async () => { + const result = await pbAdmin.searchClientEvents(data); + return { ...result, items: result.items.map(transformClientEvent) }; + }) + ); + +const clientErrorSearchSchema = z.object({ + page: z.number().optional(), + perPage: z.number().max(200).optional(), + groupHash: z.string().optional(), + resolved: z.boolean().optional(), + player: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + sortBy: z.string().optional(), +}); + +export const searchClientErrors = createServerFn() + .validator(clientErrorSearchSchema) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult>(async () => { + const result = await pbAdmin.searchClientErrors(data); + return { ...result, items: result.items.map(transformClientError) }; + }) + ); + +const errorGroupsSchema = z.object({ + from: z.string(), + to: z.string(), + includeResolved: z.boolean().optional(), + limit: z.number().max(100).optional(), +}); + +// Aggregates raw rows, not rollups: a brand-new error must appear immediately, +// not on the next rollup tick. +export const getClientErrorGroups = createServerFn() + .validator(errorGroupsSchema) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult(async () => { + const { from, to, includeResolved = false, limit = 50 } = data; + + const toExclusive = new Date(`${to}T00:00:00.000Z`); + toExclusive.setUTCDate(toExclusive.getUTCDate() + 1); + const filter = `created >= '${from} 00:00:00.000Z' && created < '${toExclusive + .toISOString() + .replace("T", " ")}'`; + + const byGroup = new Map(); + await pbAdmin.pageCollection<{ + group_hash: string; + message: string; + stack?: string; + source?: string; + path?: string; + resolved?: boolean; + created: string; + }>( + "client_errors", + { filter, fields: "group_hash,message,stack,source,path,resolved,created" }, + (rows) => { + for (const row of rows) { + const existing = byGroup.get(row.group_hash); + if (existing) { + existing.count++; + // Paged in created-asc order, so each row is the newest so far. + existing.lastSeen = row.created; + existing.resolved = !!row.resolved; + existing.sample = { + message: row.message, + stack: row.stack, + path: row.path, + source: row.source, + }; + } else { + byGroup.set(row.group_hash, { + group_hash: row.group_hash, + count: 1, + lastSeen: row.created, + resolved: !!row.resolved, + sample: { + message: row.message, + stack: row.stack, + path: row.path, + source: row.source, + }, + }); + } + } + } + ); + + return [...byGroup.values()] + .filter((group) => includeResolved || !group.resolved) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + }) + ); + +export const resolveClientErrorGroup = createServerFn({ method: "POST" }) + .validator(z.object({ groupHash: z.string(), resolved: z.boolean().optional() })) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult<{ updated: number }>(async () => { + const updated = await pbAdmin.resolveErrorsByGroup(data.groupHash, data.resolved ?? true); + return { updated }; + }) + ); + +const rollupRangeSchema = z.object({ + metrics: z.array(z.string()).min(1).max(12), + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + dim: z.string().optional(), +}); + +export const getRollupRange = createServerFn() + .validator(rollupRangeSchema) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult(async () => pbAdmin.getRollupRange(data)) + ); + +export const listTelemetryAlerts = createServerFn() + .validator( + z.object({ page: z.number().optional(), perPage: z.number().max(200).optional() }) + ) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult>(async () => + pbAdmin.listTelemetryAlerts(data.page, data.perPage) + ) + ); + +const pbTime = (date: Date): string => date.toISOString().replace("T", " "); + +const playerInfoById = async (ids: Set) => { + if (ids.size === 0) return new Map(); + const players = await pbAdmin.listPlayers(); + return new Map( + players + .filter((player) => ids.has(player.id)) + .map((player) => [ + player.id, + { id: player.id, first_name: player.first_name, last_name: player.last_name }, + ]) + ); +}; + +export const listSessions = createServerFn() + .validator( + z.object({ + days: z.number().min(1).max(90).optional(), + page: z.number().optional(), + perPage: z.number().max(100).optional(), + }) + ) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult>(async () => { + const { days = 7, page = 1, perPage = 30 } = data; + const from = pbTime(new Date(Date.now() - days * 86400000)); + + const sessions = new Map< + string, + { playerId?: string; started: string; lastSeen: string; pageViews: number; events: number; errors: number; user_agent?: string } + >(); + + await pbAdmin.pageCollection<{ + session_id?: string; + player?: string; + name: string; + user_agent?: string; + created: string; + }>( + "client_events", + { filter: `created >= '${from}'`, fields: "session_id,player,name,user_agent,created" }, + (rows) => { + for (const row of rows) { + if (!row.session_id) continue; + const session = sessions.get(row.session_id) ?? { + playerId: row.player, + started: row.created, + lastSeen: row.created, + pageViews: 0, + events: 0, + errors: 0, + user_agent: row.user_agent, + }; + session.playerId ||= row.player; + if (row.created < session.started) session.started = row.created; + if (row.created > session.lastSeen) session.lastSeen = row.created; + if (row.name === "page_view") session.pageViews++; + else if (!row.name.startsWith("vital.")) session.events++; + sessions.set(row.session_id, session); + } + } + ); + + await pbAdmin.pageCollection<{ session_id?: string; created: string }>( + "client_errors", + { filter: `created >= '${from}'`, fields: "session_id,created" }, + (rows) => { + for (const row of rows) { + if (!row.session_id) continue; + const session = sessions.get(row.session_id); + if (session) session.errors++; + } + } + ); + + const playerIds = new Set( + [...sessions.values()].map((s) => s.playerId).filter((id): id is string => !!id) + ); + const players = await playerInfoById(playerIds); + + const all: SessionSummary[] = [...sessions.entries()] + .map(([session_id, s]) => ({ + session_id, + player: s.playerId ? players.get(s.playerId) : undefined, + started: s.started, + lastSeen: s.lastSeen, + pageViews: s.pageViews, + events: s.events, + errors: s.errors, + user_agent: s.user_agent, + })) + .sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1)); + + const start = (page - 1) * perPage; + return { + items: all.slice(start, start + perPage), + page, + perPage, + totalPages: Math.max(1, Math.ceil(all.length / perPage)), + totalItems: all.length, + }; + }) + ); + +const SESSION_WINDOW_PAD_MS = 30_000; +const MAX_TIMELINE_ENTRIES = 500; + +export const getSessionTimeline = createServerFn() + .validator(z.object({ sessionId: z.string().min(8).max(64) })) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult(async () => { + const events = await pbAdmin.searchClientEvents({ + sessionId: data.sessionId, + perPage: 200, + sortBy: "created", + }); + const errors = await pbAdmin.searchClientErrors({ + sessionId: data.sessionId, + perPage: 200, + sortBy: "created", + }); + + const sessionErrors = errors.items; + if (events.items.length === 0 && sessionErrors.length === 0) { + throw new Error("Session not found"); + } + + const timestamps = [ + ...events.items.map((e) => e.created), + ...sessionErrors.map((e) => e.created), + ].sort(); + const started = timestamps[0]; + const lastSeen = timestamps[timestamps.length - 1]; + + const playerId = events.items.find((e) => typeof e.player === "string" && e.player) + ?.player as string | undefined; + + const entries: TimelineEntry[] = []; + + for (const event of events.items) { + if (event.name.startsWith("vital.")) continue; + const props = event.props as + | { emoji?: string; source?: string; tournament?: string; value?: string; context?: string } + | null + | undefined; + const breakdown = + props?.emoji ?? props?.value ?? props?.source ?? props?.tournament; + const detail = [breakdown, props?.context].filter(Boolean).join(" · "); + entries.push({ + ts: event.created, + kind: event.name === "page_view" ? "page_view" : "event", + label: event.name === "page_view" ? event.path || event.route_id || "?" : event.name, + detail: event.name === "page_view" ? undefined : (detail || event.path), + }); + } + + for (const error of sessionErrors) { + entries.push({ + ts: error.created, + kind: "error", + label: error.message, + detail: error.path, + success: false, + }); + } + + // Server-fn activities have no session id; join by player inside the + // session's padded time window. + if (playerId) { + const windowFrom = pbTime(new Date(Date.parse(started.replace(" ", "T")) - SESSION_WINDOW_PAD_MS)); + const windowTo = pbTime(new Date(Date.parse(lastSeen.replace(" ", "T")) + SESSION_WINDOW_PAD_MS)); + const activities = await pbAdmin.searchActivities({ + player: playerId, + from: windowFrom, + to: windowTo, + perPage: 200, + sortBy: "created", + }); + for (const activity of activities.items) { + entries.push({ + ts: activity.created, + kind: "server_fn", + label: activity.name, + detail: activity.error, + success: activity.success, + duration: activity.duration, + }); + } + } + + entries.sort((a, b) => (a.ts < b.ts ? -1 : 1)); + + const profileIds = new Set(); + const teamIds = new Set(); + for (const entry of entries) { + if (entry.kind !== "page_view") continue; + const profileMatch = entry.label.match(/^\/profile\/([^/]+)$/); + if (profileMatch) profileIds.add(profileMatch[1]); + const teamMatch = entry.label.match(/^\/teams\/([^/]+)$/); + if (teamMatch) teamIds.add(teamMatch[1]); + } + + const players = await playerInfoById( + new Set([...(playerId ? [playerId] : []), ...profileIds]) + ); + + const teamNames = new Map(); + if (teamIds.size > 0) { + try { + const teams = await pbAdmin.listTeamInfos(); + for (const team of teams) { + if (teamIds.has(team.id)) teamNames.set(team.id, team.name); + } + } catch {} + } + + for (const entry of entries) { + if (entry.kind !== "page_view" || entry.detail) continue; + const profileMatch = entry.label.match(/^\/profile\/([^/]+)$/); + if (profileMatch) { + const player = players.get(profileMatch[1]); + if (player) { + entry.detail = [player.first_name, player.last_name].filter(Boolean).join(" "); + } + continue; + } + const teamMatch = entry.label.match(/^\/teams\/([^/]+)$/); + if (teamMatch) entry.detail = teamNames.get(teamMatch[1]); + } + + return { + session: { + session_id: data.sessionId, + player: playerId ? players.get(playerId) : undefined, + started, + lastSeen, + pageViews: events.items.filter((e) => e.name === "page_view").length, + events: events.items.filter( + (e) => e.name !== "page_view" && !e.name.startsWith("vital.") + ).length, + errors: sessionErrors.length, + user_agent: events.items[0]?.user_agent, + }, + entries: entries.slice(0, MAX_TIMELINE_ENTRIES), + }; + }) + ); + +export const getTelemetryRuntimeStatus = createServerFn() + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async () => + toServerResult(async () => { + ensureTelemetryScheduler(); + const health = await getHealthSnapshot(); + return { + health: { status: health.status, checks: health.checks }, + scheduler: getSchedulerStatus(), + activeSseConnections: getActiveConnectionCount(), + }; + }) + ); diff --git a/src/features/telemetry/types.ts b/src/features/telemetry/types.ts new file mode 100644 index 0000000..0924763 --- /dev/null +++ b/src/features/telemetry/types.ts @@ -0,0 +1,65 @@ +export type { + ClientEventRecord as ClientEvent, + ClientErrorRecord as ClientError, + ClientErrorSource, + ClientEventSearchParams, + ClientErrorSearchParams, + RollupRecord as RollupRow, + TelemetryAlertRecord as TelemetryAlert, + TelemetryListResult, +} from "@/lib/pocketbase/services/telemetry"; + +export interface ClientErrorGroup { + group_hash: string; + count: number; + lastSeen: string; + resolved: boolean; + sample: { + message: string; + stack?: string; + path?: string; + source?: string; + }; +} + +export interface SessionSummary { + session_id: string; + player?: { id: string; first_name?: string; last_name?: string }; + started: string; + lastSeen: string; + pageViews: number; + events: number; + errors: number; + user_agent?: string; +} + +export type TimelineKind = "page_view" | "event" | "error" | "server_fn"; + +export interface TimelineEntry { + ts: string; + kind: TimelineKind; + label: string; + detail?: string; + success?: boolean; + duration?: number; +} + +export interface SessionTimeline { + session: SessionSummary; + entries: TimelineEntry[]; +} + +export interface TelemetryRuntimeStatus { + health: { + status: "ok" | "degraded"; + checks: { pocketbase: string; supertokens: string }; + }; + scheduler: { + running: boolean; + lastRollupAt?: string; + lastAlertCheckAt?: string; + lastRollupError?: string; + lastAlertError?: string; + }; + activeSseConnections: number; +} 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-warnings.tsx b/src/features/tournaments/components/enrollment/enrollment-warnings.tsx new file mode 100644 index 0000000..0263b70 --- /dev/null +++ b/src/features/tournaments/components/enrollment/enrollment-warnings.tsx @@ -0,0 +1,28 @@ +import { Alert, Stack } from "@mantine/core"; +import { WarningCircleIcon } from "@phosphor-icons/react"; +import { Trans } from "@lingui/react/macro"; + +const EnrollmentWarnings = ({ count }: { count: number }) => { + const hasOddPlayers = count % 2 !== 0; + const hasEnoughPlayers = count >= 2; + + if (hasEnoughPlayers && !hasOddPlayers) return null; + + return ( + + {!hasEnoughPlayers && ( + }> + Need at least 2 players to create teams + + )} + + {hasEnoughPlayers && hasOddPlayers && ( + }> + Cannot create teams with an odd number of players. Please have one player unenroll. + + )} + + ); +}; + +export default EnrollmentWarnings; 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..d377473 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 EnrollmentWarnings from "./enrollment-warnings"; -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..66a6af8 --- /dev/null +++ b/src/features/tournaments/components/enrollment/team-section.tsx @@ -0,0 +1,280 @@ +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"; + +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..9e9a8c9 100644 --- a/src/features/tournaments/components/manage-tournament.tsx +++ b/src/features/tournaments/components/manage-tournament.tsx @@ -8,14 +8,9 @@ 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"; @@ -38,17 +33,20 @@ 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 ? ( - - ) : ( - - )} - ); }; diff --git a/src/features/tournaments/components/profile/index.tsx b/src/features/tournaments/components/profile/index.tsx index c4a9c51..91472f8 100644 --- a/src/features/tournaments/components/profile/index.tsx +++ b/src/features/tournaments/components/profile/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Box } from "@mantine/core"; import Header from "./header"; import TeamList from "@/features/teams/components/team-list"; @@ -7,6 +7,7 @@ import { useTournament } from "../../queries"; import MatchList from "@/features/matches/components/match-list"; import { TournamentStats } from "../tournament-stats"; import { useLingui } from "@lingui/react/macro"; +import { track } from "@/lib/telemetry/client"; interface ProfileProps { id: string; @@ -15,6 +16,14 @@ interface ProfileProps { const Profile = ({ id }: ProfileProps) => { const { t } = useLingui(); const { data: tournament } = useTournament(id); + const trackedId = useRef(null); + + useEffect(() => { + if (!tournament || trackedId.current === tournament.id) return; + trackedId.current = tournament.id; + track("tournament_viewed", { tournament: tournament.name }); + }, [tournament]); + if (!tournament) return null; const tabs = useMemo(() => [ diff --git a/src/features/tournaments/components/started-tournament/header.tsx b/src/features/tournaments/components/started-tournament/header.tsx index c9c38b7..5ee1095 100644 --- a/src/features/tournaments/components/started-tournament/header.tsx +++ b/src/features/tournaments/components/started-tournament/header.tsx @@ -2,11 +2,15 @@ import { Group, Stack, ThemeIcon, Text } from "@mantine/core"; import { Tournament } from "../../types"; import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react"; import { useMemo } from "react"; +import { useMounted } from "@mantine/hooks"; import { Trans, useLingui } from "@lingui/react/macro"; import GlitchAvatar from "@/components/glitch-avatar"; const Header = ({ tournament }: { tournament: Tournament }) => { const { i18n } = useLingui(); + // Local-timezone text cannot be server-rendered: the pod formats in UTC and + // hydration mismatches on every load. + const mounted = useMounted(); const tournamentStart = useMemo( () => new Date(tournament.start_time), [tournament.start_time] @@ -48,18 +52,20 @@ const Header = ({ tournament }: { tournament: Tournament }) => { - - {tournamentStart.toLocaleDateString(i18n.locale, { - weekday: "short", - month: "short", - day: "numeric", - })}{" "} - at{" "} - {tournamentStart.toLocaleTimeString(i18n.locale, { - hour: "2-digit", - minute: "2-digit", - })} - + {mounted && ( + + {tournamentStart.toLocaleDateString(i18n.locale, { + weekday: "short", + month: "short", + day: "numeric", + })}{" "} + at{" "} + {tournamentStart.toLocaleTimeString(i18n.locale, { + hour: "2-digit", + minute: "2-digit", + })} + + )} diff --git a/src/features/tournaments/components/started-tournament/index.tsx b/src/features/tournaments/components/started-tournament/index.tsx index dde5ade..7449a76 100644 --- a/src/features/tournaments/components/started-tournament/index.tsx +++ b/src/features/tournaments/components/started-tournament/index.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { Tournament } from "../../types"; import { useAuth } from "@/contexts/auth-context"; +import { useIsAdmin } from "@/features/core/hooks/use-is-admin"; import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core"; import { ClockIcon } from "@phosphor-icons/react"; import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils"; @@ -22,10 +23,10 @@ const teamPlayerIds = (t: Match["home"]): string[] => const StartedTournament: React.FC<{ tournament: Tournament }> = ({ tournament, }) => { - const { user, roles } = useAuth(); + const { user } = useAuth(); const { t } = useLingui(); - const isAdmin = useMemo(() => roles.includes("Admin"), [roles]); + const isAdmin = useIsAdmin(); const matches = useMemo(() => tournament.matches || [], [tournament.matches]); diff --git a/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx b/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx index 432400f..ac58463 100644 --- a/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx +++ b/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx @@ -24,34 +24,16 @@ import { useSheet } from "@/hooks/use-sheet"; import TeamList from "@/features/teams/components/team-list"; import WizardOrbIcon from "@/components/wizard-orb-icon"; import { Tournament } from "../../../types"; +import { track } from "@/lib/telemetry/client"; import classes from "./nav-grid.module.css"; const RulesContent = lazy( () => import("../../upcoming-tournament/rules-content") ); -interface NavTileProps { - label: string; - Icon: Icon; - onClick?: () => void; - disabled?: boolean; -} +import { NavTile } from "@/components/nav-tile"; -/** A single square-ish nav tile with icon-over-label and press feedback. */ -export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => ( - - - - - - {label} - - -); +export { NavTile }; interface NavGridProps { tournament: Tournament; @@ -105,9 +87,10 @@ const NavGrid = ({ - navigate({ to: `/tournaments/${tournament.id}/bracket` }) - } + onClick={() => { + track("bracket_viewed", { source: "nav_grid" }); + navigate({ to: `/tournaments/${tournament.id}/bracket` }); + }} /> {isPredictable && !predictionsLocked && ( { + track("teams_viewed", { source: "nav_grid" }); + teamsSheet.open(); + }} + /> + { + track("rules_viewed", { source: "nav_grid" }); + rulesSheet.open(); + }} /> - diff --git a/src/features/tournaments/components/started-tournament/sections/recent-results.tsx b/src/features/tournaments/components/started-tournament/sections/recent-results.tsx index 7940c1f..b638e9f 100644 --- a/src/features/tournaments/components/started-tournament/sections/recent-results.tsx +++ b/src/features/tournaments/components/started-tournament/sections/recent-results.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router"; import { Trans, useLingui } from "@lingui/react/macro"; import MatchCard from "@/features/matches/components/match-card"; import { Match } from "@/features/matches/types"; +import { track } from "@/lib/telemetry/client"; import SectionHeading from "./section-heading"; interface RecentResultsProps { @@ -39,9 +40,10 @@ const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) = type="button" size="xs" fw={600} - onClick={() => - navigate({ to: `/tournaments/${tournamentId}/bracket` }) - } + onClick={() => { + track("bracket_viewed", { source: "recent_results" }); + navigate({ to: `/tournaments/${tournamentId}/bracket` }); + }} > View all results diff --git a/src/features/tournaments/components/started-tournament/sections/score-report-cta.tsx b/src/features/tournaments/components/started-tournament/sections/score-report-cta.tsx index 8db5e64..59fd081 100644 --- a/src/features/tournaments/components/started-tournament/sections/score-report-cta.tsx +++ b/src/features/tournaments/components/started-tournament/sections/score-report-cta.tsx @@ -77,7 +77,7 @@ const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => { )} - + ))} diff --git a/src/features/tournaments/components/tournament-card-list.tsx b/src/features/tournaments/components/tournament-card-list.tsx index 16d531c..a99a173 100644 --- a/src/features/tournaments/components/tournament-card-list.tsx +++ b/src/features/tournaments/components/tournament-card-list.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@/contexts/auth-context"; +import { useIsAdmin } from "@/features/core/hooks/use-is-admin"; import { useTournaments } from "../queries"; import { useSheet } from "@/hooks/use-sheet"; import { Button, Stack } from "@mantine/core"; @@ -12,11 +12,11 @@ import EmptyState from "@/components/empty-state"; const TournamentCardList = () => { const { t } = useLingui(); const { data: tournaments } = useTournaments(); - const { roles } = useAuth(); + const isAdmin = useIsAdmin(); const sheet = useSheet(); return ( - {roles?.includes("Admin") ? ( + {isAdmin ? ( <>