Merge remote-tracking branch 'origin/feat/admin-telemetry-views' into deploy/test-pr69-73
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/heicode/manager/common"
|
||||||
|
"github.com/heicode/manager/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 客户端遥测(#24)管理员后台只读视图:列表 + 聚合。遥测仅诊断、隔离于计费,载荷无用户内容
|
||||||
|
// (仅枚举/哈希/计数/脱敏栈帧);device_id(client_id)经 user_id 可关联账号,故仅管理员可读。
|
||||||
|
|
||||||
|
func telemetryQueryFilterFromContext(c *gin.Context) model.TelemetryQueryFilter {
|
||||||
|
userId, _ := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
|
||||||
|
start, _ := strconv.ParseInt(strings.TrimSpace(c.Query("start_timestamp")), 10, 64)
|
||||||
|
end, _ := strconv.ParseInt(strings.TrimSpace(c.Query("end_timestamp")), 10, 64)
|
||||||
|
return model.TelemetryQueryFilter{
|
||||||
|
UserId: userId,
|
||||||
|
ClientId: strings.TrimSpace(c.Query("client_id")),
|
||||||
|
Platform: strings.TrimSpace(c.Query("platform")),
|
||||||
|
AppVersion: strings.TrimSpace(c.Query("app_version")),
|
||||||
|
ErrorCategory: strings.TrimSpace(c.Query("error_category")),
|
||||||
|
ErrorCode: strings.TrimSpace(c.Query("error_code")),
|
||||||
|
StartReceivedAt: start,
|
||||||
|
EndReceivedAt: end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListTelemetryEvents: GET /api/telemetry/events — 分页列表(最新在前),支持
|
||||||
|
// user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。
|
||||||
|
func AdminListTelemetryEvents(c *gin.Context) {
|
||||||
|
pageInfo := common.GetPageQuery(c)
|
||||||
|
events, total, err := model.ListTelemetryEvents(telemetryQueryFilterFromContext(c), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(events)
|
||||||
|
common.ApiSuccess(c, pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAggregateTelemetryEvents: GET /api/telemetry/aggregate — 按维度聚合计数。
|
||||||
|
// dimension 默认 error_category(白名单:error_category/error_code/platform/app_version/
|
||||||
|
// os_version/arch/stack_hash);返回各桶 {key,count,users},busiest first。过滤同列表。
|
||||||
|
func AdminAggregateTelemetryEvents(c *gin.Context) {
|
||||||
|
dimension := strings.TrimSpace(c.Query("dimension"))
|
||||||
|
if dimension == "" {
|
||||||
|
dimension = "error_category"
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
||||||
|
buckets, err := model.AggregateTelemetryEvents(telemetryQueryFilterFromContext(c), dimension, limit)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, gin.H{
|
||||||
|
"dimension": dimension,
|
||||||
|
"buckets": buckets,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
// TelemetryEvent stores client error-telemetry (issue #24). It is deliberately
|
// TelemetryEvent stores client error-telemetry (issue #24). It is deliberately
|
||||||
// isolated from billing: ingest never writes a consume Log nor touches
|
// isolated from billing: ingest never writes a consume Log nor touches
|
||||||
// user.Quota. Event payload carries NO user content — only hashes, enums,
|
// user.Quota. Event payload carries NO user content — only hashes, enums,
|
||||||
@@ -40,6 +47,121 @@ func InsertTelemetryEvents(events []TelemetryEvent) error {
|
|||||||
return LOG_DB.Create(&events).Error
|
return LOG_DB.Create(&events).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TelemetryQueryFilter scopes admin telemetry queries. Empty fields are ignored.
|
||||||
|
// Time bounds are server unix seconds (ReceivedAt). No content fields exist to
|
||||||
|
// filter on by design — only enums/hashes/versions.
|
||||||
|
type TelemetryQueryFilter struct {
|
||||||
|
UserId int
|
||||||
|
ClientId string
|
||||||
|
Platform string
|
||||||
|
AppVersion string
|
||||||
|
ErrorCategory string
|
||||||
|
ErrorCode string
|
||||||
|
StartReceivedAt int64
|
||||||
|
EndReceivedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f TelemetryQueryFilter) apply(db *gorm.DB) *gorm.DB {
|
||||||
|
if f.UserId > 0 {
|
||||||
|
db = db.Where("user_id = ?", f.UserId)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.ClientId) != "" {
|
||||||
|
db = db.Where("client_id = ?", strings.TrimSpace(f.ClientId))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.Platform) != "" {
|
||||||
|
db = db.Where("platform = ?", strings.TrimSpace(f.Platform))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.AppVersion) != "" {
|
||||||
|
db = db.Where("app_version = ?", strings.TrimSpace(f.AppVersion))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.ErrorCategory) != "" {
|
||||||
|
db = db.Where("error_category = ?", strings.TrimSpace(f.ErrorCategory))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.ErrorCode) != "" {
|
||||||
|
db = db.Where("error_code = ?", strings.TrimSpace(f.ErrorCode))
|
||||||
|
}
|
||||||
|
if f.StartReceivedAt > 0 {
|
||||||
|
db = db.Where("received_at >= ?", f.StartReceivedAt)
|
||||||
|
}
|
||||||
|
if f.EndReceivedAt > 0 {
|
||||||
|
db = db.Where("received_at <= ?", f.EndReceivedAt)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTelemetryEvents returns a filtered, paged slice of telemetry rows (newest
|
||||||
|
// first) plus the total matching count. Admin-only read path (#24 follow-up).
|
||||||
|
func ListTelemetryEvents(filter TelemetryQueryFilter, startIdx, pageSize int) ([]TelemetryEvent, int64, error) {
|
||||||
|
if LOG_DB == nil {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if startIdx < 0 {
|
||||||
|
startIdx = 0
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var events []TelemetryEvent
|
||||||
|
if total == 0 {
|
||||||
|
return events, 0, nil
|
||||||
|
}
|
||||||
|
err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).
|
||||||
|
Order("received_at desc").
|
||||||
|
Limit(pageSize).Offset(startIdx).
|
||||||
|
Find(&events).Error
|
||||||
|
return events, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// telemetryAggDimensions whitelists the columns admins may group by, mapping the
|
||||||
|
// public dimension name to a real column. The whitelist is the SQL-injection
|
||||||
|
// guard — the dimension is interpolated into GROUP BY/SELECT, so it MUST come
|
||||||
|
// from this map, never from raw client input.
|
||||||
|
var telemetryAggDimensions = map[string]string{
|
||||||
|
"error_category": "error_category",
|
||||||
|
"error_code": "error_code",
|
||||||
|
"platform": "platform",
|
||||||
|
"app_version": "app_version",
|
||||||
|
"os_version": "os_version",
|
||||||
|
"arch": "arch",
|
||||||
|
"stack_hash": "stack_hash",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelemetryAggBucket is one group in an aggregate: the dimension value, the row
|
||||||
|
// count, and the number of distinct accounts that produced it.
|
||||||
|
type TelemetryAggBucket struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Users int64 `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateTelemetryEvents groups matching telemetry by one whitelisted
|
||||||
|
// dimension, returning counts (and distinct-user counts) per bucket, busiest
|
||||||
|
// first. Cross-DB safe: only COUNT/COUNT(DISTINCT)/GROUP BY on a fixed column.
|
||||||
|
func AggregateTelemetryEvents(filter TelemetryQueryFilter, dimension string, limit int) ([]TelemetryAggBucket, error) {
|
||||||
|
col, ok := telemetryAggDimensions[strings.TrimSpace(dimension)]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unsupported telemetry aggregate dimension")
|
||||||
|
}
|
||||||
|
if LOG_DB == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
var buckets []TelemetryAggBucket
|
||||||
|
err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).
|
||||||
|
Select(col+" as key, COUNT(*) as count, COUNT(DISTINCT user_id) as users").
|
||||||
|
Group(col).
|
||||||
|
Order("count desc").
|
||||||
|
Limit(limit).
|
||||||
|
Scan(&buckets).Error
|
||||||
|
return buckets, err
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteTelemetryEventsBefore removes telemetry rows received before cutoffUnix
|
// DeleteTelemetryEventsBefore removes telemetry rows received before cutoffUnix
|
||||||
// (server unix seconds), enforcing the retention window (#32). Returns the
|
// (server unix seconds), enforcing the retention window (#32). Returns the
|
||||||
// number of rows deleted. Account-linkable device telemetry must not be kept
|
// number of rows deleted. Account-linkable device telemetry must not be kept
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 管理员遥测查询:列表过滤 + 分页(最新在前)、聚合(按维度计数 + 去重用户数)、维度白名单。
|
||||||
|
// TelemetryEvent 由包级 TestMain 迁移;LOG_DB == DB。
|
||||||
|
func seedTelemetry(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&TelemetryEvent{}).Error)
|
||||||
|
rows := []TelemetryEvent{
|
||||||
|
{ReceivedAt: 1000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
|
||||||
|
{ReceivedAt: 2000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ECONNRESET"},
|
||||||
|
{ReceivedAt: 3000, UserId: 2, ClientId: "dev-b", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
|
||||||
|
{ReceivedAt: 4000, UserId: 3, ClientId: "dev-c", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "crash", ErrorCode: "SIGSEGV"},
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].SchemaVersion = 1
|
||||||
|
require.NoError(t, LOG_DB.Create(&rows[i]).Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTelemetryEvents_FilterAndPaging(t *testing.T) {
|
||||||
|
seedTelemetry(t)
|
||||||
|
|
||||||
|
// no filter -> all 4, newest first.
|
||||||
|
events, total, err := ListTelemetryEvents(TelemetryQueryFilter{}, 0, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 4, total)
|
||||||
|
require.Len(t, events, 4)
|
||||||
|
require.EqualValues(t, 4000, events[0].ReceivedAt, "newest first")
|
||||||
|
require.EqualValues(t, 1000, events[3].ReceivedAt)
|
||||||
|
|
||||||
|
// platform filter.
|
||||||
|
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{Platform: "windows"}, 0, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 2, total)
|
||||||
|
require.Len(t, events, 2)
|
||||||
|
|
||||||
|
// user + category filter.
|
||||||
|
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{UserId: 1, ErrorCategory: "network"}, 0, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 2, total)
|
||||||
|
|
||||||
|
// time range (received_at in [2000,3000]).
|
||||||
|
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{StartReceivedAt: 2000, EndReceivedAt: 3000}, 0, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 2, total)
|
||||||
|
|
||||||
|
// pagination: page size 2 -> total stays 4, page returns 2.
|
||||||
|
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{}, 0, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 4, total)
|
||||||
|
require.Len(t, events, 2)
|
||||||
|
events2, _, err := ListTelemetryEvents(TelemetryQueryFilter{}, 2, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, events2, 2)
|
||||||
|
require.NotEqual(t, events[0].Id, events2[0].Id, "pages are disjoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateTelemetryEvents_CountsAndDistinctUsers(t *testing.T) {
|
||||||
|
seedTelemetry(t)
|
||||||
|
|
||||||
|
// by error_category: network=3 (users 1,2 -> 2 distinct), crash=1 (user 3).
|
||||||
|
buckets, err := AggregateTelemetryEvents(TelemetryQueryFilter{}, "error_category", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, buckets, 2)
|
||||||
|
require.Equal(t, "network", buckets[0].Key, "busiest first")
|
||||||
|
require.EqualValues(t, 3, buckets[0].Count)
|
||||||
|
require.EqualValues(t, 2, buckets[0].Users, "distinct accounts")
|
||||||
|
require.Equal(t, "crash", buckets[1].Key)
|
||||||
|
require.EqualValues(t, 1, buckets[1].Count)
|
||||||
|
require.EqualValues(t, 1, buckets[1].Users)
|
||||||
|
|
||||||
|
// by platform with a filter applied (only network rows): darwin=2, windows=1.
|
||||||
|
buckets, err = AggregateTelemetryEvents(TelemetryQueryFilter{ErrorCategory: "network"}, "platform", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, buckets, 2)
|
||||||
|
require.Equal(t, "darwin", buckets[0].Key)
|
||||||
|
require.EqualValues(t, 2, buckets[0].Count)
|
||||||
|
|
||||||
|
// unsupported dimension is rejected (SQL-injection guard).
|
||||||
|
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "user_id; drop table", 0)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "", 0)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -203,6 +203,15 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
|
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client telemetry admin read-only views (#24 follow-up): list + aggregate.
|
||||||
|
// Admin-gated — device_id is account-linkable, so only admins may read.
|
||||||
|
telemetryAdminRoute := apiRouter.Group("/telemetry")
|
||||||
|
telemetryAdminRoute.Use(middleware.AdminAuth())
|
||||||
|
{
|
||||||
|
telemetryAdminRoute.GET("/events", controller.AdminListTelemetryEvents)
|
||||||
|
telemetryAdminRoute.GET("/aggregate", controller.AdminAggregateTelemetryEvents)
|
||||||
|
}
|
||||||
|
|
||||||
// Subscription payment callbacks (no auth)
|
// Subscription payment callbacks (no auth)
|
||||||
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||||
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type TFunction } from 'i18next'
|
import { type TFunction } from 'i18next'
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
Box,
|
Box,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -84,6 +85,11 @@ export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
|||||||
url: '/usage-logs/common',
|
url: '/usage-logs/common',
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('Client Telemetry'),
|
||||||
|
url: '/telemetry',
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const workspaceRegistry: WorkspaceConfig[] = [
|
|||||||
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
|
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
|
||||||
name: 'System Settings',
|
name: 'System Settings',
|
||||||
pathPattern:
|
pathPattern:
|
||||||
/^\/(system-settings|channels|redemption-codes|users|templates|agents|subscriptions|models|usage-logs)(\/|$)/,
|
/^\/(system-settings|channels|redemption-codes|users|templates|agents|subscriptions|models|usage-logs|telemetry)(\/|$)/,
|
||||||
getNavGroups: getSystemSettingsNavGroups,
|
getNavGroups: getSystemSettingsNavGroups,
|
||||||
},
|
},
|
||||||
// Default workspace (must be last)
|
// Default workspace (must be last)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { api } from '@/lib/api'
|
||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
TelemetryAggregateData,
|
||||||
|
TelemetryFilters,
|
||||||
|
TelemetryListData,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
function buildParams(params: Record<string, unknown>): string {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null) continue
|
||||||
|
const s = String(v).trim()
|
||||||
|
if (s !== '') sp.set(k, s)
|
||||||
|
}
|
||||||
|
return sp.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTelemetryEvents(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
filters: TelemetryFilters
|
||||||
|
): Promise<ApiResponse<TelemetryListData>> {
|
||||||
|
const query = buildParams({ p: page, page_size: pageSize, ...filters })
|
||||||
|
const res = await api.get(`/api/telemetry/events?${query}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTelemetryAggregate(
|
||||||
|
dimension: string,
|
||||||
|
filters: TelemetryFilters,
|
||||||
|
limit = 100
|
||||||
|
): Promise<ApiResponse<TelemetryAggregateData>> {
|
||||||
|
const query = buildParams({ dimension, limit, ...filters })
|
||||||
|
const res = await api.get(`/api/telemetry/aggregate?${query}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { SectionPageLayout } from '@/components/layout'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { getTelemetryAggregate, getTelemetryEvents } from './api'
|
||||||
|
import {
|
||||||
|
TELEMETRY_AGG_DIMENSIONS,
|
||||||
|
type TelemetryFilters,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const EMPTY_FILTERS: TelemetryFilters = {
|
||||||
|
user_id: '',
|
||||||
|
client_id: '',
|
||||||
|
platform: '',
|
||||||
|
app_version: '',
|
||||||
|
error_category: '',
|
||||||
|
error_code: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(unix: number): string {
|
||||||
|
if (!unix) return '-'
|
||||||
|
return new Date(unix * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Telemetry() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
// Draft filters bound to inputs; `applied` is what queries actually use.
|
||||||
|
const [draft, setDraft] = useState<TelemetryFilters>(EMPTY_FILTERS)
|
||||||
|
const [applied, setApplied] = useState<TelemetryFilters>(EMPTY_FILTERS)
|
||||||
|
const [dimension, setDimension] = useState<string>('error_category')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data: listResp, isLoading: listLoading } = useQuery({
|
||||||
|
queryKey: ['admin-telemetry-events', page, applied],
|
||||||
|
queryFn: async () => (await getTelemetryEvents(page, PAGE_SIZE, applied)).data,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: aggResp, isLoading: aggLoading } = useQuery({
|
||||||
|
queryKey: ['admin-telemetry-aggregate', dimension, applied],
|
||||||
|
queryFn: async () => (await getTelemetryAggregate(dimension, applied)).data,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
const events = useMemo(() => listResp?.items ?? [], [listResp])
|
||||||
|
const total = listResp?.total ?? 0
|
||||||
|
const buckets = useMemo(() => aggResp?.buckets ?? [], [aggResp])
|
||||||
|
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0) || 1
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
setPage(1)
|
||||||
|
setApplied(draft)
|
||||||
|
}
|
||||||
|
const reset = () => {
|
||||||
|
setDraft(EMPTY_FILTERS)
|
||||||
|
setApplied(EMPTY_FILTERS)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setField = (k: keyof TelemetryFilters) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setDraft((d) => ({ ...d, [k]: e.target.value }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionPageLayout>
|
||||||
|
<SectionPageLayout.Title>{t('Client Telemetry')}</SectionPageLayout.Title>
|
||||||
|
<SectionPageLayout.Description>
|
||||||
|
{t(
|
||||||
|
'Diagnostic error telemetry from clients. No user content — only categories, codes, hashes and versions. device_id is account-linkable.'
|
||||||
|
)}
|
||||||
|
</SectionPageLayout.Description>
|
||||||
|
<SectionPageLayout.Content>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className='grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6'>
|
||||||
|
<Input placeholder={t('User ID')} value={draft.user_id} onChange={setField('user_id')} />
|
||||||
|
<Input placeholder={t('Device ID')} value={draft.client_id} onChange={setField('client_id')} />
|
||||||
|
<Input placeholder={t('Platform')} value={draft.platform} onChange={setField('platform')} />
|
||||||
|
<Input placeholder={t('App version')} value={draft.app_version} onChange={setField('app_version')} />
|
||||||
|
<Input placeholder={t('Error category')} value={draft.error_category} onChange={setField('error_category')} />
|
||||||
|
<Input placeholder={t('Error code')} value={draft.error_code} onChange={setField('error_code')} />
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button size='sm' onClick={apply}>{t('Apply')}</Button>
|
||||||
|
<Button size='sm' variant='outline' onClick={reset}>{t('Reset')}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aggregate */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between gap-2 space-y-0'>
|
||||||
|
<CardTitle className='text-sm'>{t('Aggregate')}</CardTitle>
|
||||||
|
<Select value={dimension} onValueChange={setDimension}>
|
||||||
|
<SelectTrigger className='w-44'>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TELEMETRY_AGG_DIMENSIONS.map((d) => (
|
||||||
|
<SelectItem key={d} value={d}>{d}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{aggLoading && buckets.length === 0 ? (
|
||||||
|
<p className='text-muted-foreground text-sm'>{t('Loading...')}</p>
|
||||||
|
) : buckets.length === 0 ? (
|
||||||
|
<p className='text-muted-foreground text-sm'>{t('No data')}</p>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
{buckets.map((b) => (
|
||||||
|
<div key={b.key || '(empty)'} className='flex items-center gap-2 text-sm'>
|
||||||
|
<span className='w-40 truncate font-mono text-xs' title={b.key}>
|
||||||
|
{b.key || '(empty)'}
|
||||||
|
</span>
|
||||||
|
<div className='bg-muted h-4 flex-1 overflow-hidden rounded'>
|
||||||
|
<div
|
||||||
|
className='bg-primary h-full'
|
||||||
|
style={{ width: `${(b.count / maxCount) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className='w-12 text-right tabular-nums'>{b.count}</span>
|
||||||
|
<Badge variant='secondary' className='tabular-nums' title={t('Distinct users')}>
|
||||||
|
{b.users}u
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Event list */}
|
||||||
|
<div className='rounded-md border'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('Time')}</TableHead>
|
||||||
|
<TableHead>{t('User ID')}</TableHead>
|
||||||
|
<TableHead>{t('Device ID')}</TableHead>
|
||||||
|
<TableHead>{t('Platform')}</TableHead>
|
||||||
|
<TableHead>{t('App version')}</TableHead>
|
||||||
|
<TableHead>{t('Error category')}</TableHead>
|
||||||
|
<TableHead>{t('Error code')}</TableHead>
|
||||||
|
<TableHead>{t('Stack hash')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{listLoading && events.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className='text-muted-foreground h-20 text-center'>
|
||||||
|
{t('Loading...')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className='text-muted-foreground h-20 text-center'>
|
||||||
|
{t('No data')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
events.map((ev) => (
|
||||||
|
<TableRow key={ev.id}>
|
||||||
|
<TableCell className='whitespace-nowrap'>{fmtTime(ev.received_at)}</TableCell>
|
||||||
|
<TableCell className='tabular-nums'>{ev.user_id}</TableCell>
|
||||||
|
<TableCell className='max-w-32 truncate font-mono text-xs' title={ev.client_id}>{ev.client_id}</TableCell>
|
||||||
|
<TableCell>{ev.platform}</TableCell>
|
||||||
|
<TableCell>{ev.app_version}</TableCell>
|
||||||
|
<TableCell>{ev.error_category}</TableCell>
|
||||||
|
<TableCell className='max-w-40 truncate' title={ev.error_code}>{ev.error_code}</TableCell>
|
||||||
|
<TableCell className='font-mono text-xs'>{ev.stack_hash}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
{t('Total')}: {total}
|
||||||
|
</span>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button size='sm' variant='outline' disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
{t('Previous')}
|
||||||
|
</Button>
|
||||||
|
<span className='text-sm tabular-nums'>{page} / {totalPages}</span>
|
||||||
|
<Button size='sm' variant='outline' disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
{t('Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionPageLayout.Content>
|
||||||
|
</SectionPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Client error-telemetry (#24) admin read-only views. No user content — only
|
||||||
|
// enums/hashes/versions/counts. device_id (client_id) is account-linkable, so
|
||||||
|
// these views are admin-gated.
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryEvent {
|
||||||
|
id: number
|
||||||
|
received_at: number
|
||||||
|
user_id: number
|
||||||
|
client_id: string
|
||||||
|
schema_version: number
|
||||||
|
app_version: string
|
||||||
|
platform: string
|
||||||
|
os_version: string
|
||||||
|
arch: string
|
||||||
|
locale: string
|
||||||
|
error_category: string
|
||||||
|
error_code: string
|
||||||
|
error_message_hash: string
|
||||||
|
stack_hash: string
|
||||||
|
stack_top: string
|
||||||
|
context: string
|
||||||
|
timestamp: string
|
||||||
|
session_seq: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryListData {
|
||||||
|
items: TelemetryEvent[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryAggBucket {
|
||||||
|
key: string
|
||||||
|
count: number
|
||||||
|
users: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryAggregateData {
|
||||||
|
dimension: string
|
||||||
|
buckets: TelemetryAggBucket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryFilters {
|
||||||
|
user_id?: string
|
||||||
|
client_id?: string
|
||||||
|
platform?: string
|
||||||
|
app_version?: string
|
||||||
|
error_category?: string
|
||||||
|
error_code?: string
|
||||||
|
start_timestamp?: string
|
||||||
|
end_timestamp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TELEMETRY_AGG_DIMENSIONS = [
|
||||||
|
'error_category',
|
||||||
|
'error_code',
|
||||||
|
'platform',
|
||||||
|
'app_version',
|
||||||
|
'os_version',
|
||||||
|
'arch',
|
||||||
|
'stack_hash',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type TelemetryAggDimension = (typeof TELEMETRY_AGG_DIMENSIONS)[number]
|
||||||
@@ -113,6 +113,7 @@ export function useSidebarData(): SidebarData {
|
|||||||
'/subscriptions',
|
'/subscriptions',
|
||||||
'/models',
|
'/models',
|
||||||
'/usage-logs',
|
'/usage-logs',
|
||||||
|
'/telemetry',
|
||||||
],
|
],
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
},
|
},
|
||||||
|
|||||||
+22
@@ -35,6 +35,7 @@ import { Route as PricingModelIdIndexRouteImport } from './routes/pricing/$model
|
|||||||
import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenticated/wallet/index'
|
import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenticated/wallet/index'
|
||||||
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
|
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
|
||||||
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
|
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
|
||||||
|
import { Route as AuthenticatedTelemetryIndexRouteImport } from './routes/_authenticated/telemetry/index'
|
||||||
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
|
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
|
||||||
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
|
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
|
||||||
import { Route as AuthenticatedResourcesIndexRouteImport } from './routes/_authenticated/resources/index'
|
import { Route as AuthenticatedResourcesIndexRouteImport } from './routes/_authenticated/resources/index'
|
||||||
@@ -202,6 +203,12 @@ const AuthenticatedUsageLogsIndexRoute =
|
|||||||
path: '/usage-logs/',
|
path: '/usage-logs/',
|
||||||
getParentRoute: () => AuthenticatedRouteRoute,
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedTelemetryIndexRoute =
|
||||||
|
AuthenticatedTelemetryIndexRouteImport.update({
|
||||||
|
id: '/telemetry/',
|
||||||
|
path: '/telemetry/',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedSystemSettingsIndexRoute =
|
const AuthenticatedSystemSettingsIndexRoute =
|
||||||
AuthenticatedSystemSettingsIndexRouteImport.update({
|
AuthenticatedSystemSettingsIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
@@ -452,6 +459,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/resources/': typeof AuthenticatedResourcesIndexRoute
|
'/resources/': typeof AuthenticatedResourcesIndexRoute
|
||||||
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||||
|
'/telemetry/': typeof AuthenticatedTelemetryIndexRoute
|
||||||
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
||||||
'/users/': typeof AuthenticatedUsersIndexRoute
|
'/users/': typeof AuthenticatedUsersIndexRoute
|
||||||
'/wallet/': typeof AuthenticatedWalletIndexRoute
|
'/wallet/': typeof AuthenticatedWalletIndexRoute
|
||||||
@@ -512,6 +520,7 @@ export interface FileRoutesByTo {
|
|||||||
'/resources': typeof AuthenticatedResourcesIndexRoute
|
'/resources': typeof AuthenticatedResourcesIndexRoute
|
||||||
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
|
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
|
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
|
||||||
|
'/telemetry': typeof AuthenticatedTelemetryIndexRoute
|
||||||
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
|
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
|
||||||
'/users': typeof AuthenticatedUsersIndexRoute
|
'/users': typeof AuthenticatedUsersIndexRoute
|
||||||
'/wallet': typeof AuthenticatedWalletIndexRoute
|
'/wallet': typeof AuthenticatedWalletIndexRoute
|
||||||
@@ -576,6 +585,7 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/resources/': typeof AuthenticatedResourcesIndexRoute
|
'/_authenticated/resources/': typeof AuthenticatedResourcesIndexRoute
|
||||||
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||||
|
'/_authenticated/telemetry/': typeof AuthenticatedTelemetryIndexRoute
|
||||||
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
||||||
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
||||||
'/_authenticated/wallet/': typeof AuthenticatedWalletIndexRoute
|
'/_authenticated/wallet/': typeof AuthenticatedWalletIndexRoute
|
||||||
@@ -639,6 +649,7 @@ export interface FileRouteTypes {
|
|||||||
| '/resources/'
|
| '/resources/'
|
||||||
| '/subscriptions/'
|
| '/subscriptions/'
|
||||||
| '/system-settings/'
|
| '/system-settings/'
|
||||||
|
| '/telemetry/'
|
||||||
| '/usage-logs/'
|
| '/usage-logs/'
|
||||||
| '/users/'
|
| '/users/'
|
||||||
| '/wallet/'
|
| '/wallet/'
|
||||||
@@ -699,6 +710,7 @@ export interface FileRouteTypes {
|
|||||||
| '/resources'
|
| '/resources'
|
||||||
| '/subscriptions'
|
| '/subscriptions'
|
||||||
| '/system-settings'
|
| '/system-settings'
|
||||||
|
| '/telemetry'
|
||||||
| '/usage-logs'
|
| '/usage-logs'
|
||||||
| '/users'
|
| '/users'
|
||||||
| '/wallet'
|
| '/wallet'
|
||||||
@@ -762,6 +774,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/resources/'
|
| '/_authenticated/resources/'
|
||||||
| '/_authenticated/subscriptions/'
|
| '/_authenticated/subscriptions/'
|
||||||
| '/_authenticated/system-settings/'
|
| '/_authenticated/system-settings/'
|
||||||
|
| '/_authenticated/telemetry/'
|
||||||
| '/_authenticated/usage-logs/'
|
| '/_authenticated/usage-logs/'
|
||||||
| '/_authenticated/users/'
|
| '/_authenticated/users/'
|
||||||
| '/_authenticated/wallet/'
|
| '/_authenticated/wallet/'
|
||||||
@@ -984,6 +997,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedUsageLogsIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedUsageLogsIndexRouteImport
|
||||||
parentRoute: typeof AuthenticatedRouteRoute
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/telemetry/': {
|
||||||
|
id: '/_authenticated/telemetry/'
|
||||||
|
path: '/telemetry'
|
||||||
|
fullPath: '/telemetry/'
|
||||||
|
preLoaderRoute: typeof AuthenticatedTelemetryIndexRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
'/_authenticated/system-settings/': {
|
'/_authenticated/system-settings/': {
|
||||||
id: '/_authenticated/system-settings/'
|
id: '/_authenticated/system-settings/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -1335,6 +1355,7 @@ interface AuthenticatedRouteRouteChildren {
|
|||||||
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
|
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
|
||||||
AuthenticatedResourcesIndexRoute: typeof AuthenticatedResourcesIndexRoute
|
AuthenticatedResourcesIndexRoute: typeof AuthenticatedResourcesIndexRoute
|
||||||
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
|
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
|
AuthenticatedTelemetryIndexRoute: typeof AuthenticatedTelemetryIndexRoute
|
||||||
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
|
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
|
||||||
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
|
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
|
||||||
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
|
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
|
||||||
@@ -1365,6 +1386,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
|||||||
AuthenticatedRedemptionCodesIndexRoute,
|
AuthenticatedRedemptionCodesIndexRoute,
|
||||||
AuthenticatedResourcesIndexRoute: AuthenticatedResourcesIndexRoute,
|
AuthenticatedResourcesIndexRoute: AuthenticatedResourcesIndexRoute,
|
||||||
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
|
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
|
||||||
|
AuthenticatedTelemetryIndexRoute: AuthenticatedTelemetryIndexRoute,
|
||||||
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
|
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
|
||||||
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
|
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
|
||||||
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute,
|
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
|
import { ROLE } from '@/lib/roles'
|
||||||
|
import { Telemetry } from '@/features/telemetry'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/telemetry/')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
const { auth } = useAuthStore.getState()
|
||||||
|
if (!auth.user || auth.user.role < ROLE.ADMIN) {
|
||||||
|
throw redirect({ to: '/403' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Telemetry,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user