feat: add logs UI — tRPC router, page, and sidebar nav

- logRouter with list (infinite query, cursor-based) and severityCounts procedures
- /logs route page: severity filter chips with counts, search input, expandable log rows
- Log detail expansion shows attributes, resource, trace context, device/location
- Sidebar "Logs" link with ScrollText icon between Events and Sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 12:08:39 +02:00
parent 0672857974
commit eefbeac7f8
6 changed files with 558 additions and 0 deletions

View File

@@ -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" />

View File

@@ -0,0 +1,340 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import {
AlertCircleIcon,
AlertTriangleIcon,
BugIcon,
ChevronDownIcon,
ChevronRightIcon,
InfoIcon,
SearchIcon,
XCircleIcon,
} from 'lucide-react';
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
import { 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 { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
import type { IServiceLog } from '@openpanel/trpc';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/logs',
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle(PAGE_TITLES.LOGS) }],
}),
});
const SEVERITY_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
type SeverityLevel = (typeof SEVERITY_LEVELS)[number];
function getSeverityVariant(
severity: string,
): 'default' | 'secondary' | 'info' | 'warning' | 'destructive' | 'outline' {
switch (severity.toLowerCase()) {
case 'fatal':
case 'critical':
case 'error':
return 'destructive';
case 'warn':
case 'warning':
return 'warning';
case 'info':
return 'info';
default:
return 'outline';
}
}
function SeverityIcon({ severity }: { severity: string }) {
const s = severity.toLowerCase();
const cls = 'size-3.5 shrink-0';
if (s === 'fatal' || s === 'critical') return <XCircleIcon className={cn(cls, 'text-destructive')} />;
if (s === 'error') return <AlertCircleIcon className={cn(cls, 'text-destructive')} />;
if (s === 'warn' || s === 'warning') return <AlertTriangleIcon className={cn(cls, 'text-yellow-500')} />;
if (s === 'debug' || s === 'trace') return <BugIcon className={cn(cls, 'text-muted-foreground')} />;
return <InfoIcon className={cn(cls, 'text-blue-500')} />;
}
function LogRow({ log }: { log: IServiceLog }) {
const [expanded, setExpanded] = useState(false);
const hasDetails =
(log.attributes && Object.keys(log.attributes).length > 0) ||
(log.resource && Object.keys(log.resource).length > 0) ||
log.traceId ||
log.loggerName;
return (
<>
<button
type="button"
className={cn(
'grid w-full grid-cols-[28px_90px_80px_1fr_120px] items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors hover:bg-muted/50 border-b border-border/50',
expanded && 'bg-muted/30',
)}
onClick={() => hasDetails && setExpanded((v) => !v)}
>
<span className="flex items-center justify-center text-muted-foreground">
{hasDetails ? (
expanded ? (
<ChevronDownIcon className="size-3.5" />
) : (
<ChevronRightIcon className="size-3.5" />
)
) : null}
</span>
<span className="text-muted-foreground tabular-nums text-xs">
{log.timestamp.toLocaleTimeString()}
</span>
<span>
<Badge variant={getSeverityVariant(log.severityText)} className="gap-1 font-mono uppercase text-[10px]">
<SeverityIcon severity={log.severityText} />
{log.severityText}
</Badge>
</span>
<span className="truncate font-mono text-xs">{log.body}</span>
<span className="truncate text-xs text-muted-foreground text-right">
{log.loggerName || log.os || log.device || '—'}
</span>
</button>
{expanded && (
<div className="border-b border-border/50 bg-muted/20 px-4 py-3">
<div className="grid gap-4 pl-[calc(28px+0.75rem)] text-xs sm:grid-cols-2">
{log.loggerName && (
<MetaRow label="Logger" value={log.loggerName} />
)}
{log.traceId && (
<MetaRow label="Trace ID" value={log.traceId} mono />
)}
{log.spanId && (
<MetaRow label="Span ID" value={log.spanId} mono />
)}
{log.deviceId && (
<MetaRow label="Device ID" value={log.deviceId} mono />
)}
{log.os && (
<MetaRow label="OS" value={`${log.os} ${log.osVersion}`.trim()} />
)}
{log.country && (
<MetaRow label="Location" value={[log.city, log.country].filter(Boolean).join(', ')} />
)}
{Object.keys(log.attributes).length > 0 && (
<div className="col-span-2">
<p className="mb-1.5 font-semibold text-muted-foreground">Attributes</p>
<div className="rounded-md border bg-background p-3 font-mono space-y-1">
{Object.entries(log.attributes).map(([k, v]) => (
<div key={k} className="flex gap-2">
<span className="text-blue-500 shrink-0">{k}</span>
<span className="text-muted-foreground">=</span>
<span className="break-all">{v}</span>
</div>
))}
</div>
</div>
)}
{Object.keys(log.resource).length > 0 && (
<div className="col-span-2">
<p className="mb-1.5 font-semibold text-muted-foreground">Resource</p>
<div className="rounded-md border bg-background p-3 font-mono space-y-1">
{Object.entries(log.resource).map(([k, v]) => (
<div key={k} className="flex gap-2">
<span className="text-emerald-600 shrink-0">{k}</span>
<span className="text-muted-foreground">=</span>
<span className="break-all">{v}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</>
);
}
function MetaRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex gap-2">
<span className="w-24 shrink-0 font-medium text-muted-foreground">{label}</span>
<span className={cn('break-all', mono && 'font-mono')}>{value}</span>
</div>
);
}
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''));
const [severities, setSeverities] = useQueryState(
'severity',
parseAsArrayOf(parseAsString).withDefault([]),
);
const countsQuery = useQuery(
trpc.log.severityCounts.queryOptions({ projectId }),
);
const logsQuery = useInfiniteQuery(
trpc.log.list.infiniteQueryOptions(
{
projectId,
search: search || undefined,
severity: severities.length > 0 ? (severities as SeverityLevel[]) : undefined,
take: 50,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
const logs = logsQuery.data?.pages.flatMap((p) => p.data) ?? [];
const counts = countsQuery.data ?? {};
const toggleSeverity = (s: string) => {
setSeverities((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s],
);
};
return (
<PageContainer>
<PageHeader
className="mb-6"
title="Logs"
description="Captured device and application logs in OpenTelemetry format"
/>
{/* Severity filter chips */}
<div className="mb-4 flex flex-wrap items-center gap-2">
{SEVERITY_LEVELS.map((s) => {
const active = severities.includes(s);
const count = counts[s] ?? 0;
return (
<button
key={s}
type="button"
onClick={() => toggleSeverity(s)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
active
? 'border-foreground bg-foreground text-background'
: 'border-border bg-transparent text-muted-foreground hover:border-foreground/50 hover:text-foreground',
)}
>
<SeverityIcon severity={s} />
<span className="uppercase">{s}</span>
{count > 0 && (
<span className={cn('tabular-nums', active ? 'opacity-70' : 'opacity-50')}>
{count.toLocaleString()}
</span>
)}
</button>
);
})}
{severities.length > 0 && (
<button
type="button"
onClick={() => setSeverities([])}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Clear
</button>
)}
</div>
{/* Search */}
<div className="mb-4 relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search log messages…"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
{/* Log table */}
<div className="rounded-lg border bg-card overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[28px_90px_80px_1fr_120px] gap-3 border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground">
<span />
<span>Time</span>
<span>Level</span>
<span>Message</span>
<span className="text-right">Source</span>
</div>
{logsQuery.isPending && (
<div className="space-y-0 divide-y">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse bg-muted/30" />
))}
</div>
)}
{!logsQuery.isPending && logs.length === 0 && (
<div className="flex flex-col items-center gap-2 py-16 text-center text-muted-foreground">
<ScrollTextIcon className="size-10 opacity-30" />
<p className="text-sm font-medium">No logs found</p>
<p className="text-xs">
{search || severities.length > 0
? 'Try adjusting your filters'
: 'Logs will appear here once your app starts sending them'}
</p>
</div>
)}
{logs.map((log) => (
<LogRow key={log.id} log={log} />
))}
{logsQuery.hasNextPage && (
<div className="flex justify-center p-4">
<Button
variant="outline"
size="sm"
onClick={() => logsQuery.fetchNextPage()}
disabled={logsQuery.isFetchingNextPage}
>
{logsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
</Button>
</div>
)}
</div>
</PageContainer>
);
}
function ScrollTextIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4" />
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
<path d="M15 8h-5" />
<path d="M15 12h-5" />
</svg>
);
}

