Merge branch 'admin-telemetry-redesign' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 19s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 41s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 51s
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 19s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 41s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 51s
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
"twilio": "^5.8.0",
|
"twilio": "^5.8.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
|
"web-vitals": "^6.2.0",
|
||||||
"zod": "^4.0.15",
|
"zod": "^4.0.15",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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-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=="],
|
"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=="],
|
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ spec:
|
|||||||
value: "3000"
|
value: "3000"
|
||||||
- name: NODE_ENV
|
- name: NODE_ENV
|
||||||
value: "production"
|
value: "production"
|
||||||
|
- name: TELEMETRY_EXCLUDE_PHONES
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: flxn-secrets
|
||||||
|
key: telemetry_exclude_phones
|
||||||
|
optional: true
|
||||||
|
|
||||||
- name: TWILIO_ACCOUNT_SID
|
- name: TWILIO_ACCOUNT_SID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"twilio": "^5.8.0",
|
"twilio": "^5.8.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
|
"web-vitals": "^6.2.0",
|
||||||
"zod": "^4.0.15"
|
"zod": "^4.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
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);
|
||||||
|
})
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
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);
|
||||||
|
})
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
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"));
|
||||||
|
})
|
||||||
+206
-1
@@ -22,6 +22,7 @@ import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
|||||||
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
||||||
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
||||||
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
|
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 ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
|
||||||
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
|
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
|
||||||
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
|
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 AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
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 AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||||
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||||
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
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 AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
|
||||||
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
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 AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
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 AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
|
||||||
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
|
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 AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
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'
|
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||||
|
|
||||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||||
@@ -117,6 +126,11 @@ const ApiTournamentsUploadLogoRoute =
|
|||||||
path: '/api/tournaments/upload-logo',
|
path: '/api/tournaments/upload-logo',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({
|
||||||
|
id: '/api/telemetry/beacon',
|
||||||
|
path: '/api/telemetry/beacon',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
|
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
|
||||||
id: '/api/teams/upload-logo',
|
id: '/api/teams/upload-logo',
|
||||||
path: '/api/teams/upload-logo',
|
path: '/api/teams/upload-logo',
|
||||||
@@ -193,6 +207,11 @@ const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
|
|||||||
path: '/profile/$playerId',
|
path: '/profile/$playerId',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminTelemetryRoute = AuthedAdminTelemetryRouteImport.update({
|
||||||
|
id: '/telemetry',
|
||||||
|
path: '/telemetry',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
||||||
id: '/preview',
|
id: '/preview',
|
||||||
path: '/preview',
|
path: '/preview',
|
||||||
@@ -214,6 +233,12 @@ const AuthedAdminTournamentsIndexRoute =
|
|||||||
path: '/tournaments/',
|
path: '/tournaments/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminTelemetryIndexRoute =
|
||||||
|
AuthedAdminTelemetryIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||||
|
} as any)
|
||||||
const AuthedTournamentsIdPredictionsRoute =
|
const AuthedTournamentsIdPredictionsRoute =
|
||||||
AuthedTournamentsIdPredictionsRouteImport.update({
|
AuthedTournamentsIdPredictionsRouteImport.update({
|
||||||
id: '/tournaments/$id/predictions',
|
id: '/tournaments/$id/predictions',
|
||||||
@@ -232,6 +257,36 @@ const AuthedTournamentsIdBracketRoute =
|
|||||||
path: '/tournaments/$id/bracket',
|
path: '/tournaments/$id/bracket',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} 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 =
|
const AuthedAdminTournamentsIdIndexRoute =
|
||||||
AuthedAdminTournamentsIdIndexRouteImport.update({
|
AuthedAdminTournamentsIdIndexRouteImport.update({
|
||||||
id: '/tournaments/$id/',
|
id: '/tournaments/$id/',
|
||||||
@@ -268,6 +323,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
|
|||||||
path: '/tournaments/$id/teams',
|
path: '/tournaments/$id/teams',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminTournamentsIdEnrollmentRoute =
|
||||||
|
AuthedAdminTournamentsIdEnrollmentRouteImport.update({
|
||||||
|
id: '/tournaments/$id/enrollment',
|
||||||
|
path: '/tournaments/$id/enrollment',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsIdAssignPartnersRoute =
|
const AuthedAdminTournamentsIdAssignPartnersRoute =
|
||||||
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
|
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
|
||||||
id: '/tournaments/$id/assign-partners',
|
id: '/tournaments/$id/assign-partners',
|
||||||
@@ -288,6 +349,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
'/admin/badges': typeof AuthedAdminBadgesRoute
|
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
|
'/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
@@ -303,14 +365,22 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/admin/': typeof AuthedAdminIndexRoute
|
'/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/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/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
|
'/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
|
||||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
|
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
@@ -345,14 +415,22 @@ export interface FileRoutesByTo {
|
|||||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/admin': typeof AuthedAdminIndexRoute
|
'/admin': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/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/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
|
'/admin/telemetry': typeof AuthedAdminTelemetryIndexRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
|
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
@@ -375,6 +453,7 @@ export interface FileRoutesById {
|
|||||||
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
|
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
|
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
|
'/_authed/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
|
||||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
@@ -390,14 +469,22 @@ export interface FileRoutesById {
|
|||||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/_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/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
|
'/_authed/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
|
||||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/_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/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
@@ -420,6 +507,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/activities'
|
| '/admin/activities'
|
||||||
| '/admin/badges'
|
| '/admin/badges'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
|
| '/admin/telemetry'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
@@ -435,14 +523,22 @@ export interface FileRouteTypes {
|
|||||||
| '/api/spotify/search'
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/teams/upload-logo'
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/telemetry/beacon'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/tournaments/'
|
| '/tournaments/'
|
||||||
|
| '/admin/telemetry/errors'
|
||||||
|
| '/admin/telemetry/functions'
|
||||||
|
| '/admin/telemetry/players'
|
||||||
|
| '/admin/telemetry/sessions'
|
||||||
|
| '/admin/telemetry/usage'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/tournaments/$id/groups'
|
| '/tournaments/$id/groups'
|
||||||
| '/tournaments/$id/predictions'
|
| '/tournaments/$id/predictions'
|
||||||
|
| '/admin/telemetry/'
|
||||||
| '/admin/tournaments/'
|
| '/admin/tournaments/'
|
||||||
| '/admin/tournaments/$id/assign-partners'
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
|
| '/admin/tournaments/$id/enrollment'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
| '/tournaments/$id/predictions/$playerId'
|
| '/tournaments/$id/predictions/$playerId'
|
||||||
@@ -477,14 +573,22 @@ export interface FileRouteTypes {
|
|||||||
| '/api/spotify/search'
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/teams/upload-logo'
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/telemetry/beacon'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
|
| '/admin/telemetry/errors'
|
||||||
|
| '/admin/telemetry/functions'
|
||||||
|
| '/admin/telemetry/players'
|
||||||
|
| '/admin/telemetry/sessions'
|
||||||
|
| '/admin/telemetry/usage'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/tournaments/$id/groups'
|
| '/tournaments/$id/groups'
|
||||||
| '/tournaments/$id/predictions'
|
| '/tournaments/$id/predictions'
|
||||||
|
| '/admin/telemetry'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
| '/admin/tournaments/$id/assign-partners'
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
|
| '/admin/tournaments/$id/enrollment'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
| '/tournaments/$id/predictions/$playerId'
|
| '/tournaments/$id/predictions/$playerId'
|
||||||
@@ -506,6 +610,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/admin/activities'
|
| '/_authed/admin/activities'
|
||||||
| '/_authed/admin/badges'
|
| '/_authed/admin/badges'
|
||||||
| '/_authed/admin/preview'
|
| '/_authed/admin/preview'
|
||||||
|
| '/_authed/admin/telemetry'
|
||||||
| '/_authed/profile/$playerId'
|
| '/_authed/profile/$playerId'
|
||||||
| '/_authed/teams/$teamId'
|
| '/_authed/teams/$teamId'
|
||||||
| '/_authed/tournaments/$tournamentId'
|
| '/_authed/tournaments/$tournamentId'
|
||||||
@@ -521,14 +626,22 @@ export interface FileRouteTypes {
|
|||||||
| '/api/spotify/search'
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/teams/upload-logo'
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/telemetry/beacon'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/_authed/admin/'
|
| '/_authed/admin/'
|
||||||
| '/_authed/tournaments/'
|
| '/_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/bracket'
|
||||||
| '/_authed/tournaments/$id/groups'
|
| '/_authed/tournaments/$id/groups'
|
||||||
| '/_authed/tournaments/$id/predictions'
|
| '/_authed/tournaments/$id/predictions'
|
||||||
|
| '/_authed/admin/telemetry/'
|
||||||
| '/_authed/admin/tournaments/'
|
| '/_authed/admin/tournaments/'
|
||||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||||
|
| '/_authed/admin/tournaments/$id/enrollment'
|
||||||
| '/_authed/admin/tournaments/$id/teams'
|
| '/_authed/admin/tournaments/$id/teams'
|
||||||
| '/_authed/admin/tournaments/run/$id'
|
| '/_authed/admin/tournaments/run/$id'
|
||||||
| '/_authed/tournaments/$id/predictions_/$playerId'
|
| '/_authed/tournaments/$id/predictions_/$playerId'
|
||||||
@@ -555,6 +668,7 @@ export interface RootRouteChildren {
|
|||||||
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
|
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
|
||||||
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
|
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
|
||||||
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
|
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
|
||||||
|
ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
|
||||||
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
|
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
|
||||||
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
|
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
}
|
}
|
||||||
@@ -652,6 +766,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
|
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/teams/upload-logo': {
|
||||||
id: '/api/teams/upload-logo'
|
id: '/api/teams/upload-logo'
|
||||||
path: '/api/teams/upload-logo'
|
path: '/api/teams/upload-logo'
|
||||||
@@ -757,6 +878,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
|
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
parentRoute: typeof AuthedRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/admin/telemetry': {
|
||||||
|
id: '/_authed/admin/telemetry'
|
||||||
|
path: '/telemetry'
|
||||||
|
fullPath: '/admin/telemetry'
|
||||||
|
preLoaderRoute: typeof AuthedAdminTelemetryRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
'/_authed/admin/preview': {
|
'/_authed/admin/preview': {
|
||||||
id: '/_authed/admin/preview'
|
id: '/_authed/admin/preview'
|
||||||
path: '/preview'
|
path: '/preview'
|
||||||
@@ -785,6 +913,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/admin/telemetry/': {
|
||||||
|
id: '/_authed/admin/telemetry/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/admin/telemetry/'
|
||||||
|
preLoaderRoute: typeof AuthedAdminTelemetryIndexRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||||
|
}
|
||||||
'/_authed/tournaments/$id/predictions': {
|
'/_authed/tournaments/$id/predictions': {
|
||||||
id: '/_authed/tournaments/$id/predictions'
|
id: '/_authed/tournaments/$id/predictions'
|
||||||
path: '/tournaments/$id/predictions'
|
path: '/tournaments/$id/predictions'
|
||||||
@@ -806,6 +941,41 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
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/': {
|
'/_authed/admin/tournaments/$id/': {
|
||||||
id: '/_authed/admin/tournaments/$id/'
|
id: '/_authed/admin/tournaments/$id/'
|
||||||
path: '/tournaments/$id'
|
path: '/tournaments/$id'
|
||||||
@@ -848,6 +1018,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
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': {
|
'/_authed/admin/tournaments/$id/assign-partners': {
|
||||||
id: '/_authed/admin/tournaments/$id/assign-partners'
|
id: '/_authed/admin/tournaments/$id/assign-partners'
|
||||||
path: '/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 {
|
interface AuthedAdminRouteChildren {
|
||||||
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
|
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
|
||||||
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
|
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
|
||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
|
AuthedAdminTelemetryRoute: typeof AuthedAdminTelemetryRouteWithChildren
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
|
AuthedAdminTournamentsIdEnrollmentRoute: typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||||
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
||||||
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
@@ -874,10 +1074,13 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
|||||||
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
|
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
|
||||||
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
|
AuthedAdminTelemetryRoute: AuthedAdminTelemetryRouteWithChildren,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
AuthedAdminTournamentsIdAssignPartnersRoute:
|
AuthedAdminTournamentsIdAssignPartnersRoute:
|
||||||
AuthedAdminTournamentsIdAssignPartnersRoute,
|
AuthedAdminTournamentsIdAssignPartnersRoute,
|
||||||
|
AuthedAdminTournamentsIdEnrollmentRoute:
|
||||||
|
AuthedAdminTournamentsIdEnrollmentRoute,
|
||||||
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
||||||
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
||||||
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
||||||
@@ -944,6 +1147,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
|
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
|
||||||
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
|
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
|
||||||
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
|
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
|
||||||
|
ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
|
||||||
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
|
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
|
||||||
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
|
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
|
||||||
}
|
}
|
||||||
@@ -952,10 +1156,11 @@ export const routeTree = rootRouteImport
|
|||||||
._addFileTypes<FileRouteTypes>()
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
import type { getRouter } from './router.tsx'
|
import type { getRouter } from './router.tsx'
|
||||||
import type { createStart } from '@tanstack/react-start'
|
import type { startInstance } from './start.ts'
|
||||||
declare module '@tanstack/react-start' {
|
declare module '@tanstack/react-start' {
|
||||||
interface Register {
|
interface Register {
|
||||||
ssr: true
|
ssr: true
|
||||||
router: Awaited<ReturnType<typeof getRouter>>
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
|
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
Scripts,
|
Scripts,
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
isRedirect,
|
isRedirect,
|
||||||
|
useRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
|
||||||
import { type QueryClient } from "@tanstack/react-query";
|
import { type QueryClient } from "@tanstack/react-query";
|
||||||
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
|
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
|
||||||
|
import { initTelemetry, track } from "@/lib/telemetry/client";
|
||||||
import { AuthContextType } from "@/contexts/auth-context";
|
import { AuthContextType } from "@/contexts/auth-context";
|
||||||
import Providers from "@/features/core/components/providers";
|
import Providers from "@/features/core/components/providers";
|
||||||
import { SessionMonitor } from "@/components/session-monitor";
|
import { SessionMonitor } from "@/components/session-monitor";
|
||||||
@@ -175,12 +177,15 @@ export const Route = createRootRouteWithContext<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
ensureSuperTokensFrontend();
|
ensureSuperTokensFrontend();
|
||||||
|
initTelemetry(router);
|
||||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
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 (
|
return (
|
||||||
<RootDocument>
|
<RootDocument>
|
||||||
@@ -193,7 +198,6 @@ function RootComponent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: analytics -> process.env data-website-id
|
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import type { HeaderConfig } from "@/features/core/types/header-config";
|
import type { HeaderConfig } from "@/features/core/types/header-config";
|
||||||
|
import { isAdminRole } from "@/features/core/utils/roles";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin")({
|
export const Route = createFileRoute("/_authed/admin")({
|
||||||
component: Outlet,
|
component: Outlet,
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!context.auth?.roles?.includes("Admin")) {
|
if (!isAdminRole(context.auth?.roles)) {
|
||||||
throw redirect({ to: "/" });
|
throw redirect({ to: "/" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, redirect } 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";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/activities")({
|
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||||
component: Stats,
|
beforeLoad: () => {
|
||||||
beforeLoad: ({ context }) => {
|
throw redirect({ to: "/admin/telemetry/functions" });
|
||||||
const queryClient = context.queryClient;
|
|
||||||
prefetchServerQuery(queryClient, activityQueries.search());
|
|
||||||
prefetchServerQuery(queryClient, playerQueries.activity());
|
|
||||||
},
|
},
|
||||||
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 (
|
|
||||||
<Stack gap={0}>
|
|
||||||
{withSearch && (
|
|
||||||
<Box px="md" pb="sm">
|
|
||||||
<Skeleton height={42} radius="sm" />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
|
||||||
<div key={`activity-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
|
|
||||||
<Group p="md" wrap="nowrap" w="100%" justify="space-between">
|
|
||||||
<Stack gap={6}>
|
|
||||||
<Skeleton height={14} width={160} radius="sm" />
|
|
||||||
<Skeleton height={10} width={100} radius="sm" />
|
|
||||||
</Stack>
|
|
||||||
<Skeleton height={10} width={60} radius="sm" />
|
|
||||||
</Group>
|
|
||||||
<Divider />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stats() {
|
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
|
||||||
<Tabs.List mb='md'>
|
|
||||||
<Tabs.Tab value="server-functions"><Trans>Server Functions</Trans></Tabs.Tab>
|
|
||||||
<Tabs.Tab value="player-activity"><Trans>Player Activity</Trans></Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="server-functions">
|
|
||||||
<Suspense fallback={<ActivityRowsSkeleton withSearch />}>
|
|
||||||
<ActivitiesTable />
|
|
||||||
</Suspense>
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="player-activity">
|
|
||||||
<Suspense fallback={<ActivityRowsSkeleton />}>
|
|
||||||
<PlayersActivityTable />
|
|
||||||
</Suspense>
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { AdminPage } from "@/features/admin";
|
|
||||||
import { msg } from "@lingui/core/macro";
|
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/")({
|
export const Route = createFileRoute("/_authed/admin/")({
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
prefetchServerQuery(context.queryClient, tournamentQueries.current());
|
||||||
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
@@ -10,5 +15,5 @@ export const Route = createFileRoute("/_authed/admin/")({
|
|||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
component: () => <AdminPage />,
|
component: () => <AdminDashboard />,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Outlet, createFileRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { Tabs } from "@mantine/core";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
const TAB_ROUTES = {
|
||||||
|
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 (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value && value in TAB_ROUTES) {
|
||||||
|
navigate({ to: TAB_ROUTES[value as TabValue] });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.List mb="md" style={{ flexWrap: "nowrap", overflowX: "auto", overflowY: "hidden" }}>
|
||||||
|
<Tabs.Tab value="usage">
|
||||||
|
<Trans>Usage</Trans>
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="players">
|
||||||
|
<Trans>Players</Trans>
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="functions">
|
||||||
|
<Trans>Functions</Trans>
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="sessions">
|
||||||
|
<Trans>Sessions</Trans>
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="errors">
|
||||||
|
<Trans>Errors</Trans>
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
|
import { telemetryQueries, lastNDays, ErrorBrowser } from "@/features/telemetry";
|
||||||
|
import { ListSkeleton } from "@/components/skeletons";
|
||||||
|
|
||||||
|
const errorGroupParams = () => ({ ...lastNDays(30), includeResolved: false });
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/telemetry/errors")({
|
||||||
|
component: ErrorsTab,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
prefetchServerQuery(context.queryClient, telemetryQueries.errorGroups(errorGroupParams()));
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false,
|
||||||
|
fullWidth: true,
|
||||||
|
refresh: [telemetryQueries.errorGroups(errorGroupParams()).queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function ErrorsTab() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ListSkeleton rows={6} withSubtitle />}>
|
||||||
|
<ErrorBrowser />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
|
import { ActivitiesTable, activityQueries } from "@/features/activities";
|
||||||
|
import { playerQueries } from "@/features/players";
|
||||||
|
import { ListSkeleton } from "@/components/skeletons";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/telemetry/functions")({
|
||||||
|
component: FunctionsTab,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const queryClient = context.queryClient;
|
||||||
|
prefetchServerQuery(queryClient, activityQueries.search());
|
||||||
|
prefetchServerQuery(queryClient, playerQueries.list());
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false,
|
||||||
|
fullWidth: true,
|
||||||
|
refresh: [activityQueries.search().queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function FunctionsTab() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ListSkeleton rows={8} withSearch withSubtitle withTrailing="text" />}>
|
||||||
|
<ActivitiesTable />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/telemetry/")({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/admin/telemetry/usage" });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
|
import { PlayersActivityTable, playerQueries } from "@/features/players";
|
||||||
|
import { ListSkeleton } from "@/components/skeletons";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/telemetry/players")({
|
||||||
|
component: PlayersTab,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
prefetchServerQuery(context.queryClient, playerQueries.activity());
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false,
|
||||||
|
fullWidth: true,
|
||||||
|
refresh: [playerQueries.activity().queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function PlayersTab() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
|
||||||
|
<PlayersActivityTable />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 (
|
||||||
|
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
|
||||||
|
<SessionsList />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <UsageScreen />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
|
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
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 { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
|
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
|
||||||
@@ -37,10 +38,7 @@ function AssignPartnersPending() {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group gap="xs" align="baseline">
|
<StatTileSkeleton align="row" />
|
||||||
<Skeleton height={28} width={36} radius="sm" />
|
|
||||||
<Skeleton height={14} width={110} radius="sm" />
|
|
||||||
</Group>
|
|
||||||
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
|
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
|
import Enrollment from "@/features/tournaments/components/enrollment";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||||
|
import { Skeleton, Stack } from "@mantine/core";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/enrollment")({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||||
|
if (tournament.regional) {
|
||||||
|
await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.free_agents(params.id)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.unenrolled(params.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { tournament };
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: msg`Enrollment`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
pendingComponent: EnrollmentPending,
|
||||||
|
});
|
||||||
|
|
||||||
|
function EnrollmentPending() {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<StatTileSkeleton align="row" />
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Skeleton height={14} width={80} radius="sm" />
|
||||||
|
<Skeleton height={42} radius="sm" />
|
||||||
|
</Stack>
|
||||||
|
<ListSkeleton rows={6} withAvatar avatarSize={32} withTrailing padding="xs" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { tournament } = Route.useRouteContext();
|
||||||
|
return <Enrollment tournament={tournament} />;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
|||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
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";
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||||
@@ -31,18 +31,14 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
|||||||
|
|
||||||
function ManageTournamentPending() {
|
function ManageTournamentPending() {
|
||||||
return (
|
return (
|
||||||
<Stack gap={0}>
|
<ListSkeleton
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
rows={5}
|
||||||
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
|
withAvatar
|
||||||
<Group p="md" wrap="nowrap" w="100%">
|
avatarSize={20}
|
||||||
<Skeleton height={20} width={20} radius="sm" />
|
withTrailing
|
||||||
<Skeleton height={16} width="45%" radius="sm" />
|
fadeStep={0.14}
|
||||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
fadeFloor={0.4}
|
||||||
</Group>
|
/>
|
||||||
<Divider />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
|||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
import ManageTeams from "@/features/teams/components/manage-teams";
|
import ManageTeams from "@/features/teams/components/manage-teams";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
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";
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
||||||
@@ -38,21 +39,7 @@ function ManageTeamsPending() {
|
|||||||
<Box px="md">
|
<Box px="md">
|
||||||
<Skeleton height={12} width={90} radius="sm" />
|
<Skeleton height={12} width={90} radius="sm" />
|
||||||
</Box>
|
</Box>
|
||||||
<Stack gap={0}>
|
<ListSkeleton rows={8} withAvatar avatarSize={40} withTrailing="double" padding="xs" />
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
|
||||||
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
|
|
||||||
<Group p="xs" wrap="nowrap" w="100%">
|
|
||||||
<Skeleton height={40} width={40} radius="sm" />
|
|
||||||
<Skeleton height={16} width="45%" radius="sm" />
|
|
||||||
<Stack ml="auto" gap={6} align="flex-end">
|
|
||||||
<Skeleton height={10} width={90} radius="sm" />
|
|
||||||
<Skeleton height={10} width={70} radius="sm" />
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<Divider />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ManageTournaments from "@/features/admin/components/manage-tournaments";
|
import ManageTournaments from "@/features/admin/components/manage-tournaments";
|
||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
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 { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
@@ -22,25 +22,13 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
|||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function TournamentListSkeleton() {
|
|
||||||
return (
|
|
||||||
<Stack gap={0}>
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
|
|
||||||
<Group p="md" wrap="nowrap" w="100%">
|
|
||||||
<Skeleton height={16} width="55%" radius="sm" />
|
|
||||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
|
||||||
</Group>
|
|
||||||
<Divider />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<TournamentListSkeleton />}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<ListSkeleton rows={6} titleWidth="55%" withTrailing fadeStep={0.12} fadeFloor={0.4} />
|
||||||
|
}
|
||||||
|
>
|
||||||
<ManageTournaments />
|
<ManageTournaments />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
|||||||
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
|
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
|
||||||
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
||||||
import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
|
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 BracketView from "@/features/bracket/components/bracket-view";
|
||||||
import { SpotifyControlsBar } from "@/features/spotify/components";
|
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 { Trans } from "@lingui/react/macro";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
@@ -83,67 +81,10 @@ function RunTournamentPending() {
|
|||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const { data: tournament } = useTournament(id);
|
const { data: tournament } = useTournament(id);
|
||||||
const { roles } = useAuth();
|
const isAdmin = useIsAdmin();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
|
||||||
|
|
||||||
const hasGroupStage = useMemo(() => {
|
const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } =
|
||||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
useTournamentRunState(tournament.matches);
|
||||||
}, [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<number, Match[]>();
|
|
||||||
const losersMap = new Map<number, Match[]>();
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { tournamentQueries } from '@/features/tournaments/queries';
|
import { tournamentQueries } from '@/features/tournaments/queries';
|
||||||
import Profile from '@/features/tournaments/components/profile';
|
import Profile from '@/features/tournaments/components/profile';
|
||||||
|
import { isAdminRole } from '@/features/core/utils/roles';
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
@@ -20,7 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
header: {
|
header: {
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
withBackButton: 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],
|
refresh: [tournamentQueries.details(params.tournamentId).queryKey],
|
||||||
withPadding: false
|
withPadding: false
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ ensureSuperTokensBackend();
|
|||||||
// forwards all supertokens api methods to our API
|
// forwards all supertokens api methods to our API
|
||||||
const superTokensHandler = handleAuthAPIRequest();
|
const superTokensHandler = handleAuthAPIRequest();
|
||||||
const handleRequest = async ({ request }: {request: Request}) => {
|
const handleRequest = async ({ request }: {request: Request}) => {
|
||||||
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
|
|
||||||
console.log("Handling auth request:", request.method, request.url);
|
|
||||||
return superTokensHandler(request);
|
return superTokensHandler(request);
|
||||||
};
|
};
|
||||||
export const Route = createFileRoute('/api/auth/$')({
|
export const Route = createFileRoute('/api/auth/$')({
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
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 { logger } from "@/lib/logger";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
|
||||||
let activeConnections = 0;
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/events/$")({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
@@ -11,9 +16,9 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: ({ request }) => {
|
GET: ({ request }) => {
|
||||||
activeConnections++;
|
const active = incrementConnections();
|
||||||
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
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 cleanedUp = false;
|
||||||
let cleanup = () => {};
|
let cleanup = () => {};
|
||||||
@@ -41,12 +46,12 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
if (cleanedUp) return;
|
if (cleanedUp) return;
|
||||||
cleanedUp = true;
|
cleanedUp = true;
|
||||||
activeConnections--;
|
const remaining = decrementConnections();
|
||||||
for (const type of EVENT_TYPES) {
|
for (const type of EVENT_TYPES) {
|
||||||
serverEvents.off(type, handleEvent);
|
serverEvents.off(type, handleEvent);
|
||||||
}
|
}
|
||||||
clearInterval(pingInterval);
|
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);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { ensureTelemetryScheduler } from "@/lib/telemetry/scheduler.server";
|
||||||
|
import { getHealthSnapshot } from "@/lib/telemetry/health.server";
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/health")({
|
export const Route = createFileRoute("/api/health")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: () => {
|
GET: async () => {
|
||||||
|
ensureTelemetryScheduler();
|
||||||
|
const snapshot = await getHealthSnapshot();
|
||||||
|
|
||||||
|
// Always 200: a dependency outage must not fail the k8s probes.
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
status: "ok",
|
status: snapshot.status,
|
||||||
|
checks: snapshot.checks,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) =>
|
|||||||
|
|
||||||
export const Route = createFileRoute("/api/push/subscribe")({
|
export const Route = createFileRoute("/api/push/subscribe")({
|
||||||
server: {
|
server: {
|
||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
const player = (context as any).player;
|
const player = (context as any).player;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||||
import { sendPushToPlayer } from "@/lib/push";
|
import { sendPushToPlayer } from "@/lib/push";
|
||||||
import { isPushConfigured } from "@/lib/config";
|
import { isPushConfigured } from "@/lib/config";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -13,7 +14,7 @@ const json = (body: unknown, status = 200) =>
|
|||||||
|
|
||||||
export const Route = createFileRoute("/api/push/test")({
|
export const Route = createFileRoute("/api/push/test")({
|
||||||
server: {
|
server: {
|
||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ context }) => {
|
POST: async ({ context }) => {
|
||||||
const player = (context as any).player;
|
const player = (context as any).player;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) =>
|
|||||||
|
|
||||||
export const Route = createFileRoute("/api/push/unsubscribe")({
|
export const Route = createFileRoute("/api/push/unsubscribe")({
|
||||||
server: {
|
server: {
|
||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
const player = (context as any).player;
|
const player = (context as any).player;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { localizedFor } from "@/lib/i18n/server-messages";
|
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||||
@@ -12,7 +13,7 @@ const uploadSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute("/api/teams/upload-logo")({
|
export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||||
server: {
|
server: {
|
||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
const i18n = localizedFor(context.metadata);
|
const i18n = localizedFor(context.metadata);
|
||||||
|
|||||||
@@ -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<string, { tokens: number; updatedAt: number }>();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { localizedFor } from "@/lib/i18n/server-messages";
|
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||||
@@ -12,7 +13,7 @@ const uploadSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||||
server: {
|
server: {
|
||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
const i18n = localizedFor(context.metadata);
|
const i18n = localizedFor(context.metadata);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
}));
|
||||||
@@ -22,6 +22,7 @@ import { useDisclosure } from '@mantine/hooks'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { logger } from '@/lib/logger'
|
import { logger } from '@/lib/logger'
|
||||||
|
import { captureBoundaryError } from '@/lib/telemetry/client'
|
||||||
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
||||||
import { Trans, useLingui } from '@lingui/react/macro'
|
import { Trans, useLingui } from '@lingui/react/macro'
|
||||||
import Button from './button'
|
import Button from './button'
|
||||||
@@ -41,6 +42,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.error('DefaultCatchBoundary | ', error)
|
logger.error('DefaultCatchBoundary | ', error)
|
||||||
|
captureBoundaryError(error)
|
||||||
|
|
||||||
if (errorMessage.toLowerCase().includes('unauthenticated')) {
|
if (errorMessage.toLowerCase().includes('unauthenticated')) {
|
||||||
toast.error(t`You've been logged out`)
|
toast.error(t`You've been logged out`)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { track } from '@/lib/telemetry/client'
|
||||||
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
||||||
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
||||||
import { Trans, useLingui } from '@lingui/react/macro'
|
import { Trans, useLingui } from '@lingui/react/macro'
|
||||||
@@ -26,13 +27,17 @@ export function IOSInstallPrompt() {
|
|||||||
|
|
||||||
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
|
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
|
||||||
setPlatform(isIOS ? 'ios' : 'android')
|
setPlatform(isIOS ? 'ios' : 'android')
|
||||||
const timer = setTimeout(() => setShow(true), 3000)
|
const timer = setTimeout(() => {
|
||||||
|
setShow(true)
|
||||||
|
track('pwa_ios_prompt_shown')
|
||||||
|
}, 3000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDismiss = () => {
|
const handleDismiss = () => {
|
||||||
localStorage.setItem('pwa-install-prompt-dismissed', 'true')
|
localStorage.setItem('pwa-install-prompt-dismissed', 'true')
|
||||||
|
track('pwa_ios_prompt_dismissed')
|
||||||
setShow(false)
|
setShow(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<UnstyledButton
|
||||||
|
className={`${classes.tile} flxn-tile`}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className={`${classes.icon} flxn-tile-icon`}>
|
||||||
|
<Icon size={22} weight="bold" />
|
||||||
|
</span>
|
||||||
|
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NavTile;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
import Drawer from "./drawer";
|
import Drawer from "./drawer";
|
||||||
import Modal from "./modal";
|
import Modal from "./modal";
|
||||||
import { ScrollArea, Flex, Loader } from "@mantine/core";
|
import { Box, Flex, Loader } from "@mantine/core";
|
||||||
|
|
||||||
interface SheetProps extends PropsWithChildren {
|
interface SheetProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -60,14 +60,19 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
<Loader size='lg' />
|
<Loader size='lg' />
|
||||||
</Flex>
|
</Flex>
|
||||||
}>
|
}>
|
||||||
<ScrollArea.Autosize
|
{/* Native overflow, not ScrollArea: vaul's drag-vs-scroll detection
|
||||||
style={{ flex: 1, maxHeight: '75dvh' }}
|
only recognizes natively scrollable elements. */}
|
||||||
scrollbarSize={8}
|
<Box
|
||||||
scrollbars="y"
|
style={{
|
||||||
type="scroll"
|
flex: 1,
|
||||||
|
maxHeight: "75dvh",
|
||||||
|
overflowY: "auto",
|
||||||
|
overscrollBehavior: "contain",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollArea.Autosize>
|
</Box>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Paper p="sm" withBorder radius={radius} style={{ opacity }}>
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
{withAvatar && (
|
||||||
|
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
|
||||||
|
)}
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Skeleton height={16} width="70%" mb={4} />
|
||||||
|
<Skeleton height={12} width="40%" />
|
||||||
|
</Box>
|
||||||
|
{withBadge && <Skeleton height={20} width={60} radius="xl" />}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardSkeleton;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./list-skeleton";
|
||||||
|
export * from "./card-skeleton";
|
||||||
|
export * from "./stat-tile-skeleton";
|
||||||
@@ -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 (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{withSearch && (
|
||||||
|
<Box px={padding} pb="sm">
|
||||||
|
<Skeleton height={42} radius="sm" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{ opacity: Math.max(1 - index * fadeStep, fadeFloor) }}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
p={padding}
|
||||||
|
wrap="nowrap"
|
||||||
|
w="100%"
|
||||||
|
justify={withTrailing === "text" ? "space-between" : undefined}
|
||||||
|
>
|
||||||
|
{withAvatar && (
|
||||||
|
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
|
||||||
|
)}
|
||||||
|
{withSubtitle ? (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Skeleton height={14} width={160} radius="sm" />
|
||||||
|
<Skeleton height={10} width={100} radius="sm" />
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Skeleton height={16} width={titleWidth} radius="sm" />
|
||||||
|
)}
|
||||||
|
{withTrailing === "double" ? (
|
||||||
|
<Stack ml="auto" gap={6} align="flex-end">
|
||||||
|
<Skeleton height={10} width={90} radius="sm" />
|
||||||
|
<Skeleton height={10} width={70} radius="sm" />
|
||||||
|
</Stack>
|
||||||
|
) : withTrailing === "text" ? (
|
||||||
|
<Skeleton height={10} width={60} radius="sm" />
|
||||||
|
) : withTrailing ? (
|
||||||
|
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListSkeleton;
|
||||||
@@ -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" ? (
|
||||||
|
<Group gap="xs" align="baseline">
|
||||||
|
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
|
||||||
|
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap={4} align="center">
|
||||||
|
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
|
||||||
|
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!withCard) return content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
{content}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatTileSkeleton;
|
||||||
@@ -18,6 +18,7 @@ interface AuthData {
|
|||||||
theme?: string;
|
theme?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
seenThemesHint?: boolean;
|
seenThemesHint?: boolean;
|
||||||
|
telemetryAlerts?: boolean;
|
||||||
};
|
};
|
||||||
roles: string[];
|
roles: string[];
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
|
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { SpotifyAuth } from '@/lib/spotify/auth';
|
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 { useConfig } from '@/hooks/use-config';
|
||||||
import type {
|
import type {
|
||||||
SpotifyContextType,
|
SpotifyContextType,
|
||||||
@@ -40,8 +40,7 @@ const deepEqual = (a: unknown, b: unknown): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const { roles } = useAuth();
|
const isAdmin = useIsAdmin();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@@ -1,268 +1,32 @@
|
|||||||
import { useState, useMemo, memo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import { Alert, Code, Container, Group, Stack, Text } from "@mantine/core";
|
||||||
Text,
|
import { CheckIcon, XIcon } from "@phosphor-icons/react";
|
||||||
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 { Activity, ActivitySearchParams } from "../types";
|
import { Activity, ActivitySearchParams } from "../types";
|
||||||
import { useActivities } from "../queries";
|
import { useActivities } from "../queries";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import { usePlayers } from "@/features/players/queries";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import {
|
||||||
|
DetailSheet,
|
||||||
|
PaginatedList,
|
||||||
|
SearchFilterBar,
|
||||||
|
TelemetryListItem,
|
||||||
|
} from "@/features/telemetry/components/shared";
|
||||||
|
|
||||||
interface ActivityListItemProps {
|
const SLOW_THRESHOLD_MS = 1000;
|
||||||
activity: Activity;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
|
||||||
const { t, i18n } = useLingui();
|
|
||||||
const playerName = typeof activity.player === "object" && activity.player
|
|
||||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
|
||||||
: t`System`;
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleString(i18n.locale);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
w="100%"
|
|
||||||
p="md"
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
borderRadius: 0,
|
|
||||||
transition: "background-color 0.15s ease",
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="flex-start" w="100%">
|
|
||||||
<Stack gap={4} flex={1}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{activity.name}
|
|
||||||
</Text>
|
|
||||||
{activity.success ? (
|
|
||||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
|
||||||
) : (
|
|
||||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<Group gap="md">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{playerName}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
<Trans>{activity.duration}ms</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{formatDate(activity.created)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{activity.error && (
|
|
||||||
<Text size="xs" c="red" lineClamp={1}>
|
|
||||||
{activity.error}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ActivityDetailsSheetProps {
|
|
||||||
activity: Activity | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
|
||||||
const { t, i18n } = useLingui();
|
|
||||||
if (!activity) return null;
|
|
||||||
|
|
||||||
const playerName = typeof activity.player === "object" && activity.player
|
|
||||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
|
||||||
: t`System`;
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleString(i18n.locale);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
|
|
||||||
<Stack gap="md" p="md">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>Function Name</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">{activity.name}</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>Status</Trans>
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{activity.success ? (
|
|
||||||
<>
|
|
||||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
|
||||||
<Text size="sm" c="green">
|
|
||||||
<Trans>Success</Trans>
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
|
||||||
<Text size="sm" c="red">
|
|
||||||
<Trans>Failed</Trans>
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>Player</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">{playerName}</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>Duration</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>Created</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">{formatDate(activity.created)}</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{activity.user_agent && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>User Agent</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
|
||||||
{activity.user_agent}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activity.error && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>Error Message</Trans>
|
|
||||||
</Text>
|
|
||||||
<Alert color="red" variant="light">
|
|
||||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
|
||||||
{activity.error}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activity.arguments && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
|
||||||
<Trans>Arguments</Trans>
|
|
||||||
</Text>
|
|
||||||
<Code block style={{ fontSize: "11px" }}>
|
|
||||||
{JSON.stringify(activity.arguments, null, 2)}
|
|
||||||
</Code>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
|
|
||||||
const { data: result } = useActivities(searchParams);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack gap={0}>
|
|
||||||
{result.items.map((activity: Activity, index: number) => (
|
|
||||||
<Box key={activity.id}>
|
|
||||||
<ActivityListItem
|
|
||||||
activity={activity}
|
|
||||||
onClick={() => onActivityClick(activity)}
|
|
||||||
/>
|
|
||||||
{index < result.items.length - 1 && <Divider />}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{result.items.length === 0 && (
|
|
||||||
<Stack align="center" gap="md" py="xl">
|
|
||||||
<ThemeIcon size="xl" variant="light" radius="md">
|
|
||||||
<PulseIcon size={32} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Title order={3} c="dimmed">
|
|
||||||
<Trans>No Activities Found</Trans>
|
|
||||||
</Title>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.totalPages > 1 && (
|
|
||||||
<Group justify="center" py="md">
|
|
||||||
<Pagination
|
|
||||||
total={result.totalPages}
|
|
||||||
value={page}
|
|
||||||
onChange={setPage}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ActivitiesTable = () => {
|
export const ActivitiesTable = () => {
|
||||||
const { t } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
||||||
|
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
|
||||||
|
const [slowOnly, setSlowOnly] = useState(false);
|
||||||
const [sortBy, setSortBy] = useState("-created");
|
const [sortBy, setSortBy] = useState("-created");
|
||||||
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||||
|
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
|
||||||
|
|
||||||
const {
|
const { data: players } = usePlayers();
|
||||||
isOpen: detailsOpened,
|
|
||||||
open: openDetails,
|
|
||||||
close: closeDetails,
|
|
||||||
} = useSheet();
|
|
||||||
|
|
||||||
const searchParams: ActivitySearchParams = useMemo(
|
const searchParams: ActivitySearchParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -270,13 +34,24 @@ export const ActivitiesTable = () => {
|
|||||||
perPage: 100,
|
perPage: 100,
|
||||||
name: search || undefined,
|
name: search || undefined,
|
||||||
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
|
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
|
||||||
|
player: playerFilter || undefined,
|
||||||
sortBy,
|
sortBy,
|
||||||
}),
|
}),
|
||||||
[page, search, successFilter, sortBy]
|
[page, search, successFilter, playerFilter, sortBy]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: result } = useActivities(searchParams);
|
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) => {
|
const handleSort = (field: string) => {
|
||||||
setSortBy((prev) => {
|
setSortBy((prev) => {
|
||||||
if (prev === field) return `-${field}`;
|
if (prev === field) return `-${field}`;
|
||||||
@@ -285,12 +60,6 @@ export const ActivitiesTable = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSortIcon = (field: string) => {
|
|
||||||
if (sortBy === field) return <CaretUpIcon size={14} />;
|
|
||||||
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActivityClick = (activity: Activity) => {
|
const handleActivityClick = (activity: Activity) => {
|
||||||
setSelectedActivity(activity);
|
setSelectedActivity(activity);
|
||||||
openDetails();
|
openDetails();
|
||||||
@@ -304,90 +73,163 @@ export const ActivitiesTable = () => {
|
|||||||
return (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Stack gap="xs" px="md">
|
<SearchFilterBar
|
||||||
<TextInput
|
searchValue={search}
|
||||||
placeholder={t`serverFn name`}
|
onSearchChange={(value) => {
|
||||||
value={search}
|
setSearch(value);
|
||||||
onChange={(e) => {
|
setPage(1);
|
||||||
setSearch(e.currentTarget.value);
|
}}
|
||||||
setPage(1);
|
searchPlaceholder={t`serverFn name`}
|
||||||
}}
|
filters={[
|
||||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
{
|
||||||
size="md"
|
placeholder: t`Status`,
|
||||||
/>
|
value: successFilter,
|
||||||
|
onChange: (value) => {
|
||||||
<Group>
|
|
||||||
<Select
|
|
||||||
placeholder={t`Status`}
|
|
||||||
value={successFilter}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSuccessFilter(value);
|
setSuccessFilter(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
},
|
||||||
data={[
|
data: [
|
||||||
{ value: "all", label: t`All` },
|
{ value: "all", label: t`All` },
|
||||||
{ value: "success", label: t`Success` },
|
{ value: "success", label: t`Success` },
|
||||||
{ value: "failure", label: t`Failure` },
|
{ value: "failure", label: t`Failure` },
|
||||||
]}
|
],
|
||||||
clearable
|
},
|
||||||
size="sm"
|
{
|
||||||
style={{ flex: 1 }}
|
placeholder: t`Player`,
|
||||||
/>
|
value: playerFilter,
|
||||||
</Group>
|
onChange: (value) => {
|
||||||
</Stack>
|
setPlayerFilter(value);
|
||||||
|
setPage(1);
|
||||||
<Group px="md" justify="space-between" align="center">
|
},
|
||||||
<Text size="10px" lh={0} c="dimmed">
|
data: players.map((player) => ({
|
||||||
|
value: player.id,
|
||||||
|
label: `${player.first_name} ${player.last_name}`,
|
||||||
|
})),
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
chips={[
|
||||||
|
{
|
||||||
|
label: t`Slow >1s (this page)`,
|
||||||
|
checked: slowOnly,
|
||||||
|
onChange: setSlowOnly,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
sortFields={[
|
||||||
|
{ field: "created", label: <Trans>Date</Trans> },
|
||||||
|
{ field: "duration", label: <Trans>Duration</Trans> },
|
||||||
|
]}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSort={handleSort}
|
||||||
|
summary={
|
||||||
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
|
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
|
||||||
</Text>
|
}
|
||||||
<Group gap="xs">
|
/>
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
<Trans>Sort:</Trans>
|
|
||||||
</Text>
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={() => handleSort("created")}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
fw={sortBy.includes("created") ? 600 : 400}
|
|
||||||
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
|
|
||||||
>
|
|
||||||
<Trans>Date</Trans>
|
|
||||||
</Text>
|
|
||||||
{getSortIcon("created")}
|
|
||||||
</UnstyledButton>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
•
|
|
||||||
</Text>
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={() => handleSort("duration")}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
fw={sortBy.includes("duration") ? 600 : 400}
|
|
||||||
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
|
|
||||||
>
|
|
||||||
<Trans>Duration</Trans>
|
|
||||||
</Text>
|
|
||||||
{getSortIcon("duration")}
|
|
||||||
</UnstyledButton>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ActivitiesResults
|
<PaginatedList
|
||||||
searchParams={searchParams}
|
items={items}
|
||||||
|
keyOf={(activity) => activity.id}
|
||||||
|
empty={t`No Activities Found`}
|
||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
totalPages={result.totalPages}
|
||||||
onActivityClick={handleActivityClick}
|
onPageChange={setPage}
|
||||||
|
renderItem={(activity) => (
|
||||||
|
<TelemetryListItem
|
||||||
|
title={activity.name}
|
||||||
|
icon={
|
||||||
|
activity.success ? (
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
) : (
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
meta={[
|
||||||
|
playerName(activity),
|
||||||
|
<Trans>{activity.duration}ms</Trans>,
|
||||||
|
new Date(activity.created).toLocaleString(i18n.locale),
|
||||||
|
]}
|
||||||
|
error={activity.error}
|
||||||
|
onClick={() => handleActivityClick(activity)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<ActivityDetailsSheet
|
<DetailSheet
|
||||||
activity={selectedActivity}
|
title={t`Activity Details`}
|
||||||
isOpen={detailsOpened}
|
opened={detailsOpened}
|
||||||
onClose={handleCloseDetails}
|
onClose={handleCloseDetails}
|
||||||
|
fields={
|
||||||
|
selectedActivity
|
||||||
|
? [
|
||||||
|
{ label: <Trans>Function Name</Trans>, value: selectedActivity.name },
|
||||||
|
{
|
||||||
|
label: <Trans>Status</Trans>,
|
||||||
|
value: (
|
||||||
|
<Group gap="xs">
|
||||||
|
{selectedActivity.success ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
<Text size="sm" c="green">
|
||||||
|
<Trans>Success</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
<Trans>Failed</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: <Trans>Player</Trans>, value: playerName(selectedActivity) },
|
||||||
|
{
|
||||||
|
label: <Trans>Duration</Trans>,
|
||||||
|
value: (
|
||||||
|
<Text size="sm">
|
||||||
|
<Trans>{selectedActivity.duration}ms</Trans>
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: <Trans>Created</Trans>,
|
||||||
|
value: new Date(selectedActivity.created).toLocaleString(i18n.locale),
|
||||||
|
},
|
||||||
|
...(selectedActivity.user_agent
|
||||||
|
? [{ label: <Trans>User Agent</Trans>, value: selectedActivity.user_agent }]
|
||||||
|
: []),
|
||||||
|
...(selectedActivity.error
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: <Trans>Error Message</Trans>,
|
||||||
|
value: (
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||||
|
{selectedActivity.error}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(selectedActivity.arguments
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: <Trans>Arguments</Trans>,
|
||||||
|
value: (
|
||||||
|
<Code block style={{ fontSize: "11px" }}>
|
||||||
|
{JSON.stringify(selectedActivity.arguments, null, 2)}
|
||||||
|
</Code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { awardManualBadge } from "@/features/badges/server";
|
import useAwardManualBadge from "@/features/badges/hooks/use-award-manual-badge";
|
||||||
import { useAllBadges } from "@/features/badges/queries";
|
import { useAllBadges } from "@/features/badges/queries";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
import { usePlayers } from "@/features/players/queries";
|
import { usePlayers } from "@/features/players/queries";
|
||||||
@@ -13,35 +13,29 @@ const AwardBadges = () => {
|
|||||||
|
|
||||||
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
||||||
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
|
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
|
||||||
const [isAwarding, setIsAwarding] = useState(false);
|
|
||||||
|
const awardBadge = useAwardManualBadge();
|
||||||
|
|
||||||
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
|
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
|
||||||
|
|
||||||
const handleAwardBadge = async () => {
|
const handleAwardBadge = () => {
|
||||||
if (!selectedPlayerId || !selectedBadgeId) return;
|
if (!selectedPlayerId || !selectedBadgeId) return;
|
||||||
|
|
||||||
setIsAwarding(true);
|
awardBadge.mutate(
|
||||||
try {
|
{ playerId: selectedPlayerId, badgeId: selectedBadgeId },
|
||||||
await awardManualBadge({
|
{
|
||||||
data: {
|
onSuccess: () => {
|
||||||
playerId: selectedPlayerId,
|
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
||||||
badgeId: selectedBadgeId,
|
const playerName = selectedPlayer
|
||||||
|
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
||||||
|
: t`Player`;
|
||||||
|
|
||||||
|
toast.success(t`Badge awarded to ${playerName}`);
|
||||||
|
|
||||||
|
setSelectedPlayerId(null);
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
|
||||||
const playerName = selectedPlayer
|
|
||||||
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
|
||||||
: t`Player`;
|
|
||||||
|
|
||||||
toast.success(t`Badge awarded to ${playerName}`);
|
|
||||||
|
|
||||||
setSelectedPlayerId(null);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t`Failed to award badge`);
|
|
||||||
} finally {
|
|
||||||
setIsAwarding(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const playerOptions = players.map((player) => ({
|
const playerOptions = players.map((player) => ({
|
||||||
@@ -98,7 +92,7 @@ const AwardBadges = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleAwardBadge}
|
onClick={handleAwardBadge}
|
||||||
disabled={!selectedPlayerId}
|
disabled={!selectedPlayerId}
|
||||||
loading={isAwarding}
|
loading={awardBadge.isPending}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<Trans>Award Badge</Trans>
|
<Trans>Award Badge</Trans>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { SimpleGrid, Skeleton } from "@mantine/core";
|
||||||
|
import QuietBoundary from "./quiet-boundary";
|
||||||
|
import SectionBox from "./section-box";
|
||||||
|
import TournamentSection from "./tournament-section";
|
||||||
|
import ToolsList from "./tools-list";
|
||||||
|
|
||||||
|
const TournamentSkeleton = () => (
|
||||||
|
<SectionBox>
|
||||||
|
<Skeleton height={24} width={180} radius="sm" />
|
||||||
|
<SimpleGrid cols={3} spacing="sm" mt="sm">
|
||||||
|
<Skeleton height={72} radius="md" />
|
||||||
|
<Skeleton height={72} radius="md" />
|
||||||
|
<Skeleton height={72} radius="md" />
|
||||||
|
</SimpleGrid>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AdminDashboard = () => (
|
||||||
|
<>
|
||||||
|
<QuietBoundary>
|
||||||
|
<Suspense fallback={<TournamentSkeleton />}>
|
||||||
|
<TournamentSection />
|
||||||
|
</Suspense>
|
||||||
|
</QuietBoundary>
|
||||||
|
<ToolsList />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminDashboard;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Component, ReactNode } from "react";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
interface QuietBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuietBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuietBoundary extends Component<QuietBoundaryProps, QuietBoundaryState> {
|
||||||
|
state: QuietBoundaryState = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): QuietBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
<Trans>Unavailable right now</Trans>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuietBoundary;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
|
export const SectionBox = ({ children }: { children: ReactNode }) => (
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SectionBox;
|
||||||
+9
-9
@@ -1,20 +1,20 @@
|
|||||||
import { List, Text } from "@mantine/core";
|
import { List, Text } from "@mantine/core";
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||||
import ListLink from "@/components/list-link";
|
|
||||||
import {
|
import {
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
MedalIcon,
|
MedalIcon,
|
||||||
CrownIcon,
|
CrownIcon,
|
||||||
ListIcon,
|
PulseIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
import ListLink from "@/components/list-link";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
|
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
|
||||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
const AdminPage = () => {
|
export const ToolsList = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
|
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
|
||||||
@@ -51,6 +51,11 @@ const AdminPage = () => {
|
|||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
to="/admin/tournaments"
|
to="/admin/tournaments"
|
||||||
/>
|
/>
|
||||||
|
<ListLink
|
||||||
|
label={t`Telemetry`}
|
||||||
|
Icon={PulseIcon}
|
||||||
|
to="/admin/telemetry/usage"
|
||||||
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
label={t`Award Badges`}
|
label={t`Award Badges`}
|
||||||
Icon={CrownIcon}
|
Icon={CrownIcon}
|
||||||
@@ -86,11 +91,6 @@ const AdminPage = () => {
|
|||||||
<Trans>Badge recalculation failed. Check the server logs.</Trans>
|
<Trans>Badge recalculation failed. Check the server logs.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<ListLink
|
|
||||||
label={t`Activities`}
|
|
||||||
Icon={ListIcon}
|
|
||||||
to="/admin/activities"
|
|
||||||
/>
|
|
||||||
<ListButton
|
<ListButton
|
||||||
label={t`Open Pocketbase`}
|
label={t`Open Pocketbase`}
|
||||||
Icon={DatabaseIcon}
|
Icon={DatabaseIcon}
|
||||||
@@ -107,4 +107,4 @@ const AdminPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminPage;
|
export default ToolsList;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { SimpleGrid, Text, Title } from "@mantine/core";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { GearIcon, PlayIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||||
|
import { useCurrentTournament } from "@/features/tournaments/queries";
|
||||||
|
import { NavTile } from "@/components/nav-tile";
|
||||||
|
import { Countdown } from "@/components/countdown";
|
||||||
|
import SectionBox from "./section-box";
|
||||||
|
|
||||||
|
export const TournamentSection = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: tournament } = useCurrentTournament();
|
||||||
|
|
||||||
|
if (!tournament) return null;
|
||||||
|
|
||||||
|
const upcoming = !!tournament.start_time && Date.parse(tournament.start_time) > Date.now();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox>
|
||||||
|
<Title order={3}>{tournament.name}</Title>
|
||||||
|
{upcoming && (
|
||||||
|
<Text size="sm" c="dimmed" component="div">
|
||||||
|
<Countdown date={new Date(tournament.start_time)} label={t`Starts in`} />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<SimpleGrid cols={3} spacing="sm" mt="sm">
|
||||||
|
<NavTile
|
||||||
|
label={t`Run`}
|
||||||
|
Icon={PlayIcon}
|
||||||
|
onClick={() => navigate({ to: `/admin/tournaments/run/${tournament.id}` })}
|
||||||
|
/>
|
||||||
|
<NavTile
|
||||||
|
label={t`Enrollment`}
|
||||||
|
Icon={UsersThreeIcon}
|
||||||
|
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}/enrollment` })}
|
||||||
|
/>
|
||||||
|
<NavTile
|
||||||
|
label={t`Manage`}
|
||||||
|
Icon={GearIcon}
|
||||||
|
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}` })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TournamentSection;
|
||||||
@@ -2,4 +2,4 @@ import { Logger } from "@/lib/logger";
|
|||||||
|
|
||||||
export const logger = new Logger("Admin");
|
export const logger = new Logger("Admin");
|
||||||
|
|
||||||
export { default as AdminPage } from "./components/admin-page";
|
export { default as AdminDashboard } from "./components/dashboard";
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||||
|
import { awardManualBadge } from "@/features/badges/server";
|
||||||
|
import { badgeKeys } from "@/features/badges/queries";
|
||||||
|
|
||||||
|
interface AwardManualBadgeInput {
|
||||||
|
playerId: string;
|
||||||
|
badgeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useAwardManualBadge() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: (data: AwardManualBadgeInput) => awardManualBadge({ data }),
|
||||||
|
onSuccess: (_data, { playerId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: badgeKeys.playerBadges(playerId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: badgeKeys.allEarnedBadges() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -15,7 +14,7 @@ export const getPlayerBadges = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const migrateBadgeProgress = createServerFn()
|
export const migrateBadgeProgress = createServerFn()
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(async () => startBadgeMigration())
|
toServerResult(async () => startBadgeMigration())
|
||||||
);
|
);
|
||||||
@@ -41,7 +40,7 @@ export const awardManualBadge = createServerFn()
|
|||||||
playerId: z.string(),
|
playerId: z.string(),
|
||||||
badgeId: z.string(),
|
badgeId: z.string(),
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { isAdminRole } from "../utils/roles";
|
||||||
|
|
||||||
|
export const useIsAdmin = (): boolean => {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
return isAdminRole(roles);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { isAdminRole } from "../utils/roles";
|
||||||
|
|
||||||
export const useLinks = (userId: string | undefined, roles: string[]) => {
|
export const useLinks = (userId: string | undefined, roles: string[]) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -31,7 +32,7 @@ export const useLinks = (userId: string | undefined, roles: string[]) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (roles.includes('Admin')) {
|
if (isAdminRole(roles)) {
|
||||||
links.push({
|
links.push({
|
||||||
label: t`Admin`,
|
label: t`Admin`,
|
||||||
href: '/admin',
|
href: '/admin',
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const ADMIN_ROLE = "Admin";
|
||||||
|
|
||||||
|
export const isAdminRole = (roles?: string[] | null): boolean =>
|
||||||
|
!!roles?.includes(ADMIN_ROLE);
|
||||||
@@ -9,7 +9,6 @@ import { Match, MatchInput } from "@/features/matches/types";
|
|||||||
import { emitServerEvent } from "@/lib/events/emitter";
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
|
||||||
|
|
||||||
const orderedTeamsSchema = z.object({
|
const orderedTeamsSchema = z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
@@ -18,7 +17,7 @@ const orderedTeamsSchema = z.object({
|
|||||||
|
|
||||||
export const generateTournamentBracket = createServerFn()
|
export const generateTournamentBracket = createServerFn()
|
||||||
.validator(orderedTeamsSchema)
|
.validator(orderedTeamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Generating tournament bracket", {
|
logger.info("Generating tournament bracket", {
|
||||||
@@ -141,7 +140,7 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
|
|
||||||
export const startMatch = createServerFn()
|
export const startMatch = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Starting match", data);
|
logger.info("Starting match", data);
|
||||||
@@ -170,7 +169,7 @@ export const populateKnockoutBracket = createServerFn()
|
|||||||
.validator(z.object({
|
.validator(z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId } }) =>
|
.handler(async ({ data: { tournamentId } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
@@ -801,7 +800,7 @@ const endMatchSchema = z.object({
|
|||||||
});
|
});
|
||||||
export const endMatch = createServerFn()
|
export const endMatch = createServerFn()
|
||||||
.validator(endMatchSchema)
|
.validator(endMatchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Ending match", matchId);
|
logger.info("Ending match", matchId);
|
||||||
@@ -827,7 +826,7 @@ const reportScoreSchema = z.object({
|
|||||||
|
|
||||||
export const reportMatchScore = createServerFn()
|
export const reportMatchScore = createServerFn()
|
||||||
.validator(reportScoreSchema)
|
.validator(reportScoreSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
@@ -890,7 +889,7 @@ const matchIdSchema = z.object({ matchId: z.string() });
|
|||||||
|
|
||||||
export const confirmMatchScore = createServerFn()
|
export const confirmMatchScore = createServerFn()
|
||||||
.validator(matchIdSchema)
|
.validator(matchIdSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId }, context }) =>
|
.handler(async ({ data: { matchId }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
@@ -929,7 +928,7 @@ export const confirmMatchScore = createServerFn()
|
|||||||
|
|
||||||
export const clearMatchReport = createServerFn()
|
export const clearMatchReport = createServerFn()
|
||||||
.validator(matchIdSchema)
|
.validator(matchIdSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId }, context }) =>
|
.handler(async ({ data: { matchId }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { z } from "zod";
|
|||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import { getRequest } from "@tanstack/react-start/server";
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
|
||||||
|
|
||||||
const logger = new Logger('Players');
|
const logger = new Logger('Players');
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ export const getPlayer = createServerFn()
|
|||||||
|
|
||||||
export const updatePlayer = createServerFn()
|
export const updatePlayer = createServerFn()
|
||||||
.validator(playerUpdateSchema)
|
.validator(playerUpdateSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
@@ -84,7 +83,7 @@ export const updatePlayer = createServerFn()
|
|||||||
|
|
||||||
export const createPlayer = createServerFn()
|
export const createPlayer = createServerFn()
|
||||||
.validator(playerInputSchema)
|
.validator(playerInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
@@ -117,7 +116,7 @@ export const createPlayer = createServerFn()
|
|||||||
|
|
||||||
export const associatePlayer = createServerFn()
|
export const associatePlayer = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { pbAdmin } from "@/lib/pocketbase/client";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
import {
|
import {
|
||||||
MyPrediction,
|
MyPrediction,
|
||||||
@@ -56,7 +55,7 @@ const submitPredictionSchema = z.object({
|
|||||||
|
|
||||||
export const submitPrediction = createServerFn()
|
export const submitPrediction = createServerFn()
|
||||||
.validator(submitPredictionSchema)
|
.validator(submitPredictionSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, picks }, context }) =>
|
.handler(async ({ data: { tournamentId, picks }, context }) =>
|
||||||
toServerResult(async (): Promise<Prediction> => {
|
toServerResult(async (): Promise<Prediction> => {
|
||||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
|||||||
@@ -1,11 +1,46 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
|
import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useWebPush } from "@/hooks/use-web-push";
|
import { useWebPush } from "@/hooks/use-web-push";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||||
|
import { updateUserTelemetryAlerts } from "@/features/settings/server";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
|
const TelemetryAlertsSwitch = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { metadata, set } = useAuth();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = async (checked: boolean) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateUserTelemetryAlerts({ data: checked });
|
||||||
|
if (result?.metadata) {
|
||||||
|
set({ metadata: result.metadata });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t`Couldn't update telemetry alerts`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={metadata?.telemetryAlerts === true}
|
||||||
|
onChange={(e) => handleToggle(e.currentTarget.checked)}
|
||||||
|
disabled={saving}
|
||||||
|
label={t`Telemetry alerts`}
|
||||||
|
description={t`Error spikes and failing functions, pushed to this account`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function NotificationsSection() {
|
export function NotificationsSection() {
|
||||||
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
|
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
|
||||||
useWebPush();
|
useWebPush();
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
if (!supported || !configured) {
|
if (!supported || !configured) {
|
||||||
@@ -64,6 +99,7 @@ export function NotificationsSection() {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
label={t`Enable notifications on this device`}
|
label={t`Enable notifications on this device`}
|
||||||
/>
|
/>
|
||||||
|
{subscribed && isAdmin && <TelemetryAlertsSwitch />}
|
||||||
{subscribed && (
|
{subscribed && (
|
||||||
<Group>
|
<Group>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import {
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
superTokensAdminFunctionMiddleware,
|
||||||
|
superTokensFunctionMiddleware,
|
||||||
|
} from "@/utils/supertokens";
|
||||||
|
|
||||||
// Whitelists: these values flow into Mantine's primaryColor / the
|
// Whitelists: these values flow into Mantine's primaryColor / the
|
||||||
// data-mantine-color-scheme attribute — an unknown value crash-loops or
|
// data-mantine-color-scheme attribute — an unknown value crash-loops or
|
||||||
@@ -19,7 +21,7 @@ const COLOR_SCHEMES = ["light", "dark", "auto"];
|
|||||||
|
|
||||||
export const updateUserColorScheme = createServerFn({ method: "POST" })
|
export const updateUserColorScheme = createServerFn({ method: "POST" })
|
||||||
.validator((data: string) => data)
|
.validator((data: string) => data)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) => {
|
||||||
const { userAuthId, metadata } = context;
|
const { userAuthId, metadata } = context;
|
||||||
if (!userAuthId) return;
|
if (!userAuthId) return;
|
||||||
@@ -43,7 +45,7 @@ export const updateUserColorScheme = createServerFn({ method: "POST" })
|
|||||||
|
|
||||||
export const updateUserLocale = createServerFn({ method: "POST" })
|
export const updateUserLocale = createServerFn({ method: "POST" })
|
||||||
.validator((data: string) => data)
|
.validator((data: string) => data)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) => {
|
||||||
const { userAuthId, metadata } = context;
|
const { userAuthId, metadata } = context;
|
||||||
if (!userAuthId) return;
|
if (!userAuthId) return;
|
||||||
@@ -69,7 +71,7 @@ export const updateUserLocale = createServerFn({ method: "POST" })
|
|||||||
|
|
||||||
export const updateUserTheme = createServerFn({ method: "POST" })
|
export const updateUserTheme = createServerFn({ method: "POST" })
|
||||||
.validator((data: string) => data)
|
.validator((data: string) => data)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) => {
|
||||||
const { userAuthId, metadata } = context;
|
const { userAuthId, metadata } = context;
|
||||||
if (!userAuthId) return;
|
if (!userAuthId) return;
|
||||||
@@ -96,7 +98,7 @@ export const updateUserTheme = createServerFn({ method: "POST" })
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateUserSeenThemesHint = createServerFn({ method: "POST" })
|
export const updateUserSeenThemesHint = createServerFn({ method: "POST" })
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
const { userAuthId, metadata } = context;
|
const { userAuthId, metadata } = context;
|
||||||
if (!userAuthId) return;
|
if (!userAuthId) return;
|
||||||
@@ -119,7 +121,7 @@ export const updateUserSeenThemesHint = createServerFn({ method: "POST" })
|
|||||||
|
|
||||||
export const updateUserAccentColor = createServerFn({ method: "POST" })
|
export const updateUserAccentColor = createServerFn({ method: "POST" })
|
||||||
.validator((data: string) => data)
|
.validator((data: string) => data)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) => {
|
||||||
const { userAuthId, metadata } = context;
|
const { userAuthId, metadata } = context;
|
||||||
if (!userAuthId) return;
|
if (!userAuthId) return;
|
||||||
@@ -140,3 +142,26 @@ export const updateUserAccentColor = createServerFn({ method: "POST" })
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateUserTelemetryAlerts = createServerFn({ method: "POST" })
|
||||||
|
.validator((data: boolean) => data === true)
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ context, data }) => {
|
||||||
|
const { userAuthId, metadata } = context;
|
||||||
|
if (!userAuthId) return;
|
||||||
|
|
||||||
|
const { updateUserMetadataFields } = await import(
|
||||||
|
"@/utils/supertokens-core.server"
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateUserMetadataFields(userAuthId, {
|
||||||
|
telemetryAlerts: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
telemetryAlerts: data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import {
|
|||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useSpotify } from '@/lib/spotify/hooks';
|
import { useSpotify } from '@/lib/spotify/hooks';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
|
||||||
import SpotifySheet from './spotify-sheet';
|
import SpotifySheet from './spotify-sheet';
|
||||||
|
|
||||||
const SpotifyControlsBar = () => {
|
const SpotifyControlsBar = () => {
|
||||||
const { roles } = useAuth();
|
const isAdmin = useIsAdmin();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
|
||||||
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
|
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useSpotify } from '@/lib/spotify/hooks';
|
import { useSpotify } from '@/lib/spotify/hooks';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
|
||||||
import Sheet from '@/components/sheet/sheet';
|
import Sheet from '@/components/sheet/sheet';
|
||||||
|
|
||||||
interface SpotifySheetProps {
|
interface SpotifySheetProps {
|
||||||
@@ -26,8 +26,7 @@ interface SpotifySheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
|
const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
|
||||||
const { roles } = useAuth();
|
const isAdmin = useIsAdmin();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
|
||||||
const spotify = useSpotify();
|
const spotify = useSpotify();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||||
import { useUnenrolledPlayers } from "@/features/players/queries";
|
import { useUnenrolledPlayers } from "@/features/players/queries";
|
||||||
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
|
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@@ -15,8 +16,8 @@ interface PlayersPickerProps {
|
|||||||
|
|
||||||
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
|
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { roles, user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = roles.includes("Admin");
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const { data } = useUnenrolledPlayers(tournamentId);
|
const { data } = useUnenrolledPlayers(tournamentId);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
|||||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { Match } from "../matches/types";
|
import { Match } from "../matches/types";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
|
||||||
import { emitServerEvent } from "@/lib/events/emitter";
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ export const getTeamInfo = createServerFn()
|
|||||||
|
|
||||||
export const createTeam = createServerFn()
|
export const createTeam = createServerFn()
|
||||||
.validator(teamInputSchema)
|
.validator(teamInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
@@ -54,7 +53,7 @@ export const updateTeam = createServerFn()
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: teamUpdateSchema
|
updates: teamUpdateSchema
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { id, updates }, context }) =>
|
.handler(async ({ data: { id, updates }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface BarListItem {
|
||||||
|
label: ReactNode;
|
||||||
|
value: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarListProps {
|
||||||
|
items: BarListItem[];
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BarRow = ({
|
||||||
|
item,
|
||||||
|
max,
|
||||||
|
formatValue,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
item: BarListItem;
|
||||||
|
max: number;
|
||||||
|
formatValue: (value: number) => string;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
const content = (
|
||||||
|
<Stack gap={4} w="100%">
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||||
|
<Text size="sm" truncate style={{ minWidth: 0 }}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{formatValue(item.value)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Box
|
||||||
|
h={8}
|
||||||
|
w={`${max > 0 ? Math.max((item.value / max) * 100, 1) : 0}%`}
|
||||||
|
bg={color}
|
||||||
|
style={{ borderRadius: "0 4px 4px 0" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item.onClick) {
|
||||||
|
return <Box py={6}>{content}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton w="100%" py={6} onClick={item.onClick}>
|
||||||
|
{content}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BarList = ({
|
||||||
|
items,
|
||||||
|
formatValue = (value) => value.toLocaleString(),
|
||||||
|
color = "var(--mantine-color-anchor)",
|
||||||
|
}: BarListProps) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...items.map((item) => item.value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<BarRow
|
||||||
|
key={index}
|
||||||
|
item={item}
|
||||||
|
max={max}
|
||||||
|
formatValue={formatValue}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BarList;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { niceExtent, pointsToPath } from "./chart-path";
|
||||||
|
|
||||||
|
describe("pointsToPath", () => {
|
||||||
|
it("returns an empty string for an empty series", () => {
|
||||||
|
expect(pointsToPath([], 100, 40)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a single point as a horizontal segment without NaN", () => {
|
||||||
|
const path = pointsToPath([5], 100, 40, 2);
|
||||||
|
expect(path).not.toContain("NaN");
|
||||||
|
expect(path).toBe("M2,20 L98,20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centers a flat series vertically without NaN", () => {
|
||||||
|
const path = pointsToPath([3, 3, 3], 100, 40);
|
||||||
|
expect(path).not.toContain("NaN");
|
||||||
|
expect(path).toBe("M0,20 L50,20 L100,20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps min to the bottom and max to the top", () => {
|
||||||
|
const path = pointsToPath([0, 10], 100, 40);
|
||||||
|
expect(path).toBe("M0,40 L100,0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects padding", () => {
|
||||||
|
const path = pointsToPath([0, 10], 100, 40, 4);
|
||||||
|
expect(path).toBe("M4,36 L96,4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a shared extent when provided", () => {
|
||||||
|
const path = pointsToPath([0, 5], 100, 40, 0, [0, 10]);
|
||||||
|
expect(path).toBe("M0,40 L100,20");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("niceExtent", () => {
|
||||||
|
it("handles an empty series", () => {
|
||||||
|
expect(niceExtent([])).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an all-zero series", () => {
|
||||||
|
expect(niceExtent([0, 0, 0])).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spreads a flat non-zero series", () => {
|
||||||
|
const [lo, hi] = niceExtent([10, 10]);
|
||||||
|
expect(lo).toBeLessThan(10);
|
||||||
|
expect(hi).toBeGreaterThan(10);
|
||||||
|
expect(Number.isNaN(lo)).toBe(false);
|
||||||
|
expect(Number.isNaN(hi)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the data with rounded bounds", () => {
|
||||||
|
const [lo, hi] = niceExtent([3, 47, 12]);
|
||||||
|
expect(lo).toBeLessThanOrEqual(3);
|
||||||
|
expect(hi).toBeGreaterThanOrEqual(47);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never dips below zero for non-negative data", () => {
|
||||||
|
const [lo] = niceExtent([1, 2, 3]);
|
||||||
|
expect(lo).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export function pointsToPath(
|
||||||
|
values: number[],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pad = 0,
|
||||||
|
extent?: [number, number]
|
||||||
|
): string {
|
||||||
|
if (values.length === 0) return "";
|
||||||
|
|
||||||
|
const [lo, hi] = extent ?? [Math.min(...values), Math.max(...values)];
|
||||||
|
const span = hi - lo;
|
||||||
|
const innerWidth = Math.max(width - pad * 2, 0);
|
||||||
|
const innerHeight = Math.max(height - pad * 2, 0);
|
||||||
|
|
||||||
|
const yFor = (value: number) =>
|
||||||
|
span === 0
|
||||||
|
? pad + innerHeight / 2
|
||||||
|
: pad + innerHeight - ((value - lo) / span) * innerHeight;
|
||||||
|
|
||||||
|
if (values.length === 1) {
|
||||||
|
const y = yFor(values[0]);
|
||||||
|
return `M${pad},${y} L${pad + innerWidth},${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = innerWidth / (values.length - 1);
|
||||||
|
return values
|
||||||
|
.map((value, index) => {
|
||||||
|
const command = index === 0 ? "M" : "L";
|
||||||
|
return `${command}${pad + step * index},${yFor(value)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function niceExtent(values: number[]): [number, number] {
|
||||||
|
if (values.length === 0) return [0, 1];
|
||||||
|
|
||||||
|
let min = Math.min(...values);
|
||||||
|
let max = Math.max(...values);
|
||||||
|
|
||||||
|
if (min === max) {
|
||||||
|
if (min === 0) return [0, 1];
|
||||||
|
const spread = Math.abs(min) / 2;
|
||||||
|
min -= spread;
|
||||||
|
max += spread;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = niceStep((max - min) / 4);
|
||||||
|
let lo = Math.floor(min / step) * step;
|
||||||
|
const hi = Math.ceil(max / step) * step;
|
||||||
|
if (min >= 0 && lo < 0) lo = 0;
|
||||||
|
return [lo, hi];
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceStep(rough: number): number {
|
||||||
|
const power = 10 ** Math.floor(Math.log10(rough));
|
||||||
|
const fraction = rough / power;
|
||||||
|
if (fraction <= 1) return power;
|
||||||
|
if (fraction <= 2) return 2 * power;
|
||||||
|
if (fraction <= 5) return 5 * power;
|
||||||
|
return 10 * power;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { pointsToPath, niceExtent } from "./chart-path";
|
||||||
|
export { Sparkline } from "./sparkline";
|
||||||
|
export { BarList } from "./bar-list";
|
||||||
|
export type { BarListItem } from "./bar-list";
|
||||||
|
export { StatTile } from "./stat-tile";
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { niceExtent, pointsToPath } from "./chart-path";
|
||||||
|
|
||||||
|
const VIEW_WIDTH = 240;
|
||||||
|
const PAD = 3;
|
||||||
|
|
||||||
|
export interface SparklineScale {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SparklineProps {
|
||||||
|
data: number[];
|
||||||
|
series2?: number[];
|
||||||
|
height?: number;
|
||||||
|
showArea?: boolean;
|
||||||
|
label?: string;
|
||||||
|
color?: string;
|
||||||
|
series2Color?: string;
|
||||||
|
scale?: SparklineScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sparkline = ({
|
||||||
|
data,
|
||||||
|
series2,
|
||||||
|
height = 48,
|
||||||
|
showArea = false,
|
||||||
|
label,
|
||||||
|
color = "var(--mantine-color-anchor)",
|
||||||
|
series2Color = "var(--mantine-color-dimmed)",
|
||||||
|
scale,
|
||||||
|
}: SparklineProps) => {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" size="sm" aria-label={label}>
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extent = niceExtent(series2?.length ? [...data, ...series2] : data);
|
||||||
|
const path = pointsToPath(data, VIEW_WIDTH, height, PAD, extent);
|
||||||
|
const secondPath = series2?.length
|
||||||
|
? pointsToPath(series2, VIEW_WIDTH, height, PAD, extent)
|
||||||
|
: null;
|
||||||
|
const areaPath = showArea && !secondPath
|
||||||
|
? `${path} L${VIEW_WIDTH - PAD},${height - PAD} L${PAD},${height - PAD} Z`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const allValues = series2?.length ? [...data, ...series2] : data;
|
||||||
|
const max = Math.max(...allValues);
|
||||||
|
const formatValue = scale?.formatValue ?? ((value: number) => value.toLocaleString());
|
||||||
|
|
||||||
|
const svg = (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
width="100%"
|
||||||
|
height={height}
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
style={{ display: "block" }}
|
||||||
|
>
|
||||||
|
{areaPath && <path d={areaPath} fill={color} fillOpacity={0.1} stroke="none" />}
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{secondPath && (
|
||||||
|
<path
|
||||||
|
d={secondPath}
|
||||||
|
fill="none"
|
||||||
|
stroke={series2Color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!scale) return svg;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{svg}
|
||||||
|
<Group justify="space-between" gap="xs" mt={2}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{scale.start}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
↑{formatValue(max)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{scale.end}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sparkline;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
import Sparkline, { SparklineScale } from "./sparkline";
|
||||||
|
|
||||||
|
interface StatTileProps {
|
||||||
|
label: ReactNode;
|
||||||
|
value: ReactNode;
|
||||||
|
valueColor?: string;
|
||||||
|
delta?: number;
|
||||||
|
spark?: number[];
|
||||||
|
sparkScale?: SparklineScale;
|
||||||
|
caption?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatTile = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor,
|
||||||
|
delta,
|
||||||
|
spark,
|
||||||
|
sparkScale,
|
||||||
|
caption,
|
||||||
|
onClick,
|
||||||
|
}: StatTileProps) => {
|
||||||
|
const content = (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap="xs" align="baseline" wrap="nowrap">
|
||||||
|
<Text fz="xl" fw={700} lh={1.2} c={valueColor}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{delta !== undefined && delta !== 0 && (
|
||||||
|
<Text size="xs" c={delta > 0 ? "green" : "red"}>
|
||||||
|
{delta > 0 ? "+" : ""}
|
||||||
|
{delta.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{caption && <Box>{caption}</Box>}
|
||||||
|
{spark && spark.length > 0 && (
|
||||||
|
<Sparkline data={spark} height={28} scale={sparkScale} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<UnstyledButton className="flxn-press" onClick={onClick} w="100%">
|
||||||
|
{content}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatTile;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Suspense, useMemo, useState } from "react";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { Container, Divider, Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
|
import { ListSkeleton } from "@/components/skeletons";
|
||||||
|
import { useClientErrorGroups } from "../../queries";
|
||||||
|
import { lastNDays } from "../../range";
|
||||||
|
import { ErrorGroupRow } from "./error-group-row";
|
||||||
|
|
||||||
|
const ErrorGroupList = ({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
includeResolved,
|
||||||
|
}: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
includeResolved: boolean;
|
||||||
|
}) => {
|
||||||
|
const { data: groups } = useClientErrorGroups({ from, to, includeResolved });
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed" px="md" py="xl" ta="center">
|
||||||
|
<Trans>No unresolved errors</Trans>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<div key={group.group_hash}>
|
||||||
|
<ErrorGroupRow group={group} />
|
||||||
|
{index < groups.length - 1 && <Divider />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorBrowser = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [includeResolved, setIncludeResolved] = useState(false);
|
||||||
|
const range = useMemo(() => lastNDays(30), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group px="md" justify="flex-end">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
label={t`Include resolved`}
|
||||||
|
checked={includeResolved}
|
||||||
|
onChange={(event) => setIncludeResolved(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Suspense fallback={<ListSkeleton rows={6} withSubtitle />}>
|
||||||
|
<ErrorGroupList
|
||||||
|
from={range.from}
|
||||||
|
to={range.to}
|
||||||
|
includeResolved={includeResolved}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorBrowser;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Code,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { resolveClientErrorGroup } from "../../server";
|
||||||
|
import { telemetryKeys } from "../../queries";
|
||||||
|
import type { ClientErrorGroup } from "../../types";
|
||||||
|
import { RelativeTime } from "../shared";
|
||||||
|
|
||||||
|
export const ErrorGroupRow = ({ group }: { group: ClientErrorGroup }) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const resolveMutation = useServerMutation({
|
||||||
|
mutationFn: (variables: { groupHash: string; resolved: boolean }) =>
|
||||||
|
resolveClientErrorGroup({ data: variables }),
|
||||||
|
successMessage: group.resolved ? t`Error group reopened` : t`Error group resolved`,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: telemetryKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
|
style={{ borderRadius: 0 }}
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={700}
|
||||||
|
c={group.resolved ? "dimmed" : "red"}
|
||||||
|
style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
|
{group.count.toLocaleString()}×
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} lineClamp={2} style={{ minWidth: 0 }}>
|
||||||
|
{group.sample.message}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
{group.sample.source && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{group.sample.source}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<RelativeTime date={group.lastSeen} />
|
||||||
|
</Text>
|
||||||
|
{group.resolved && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Trans>Resolved</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
<Collapse in={expanded} transitionDuration={200}>
|
||||||
|
<Stack gap="sm" px="md" pb="md">
|
||||||
|
{group.sample.path && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{group.sample.path}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{group.sample.stack && (
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
style={{ fontSize: "11px", maxHeight: 240, overflow: "auto" }}
|
||||||
|
>
|
||||||
|
{group.sample.stack}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color={group.resolved ? "yellow" : "green"}
|
||||||
|
loading={resolveMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
resolveMutation.mutate({
|
||||||
|
groupHash: group.group_hash,
|
||||||
|
resolved: !group.resolved,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.resolved ? <Trans>Reopen</Trans> : <Trans>Mark resolved</Trans>}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorGroupRow;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ErrorBrowser } from "./error-browser";
|
||||||
|
export { ErrorGroupRow } from "./error-group-row";
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { Plural, useLingui } from "@lingui/react/macro";
|
||||||
|
import { useSessionTimeline } from "../../queries";
|
||||||
|
import type { TimelineEntry } from "../../types";
|
||||||
|
|
||||||
|
const GAP_MARKER_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const parseTs = (ts: string) => Date.parse(ts.replace(" ", "T"));
|
||||||
|
|
||||||
|
const dotColor = (entry: TimelineEntry): string => {
|
||||||
|
if (entry.kind === "error" || entry.success === false) return "var(--mantine-color-red-6)";
|
||||||
|
if (entry.kind === "server_fn") return "var(--mantine-color-green-6)";
|
||||||
|
return "var(--mantine-color-dimmed)";
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dot = ({ color }: { color: string }) => (
|
||||||
|
<Box
|
||||||
|
w={6}
|
||||||
|
h={6}
|
||||||
|
style={{ borderRadius: "50%", backgroundColor: color, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SessionTimelineView = ({ sessionId }: { sessionId: string }) => {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
const { data } = useSessionTimeline(sessionId);
|
||||||
|
|
||||||
|
const clock = (ts: string) =>
|
||||||
|
i18n.date(new Date(parseTs(ts)), { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group gap="md" pb="sm">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{i18n.date(new Date(parseTs(data.session.started)), {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Plural value={data.session.pageViews} one="# page" other="# pages" />
|
||||||
|
</Text>
|
||||||
|
{data.session.errors > 0 && (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
<Plural value={data.session.errors} one="# error" other="# errors" />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{data.entries.map((entry, index) => {
|
||||||
|
const prev = data.entries[index - 1];
|
||||||
|
const gapMs = prev ? parseTs(entry.ts) - parseTs(prev.ts) : 0;
|
||||||
|
return (
|
||||||
|
<Fragment key={`${entry.ts}-${index}`}>
|
||||||
|
{gapMs > GAP_MARKER_MS && (
|
||||||
|
<Text size="xs" c="dimmed" py={4} pl={46}>
|
||||||
|
+{Math.round(gapMs / 60000)}m
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group gap="xs" py={5} wrap="nowrap" align="center">
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
w={64}
|
||||||
|
style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
|
{clock(entry.ts)}
|
||||||
|
</Text>
|
||||||
|
<Dot color={dotColor(entry)} />
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
c={entry.kind === "error" ? "red" : undefined}
|
||||||
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</Text>
|
||||||
|
{entry.kind === "server_fn" && entry.success === false && (
|
||||||
|
<Text size="xs" c="red" style={{ flexShrink: 0 }}>
|
||||||
|
✗
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entry.kind === "server_fn" && typeof entry.duration === "number" && entry.duration > 0 && (
|
||||||
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{entry.duration}ms
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SessionTimelineView;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { ListSkeleton } from "@/components/skeletons";
|
||||||
|
import { useSessions } from "../../queries";
|
||||||
|
import type { SessionSummary } from "../../types";
|
||||||
|
import { PaginatedList, RelativeTime, TelemetryListItem } from "../shared";
|
||||||
|
import SessionTimelineView from "./session-timeline";
|
||||||
|
|
||||||
|
export const defaultSessionParams = { days: 7, page: 1 } as const;
|
||||||
|
|
||||||
|
const playerName = (player?: SessionSummary["player"]): string | null => {
|
||||||
|
if (!player) return null;
|
||||||
|
return [player.first_name, player.last_name].filter(Boolean).join(" ") || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const durationLabel = (session: SessionSummary): string => {
|
||||||
|
const ms =
|
||||||
|
Date.parse(session.lastSeen.replace(" ", "T")) -
|
||||||
|
Date.parse(session.started.replace(" ", "T"));
|
||||||
|
const minutes = Math.round(ms / 60000);
|
||||||
|
return minutes < 1 ? "<1m" : `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionsList = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selected, setSelected] = useState<SessionSummary | null>(null);
|
||||||
|
|
||||||
|
const { data } = useSessions({ days: 7, page });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PaginatedList
|
||||||
|
items={data.items}
|
||||||
|
keyOf={(session) => session.session_id}
|
||||||
|
empty={t`No sessions yet`}
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
renderItem={(session) => (
|
||||||
|
<TelemetryListItem
|
||||||
|
title={playerName(session.player) ?? t`Anonymous`}
|
||||||
|
meta={[
|
||||||
|
<RelativeTime key="time" date={session.lastSeen} />,
|
||||||
|
<Plural key="pages" value={session.pageViews} one="# page" other="# pages" />,
|
||||||
|
durationLabel(session),
|
||||||
|
]}
|
||||||
|
error={
|
||||||
|
session.errors > 0 ? (
|
||||||
|
<Plural value={session.errors} one="# error" other="# errors" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={() => setSelected(session)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
title={selected ? (playerName(selected.player) ?? t`Anonymous`) : ""}
|
||||||
|
opened={selected !== null}
|
||||||
|
onChange={() => setSelected(null)}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<Suspense fallback={<ListSkeleton rows={8} />}>
|
||||||
|
<SessionTimelineView sessionId={selected.session_id} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{data.totalItems > 0 && (
|
||||||
|
<Text size="xs" c="dimmed" px="md" py="sm">
|
||||||
|
<Trans>Last 7 days</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SessionsList;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ReactNode, memo } from "react";
|
||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
|
||||||
|
export interface DetailField {
|
||||||
|
label: ReactNode;
|
||||||
|
value: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailSheetProps {
|
||||||
|
title: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
fields: DetailField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailSheet = memo(({ title, opened, onClose, fields }: DetailSheetProps) => (
|
||||||
|
<Sheet title={title} opened={opened} onChange={onClose}>
|
||||||
|
<Stack gap="md" p="md">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<Stack gap="xs" key={index}>
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
{field.label}
|
||||||
|
</Text>
|
||||||
|
{typeof field.value === "string" || typeof field.value === "number" ? (
|
||||||
|
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||||
|
{field.value}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
field.value
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Sheet>
|
||||||
|
));
|
||||||
|
|
||||||
|
export default DetailSheet;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { TelemetryListItem } from "./telemetry-list-item";
|
||||||
|
export { DetailSheet } from "./detail-sheet";
|
||||||
|
export type { DetailField } from "./detail-sheet";
|
||||||
|
export { SearchFilterBar } from "./search-filter-bar";
|
||||||
|
export type { FilterSelectConfig, FilterChipConfig, SortFieldConfig } from "./search-filter-bar";
|
||||||
|
export { PaginatedList } from "./paginated-list";
|
||||||
|
export { RelativeTime } from "./relative-time";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Box, Divider, Group, Pagination, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
interface PaginatedListProps<T> {
|
||||||
|
items: T[];
|
||||||
|
renderItem: (item: T) => ReactNode;
|
||||||
|
keyOf: (item: T) => string;
|
||||||
|
empty: string;
|
||||||
|
page?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaginatedList = <T,>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
keyOf,
|
||||||
|
empty,
|
||||||
|
page = 1,
|
||||||
|
totalPages = 1,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginatedListProps<T>) => (
|
||||||
|
<>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Box key={keyOf(item)}>
|
||||||
|
{renderItem(item)}
|
||||||
|
{index < items.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<Text size="sm" c="dimmed" px="md" py="xl" ta="center">
|
||||||
|
{empty}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && onPageChange && (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Pagination total={totalPages} value={page} onChange={onPageChange} size="sm" />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PaginatedList;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
export const RelativeTime = ({ date }: { date?: string }) => {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
|
if (!date) return <>{t`Never`}</>;
|
||||||
|
|
||||||
|
const then = new Date(date);
|
||||||
|
const diffMs = Date.now() - then.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMins < 1) return <>{t`Just now`}</>;
|
||||||
|
if (diffMins < 60) return <>{t`${diffMins}m ago`}</>;
|
||||||
|
if (diffHours < 24) return <>{t`${diffHours}h ago`}</>;
|
||||||
|
if (diffDays < 30) return <>{t`${diffDays}d ago`}</>;
|
||||||
|
return <>{then.toLocaleString(i18n.locale)}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RelativeTime;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Chip, Group, Select, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||||
|
import { CaretDownIcon, CaretUpIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
export interface FilterSelectConfig {
|
||||||
|
placeholder: string;
|
||||||
|
value: string | null;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
data: { value: string; label: string }[];
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterChipConfig {
|
||||||
|
label: ReactNode;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortFieldConfig {
|
||||||
|
field: string;
|
||||||
|
label: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFilterBarProps {
|
||||||
|
searchValue: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
filters?: FilterSelectConfig[];
|
||||||
|
chips?: FilterChipConfig[];
|
||||||
|
sortFields?: SortFieldConfig[];
|
||||||
|
sortBy?: string;
|
||||||
|
onSort?: (field: string) => void;
|
||||||
|
summary?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchFilterBar = ({
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder,
|
||||||
|
filters,
|
||||||
|
chips,
|
||||||
|
sortFields,
|
||||||
|
sortBy = "",
|
||||||
|
onSort,
|
||||||
|
summary,
|
||||||
|
}: SearchFilterBarProps) => {
|
||||||
|
const getSortIcon = (field: string) => {
|
||||||
|
if (sortBy === field) return <CaretUpIcon size={14} />;
|
||||||
|
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap="xs" px="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||||
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filters && filters.length > 0 && (
|
||||||
|
<Group>
|
||||||
|
{filters.map((filter, index) => (
|
||||||
|
<Select
|
||||||
|
key={index}
|
||||||
|
placeholder={filter.placeholder}
|
||||||
|
value={filter.value}
|
||||||
|
onChange={filter.onChange}
|
||||||
|
data={filter.data}
|
||||||
|
searchable={filter.searchable}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chips && chips.length > 0 && (
|
||||||
|
<Group gap="xs">
|
||||||
|
{chips.map((chip, index) => (
|
||||||
|
<Chip key={index} size="xs" checked={chip.checked} onChange={chip.onChange}>
|
||||||
|
{chip.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{(summary || (sortFields && sortFields.length > 0)) && (
|
||||||
|
<Group px="md" justify="space-between" align="center">
|
||||||
|
<Text size="10px" lh={0} c="dimmed" component="div">
|
||||||
|
{summary}
|
||||||
|
</Text>
|
||||||
|
{sortFields && sortFields.length > 0 && onSort && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Trans>Sort:</Trans>
|
||||||
|
</Text>
|
||||||
|
{sortFields.map((sortField, index) => (
|
||||||
|
<Group gap="xs" key={sortField.field}>
|
||||||
|
{index > 0 && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => onSort(sortField.field)}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={sortBy.includes(sortField.field) ? 600 : 400}
|
||||||
|
c={sortBy.includes(sortField.field) ? "var(--mantine-color-text)" : "dimmed"}
|
||||||
|
>
|
||||||
|
{sortField.label}
|
||||||
|
</Text>
|
||||||
|
{getSortIcon(sortField.field)}
|
||||||
|
</UnstyledButton>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchFilterBar;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Fragment, ReactNode, memo } from "react";
|
||||||
|
import { Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
|
interface TelemetryListItemProps {
|
||||||
|
title: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
meta?: ReactNode[];
|
||||||
|
error?: ReactNode;
|
||||||
|
rightSection?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TelemetryListItem = memo(
|
||||||
|
({ title, icon, meta, error, rightSection, onClick }: TelemetryListItemProps) => (
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "var(--mantine-color-gray-0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" w="100%" wrap="nowrap">
|
||||||
|
<Stack gap={4} flex={1} style={{ minWidth: 0 }}>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
{meta && meta.length > 0 && (
|
||||||
|
<Group gap="md">
|
||||||
|
{meta.map((item, index) => (
|
||||||
|
<Text key={index} size="xs" c="dimmed" component="span">
|
||||||
|
{item}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Text size="xs" c="red" lineClamp={1}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{rightSection && <Fragment>{rightSection}</Fragment>}
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TelemetryListItem;
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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 { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||||
|
import { useRollupRange } from "../queries";
|
||||||
|
import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range";
|
||||||
|
import { BarList, Sparkline, StatTile } from "./charts";
|
||||||
|
import { DetailSheet } from "./shared";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
|
||||||
|
export const USAGE_METRICS = [
|
||||||
|
"dau",
|
||||||
|
"wau",
|
||||||
|
"mau",
|
||||||
|
"page_view.count",
|
||||||
|
"page_view.sessions",
|
||||||
|
"server_fn.count",
|
||||||
|
"client_error.count",
|
||||||
|
"vital.p75",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOP_LIST_SIZE = 8;
|
||||||
|
|
||||||
|
type VitalName = "LCP" | "INP" | "CLS";
|
||||||
|
|
||||||
|
const VITAL_THRESHOLDS: Record<VitalName, { good: number; poor: number }> = {
|
||||||
|
LCP: { good: 2500, poor: 4000 },
|
||||||
|
INP: { good: 200, poor: 500 },
|
||||||
|
CLS: { good: 0.1, poor: 0.25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const vitalRating = (name: VitalName, value: number): "good" | "ok" | "poor" => {
|
||||||
|
const { good, poor } = VITAL_THRESHOLDS[name];
|
||||||
|
if (value <= good) return "good";
|
||||||
|
if (value > poor) return "poor";
|
||||||
|
return "ok";
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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<string, number>, onClick?: (dim: string) => void) =>
|
||||||
|
[...totals.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, TOP_LIST_SIZE)
|
||||||
|
.map(([dim, value]) => ({
|
||||||
|
label: dim,
|
||||||
|
value,
|
||||||
|
onClick: onClick ? () => onClick(dim) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: string }) => {
|
||||||
|
const { 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 (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Sparkline
|
||||||
|
data={p50}
|
||||||
|
series2={p95}
|
||||||
|
height={64}
|
||||||
|
scale={{
|
||||||
|
start: dayLabel(i18n, from),
|
||||||
|
end: dayLabel(i18n, to),
|
||||||
|
formatValue: (value) => `${Math.round(value)}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group gap="md">
|
||||||
|
<Group gap={6}>
|
||||||
|
<svg width={18} height={8} aria-hidden>
|
||||||
|
<line x1={0} y1={4} x2={18} y2={4} stroke="var(--mantine-color-anchor)" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Trans>p50</Trans> · {Math.round(lastNonZero(p50))}ms
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6}>
|
||||||
|
<svg width={18} height={8} aria-hidden>
|
||||||
|
<line
|
||||||
|
x1={0}
|
||||||
|
y1={4}
|
||||||
|
x2={18}
|
||||||
|
y2={4}
|
||||||
|
stroke="var(--mantine-color-dimmed)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Trans>p95</Trans> · {Math.round(lastNonZero(p95))}ms
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const [selectedFn, setSelectedFn] = useState<string | null>(null);
|
||||||
|
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
|
||||||
|
|
||||||
|
const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to });
|
||||||
|
|
||||||
|
const days = useMemo(() => listDays({ from, to }), [from, to]);
|
||||||
|
|
||||||
|
const dauSeries = fillDailySeries(rows, "dau", "", days);
|
||||||
|
const errorSeries = fillDailySeries(rows, "client_error.count", "", days);
|
||||||
|
const sessions = fillDailySeries(rows, "page_view.sessions", "", days).reduce(
|
||||||
|
(sum, value) => sum + value,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const pageViewItems = topItems(sumByDim(rows, "page_view.count"));
|
||||||
|
const serverFnItems = topItems(sumByDim(rows, "server_fn.count"), (dim) => {
|
||||||
|
setSelectedFn(dim);
|
||||||
|
openFnDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({
|
||||||
|
name,
|
||||||
|
value: latestValue(rows, "vital.p75", name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleCloseFnDetail = () => {
|
||||||
|
setSelectedFn(null);
|
||||||
|
closeFnDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Engagement</Trans>
|
||||||
|
</Title>
|
||||||
|
<StatTile
|
||||||
|
label={<Trans>Daily active players</Trans>}
|
||||||
|
value={(dauSeries[dauSeries.length - 1] ?? 0).toLocaleString()}
|
||||||
|
spark={dauSeries}
|
||||||
|
sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={3} spacing="sm">
|
||||||
|
<StatTile
|
||||||
|
label={<Trans>WAU</Trans>}
|
||||||
|
value={(latestValue(rows, "wau", "") ?? 0).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={<Trans>MAU</Trans>}
|
||||||
|
value={(latestValue(rows, "mau", "") ?? 0).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatTile label={<Trans>Sessions</Trans>} value={sessions.toLocaleString()} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Trans>Page views by route</Trans>
|
||||||
|
</Text>
|
||||||
|
<BarList items={pageViewItems} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Server functions</Trans>
|
||||||
|
</Title>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Trans>Top functions by call count</Trans>
|
||||||
|
</Text>
|
||||||
|
<BarList items={serverFnItems} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Errors</Trans>
|
||||||
|
</Title>
|
||||||
|
<StatTile
|
||||||
|
label={<Trans>Client errors</Trans>}
|
||||||
|
value={errorSeries.reduce((sum, value) => sum + value, 0).toLocaleString()}
|
||||||
|
spark={errorSeries}
|
||||||
|
sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Vitals</Trans>
|
||||||
|
</Title>
|
||||||
|
<SimpleGrid cols={3} spacing="sm">
|
||||||
|
{vitals.map(({ name, value }) => (
|
||||||
|
<StatTile
|
||||||
|
key={name}
|
||||||
|
label={name}
|
||||||
|
value={value !== undefined ? formatVital(name, value) : "—"}
|
||||||
|
valueColor={value !== undefined ? vitalColor(name, value) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<DetailSheet
|
||||||
|
title={selectedFn ?? t`Function`}
|
||||||
|
opened={fnDetailOpened}
|
||||||
|
onClose={handleCloseFnDetail}
|
||||||
|
fields={
|
||||||
|
selectedFn
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: <Trans>Duration p50 / p95</Trans>,
|
||||||
|
value: <FnDurationDetail fn={selectedFn} from={from} to={to} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UsageScreen = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [rangeDays, setRangeDays] = useState<"7" | "30">("7");
|
||||||
|
const range = useMemo(() => lastNDays(Number(rangeDays)), [rangeDays]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" px="md" pb="md">
|
||||||
|
<SegmentedControl
|
||||||
|
value={rangeDays}
|
||||||
|
onChange={(value) => setRangeDays(value as "7" | "30")}
|
||||||
|
data={[
|
||||||
|
{ value: "7", label: t`7d` },
|
||||||
|
{ value: "30", label: t`30d` },
|
||||||
|
]}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Stack gap="lg">
|
||||||
|
<StatTileSkeleton align="row" />
|
||||||
|
<SimpleGrid cols={3} spacing="sm">
|
||||||
|
<StatTileSkeleton />
|
||||||
|
<StatTileSkeleton />
|
||||||
|
<StatTileSkeleton />
|
||||||
|
</SimpleGrid>
|
||||||
|
<ListSkeleton rows={6} withTrailing="text" />
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UsageSections from={range.from} to={range.to} />
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsageScreen;
|
||||||
@@ -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";
|
||||||
@@ -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());
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { RollupRow } from "./types";
|
||||||
|
|
||||||
|
export const isoDay = (date: Date): string => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export interface DayRange {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lastNDays = (days: number): DayRange => {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
from: isoDay(new Date(now - (days - 1) * 86400000)),
|
||||||
|
to: isoDay(new Date(now)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDays = ({ from, to }: DayRange): string[] => {
|
||||||
|
const start = Date.parse(`${from}T00:00:00Z`);
|
||||||
|
const end = Date.parse(`${to}T00:00:00Z`);
|
||||||
|
const days: string[] = [];
|
||||||
|
for (let ts = start; ts <= end; ts += 86400000) {
|
||||||
|
days.push(isoDay(new Date(ts)));
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fillDailySeries = (
|
||||||
|
rows: RollupRow[],
|
||||||
|
metric: string,
|
||||||
|
dim: string,
|
||||||
|
days: string[]
|
||||||
|
): number[] => {
|
||||||
|
const byDate = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.metric === metric && row.dim === dim) {
|
||||||
|
byDate.set(row.date, (byDate.get(row.date) ?? 0) + row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days.map((day) => byDate.get(day) ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sumByDim = (rows: RollupRow[], metric: string): Map<string, number> => {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.metric === metric && row.dim !== "") {
|
||||||
|
totals.set(row.dim, (totals.get(row.dim) ?? 0) + row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const latestValue = (
|
||||||
|
rows: RollupRow[],
|
||||||
|
metric: string,
|
||||||
|
dim: string
|
||||||
|
): number | undefined => {
|
||||||
|
let latest: RollupRow | undefined;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.metric !== metric || row.dim !== dim) continue;
|
||||||
|
if (!latest || row.date > latest.date) latest = row;
|
||||||
|
}
|
||||||
|
return latest?.value;
|
||||||
|
};
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
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<TelemetryListResult<ClientEvent>>(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<TelemetryListResult<ClientError>>(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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getClientErrorGroups = createServerFn()
|
||||||
|
.validator(errorGroupsSchema)
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<ClientErrorGroup[]>(async () => {
|
||||||
|
const { from, to, includeResolved = false, limit = 50 } = data;
|
||||||
|
|
||||||
|
const rollups = await pbAdmin.getRollupRange({
|
||||||
|
metrics: ["client_error.count"],
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
|
||||||
|
const countByGroup = new Map<string, number>();
|
||||||
|
for (const rollup of rollups) {
|
||||||
|
if (!rollup.dim) continue;
|
||||||
|
countByGroup.set(rollup.dim, (countByGroup.get(rollup.dim) ?? 0) + rollup.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topGroups = [...countByGroup.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
const groups: ClientErrorGroup[] = [];
|
||||||
|
for (const [groupHash, count] of topGroups) {
|
||||||
|
const latest = await pbAdmin.searchClientErrors({ groupHash, perPage: 1 });
|
||||||
|
const sample = latest.items[0];
|
||||||
|
if (!sample) continue;
|
||||||
|
|
||||||
|
const resolved = !!sample.resolved;
|
||||||
|
if (resolved && !includeResolved) continue;
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
group_hash: groupHash,
|
||||||
|
count,
|
||||||
|
lastSeen: sample.created,
|
||||||
|
resolved,
|
||||||
|
sample: {
|
||||||
|
message: sample.message,
|
||||||
|
stack: sample.stack,
|
||||||
|
path: sample.path,
|
||||||
|
source: sample.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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(8),
|
||||||
|
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<RollupRow[]>(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<TelemetryListResult<TelemetryAlert>>(async () =>
|
||||||
|
pbAdmin.listTelemetryAlerts(data.page, data.perPage)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pbTime = (date: Date): string => date.toISOString().replace("T", " ");
|
||||||
|
|
||||||
|
const playerInfoById = async (ids: Set<string>) => {
|
||||||
|
if (ids.size === 0) return new Map<string, { id: string; first_name?: string; last_name?: string }>();
|
||||||
|
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<TelemetryListResult<SessionSummary>>(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<SessionTimeline>(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;
|
||||||
|
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 : 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 players = await playerInfoById(new Set(playerId ? [playerId] : []));
|
||||||
|
|
||||||
|
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<TelemetryRuntimeStatus>(async () => {
|
||||||
|
ensureTelemetryScheduler();
|
||||||
|
const health = await getHealthSnapshot();
|
||||||
|
return {
|
||||||
|
health: { status: health.status, checks: health.checks },
|
||||||
|
scheduler: getSchedulerStatus(),
|
||||||
|
activeSseConnections: getActiveConnectionCount(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import {
|
|
||||||
Stack,
|
|
||||||
ActionIcon,
|
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
|
||||||
import { TrashIcon } from "@phosphor-icons/react";
|
|
||||||
import { useState, useCallback, useMemo, memo } from "react";
|
|
||||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
|
||||||
import { useTournament, useUnenrolledTeams } from "../queries";
|
|
||||||
import useEnrollTeam from "../hooks/use-enroll-team";
|
|
||||||
import useUnenrollTeam from "../hooks/use-unenroll-team";
|
|
||||||
import TeamAvatar from "@/components/team-avatar";
|
|
||||||
import { Team, TeamInfo } from "@/features/teams/types";
|
|
||||||
|
|
||||||
interface EditEnrolledTeamsProps {
|
|
||||||
tournamentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TeamItemProps {
|
|
||||||
team: TeamInfo;
|
|
||||||
onUnenroll: (teamId: string) => void;
|
|
||||||
disabled: boolean;
|
|
||||||
isRegional?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TeamItem = memo(({ team, onUnenroll, disabled, isRegional }: TeamItemProps) => {
|
|
||||||
const playerNames = useMemo(
|
|
||||||
() =>
|
|
||||||
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
|
|
||||||
"",
|
|
||||||
[team.players]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
|
||||||
<TeamAvatar
|
|
||||||
team={team}
|
|
||||||
size={32}
|
|
||||||
radius="sm"
|
|
||||||
isRegional={isRegional}
|
|
||||||
/>
|
|
||||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Text fw={500} truncate>
|
|
||||||
{team.name}
|
|
||||||
</Text>
|
|
||||||
{playerNames && (
|
|
||||||
<Text size="xs" c="dimmed" truncate>
|
|
||||||
{playerNames}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={() => onUnenroll(team.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<TrashIcon size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { data: tournament, isLoading: tournamentLoading } =
|
|
||||||
useTournament(tournamentId);
|
|
||||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
|
||||||
useUnenrolledTeams(tournamentId);
|
|
||||||
|
|
||||||
const isRegional = tournament?.regional;
|
|
||||||
|
|
||||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
|
||||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
|
||||||
|
|
||||||
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
|
||||||
if (!query.trim()) return [];
|
|
||||||
|
|
||||||
const filtered = unenrolledTeams.filter((team: Team) =>
|
|
||||||
team.name.toLowerCase().includes(query.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return filtered.map((team: Team) => ({
|
|
||||||
id: team.id,
|
|
||||||
data: team
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnrollTeam = useCallback(
|
|
||||||
(option: TypeaheadOption<Team>) => {
|
|
||||||
enrollTeam({ tournamentId, teamId: option.data.id });
|
|
||||||
},
|
|
||||||
[enrollTeam, tournamentId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUnenrollTeam = useCallback(
|
|
||||||
(teamId: string) => {
|
|
||||||
unenrollTeam({ tournamentId, teamId });
|
|
||||||
},
|
|
||||||
[unenrollTeam, tournamentId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
|
||||||
const team = option.data;
|
|
||||||
return (
|
|
||||||
<Group py="xs" px="sm" gap="sm" align="center">
|
|
||||||
<TeamAvatar
|
|
||||||
team={team as any}
|
|
||||||
size={32}
|
|
||||||
radius="sm"
|
|
||||||
isRegional={isRegional}
|
|
||||||
/>
|
|
||||||
<Text fw={500} truncate>
|
|
||||||
{team.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTeam = (option: TypeaheadOption<Team>) => {
|
|
||||||
return option.data.name;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoading = tournamentLoading || unenrolledLoading;
|
|
||||||
const enrolledTeams = tournament?.teams || [];
|
|
||||||
const hasEnrolledTeams = enrolledTeams.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="lg" w="100%">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
<Trans>Add Team</Trans>
|
|
||||||
</Text>
|
|
||||||
<Typeahead
|
|
||||||
placeholder={t`Search for teams to enroll...`}
|
|
||||||
onSelect={handleEnrollTeam}
|
|
||||||
searchFn={searchTeams}
|
|
||||||
renderOption={renderTeamOption}
|
|
||||||
format={formatTeam}
|
|
||||||
disabled={isEnrolling || unenrolledLoading}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
<Trans>Enrolled Teams</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
<Plural value={enrolledTeams.length} one="# team" other="# teams" />
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Group justify="center" py="md">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Group>
|
|
||||||
) : !hasEnrolledTeams ? (
|
|
||||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
|
||||||
<Trans>No teams enrolled yet</Trans>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Stack gap="xs" w="100%">
|
|
||||||
{enrolledTeams.map((team: TeamInfo) => (
|
|
||||||
<TeamItem
|
|
||||||
key={team.id}
|
|
||||||
team={team}
|
|
||||||
onUnenroll={handleUnenrollTeam}
|
|
||||||
disabled={isUnenrolling}
|
|
||||||
isRegional={isRegional}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditEnrolledTeams;
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Alert, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { WarningCircleIcon } from "@phosphor-icons/react";
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
interface EnrollmentStatsProps {
|
||||||
|
count: number;
|
||||||
|
regional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnrollmentStats = (props: EnrollmentStatsProps) => {
|
||||||
|
const hasOddPlayers = props.count % 2 !== 0;
|
||||||
|
const hasEnoughPlayers = props.count >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="xs" align="baseline">
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
{props.count}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{props.regional ? (
|
||||||
|
<Plural
|
||||||
|
value={props.count}
|
||||||
|
one="player enrolled"
|
||||||
|
other="players enrolled"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Trans>Enrolled Teams</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{props.regional && !hasEnoughPlayers && (
|
||||||
|
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
|
||||||
|
<Trans>Need at least 2 players to create teams</Trans>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.regional && hasOddPlayers && (
|
||||||
|
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
|
||||||
|
<Trans>Cannot create teams with an odd number of players. Please have one player unenroll.</Trans>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnrollmentStats;
|
||||||
+43
-20
@@ -4,19 +4,23 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Box,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { TrashIcon } from "@phosphor-icons/react";
|
import { TrashIcon, ShuffleIcon } from "@phosphor-icons/react";
|
||||||
import { useCallback, memo } from "react";
|
import { useCallback, memo } from "react";
|
||||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
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 PlayerAvatar from "@/components/player-avatar";
|
||||||
import { PlayerInfo, Player } from "@/features/players/types";
|
import { PlayerInfo, Player } from "@/features/players/types";
|
||||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
import { usePlayers } from "@/features/players/queries";
|
import { usePlayers } from "@/features/players/queries";
|
||||||
import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
|
import useAdminEnrollPlayer from "../../hooks/use-admin-enroll-player";
|
||||||
import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
|
import useAdminUnenrollPlayer from "../../hooks/use-admin-unenroll-player";
|
||||||
|
import EnrollmentStats from "./enrollment-stats";
|
||||||
|
|
||||||
interface EditEnrolledPlayersProps {
|
interface FreeAgentSectionProps {
|
||||||
tournamentId: string;
|
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 { t } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
|
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
|
||||||
const { data: allPlayers = [] } = usePlayers();
|
const { data: allPlayers = [] } = usePlayers();
|
||||||
|
|
||||||
@@ -112,19 +117,37 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" w="100%">
|
<Stack gap="lg" w="100%">
|
||||||
<Stack gap="xs">
|
<EnrollmentStats count={enrolledPlayers.length} regional />
|
||||||
<Text fw={600} size="sm">
|
|
||||||
<Trans>Add Player</Trans>
|
<Box
|
||||||
</Text>
|
pos="sticky"
|
||||||
<Typeahead
|
top={0}
|
||||||
placeholder={t`Search for players to enroll...`}
|
py="xs"
|
||||||
onSelect={handleEnrollPlayer}
|
style={{ zIndex: 5, backgroundColor: "var(--mantine-color-body)" }}
|
||||||
searchFn={searchPlayers}
|
>
|
||||||
renderOption={renderPlayerOption}
|
<Stack gap="xs">
|
||||||
format={formatPlayer}
|
<Text fw={600} size="sm">
|
||||||
disabled={isEnrolling}
|
<Trans>Add Player</Trans>
|
||||||
/>
|
</Text>
|
||||||
</Stack>
|
<Typeahead
|
||||||
|
placeholder={t`Search for players to enroll...`}
|
||||||
|
onSelect={handleEnrollPlayer}
|
||||||
|
searchFn={searchPlayers}
|
||||||
|
renderOption={renderPlayerOption}
|
||||||
|
format={formatPlayer}
|
||||||
|
disabled={isEnrolling}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftSection={<ShuffleIcon size={18} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/admin/tournaments/${tournamentId}/assign-partners` })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Assign Partners</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
@@ -161,4 +184,4 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditEnrolledPlayers;
|
export default FreeAgentSection;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Tournament } from "../../types";
|
||||||
|
import FreeAgentSection from "./free-agent-section";
|
||||||
|
import TeamSection from "./team-section";
|
||||||
|
|
||||||
|
interface EnrollmentProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Enrollment = ({ tournament }: EnrollmentProps) => {
|
||||||
|
return tournament.regional === true ? (
|
||||||
|
<FreeAgentSection tournamentId={tournament.id} />
|
||||||
|
) : (
|
||||||
|
<TeamSection tournamentId={tournament.id} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Enrollment;
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
ActionIcon,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Box,
|
||||||
|
Button as MantineButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
|
import { TrashIcon, PencilIcon, MusicNotesIcon } from "@phosphor-icons/react";
|
||||||
|
import { useState, useCallback, useMemo, memo } from "react";
|
||||||
|
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useTournament, useUnenrolledTeams } from "../../queries";
|
||||||
|
import useEnrollTeam from "../../hooks/use-enroll-team";
|
||||||
|
import useUnenrollTeam from "../../hooks/use-unenroll-team";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
import { Team, TeamInfo } from "@/features/teams/types";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import TeamForm from "@/features/teams/components/team-form";
|
||||||
|
import { useTeam } from "@/features/teams/queries";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import EnrollmentStats from "./enrollment-stats";
|
||||||
|
|
||||||
|
interface TeamSectionProps {
|
||||||
|
tournamentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamItemProps {
|
||||||
|
team: TeamInfo;
|
||||||
|
onUnenroll: (teamId: string) => void;
|
||||||
|
onEdit: (teamId: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
isRegional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamItem = memo(({ team, onUnenroll, onEdit, disabled, isRegional }: TeamItemProps) => {
|
||||||
|
const playerNames = useMemo(
|
||||||
|
() =>
|
||||||
|
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
|
||||||
|
"",
|
||||||
|
[team.players]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||||
|
<TeamAvatar
|
||||||
|
team={team}
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
isRegional={isRegional}
|
||||||
|
/>
|
||||||
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
{playerNames && (
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{playerNames}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => onEdit(team.id)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<PencilIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => onUnenroll(team.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<TrashIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TeamEditSheetProps {
|
||||||
|
teamId: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
|
||||||
|
const { data: team } = useTeam(teamId);
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
title={team ? t`Edit ${team.name}` : t`Edit Team`}
|
||||||
|
opened={isOpen}
|
||||||
|
onChange={onClose}
|
||||||
|
>
|
||||||
|
{team && (
|
||||||
|
<TeamForm
|
||||||
|
teamId={team.id}
|
||||||
|
initialValues={{
|
||||||
|
...team,
|
||||||
|
players: team.players ? team.players.map((p) => p.id) : [],
|
||||||
|
logo: typeof team.logo === "string" ? undefined : team.logo,
|
||||||
|
}}
|
||||||
|
close={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TeamSection = ({ tournamentId }: TeamSectionProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: tournament, isLoading: tournamentLoading } =
|
||||||
|
useTournament(tournamentId);
|
||||||
|
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||||
|
useUnenrolledTeams(tournamentId);
|
||||||
|
|
||||||
|
const isRegional = tournament?.regional;
|
||||||
|
|
||||||
|
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||||
|
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||||
|
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||||
|
const {
|
||||||
|
isOpen: editTeamOpened,
|
||||||
|
open: openEditTeam,
|
||||||
|
close: closeEditTeam,
|
||||||
|
} = useSheet();
|
||||||
|
|
||||||
|
const handleEditTeam = useCallback(
|
||||||
|
(teamId: string) => {
|
||||||
|
setSelectedTeamId(teamId);
|
||||||
|
openEditTeam();
|
||||||
|
},
|
||||||
|
[openEditTeam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseEditTeam = useCallback(() => {
|
||||||
|
setSelectedTeamId(null);
|
||||||
|
closeEditTeam();
|
||||||
|
}, [closeEditTeam]);
|
||||||
|
|
||||||
|
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
const filtered = unenrolledTeams.filter((team: Team) =>
|
||||||
|
team.name.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return filtered.map((team: Team) => ({
|
||||||
|
id: team.id,
|
||||||
|
data: team
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnrollTeam = useCallback(
|
||||||
|
(option: TypeaheadOption<Team>) => {
|
||||||
|
enrollTeam({ tournamentId, teamId: option.data.id });
|
||||||
|
},
|
||||||
|
[enrollTeam, tournamentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnenrollTeam = useCallback(
|
||||||
|
(teamId: string) => {
|
||||||
|
unenrollTeam({ tournamentId, teamId });
|
||||||
|
},
|
||||||
|
[unenrollTeam, tournamentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
||||||
|
const team = option.data;
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" gap="sm" align="center">
|
||||||
|
<TeamAvatar
|
||||||
|
team={team as any}
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
isRegional={isRegional}
|
||||||
|
/>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTeam = (option: TypeaheadOption<Team>) => {
|
||||||
|
return option.data.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = tournamentLoading || unenrolledLoading;
|
||||||
|
const enrolledTeams = tournament?.teams || [];
|
||||||
|
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap="lg" w="100%">
|
||||||
|
<EnrollmentStats count={enrolledTeams.length} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
pos="sticky"
|
||||||
|
top={0}
|
||||||
|
py="xs"
|
||||||
|
style={{ zIndex: 5, backgroundColor: "var(--mantine-color-body)" }}
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
<Trans>Add Team</Trans>
|
||||||
|
</Text>
|
||||||
|
<Typeahead
|
||||||
|
placeholder={t`Search for teams to enroll...`}
|
||||||
|
onSelect={handleEnrollTeam}
|
||||||
|
searchFn={searchTeams}
|
||||||
|
renderOption={renderTeamOption}
|
||||||
|
format={formatTeam}
|
||||||
|
disabled={isEnrolling || unenrolledLoading}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<MantineButton
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
leftSection={<MusicNotesIcon size={18} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/admin/tournaments/${tournamentId}/teams` })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Manage Team Songs/Logos</Trans>
|
||||||
|
</MantineButton>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
<Trans>Enrolled Teams</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Plural value={enrolledTeams.length} one="# team" other="# teams" />
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : !hasEnrolledTeams ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||||
|
<Trans>No teams enrolled yet</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs" w="100%">
|
||||||
|
{enrolledTeams.map((team: TeamInfo) => (
|
||||||
|
<TeamItem
|
||||||
|
key={team.id}
|
||||||
|
team={team}
|
||||||
|
onUnenroll={handleUnenrollTeam}
|
||||||
|
onEdit={handleEditTeam}
|
||||||
|
disabled={isUnenrolling}
|
||||||
|
isRegional={isRegional}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{selectedTeamId && (
|
||||||
|
<TeamEditSheet
|
||||||
|
teamId={selectedTeamId}
|
||||||
|
isOpen={editTeamOpened}
|
||||||
|
onClose={handleCloseEditTeam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamSection;
|
||||||
@@ -8,14 +8,9 @@ import {
|
|||||||
PencilLineIcon,
|
PencilLineIcon,
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
UsersThreeIcon,
|
UsersThreeIcon,
|
||||||
UsersIcon,
|
|
||||||
ShuffleIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
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 ListLink from "@/components/list-link";
|
||||||
import React from "react";
|
|
||||||
import EditRules from "./edit-rules";
|
import EditRules from "./edit-rules";
|
||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
@@ -38,17 +33,20 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
open: openEditRules,
|
open: openEditRules,
|
||||||
close: closeEditRules,
|
close: closeEditRules,
|
||||||
} = useSheet();
|
} = useSheet();
|
||||||
const {
|
|
||||||
isOpen: editTeamsOpened,
|
|
||||||
open: openEditTeams,
|
|
||||||
close: closeEditTeams,
|
|
||||||
} = useSheet();
|
|
||||||
|
|
||||||
const [v, setV] = React.useState("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List p="0">
|
<List p="0">
|
||||||
|
<ListLink
|
||||||
|
label={t`Run Tournament`}
|
||||||
|
Icon={TreeStructureIcon}
|
||||||
|
to={`/admin/tournaments/run/${tournamentId}`}
|
||||||
|
/>
|
||||||
|
<ListLink
|
||||||
|
label={t`Enrollment & Teams`}
|
||||||
|
Icon={UsersThreeIcon}
|
||||||
|
to={`/admin/tournaments/${tournamentId}/enrollment`}
|
||||||
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label={t`Edit Tournament`}
|
label={t`Edit Tournament`}
|
||||||
Icon={HardDrivesIcon}
|
Icon={HardDrivesIcon}
|
||||||
@@ -59,30 +57,6 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
Icon={PencilLineIcon}
|
Icon={PencilLineIcon}
|
||||||
onClick={openEditRules}
|
onClick={openEditRules}
|
||||||
/>
|
/>
|
||||||
<ListButton
|
|
||||||
label={t`Edit Enrollments`}
|
|
||||||
Icon={UsersThreeIcon}
|
|
||||||
onClick={openEditTeams}
|
|
||||||
/>
|
|
||||||
{tournament.regional && (
|
|
||||||
<ListLink
|
|
||||||
label={t`Assign Partners`}
|
|
||||||
Icon={ShuffleIcon}
|
|
||||||
to={`/admin/tournaments/${tournamentId}/assign-partners`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!tournament.regional && (
|
|
||||||
<ListLink
|
|
||||||
label={t`Manage Team Songs/Logos`}
|
|
||||||
Icon={UsersIcon}
|
|
||||||
to={`/admin/tournaments/${tournamentId}/teams`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ListLink
|
|
||||||
label={t`Run Tournament`}
|
|
||||||
Icon={TreeStructureIcon}
|
|
||||||
to={`/admin/tournaments/run/${tournamentId}`}
|
|
||||||
/>
|
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Sheet
|
<Sheet
|
||||||
@@ -112,18 +86,6 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
>
|
>
|
||||||
<EditRules tournamentId={tournamentId} onClose={closeEditRules} />
|
<EditRules tournamentId={tournamentId} onClose={closeEditRules} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Sheet
|
|
||||||
title={tournament.regional === true ? t`Manage Enrollments` : t`Edit Enrolled Teams`}
|
|
||||||
opened={editTeamsOpened}
|
|
||||||
onChange={closeEditTeams}
|
|
||||||
>
|
|
||||||
{tournament.regional === true ? (
|
|
||||||
<EditEnrolledPlayers tournamentId={tournamentId} />
|
|
||||||
) : (
|
|
||||||
<EditEnrolledTeams tournamentId={tournamentId} />
|
|
||||||
)}
|
|
||||||
</Sheet>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
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 { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
|
||||||
import { ClockIcon } from "@phosphor-icons/react";
|
import { ClockIcon } from "@phosphor-icons/react";
|
||||||
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||||
@@ -22,10 +23,10 @@ const teamPlayerIds = (t: Match["home"]): string[] =>
|
|||||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||||
tournament,
|
tournament,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, roles } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
|
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
|
||||||
|
|
||||||
|
|||||||
@@ -30,28 +30,9 @@ const RulesContent = lazy(
|
|||||||
() => import("../../upcoming-tournament/rules-content")
|
() => import("../../upcoming-tournament/rules-content")
|
||||||
);
|
);
|
||||||
|
|
||||||
interface NavTileProps {
|
import { NavTile } from "@/components/nav-tile";
|
||||||
label: string;
|
|
||||||
Icon: Icon;
|
|
||||||
onClick?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single square-ish nav tile with icon-over-label and press feedback. */
|
export { NavTile };
|
||||||
export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => (
|
|
||||||
<UnstyledButton
|
|
||||||
className={`${classes.tile} flxn-tile`}
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<span className={`${classes.icon} flxn-tile-icon`}>
|
|
||||||
<Icon size={22} weight="bold" />
|
|
||||||
</span>
|
|
||||||
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface NavGridProps {
|
interface NavGridProps {
|
||||||
tournament: Tournament;
|
tournament: Tournament;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user