Dry up drawers (#27)

This commit is contained in:
Chris Estreich 2025-05-17 14:08:06 -07:00 committed by GitHub
parent d7a2997bac
commit 4a0697c09f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 195 additions and 174 deletions

View file

@ -5,7 +5,7 @@ import { eq, gte, and, desc } from 'drizzle-orm';
import { db } from '@/db';
import { auditLogs } from '@/db/schema';
import { logger } from '@/lib/server/logger';
import { AuditLogType } from '@/db/schema';
import { AuditLog } from '@/db/schema';
/**
* getAuditLogs
@ -20,7 +20,7 @@ export const getAuditLogs = async ({
orgId?: string | null;
limit?: number;
nRecentDays?: number;
}): Promise<AuditLogType[]> => {
}): Promise<AuditLog[]> => {
if (!orgId) {
return [];
}

View file

@ -4,7 +4,7 @@ import { useState } from 'react';
import { useOrganization } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import type { AuditLogType } from '@/db/schema';
import type { AuditLog } from '@/db/schema';
import { timePeriods, type TimePeriod } from '@/schemas';
import { getAuditLogs } from '@/actions/auditLogs';
import {
@ -14,20 +14,16 @@ import {
CardTitle,
CardDescription,
CardContent,
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
Skeleton,
} from '@/components/ui';
import { AuditLogDetails } from '@/components/audit-logs/AuditLogDetails';
import { AuditLogEntry } from '@/components/audit-logs/AuditLogEntry';
import { AuditLogEntry, AuditLogDrawer } from '@/components/audit-logs';
export const AuditLogs = () => {
const { organization } = useOrganization();
const [timePeriod, setTimePeriod] = useState<TimePeriod>(7);
const [selectedLog, setSelectedLog] = useState<AuditLogType | null>(null);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const { data: logs = [], isLoading } = useQuery({
const { data: logs = [], isPending } = useQuery({
queryKey: ['auditLogs', organization?.id, timePeriod],
queryFn: () =>
getAuditLogs({
@ -60,37 +56,33 @@ export const AuditLogs = () => {
</Button>
))}
</div>
<div className="space-y-1">
{isLoading ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
<div className="space-y-2">
{isPending ? (
<>
<Skeleton className="h-[64px] w-full" />
<Skeleton className="h-[64px] w-full" />
<Skeleton className="h-[64px] w-full" />
</>
) : logs.length > 0 ? (
logs.map((log: AuditLogType) => (
logs.map((log: AuditLog) => (
<AuditLogEntry
key={log.id}
log={log}
onClick={(log: AuditLogType) => setSelectedLog(log)}
onClick={(log: AuditLog) => setSelectedLog(log)}
/>
))
) : (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No activity found
</p>
<div className="text-center text-sm text-muted-foreground">
No activity found.
</div>
)}
</div>
</CardContent>
</Card>
<Drawer open={!!selectedLog} onClose={() => setSelectedLog(null)}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Activity Details</DrawerTitle>
</DrawerHeader>
{selectedLog && <AuditLogDetails log={selectedLog} />}
</DrawerContent>
</Drawer>
<AuditLogDrawer
selectedLog={selectedLog}
onClose={() => setSelectedLog(null)}
/>
</>
);
};

View file

@ -17,79 +17,72 @@ export const TaskDetails = ({
task ? (
<Drawer open={true} onOpenChange={onClose} direction="right">
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Task Details</DrawerTitle>
</DrawerHeader>
<div className="p-4 pb-0">
<div className="mb-6 space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Task ID:</span>
<span className="font-medium">{task.id}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Date:</span>
<span className="font-medium">
{task.date.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Developer:
</span>
<span className="font-medium">{task.developerName}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Model:</span>
<span className="font-medium">{task.modelName}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Tokens:</span>
<span className="font-medium">
{task.tokensConsumed.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Cost:</span>
<span className="font-medium">${task.cost.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Status:</span>
<span
className={
task.status === 'completed'
? 'font-medium text-green-500'
: task.status === 'started'
? 'font-medium text-blue-500'
: 'font-medium text-red-500'
}
>
{task.status.charAt(0).toUpperCase() + task.status.slice(1)}
</span>
</div>
<DrawerHeader>
<DrawerTitle>Task Details</DrawerTitle>
</DrawerHeader>
<div className="p-4">
<div className="mb-6 space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Task ID:</span>
<span className="font-medium">{task.id}</span>
</div>
<h3 className="mb-2 text-lg font-medium">Conversation</h3>
<div className="space-y-4">
{task.conversation.map((message) => (
<div
key={message.id}
className={`rounded-lg p-3 ${
message.role === 'user'
? 'ml-4 bg-primary/10'
: message.role === 'assistant'
? 'mr-4 bg-secondary/10'
: 'bg-muted'
}`}
>
<div className="mb-1 text-xs font-medium text-muted-foreground">
{message.role.charAt(0).toUpperCase() +
message.role.slice(1)}{' '}
{message.timestamp.toLocaleTimeString()}
</div>
<div className="text-sm">{message.content}</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Date:</span>
<span className="font-medium">{task.date.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Developer:</span>
<span className="font-medium">{task.developerName}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Model:</span>
<span className="font-medium">{task.modelName}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Tokens:</span>
<span className="font-medium">
{task.tokensConsumed.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Cost:</span>
<span className="font-medium">${task.cost.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Status:</span>
<span
className={
task.status === 'completed'
? 'font-medium text-green-500'
: task.status === 'started'
? 'font-medium text-blue-500'
: 'font-medium text-red-500'
}
>
{task.status.charAt(0).toUpperCase() + task.status.slice(1)}
</span>
</div>
</div>
<h3 className="mb-2 text-lg font-medium">Conversation</h3>
<div className="space-y-4">
{task.conversation.map((message) => (
<div
key={message.id}
className={`rounded-lg p-3 ${
message.role === 'user'
? 'ml-4 bg-primary/10'
: message.role === 'assistant'
? 'mr-4 bg-secondary/10'
: 'bg-muted'
}`}
>
<div className="mb-1 text-xs font-medium text-muted-foreground">
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}{' '}
{message.timestamp.toLocaleTimeString()}
</div>
))}
</div>
<div className="text-sm">{message.content}</div>
</div>
))}
</div>
</div>
</DrawerContent>

View file

@ -7,7 +7,7 @@ import { useOrganization } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import { ArrowRightIcon } from 'lucide-react';
import type { AuditLogType } from '@/db/schema';
import type { AuditLog } from '@/db/schema';
import { getAuditLogs } from '@/actions/auditLogs';
import {
Card,
@ -15,21 +15,20 @@ import {
CardTitle,
CardDescription,
CardContent,
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
Skeleton,
} from '@/components/ui';
import { Button as EnhancedButton } from '@/components/ui/ecosystem';
import { AuditLogDetails, AuditLogEntry } from '@/components/audit-logs';
import { AuditLogEntry } from '@/components/audit-logs';
import { AuditLogDrawer } from './AuditLogDrawer';
export function AuditLogCard() {
const [selectedLog, setSelectedLog] = useState<AuditLogType | null>(null);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const { organization } = useOrganization();
const path = usePathname();
const { data: logs = [], isLoading } = useQuery({
const { data: logs = [], isPending } = useQuery({
queryKey: ['auditLogs', organization?.id, 5],
queryFn: () => getAuditLogs({ orgId: organization?.id, limit: 5 }),
enabled: !!organization?.id,
@ -61,37 +60,33 @@ export function AuditLogCard() {
)}
</CardHeader>
<CardContent>
<div className="space-y-1">
{isLoading ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
<div className="space-y-2">
{isPending ? (
<>
<Skeleton className="h-[64px] w-full" />
<Skeleton className="h-[64px] w-full" />
<Skeleton className="h-[64px] w-full" />
</>
) : logs.length > 0 ? (
logs.map((log: AuditLogType) => (
logs.map((log: AuditLog) => (
<AuditLogEntry
key={log.id}
log={log}
onClick={(log: AuditLogType) => setSelectedLog(log)}
onClick={(log: AuditLog) => setSelectedLog(log)}
/>
))
) : (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No recent activity
</p>
<div className="text-center text-sm text-muted-foreground">
No recent activity.
</div>
)}
</div>
</CardContent>
</Card>
<Drawer open={!!selectedLog} onClose={() => setSelectedLog(null)}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Activity Details</DrawerTitle>
</DrawerHeader>
{selectedLog && <AuditLogDetails log={selectedLog} />}
</DrawerContent>
</Drawer>
<AuditLogDrawer
selectedLog={selectedLog}
onClose={() => setSelectedLog(null)}
/>
</>
);
}

View file

@ -3,10 +3,10 @@
import { ArrowRight, Calendar, Clock, User } from 'lucide-react';
import Link from 'next/link';
import type { AuditLogType } from '@/db/schema';
import type { AuditLog } from '@/db/schema';
type AuditLogDetailsProps = {
log: AuditLogType;
log: AuditLog;
};
const formatValue = (value: unknown) => {

View file

@ -0,0 +1,31 @@
import type { AuditLog } from '@/db/schema';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui';
import { AuditLogDetails } from '@/components/audit-logs';
type AuditLogDrawerProps = {
selectedLog: AuditLog | null;
onClose: () => void;
};
export const AuditLogDrawer = ({
selectedLog,
onClose,
}: AuditLogDrawerProps) => {
return (
<Drawer open={!!selectedLog} onClose={onClose} direction="right">
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Activity Details</DrawerTitle>
</DrawerHeader>
<div className="p-4">
{selectedLog && <AuditLogDetails log={selectedLog} />}
</div>
</DrawerContent>
</Drawer>
);
};

View file

@ -1,12 +1,11 @@
import { Settings, Sliders, Users } from 'lucide-react';
import { formatDistance } from 'date-fns';
import { Settings, Sliders, Users } from 'lucide-react';
import { type AuditLogType, AuditLogTargetType } from '@/db/schema';
import { cn } from '@/lib/utils';
import { type AuditLog, AuditLogTargetType } from '@/db/schema';
type AuditLogEntryProps = {
log: AuditLogType;
onClick: (log: AuditLogType) => void;
log: AuditLog;
onClick: (log: AuditLog) => void;
};
const getIconByType = (type: AuditLogTargetType) => {
@ -23,24 +22,21 @@ const getIconByType = (type: AuditLogTargetType) => {
};
export const AuditLogEntry = ({ log, onClick }: AuditLogEntryProps) => (
<button
<div
onClick={() => onClick(log)}
className={cn(
'w-full rounded-md p-3 text-left transition-colors',
'hover:bg-muted focus:bg-muted focus:outline-none',
)}
className="rounded-md p-3 transition-colors hover:bg-muted active:opacity-80 cursor-pointer"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">{getIconByType(log.targetType)}</div>
<div className="flex-1 space-y-1">
<p className="text-sm text-foreground">{log.description}</p>
<div className="flex items-center justify-between">
<div className="flex flex-row gap-2">
<div className="shrink-0 pt-1">{getIconByType(log.targetType)}</div>
<div className="flex flex-row justify-between items-center gap-2 flex-1">
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground">{log.description}</p>
<p className="text-xs text-muted-foreground">{log.userId}</p>
<p className="text-xs text-muted-foreground">
{formatDistance(log.createdAt, new Date(), { addSuffix: true })}
</p>
</div>
<p className="text-xs text-muted-foreground hidden sm:block">
{formatDistance(log.createdAt, new Date(), { addSuffix: true })}
</p>
</div>
</div>
</button>
</div>
);

View file

@ -1,3 +1,4 @@
export { AuditLogCard } from './AuditLogCard';
export { AuditLogDetails } from './AuditLogDetails';
export { AuditLogDrawer } from './AuditLogDrawer';
export { AuditLogEntry } from './AuditLogEntry';

View file

@ -22,9 +22,11 @@ export const Logo = ({
const { resolvedTheme } = useTheme();
const [fill, setFill] = useState('#000');
useEffect(() => {
setFill(resolvedTheme === 'dark' ? '#fff' : '#000');
}, [resolvedTheme]);
// Fixes hydration error.
useEffect(
() => setFill(resolvedTheme === 'dark' ? '#fff' : '#000'),
[resolvedTheme],
);
return (
<svg
@ -52,6 +54,7 @@ export const HoppingLogo = (props: LogoProps) => {
useEffect(() => {
const element = ref.current;
const isHopping =
element !== null && element.classList.contains('animate-hop');

View file

@ -1,5 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
@ -7,11 +8,18 @@ import { Switch } from '@/components/ui';
export function ThemeSwitcher() {
const { resolvedTheme, setTheme } = useTheme();
const [mode, setMode] = useState<'light' | 'dark'>('light');
// Fixes hydration error.
useEffect(
() => setMode(resolvedTheme === 'dark' ? 'dark' : 'light'),
[resolvedTheme],
);
return (
<div className="flex items-center gap-2">
<Switch
checked={resolvedTheme === 'dark'}
checked={mode === 'dark'}
onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
/>
<div className="relative h-[1.2rem] w-[1.2rem]">

View file

@ -4,7 +4,11 @@ function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
className={cn(
'bg-accent animate-pulse rounded-md',
'dark:bg-accent/25',
className,
)}
{...props}
/>
);

View file

@ -1,17 +1,17 @@
import { Skeleton } from '../ui';
import { formatNumber } from '@/lib/formatters';
import { Skeleton } from '@/components/ui';
type MetricProps = {
label: string;
value?: number | string;
isLoading: boolean;
isPending: boolean;
};
export const Metric = ({ label, value, isLoading }: MetricProps) => (
export const Metric = ({ label, value, isPending }: MetricProps) => (
<div className="flex flex-col gap-1 rounded-lg border border-secondary bg-background px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="font-mono font-semibold text-2xl h-8">
{isLoading ? (
{isPending ? (
<div>
<Skeleton className="h-6 w-12 my-1" />
</div>

View file

@ -33,7 +33,7 @@ export const UsageCard = () => {
const path = usePathname();
const usage = useQuery({
const { data: usage = {}, isPending } = useQuery({
queryKey: ['usage', orgId, timePeriod],
queryFn: () => getUsage({ orgId, timePeriod }),
});
@ -79,34 +79,30 @@ export const UsageCard = () => {
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<Metric
label={t('analytics_active_developers')}
value={usage.data?.[TelemetryEventName.LLM_COMPLETION]?.userCount}
isLoading={usage.isLoading}
value={usage[TelemetryEventName.TASK_CREATED]?.userCount}
isPending={isPending}
/>
<Metric
label={t('analytics_tasks_started')}
value={usage.data?.[TelemetryEventName.TASK_CREATED]?.eventCount}
isLoading={usage.isLoading}
value={usage[TelemetryEventName.TASK_CREATED]?.eventCount}
isPending={isPending}
/>
<Metric
label={t('analytics_tasks_completed')}
value={
usage.data?.[TelemetryEventName.TASK_COMPLETED]?.eventCount
}
isLoading={usage.isLoading}
value={usage[TelemetryEventName.TASK_COMPLETED]?.eventCount}
isPending={isPending}
/>
<Metric
label={t('analytics_tokens_consumed')}
value={
usage.data?.[TelemetryEventName.LLM_COMPLETION]?.inputTokens
}
isLoading={usage.isLoading}
value={usage[TelemetryEventName.LLM_COMPLETION]?.inputTokens}
isPending={isPending}
/>
<Metric
label={t('analytics_llm_costs')}
value={formatCurrency(
usage.data?.[TelemetryEventName.LLM_COMPLETION]?.cost,
usage[TelemetryEventName.LLM_COMPLETION]?.cost,
)}
isLoading={usage.isLoading}
isPending={isPending}
/>
</div>
</div>

View file

@ -26,4 +26,6 @@ export enum AuditLogTargetType {
MEMBER_CHANGE = 3, // TODO: Currently no logs of this type are collected.
}
export type AuditLogType = typeof auditLogs.$inferSelect;
export type AuditLog = typeof auditLogs.$inferSelect & {
targetType: AuditLogTargetType;
};