View File

@@ -88,6 +88,7 @@ export const PAGE_TITLES = {
MEMBERS: 'Members',
BILLING: 'Billing',
CHAT: 'AI Assistant',
LOGS: 'Logs',
REALTIME: 'Realtime',
REFERENCES: 'References',
INSIGHTS: 'Insights',

View File

@@ -1,3 +1,4 @@
export * from './src/root';
export * from './src/trpc';
export { getProjectAccess } from './src/access';
export type { IServiceLog } from './src/routers/log';

View File

@@ -8,6 +8,7 @@ import { eventRouter } from './routers/event';
import { groupRouter } from './routers/group';
import { gscRouter } from './routers/gsc';
import { importRouter } from './routers/import';
import { logRouter } from './routers/log';
import { insightRouter } from './routers/insight';
import { integrationRouter } from './routers/integration';
import { notificationRouter } from './routers/notification';
@@ -57,6 +58,7 @@ export const appRouter = createTRPCRouter({
email: emailRouter,
gsc: gscRouter,
group: groupRouter,
log: logRouter,
});
// export type definition of API

View File

@@ -0,0 +1,212 @@
import { chQuery, convertClickhouseDateToJs } from '@openpanel/db';
import { zSeverityText } from '@openpanel/validation';
import sqlstring from 'sqlstring';
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export interface IServiceLog {
id: string;
projectId: string;
deviceId: string;
profileId: string;
sessionId: string;
timestamp: Date;
severityNumber: number;
severityText: string;
body: string;
traceId: string;
spanId: string;
traceFlags: number;
loggerName: string;
attributes: Record<string, string>;
resource: Record<string, string>;
sdkName: string;
sdkVersion: string;
country: string;
city: string;
region: string;
os: string;
osVersion: string;
browser: string;
browserVersion: string;
device: string;
brand: string;
model: string;
}
interface IClickhouseLog {
id: string;
project_id: string;
device_id: string;
profile_id: string;
session_id: string;
timestamp: string;
severity_number: number;
severity_text: string;
body: string;
trace_id: string;
span_id: string;
trace_flags: number;
logger_name: string;
attributes: Record<string, string>;
resource: Record<string, string>;
sdk_name: string;
sdk_version: string;
country: string;
city: string;
region: string;
os: string;
os_version: string;
browser: string;
browser_version: string;
device: string;
brand: string;
model: string;
}
function toServiceLog(row: IClickhouseLog): IServiceLog {
return {
id: row.id,
projectId: row.project_id,
deviceId: row.device_id,
profileId: row.profile_id,
sessionId: row.session_id,
timestamp: convertClickhouseDateToJs(row.timestamp),
severityNumber: row.severity_number,
severityText: row.severity_text,
body: row.body,
traceId: row.trace_id,
spanId: row.span_id,
traceFlags: row.trace_flags,
loggerName: row.logger_name,
attributes: row.attributes,
resource: row.resource,
sdkName: row.sdk_name,
sdkVersion: row.sdk_version,
country: row.country,
city: row.city,
region: row.region,
os: row.os,
osVersion: row.os_version,
browser: row.browser,
browserVersion: row.browser_version,
device: row.device,
brand: row.brand,
model: row.model,
};
}
export const logRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
cursor: z.string().nullish(),
severity: z.array(zSeverityText).optional(),
search: z.string().optional(),
loggerName: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
take: z.number().default(50),
}),
)
.query(async ({ input }) => {
const { projectId, cursor, severity, search, loggerName, startDate, endDate, take } = input;
const conditions: string[] = [
`project_id = ${sqlstring.escape(projectId)}`,
];
if (cursor) {
conditions.push(`timestamp < ${sqlstring.escape(cursor)}`);
}
if (severity && severity.length > 0) {
const escaped = severity.map((s) => sqlstring.escape(s)).join(', ');
conditions.push(`severity_text IN (${escaped})`);
}
if (search) {
conditions.push(`body ILIKE ${sqlstring.escape(`%${search}%`)}`);
}
if (loggerName) {
conditions.push(`logger_name = ${sqlstring.escape(loggerName)}`);
}
if (startDate) {
conditions.push(`timestamp >= ${sqlstring.escape(startDate.toISOString())}`);
}
if (endDate) {
conditions.push(`timestamp <= ${sqlstring.escape(endDate.toISOString())}`);
}
const where = conditions.join(' AND ');
const rows = await chQuery<IClickhouseLog>(
`SELECT
id, project_id, device_id, profile_id, session_id,
timestamp, severity_number, severity_text, body,
trace_id, span_id, trace_flags, logger_name,
attributes, resource,
sdk_name, sdk_version,
country, city, region, os, os_version,
browser, browser_version, device, brand, model
FROM logs
WHERE ${where}
ORDER BY timestamp DESC
LIMIT ${take + 1}`,
);
const hasMore = rows.length > take;
const data = rows.slice(0, take).map(toServiceLog);
const lastItem = data[data.length - 1];
return {
data,
meta: {
next: hasMore && lastItem ? lastItem.timestamp.toISOString() : null,
},
};
}),
severityCounts: protectedProcedure
.input(
z.object({
projectId: z.string(),
startDate: z.date().optional(),
endDate: z.date().optional(),
}),
)
.query(async ({ input }) => {
const { projectId, startDate, endDate } = input;
const conditions: string[] = [
`project_id = ${sqlstring.escape(projectId)}`,
];
if (startDate) {
conditions.push(`timestamp >= ${sqlstring.escape(startDate.toISOString())}`);
}
if (endDate) {
conditions.push(`timestamp <= ${sqlstring.escape(endDate.toISOString())}`);
}
const where = conditions.join(' AND ');
const rows = await chQuery<{ severity_text: string; count: number }>(
`SELECT severity_text, count() AS count
FROM logs
WHERE ${where}
GROUP BY severity_text
ORDER BY count DESC`,
);
return rows.reduce<Record<string, number>>((acc, row) => {
acc[row.severity_text] = row.count;
return acc;
}, {});
}),
});