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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelemetryEvent stores client error-telemetry (issue #24). It is deliberately
|
||||
// isolated from billing: ingest never writes a consume Log nor touches
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// (server unix seconds), enforcing the retention window (#32). Returns the
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type TFunction } from 'i18next'
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Boxes,
|
||||
Building2,
|
||||
@@ -84,6 +85,11 @@ export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
||||
url: '/usage-logs/common',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
title: t('Client Telemetry'),
|
||||
url: '/telemetry',
|
||||
icon: Activity,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const workspaceRegistry: WorkspaceConfig[] = [
|
||||
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
|
||||
name: 'System Settings',
|
||||
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,
|
||||
},
|
||||
// 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',
|
||||
'/models',
|
||||
'/usage-logs',
|
||||
'/telemetry',
|
||||
],
|
||||
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 AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/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 AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
|
||||
import { Route as AuthenticatedResourcesIndexRouteImport } from './routes/_authenticated/resources/index'
|
||||
@@ -202,6 +203,12 @@ const AuthenticatedUsageLogsIndexRoute =
|
||||
path: '/usage-logs/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedTelemetryIndexRoute =
|
||||
AuthenticatedTelemetryIndexRouteImport.update({
|
||||
id: '/telemetry/',
|
||||
path: '/telemetry/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSystemSettingsIndexRoute =
|
||||
AuthenticatedSystemSettingsIndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -452,6 +459,7 @@ export interface FileRoutesByFullPath {
|
||||
'/resources/': typeof AuthenticatedResourcesIndexRoute
|
||||
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
'/telemetry/': typeof AuthenticatedTelemetryIndexRoute
|
||||
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
||||
'/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/wallet/': typeof AuthenticatedWalletIndexRoute
|
||||
@@ -512,6 +520,7 @@ export interface FileRoutesByTo {
|
||||
'/resources': typeof AuthenticatedResourcesIndexRoute
|
||||
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
'/telemetry': typeof AuthenticatedTelemetryIndexRoute
|
||||
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
|
||||
'/users': typeof AuthenticatedUsersIndexRoute
|
||||
'/wallet': typeof AuthenticatedWalletIndexRoute
|
||||
@@ -576,6 +585,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/resources/': typeof AuthenticatedResourcesIndexRoute
|
||||
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
'/_authenticated/telemetry/': typeof AuthenticatedTelemetryIndexRoute
|
||||
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
|
||||
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/_authenticated/wallet/': typeof AuthenticatedWalletIndexRoute
|
||||
@@ -639,6 +649,7 @@ export interface FileRouteTypes {
|
||||
| '/resources/'
|
||||
| '/subscriptions/'
|
||||
| '/system-settings/'
|
||||
| '/telemetry/'
|
||||
| '/usage-logs/'
|
||||
| '/users/'
|
||||
| '/wallet/'
|
||||
@@ -699,6 +710,7 @@ export interface FileRouteTypes {
|
||||
| '/resources'
|
||||
| '/subscriptions'
|
||||
| '/system-settings'
|
||||
| '/telemetry'
|
||||
| '/usage-logs'
|
||||
| '/users'
|
||||
| '/wallet'
|
||||
@@ -762,6 +774,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/resources/'
|
||||
| '/_authenticated/subscriptions/'
|
||||
| '/_authenticated/system-settings/'
|
||||
| '/_authenticated/telemetry/'
|
||||
| '/_authenticated/usage-logs/'
|
||||
| '/_authenticated/users/'
|
||||
| '/_authenticated/wallet/'
|
||||
@@ -984,6 +997,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedUsageLogsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/telemetry/': {
|
||||
id: '/_authenticated/telemetry/'
|
||||
path: '/telemetry'
|
||||
fullPath: '/telemetry/'
|
||||
preLoaderRoute: typeof AuthenticatedTelemetryIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/system-settings/': {
|
||||
id: '/_authenticated/system-settings/'
|
||||
path: '/'
|
||||
@@ -1335,6 +1355,7 @@ interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
|
||||
AuthenticatedResourcesIndexRoute: typeof AuthenticatedResourcesIndexRoute
|
||||
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
|
||||
AuthenticatedTelemetryIndexRoute: typeof AuthenticatedTelemetryIndexRoute
|
||||
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
|
||||
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
|
||||
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
|
||||
@@ -1365,6 +1386,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedRedemptionCodesIndexRoute,
|
||||
AuthenticatedResourcesIndexRoute: AuthenticatedResourcesIndexRoute,
|
||||
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
|
||||
AuthenticatedTelemetryIndexRoute: AuthenticatedTelemetryIndexRoute,
|
||||
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
|
||||
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
|
||||
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