feat:add otel logging
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
LayoutPanelTopIcon,
|
||||
PlusIcon,
|
||||
ScrollTextIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
TrendingUpDownIcon,
|
||||
@@ -61,6 +62,7 @@ export default function SidebarProjectMenu({
|
||||
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
|
||||
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
|
||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||
<SidebarLink href={'/logs'} icon={ScrollTextIcon} label="Logs" />
|
||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
|
||||
<SidebarLink href={'/groups'} icon={Building2Icon} label="Groups" />
|
||||
|
||||
@@ -47,6 +47,7 @@ import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/
|
||||
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
|
||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
|
||||
import { Route as AppOrganizationIdProjectIdLogsRouteImport } from './routes/_app.$organizationId.$projectId.logs'
|
||||
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups'
|
||||
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
||||
@@ -352,6 +353,12 @@ const AppOrganizationIdProjectIdPagesRoute =
|
||||
path: '/pages',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdLogsRoute =
|
||||
AppOrganizationIdProjectIdLogsRouteImport.update({
|
||||
id: '/logs',
|
||||
path: '/logs',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdInsightsRoute =
|
||||
AppOrganizationIdProjectIdInsightsRouteImport.update({
|
||||
id: '/insights',
|
||||
@@ -660,6 +667,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/logs': typeof AppOrganizationIdProjectIdLogsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
@@ -738,6 +746,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/logs': typeof AppOrganizationIdProjectIdLogsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
@@ -814,6 +823,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/_app/$organizationId/$projectId/logs': typeof AppOrganizationIdProjectIdLogsRoute
|
||||
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
@@ -905,6 +915,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/logs'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
| '/$organizationId/$projectId/references'
|
||||
@@ -983,6 +994,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/logs'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
| '/$organizationId/$projectId/references'
|
||||
@@ -1058,6 +1070,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/dashboards'
|
||||
| '/_app/$organizationId/$projectId/groups'
|
||||
| '/_app/$organizationId/$projectId/insights'
|
||||
| '/_app/$organizationId/$projectId/logs'
|
||||
| '/_app/$organizationId/$projectId/pages'
|
||||
| '/_app/$organizationId/$projectId/realtime'
|
||||
| '/_app/$organizationId/$projectId/references'
|
||||
@@ -1444,6 +1457,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdPagesRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/logs': {
|
||||
id: '/_app/$organizationId/$projectId/logs'
|
||||
path: '/logs'
|
||||
fullPath: '/$organizationId/$projectId/logs'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdLogsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/insights': {
|
||||
id: '/_app/$organizationId/$projectId/insights'
|
||||
path: '/insights'
|
||||
@@ -2028,6 +2048,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
AppOrganizationIdProjectIdLogsRoute: typeof AppOrganizationIdProjectIdLogsRoute
|
||||
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
|
||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
@@ -2054,6 +2075,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdGroupsRoute,
|
||||
AppOrganizationIdProjectIdInsightsRoute:
|
||||
AppOrganizationIdProjectIdInsightsRoute,
|
||||
AppOrganizationIdProjectIdLogsRoute: AppOrganizationIdProjectIdLogsRoute,
|
||||
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
|
||||
AppOrganizationIdProjectIdRealtimeRoute:
|
||||
AppOrganizationIdProjectIdRealtimeRoute,
|
||||
|
||||
385
apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx
Normal file
385
apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { format } from 'date-fns';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
AlertTriangleIcon,
|
||||
BugIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
InfoIcon,
|
||||
ScrollTextIcon,
|
||||
SearchIcon,
|
||||
SkullIcon,
|
||||
TerminalIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||
import type { ISeverityText } from '@openpanel/validation';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/logs'
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle(PAGE_TITLES.LOGS),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const SEVERITY_ICONS: Record<ISeverityText, typeof InfoIcon> = {
|
||||
trace: TerminalIcon,
|
||||
debug: BugIcon,
|
||||
info: InfoIcon,
|
||||
warn: AlertTriangleIcon,
|
||||
warning: AlertTriangleIcon,
|
||||
error: AlertCircleIcon,
|
||||
fatal: SkullIcon,
|
||||
critical: SkullIcon,
|
||||
};
|
||||
|
||||
const SEVERITY_COLORS: Record<ISeverityText, string> = {
|
||||
trace: 'text-gray-400',
|
||||
debug: 'text-blue-400',
|
||||
info: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
warning: 'text-yellow-400',
|
||||
error: 'text-red-400',
|
||||
fatal: 'text-red-600',
|
||||
critical: 'text-red-600',
|
||||
};
|
||||
|
||||
const SEVERITY_BG_COLORS: Record<ISeverityText, string> = {
|
||||
trace: 'bg-gray-500/10 hover:bg-gray-500/20',
|
||||
debug: 'bg-blue-500/10 hover:bg-blue-500/20',
|
||||
info: 'bg-green-500/10 hover:bg-green-500/20',
|
||||
warn: 'bg-yellow-500/10 hover:bg-yellow-500/20',
|
||||
warning: 'bg-yellow-500/10 hover:bg-yellow-500/20',
|
||||
error: 'bg-red-500/10 hover:bg-red-500/20',
|
||||
fatal: 'bg-red-600/10 hover:bg-red-600/20',
|
||||
critical: 'bg-red-600/10 hover:bg-red-600/20',
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { search, setSearch, debouncedSearch } = useSearchQueryState();
|
||||
const [selectedSeverity, setSelectedSeverity] = useState<ISeverityText[]>([]);
|
||||
const [expandedLog, setExpandedLog] = useState<string | null>(null);
|
||||
|
||||
const severityCountsQuery = useQuery(
|
||||
trpc.log.severityCounts.queryOptions({ projectId }, { enabled: !!projectId })
|
||||
);
|
||||
|
||||
const logsQuery = useInfiniteQuery(
|
||||
trpc.log.list.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
take: 50,
|
||||
search: debouncedSearch || undefined,
|
||||
severity: selectedSeverity.length > 0 ? selectedSeverity : undefined,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const logs = useMemo(() => {
|
||||
return logsQuery.data?.pages.flatMap((page) => page.data) ?? [];
|
||||
}, [logsQuery.data]);
|
||||
|
||||
const severityCounts = severityCountsQuery.data ?? {};
|
||||
const severityOptions: ISeverityText[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
|
||||
|
||||
const toggleSeverity = (severity: ISeverityText) => {
|
||||
setSelectedSeverity((prev) =>
|
||||
prev.includes(severity)
|
||||
? prev.filter((s) => s !== severity)
|
||||
: [...prev, severity]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
className="mb-8"
|
||||
description="View and search device and application logs"
|
||||
icon={ScrollTextIcon}
|
||||
title="Logs"
|
||||
/>
|
||||
|
||||
{/* Severity Filter Chips */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||
{severityOptions.map((severity) => {
|
||||
const count = severityCounts[severity] ?? 0;
|
||||
const isSelected = selectedSeverity.includes(severity);
|
||||
const Icon = SEVERITY_ICONS[severity];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={severity}
|
||||
className={`gap-2 capitalize ${
|
||||
isSelected ? 'ring-2 ring-primary ring-offset-2' : ''
|
||||
}`}
|
||||
onClick={() => toggleSeverity(severity)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${SEVERITY_COLORS[severity]}`} />
|
||||
<span className="capitalize">{severity}</span>
|
||||
{count > 0 && (
|
||||
<Badge className="ml-1" variant="secondary">
|
||||
{count.toLocaleString()}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedSeverity.length > 0 && (
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setSelectedSeverity([])}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-6">
|
||||
<SearchIcon className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logs List */}
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => {
|
||||
const isExpanded = expandedLog === log.id;
|
||||
const Icon = SEVERITY_ICONS[log.severityText as ISeverityText] ?? InfoIcon;
|
||||
const severityColor = SEVERITY_COLORS[log.severityText as ISeverityText] ?? 'text-gray-400';
|
||||
const bgColor = SEVERITY_BG_COLORS[log.severityText as ISeverityText] ?? 'bg-gray-500/10';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={log.id}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`rounded-lg border ${bgColor} transition-colors`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
layout
|
||||
>
|
||||
<button
|
||||
className="flex w-full items-start gap-3 p-4 text-left"
|
||||
onClick={() => setExpandedLog(isExpanded ? null : log.id)}
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<Icon className={`mt-1 h-4 w-4 shrink-0 ${severityColor}`} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-xs uppercase ${severityColor}`}>
|
||||
{log.severityText}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{format(new Date(log.timestamp), 'MMM d, HH:mm:ss.SSS')}
|
||||
</span>
|
||||
{log.loggerName && (
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{log.loggerName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 truncate font-mono text-sm">{log.body}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="border-t px-4 pb-4"
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
>
|
||||
<div className="space-y-4 pt-4">
|
||||
{/* Full Message */}
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-sm">Message</h4>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded bg-muted p-3 font-mono text-sm">
|
||||
{log.body}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
{Object.keys(log.attributes).length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-sm">Attributes</h4>
|
||||
<div className="grid gap-2">
|
||||
{Object.entries(log.attributes).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground text-sm">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono text-sm">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resource */}
|
||||
{Object.keys(log.resource).length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-sm">Resource</h4>
|
||||
<div className="grid gap-2">
|
||||
{Object.entries(log.resource).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground text-sm">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono text-sm">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trace Context */}
|
||||
{(log.traceId || log.spanId) && (
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-sm">Trace Context</h4>
|
||||
<div className="space-y-2">
|
||||
{log.traceId && (
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="font-mono text-muted-foreground text-sm">
|
||||
Trace ID
|
||||
</span>
|
||||
<span className="font-mono text-sm">{log.traceId}</span>
|
||||
</div>
|
||||
)}
|
||||
{log.spanId && (
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="font-mono text-muted-foreground text-sm">
|
||||
Span ID
|
||||
</span>
|
||||
<span className="font-mono text-sm">{log.spanId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Info */}
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-sm">Device</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="text-muted-foreground">Device ID</span>
|
||||
<span className="font-mono">{log.deviceId}</span>
|
||||
</div>
|
||||
{log.profileId && (
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="text-muted-foreground">Profile ID</span>
|
||||
<span className="font-mono">{log.profileId}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="text-muted-foreground">OS</span>
|
||||
<span>
|
||||
{log.os} {log.osVersion}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="text-muted-foreground">Browser</span>
|
||||
<span>
|
||||
{log.browser} {log.browserVersion}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="text-muted-foreground">Location</span>
|
||||
<span>
|
||||
{log.city}, {log.region}, {log.country}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SDK Info */}
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-sm">SDK</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="text-muted-foreground">Name</span>
|
||||
<span>{log.sdkName || 'unknown'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr,2fr] gap-4 rounded bg-muted p-2">
|
||||
<span className="text-muted-foreground">Version</span>
|
||||
<span>{log.sdkVersion || 'unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Observed At */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Observed at: {format(new Date(log.observedAt), 'MMM d, HH:mm:ss.SSS')}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
{logsQuery.hasNextPage && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
disabled={logsQuery.isFetchingNextPage}
|
||||
onClick={() => logsQuery.fetchNextPage()}
|
||||
variant="outline"
|
||||
>
|
||||
{logsQuery.isFetchingNextPage ? 'Loading...' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,8 @@ export const PAGE_TITLES = {
|
||||
PROFILE_DETAILS: 'Profile details',
|
||||
// Groups
|
||||
GROUPS: 'Groups',
|
||||
// Logs
|
||||
LOGS: 'Logs',
|
||||
GROUP_DETAILS: 'Group details',
|
||||
|
||||
// Sub-sections
|
||||
|
||||
Reference in New Issue
Block a user