diff --git a/heicode/controller/admin_telemetry.go b/heicode/controller/admin_telemetry.go new file mode 100644 index 00000000..b3ac62b4 --- /dev/null +++ b/heicode/controller/admin_telemetry.go @@ -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, + }) +} diff --git a/heicode/model/telemetry_event.go b/heicode/model/telemetry_event.go index 65fac5ce..88b8a624 100644 --- a/heicode/model/telemetry_event.go +++ b/heicode/model/telemetry_event.go @@ -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 diff --git a/heicode/model/telemetry_query_test.go b/heicode/model/telemetry_query_test.go new file mode 100644 index 00000000..19455db4 --- /dev/null +++ b/heicode/model/telemetry_query_test.go @@ -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) +} diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 1949bff3..e0306e9d 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -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) diff --git a/heicode/web/default/src/components/layout/config/system-settings.config.ts b/heicode/web/default/src/components/layout/config/system-settings.config.ts index f64d4e86..8dbed9a7 100644 --- a/heicode/web/default/src/components/layout/config/system-settings.config.ts +++ b/heicode/web/default/src/components/layout/config/system-settings.config.ts @@ -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, + }, ], }, diff --git a/heicode/web/default/src/components/layout/lib/workspace-registry.ts b/heicode/web/default/src/components/layout/lib/workspace-registry.ts index ac3f76b1..89584a77 100644 --- a/heicode/web/default/src/components/layout/lib/workspace-registry.ts +++ b/heicode/web/default/src/components/layout/lib/workspace-registry.ts @@ -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) diff --git a/heicode/web/default/src/features/telemetry/api.ts b/heicode/web/default/src/features/telemetry/api.ts new file mode 100644 index 00000000..fed04bf2 --- /dev/null +++ b/heicode/web/default/src/features/telemetry/api.ts @@ -0,0 +1,37 @@ +import { api } from '@/lib/api' +import type { + ApiResponse, + TelemetryAggregateData, + TelemetryFilters, + TelemetryListData, +} from './types' + +function buildParams(params: Record): 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> { + 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> { + const query = buildParams({ dimension, limit, ...filters }) + const res = await api.get(`/api/telemetry/aggregate?${query}`) + return res.data +} diff --git a/heicode/web/default/src/features/telemetry/index.tsx b/heicode/web/default/src/features/telemetry/index.tsx new file mode 100644 index 00000000..bc31b84b --- /dev/null +++ b/heicode/web/default/src/features/telemetry/index.tsx @@ -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(EMPTY_FILTERS) + const [applied, setApplied] = useState(EMPTY_FILTERS) + const [dimension, setDimension] = useState('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) => + setDraft((d) => ({ ...d, [k]: e.target.value })) + + return ( + + {t('Client Telemetry')} + + {t( + 'Diagnostic error telemetry from clients. No user content — only categories, codes, hashes and versions. device_id is account-linkable.' + )} + + +
+ {/* Filters */} +
+ + + + + + +
+
+ + +
+ + {/* Aggregate */} + + + {t('Aggregate')} + + + + {aggLoading && buckets.length === 0 ? ( +

{t('Loading...')}

+ ) : buckets.length === 0 ? ( +

{t('No data')}

+ ) : ( +
+ {buckets.map((b) => ( +
+ + {b.key || '(empty)'} + +
+
+
+ {b.count} + + {b.users}u + +
+ ))} +
+ )} + + + + {/* Event list */} +
+ + + + {t('Time')} + {t('User ID')} + {t('Device ID')} + {t('Platform')} + {t('App version')} + {t('Error category')} + {t('Error code')} + {t('Stack hash')} + + + + {listLoading && events.length === 0 ? ( + + + {t('Loading...')} + + + ) : events.length === 0 ? ( + + + {t('No data')} + + + ) : ( + events.map((ev) => ( + + {fmtTime(ev.received_at)} + {ev.user_id} + {ev.client_id} + {ev.platform} + {ev.app_version} + {ev.error_category} + {ev.error_code} + {ev.stack_hash} + + )) + )} + +
+
+ + {/* Pagination */} +
+ + {t('Total')}: {total} + +
+ + {page} / {totalPages} + +
+
+
+ + + ) +} diff --git a/heicode/web/default/src/features/telemetry/types.ts b/heicode/web/default/src/features/telemetry/types.ts new file mode 100644 index 00000000..c8dd65fd --- /dev/null +++ b/heicode/web/default/src/features/telemetry/types.ts @@ -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 { + 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] diff --git a/heicode/web/default/src/hooks/use-sidebar-data.ts b/heicode/web/default/src/hooks/use-sidebar-data.ts index 3dae55df..69c4e02a 100644 --- a/heicode/web/default/src/hooks/use-sidebar-data.ts +++ b/heicode/web/default/src/hooks/use-sidebar-data.ts @@ -113,6 +113,7 @@ export function useSidebarData(): SidebarData { '/subscriptions', '/models', '/usage-logs', + '/telemetry', ], icon: Settings, }, diff --git a/heicode/web/default/src/routeTree.gen.ts b/heicode/web/default/src/routeTree.gen.ts index 2b577bcf..55d89f0e 100644 --- a/heicode/web/default/src/routeTree.gen.ts +++ b/heicode/web/default/src/routeTree.gen.ts @@ -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, diff --git a/heicode/web/default/src/routes/_authenticated/telemetry/index.tsx b/heicode/web/default/src/routes/_authenticated/telemetry/index.tsx new file mode 100644 index 00000000..69d50b38 --- /dev/null +++ b/heicode/web/default/src/routes/_authenticated/telemetry/index.tsx @@ -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, +})