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:
@@ -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" />
|
||||
|
||||
340
apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx
Normal file
340
apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export const PAGE_TITLES = {
|
||||
MEMBERS: 'Members',
|
||||
BILLING: 'Billing',
|
||||
CHAT: 'AI Assistant',
|
||||
LOGS: 'Logs',
|
||||
REALTIME: 'Realtime',
|
||||
REFERENCES: 'References',
|
||||
INSIGHTS: 'Insights',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './src/root';
|
||||
export * from './src/trpc';
|
||||
export { getProjectAccess } from './src/access';
|
||||
export type { IServiceLog } from './src/routers/log';
|
||||
|
||||
@@ -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
|
||||
|
||||
212
packages/trpc/src/routers/log.ts
Normal file
212
packages/trpc/src/routers/log.ts
Normal 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;
|
||||
}, {});
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user