Merge remote-tracking branch 'origin/feat/admin-telemetry-views' into deploy/test-pr69-73

This commit is contained in:
2026-06-11 19:19:36 +08:00
12 changed files with 655 additions and 1 deletions
+64
View File
@@ -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,
})
}
+122
View File
@@ -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
+90
View File
@@ -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)
}
+9
View File
@@ -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)
+37
View File
@@ -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
}
+218
View File
@@ -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>
)
}
+71
View File
@@ -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]
+1
View File
@@ -113,6 +113,7 @@ export function useSidebarData(): SidebarData {
'/subscriptions', '/subscriptions',
'/models', '/models',
'/usage-logs', '/usage-logs',
'/telemetry',
], ],
icon: Settings, icon: Settings,
}, },
+22
View File
@@ -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,
})