feat(dashboard): update UI for execution dashboard, fix next-runtime-env issues for SSO (#1649)

This commit is contained in:
Waleed
2025-10-15 19:29:10 -07:00
committed by GitHub
parent 701bf2b510
commit 48eab7e744
16 changed files with 1860 additions and 915 deletions
@@ -2,7 +2,7 @@
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { env, isTruthy } from '@/lib/env'
import { getEnv, isTruthy } from '@/lib/env'
import { cn } from '@/lib/utils'
interface SSOLoginButtonProps {
@@ -24,7 +24,7 @@ export function SSOLoginButton({
}: SSOLoginButtonProps) {
const router = useRouter()
if (!isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)) {
if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
return null
}
+5 -5
View File
@@ -16,7 +16,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client } from '@/lib/auth-client'
import { quickValidateEmail } from '@/lib/email/validation'
import { env, isFalsy, isTruthy } from '@/lib/env'
import { getEnv, isFalsy, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
@@ -368,8 +368,8 @@ export default function LoginPage({
}
}
const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)
const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED)
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showTopSSO = hasOnlySSO
@@ -399,7 +399,7 @@ export default function LoginPage({
)}
{/* Email/Password Form - show unless explicitly disabled */}
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
<div className='space-y-6'>
<div className='space-y-2'>
@@ -522,7 +522,7 @@ export default function LoginPage({
)}
{/* Only show signup link if email/password signup is enabled */}
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<span className='font-normal'>Don't have an account? </span>
<Link
+10 -10
View File
@@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client, useSession } from '@/lib/auth-client'
import { quickValidateEmail } from '@/lib/email/validation'
import { env, isFalsy, isTruthy } from '@/lib/env'
import { getEnv, isFalsy, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
@@ -383,8 +383,8 @@ function SignupFormContent({
{/* SSO Login Button (primary top-only when it is the only method) */}
{(() => {
const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)
const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED)
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
return hasOnlySSO
@@ -399,7 +399,7 @@ function SignupFormContent({
)}
{/* Email/Password Form - show unless explicitly disabled */}
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
<div className='space-y-6'>
<div className='space-y-2'>
@@ -516,8 +516,8 @@ function SignupFormContent({
{/* Divider - show when we have multiple auth methods */}
{(() => {
const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)
const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED)
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
@@ -535,8 +535,8 @@ function SignupFormContent({
)}
{(() => {
const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)
const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED)
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
@@ -545,7 +545,7 @@ function SignupFormContent({
<div
className={cn(
inter.className,
isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) ? 'mt-8' : undefined
isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) ? 'mt-8' : undefined
)}
>
<SocialLoginButtons
@@ -554,7 +554,7 @@ function SignupFormContent({
callbackURL={redirectUrl || '/workspace'}
isProduction={isProduction}
>
{isTruthy(env.NEXT_PUBLIC_SSO_ENABLED) && (
{isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) && (
<SSOLoginButton
callbackURL={redirectUrl || '/workspace'}
variant='outline'
+2 -4
View File
@@ -1,13 +1,11 @@
import { redirect } from 'next/navigation'
import { env, isTruthy } from '@/lib/env'
import { getEnv, isTruthy } from '@/lib/env'
import SSOForm from './sso-form'
// Force dynamic rendering to avoid prerender errors with search params
export const dynamic = 'force-dynamic'
export default async function SSOPage() {
// Redirect if SSO is not enabled
if (!isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)) {
if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
redirect('/login')
}
@@ -0,0 +1,185 @@
import type { ReactNode } from 'react'
import { Loader2, Play, RefreshCw, Search, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
export function Controls({
searchQuery,
setSearchQuery,
isRefetching,
resetToNow,
live,
setLive,
viewMode,
setViewMode,
searchComponent,
showExport = true,
onExport,
}: {
searchQuery?: string
setSearchQuery?: (v: string) => void
isRefetching: boolean
resetToNow: () => void
live: boolean
setLive: (v: (prev: boolean) => boolean) => void
viewMode: string
setViewMode: (mode: 'logs' | 'dashboard') => void
searchComponent?: ReactNode
showExport?: boolean
onExport?: () => void
}) {
return (
<div className='mb-8 flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start'>
{searchComponent ? (
searchComponent
) : (
<div className='relative w-full max-w-md'>
<Search className='-translate-y-1/2 absolute top-1/2 left-3 h-[18px] w-[18px] text-muted-foreground' />
<Input
type='text'
placeholder='Search workflows...'
value={searchQuery}
onChange={(e) => setSearchQuery?.(e.target.value)}
className='h-9 w-full rounded-[11px] border-[#E5E5E5] bg-[#FFFFFF] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
/>
{searchQuery && (
<button
onClick={() => setSearchQuery?.('')}
className='-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground hover:text-foreground'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
>
<path d='M12 4L4 12M4 4l8 8' />
</svg>
</button>
)}
</div>
)}
<div className='ml-auto flex flex-shrink-0 items-center gap-3'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={resetToNow}
className='h-9 rounded-[11px] hover:bg-secondary'
disabled={isRefetching}
>
{isRefetching ? (
<Loader2 className='h-5 w-5 animate-spin' />
) : (
<RefreshCw className='h-5 w-5' />
)}
<span className='sr-only'>Refresh</span>
</Button>
</TooltipTrigger>
<TooltipContent>{isRefetching ? 'Refreshing...' : 'Refresh'}</TooltipContent>
</Tooltip>
{showExport && viewMode !== 'dashboard' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={onExport}
className='h-9 rounded-[11px] hover:bg-secondary'
aria-label='Export CSV'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='h-5 w-5'
>
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<polyline points='7 10 12 15 17 10' />
<line x1='12' y1='15' x2='12' y2='3' />
</svg>
<span className='sr-only'>Export CSV</span>
</Button>
</TooltipTrigger>
<TooltipContent>Export CSV</TooltipContent>
</Tooltip>
)}
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
variant='ghost'
size='sm'
onClick={() => setLive((v) => !v)}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
live ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={live}
>
{live ? (
<>
<Square className='mr-1.5 h-3 w-3 fill-current' />
Live
</>
) : (
<>
<Play className='mr-1.5 h-3 w-3' />
Live
</>
)}
</Button>
</div>
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
variant='ghost'
size='sm'
onClick={() => setViewMode('logs')}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
(viewMode as string) !== 'dashboard'
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={(viewMode as string) !== 'dashboard'}
>
Logs
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setViewMode('dashboard')}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
(viewMode as string) === 'dashboard'
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={(viewMode as string) === 'dashboard'}
>
Dashboard
</Button>
</div>
</div>
<div className='sm:hidden'>
<TooltipProvider>
<Timeline />
</TooltipProvider>
</div>
</div>
)
}
export default Controls
@@ -0,0 +1,38 @@
export interface AggregateMetrics {
totalExecutions: number
successfulExecutions: number
failedExecutions: number
activeWorkflows: number
successRate: number
}
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
return (
<div className='mb-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Total executions</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>
{aggregate.totalExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Success rate</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>
{aggregate.successRate.toFixed(1)}%
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Failed executions</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>
{aggregate.failedExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Active workflows</div>
<div className='mt-1 font-semibold text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
</div>
</div>
)
}
export default KPIs
@@ -0,0 +1,308 @@
import { useEffect, useRef, useState } from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
export interface LineChartPoint {
timestamp: string
value: number
}
export function LineChart({
data,
label,
color,
unit,
}: {
data: LineChartPoint[]
label: string
color: string
unit?: string
}) {
// Responsive sizing: chart fills its container width
const containerRef = useRef<HTMLDivElement | null>(null)
const [containerWidth, setContainerWidth] = useState<number>(420)
const width = containerWidth
const height = 176
// Add a touch more space below the axis so curves never visually clip it
const padding = { top: 18, right: 18, bottom: 32, left: 42 }
// Observe container width for responsiveness
useEffect(() => {
if (!containerRef.current) return
const element = containerRef.current
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry?.contentRect) {
const w = Math.max(280, Math.floor(entry.contentRect.width))
setContainerWidth(w)
}
})
ro.observe(element)
// Initialize once immediately
const rect = element.getBoundingClientRect()
if (rect?.width) setContainerWidth(Math.max(280, Math.floor(rect.width)))
return () => ro.disconnect()
}, [])
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [isDark, setIsDark] = useState<boolean>(true)
useEffect(() => {
if (typeof window === 'undefined') return
const el = document.documentElement
const update = () => setIsDark(el.classList.contains('dark'))
update()
const observer = new MutationObserver(update)
observer.observe(el, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
if (data.length === 0) {
return (
<div
className='flex items-center justify-center rounded-lg border bg-card p-4'
style={{ width, height }}
>
<p className='text-muted-foreground text-sm'>No data</p>
</div>
)
}
// Ensure nice padding on the y-domain so the line never hugs the axes
const rawMax = Math.max(...data.map((d) => d.value), 1)
const rawMin = Math.min(...data.map((d) => d.value), 0)
const paddedMax = rawMax === 0 ? 1 : rawMax * 1.1
const paddedMin = Math.min(0, rawMin) // never below zero for our metrics
const maxValue = Math.ceil(paddedMax)
const minValue = Math.floor(paddedMin)
const valueRange = maxValue - minValue || 1
const yMin = padding.top + 3
const yMax = padding.top + chartHeight - 3
const scaledPoints = data.map((d, i) => {
const x = padding.left + (i / (data.length - 1 || 1)) * chartWidth
const rawY = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight
// keep the line safely within the plotting area to avoid clipping behind the x-axis
const y = Math.max(yMin, Math.min(yMax, rawY))
return { x, y }
})
const pathD = (() => {
if (scaledPoints.length <= 1) return ''
const p = scaledPoints
const tension = 0.2
let d = `M ${p[0].x} ${p[0].y}`
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i]
const p1 = p[i]
const p2 = p[i + 1]
const p3 = p[i + 2] || p[i + 1]
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
// Clamp control points vertically to avoid bezier overshoot below the axis
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
}
return d
})()
return (
<div ref={containerRef} className='w-full rounded-[11px] border bg-card p-4 shadow-sm'>
<h4 className='mb-3 font-medium text-foreground text-sm'>{label}</h4>
<TooltipProvider delayDuration={0}>
<div className='relative' style={{ width, height }}>
<svg
width={width}
height={height}
className='overflow-visible'
onMouseMove={(e) => {
if (scaledPoints.length === 0) return
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect()
const x = e.clientX - rect.left
const clamped = Math.max(padding.left, Math.min(width - padding.right, x))
const ratio = (clamped - padding.left) / (chartWidth || 1)
const i = Math.round(ratio * (scaledPoints.length - 1))
setHoverIndex(i)
}}
onMouseLeave={() => setHoverIndex(null)}
>
<defs>
<linearGradient id={`area-${label.replace(/\s+/g, '-')}`} x1='0' x2='0' y1='0' y2='1'>
<stop offset='0%' stopColor={color} stopOpacity={isDark ? 0.25 : 0.45} />
<stop offset='100%' stopColor={color} stopOpacity={isDark ? 0.03 : 0.08} />
</linearGradient>
<clipPath id={`clip-${label.replace(/\s+/g, '-')}`}>
<rect
x={padding.left}
y={yMin}
width={chartWidth}
height={chartHeight - (yMin - padding.top) * 2}
rx='2'
/>
</clipPath>
</defs>
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
strokeWidth='1'
/>
{[0.25, 0.5, 0.75].map((p) => (
<line
key={p}
x1={padding.left}
y1={padding.top + chartHeight * p}
x2={width - padding.right}
y2={padding.top + chartHeight * p}
stroke='hsl(var(--muted))'
strokeOpacity='0.35'
strokeWidth='1'
/>
))}
{/* axis baseline is drawn last (after line) to visually mask any overshoot */}
{scaledPoints.length > 1 && (
<path
d={`${pathD} L ${scaledPoints[scaledPoints.length - 1].x} ${height - padding.bottom} L ${scaledPoints[0].x} ${height - padding.bottom} Z`}
fill={`url(#area-${label.replace(/\s+/g, '-')})`}
stroke='none'
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
/>
)}
{scaledPoints.length > 1 ? (
<path
d={pathD}
fill='none'
stroke={color}
strokeWidth={isDark ? 1.75 : 2.25}
strokeLinecap='round'
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
style={{ mixBlendMode: isDark ? 'screen' : 'normal' }}
/>
) : (
// Single-point series: show a dot so the value doesn't "disappear"
<circle cx={scaledPoints[0].x} cy={scaledPoints[0].y} r='3' fill={color} />
)}
{hoverIndex !== null && scaledPoints[hoverIndex] && scaledPoints.length > 1 && (
<g pointerEvents='none' clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}>
<line
x1={scaledPoints[hoverIndex].x}
y1={padding.top}
x2={scaledPoints[hoverIndex].x}
y2={height - padding.bottom}
stroke={color}
strokeOpacity='0.35'
strokeDasharray='3 3'
/>
<circle
cx={scaledPoints[hoverIndex].x}
cy={scaledPoints[hoverIndex].y}
r='3'
fill={color}
/>
</g>
)}
{(() => {
if (data.length < 2) return null
const idx = [0, Math.floor(data.length / 2), data.length - 1]
return idx.map((i) => {
const x = padding.left + (i / (data.length - 1 || 1)) * chartWidth
const tsSource = data[i]?.timestamp
if (!tsSource) return null
const ts = new Date(tsSource)
const labelStr = Number.isNaN(ts.getTime())
? ''
: ts.toLocaleString('en-US', { month: 'short', day: 'numeric' })
return (
<text
key={i}
x={x}
y={height - padding.bottom + 14}
fontSize='10'
textAnchor='middle'
fill='hsl(var(--muted-foreground))'
>
{labelStr}
</text>
)
})
})()}
<text
x={padding.left - 10}
y={padding.top}
textAnchor='end'
fontSize='10'
fill='hsl(var(--muted-foreground))'
>
{maxValue}
{unit}
</text>
<text
x={padding.left - 10}
y={height - padding.bottom}
textAnchor='end'
fontSize='10'
fill='hsl(var(--muted-foreground))'
>
{minValue}
{unit}
</text>
<line
x1={padding.left}
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
strokeWidth='1'
/>
</svg>
{hoverIndex !== null &&
scaledPoints[hoverIndex] &&
(() => {
const pt = scaledPoints[hoverIndex]
const val = data[hoverIndex]?.value
let formatted = ''
if (typeof val === 'number' && Number.isFinite(val)) {
const u = unit || ''
if (u.includes('%')) {
formatted = `${val.toFixed(1)}%`
} else if (u.toLowerCase().includes('ms')) {
formatted = `${Math.round(val)}ms`
} else if (u.toLowerCase().includes('exec')) {
formatted = `${Math.round(val)}${u}` // keep label like " execs"
} else {
formatted = `${Math.round(val)}${u}`
}
}
const left = Math.min(Math.max(pt.x + 8, padding.left), width - padding.right - 60)
const top = Math.min(Math.max(pt.y - 26, padding.top), height - padding.bottom - 18)
return (
<div
className='pointer-events-none absolute rounded-md bg-background/80 px-2 py-1 font-medium text-[11px] shadow-sm ring-1 ring-border backdrop-blur'
style={{ left, top }}
>
{formatted}
</div>
)
})()}
</div>
</TooltipProvider>
</div>
)
}
export default LineChart
@@ -0,0 +1,142 @@
import type React from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export interface StatusBarSegment {
successRate: number
hasExecutions: boolean
totalExecutions: number
successfulExecutions: number
timestamp: string
}
export function StatusBar({
segments,
selectedSegmentIndices,
onSegmentClick,
workflowId,
segmentDurationMs,
}: {
segments: StatusBarSegment[]
selectedSegmentIndices: number[] | null
onSegmentClick: (
workflowId: string,
index: number,
timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => void
workflowId: string
segmentDurationMs: number
}) {
return (
<TooltipProvider delayDuration={0}>
<div className='flex select-none items-stretch gap-[2px]'>
{segments.map((segment, i) => {
let color: string
let tooltipContent: React.ReactNode
const isSelected = Array.isArray(selectedSegmentIndices)
? selectedSegmentIndices.includes(i)
: false
if (!segment.hasExecutions) {
color = 'bg-gray-300/60 dark:bg-gray-500/40'
} else {
if (segment.successRate === 100) {
color = 'bg-emerald-400/90'
} else if (segment.successRate >= 95) {
color = 'bg-amber-400/90'
} else {
color = 'bg-red-400/90'
}
const start = new Date(segment.timestamp)
const end = new Date(start.getTime() + (segmentDurationMs || 0))
const rangeLabel = Number.isNaN(start.getTime())
? ''
: `${start.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' })} – ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
tooltipContent = (
<div className='text-center'>
<div className='font-semibold'>{segment.successRate.toFixed(1)}%</div>
<div className='mt-1 text-xs'>
{segment.successfulExecutions ?? 0}/{segment.totalExecutions ?? 0} succeeded
</div>
{rangeLabel && (
<div className='mt-1 text-[11px] text-muted-foreground'>{rangeLabel}</div>
)}
</div>
)
}
// For empty segments: show a minimal tooltip with just the time range
if (!segment.hasExecutions) {
const start = new Date(segment.timestamp)
const end = new Date(start.getTime() + (segmentDurationMs || 0))
const rangeLabel = Number.isNaN(start.getTime())
? ''
: `${start.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' })} – ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}`
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className={`h-6 flex-1 rounded-[3px] ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${
isSelected
? 'relative z-10 ring-2 ring-primary ring-offset-1'
: 'relative z-0'
}`}
aria-label={`Segment ${i + 1}`}
onClick={(e) => {
e.stopPropagation()
const mode = e.shiftKey
? 'range'
: e.metaKey || e.ctrlKey
? 'toggle'
: 'single'
onSegmentClick(workflowId, i, segment.timestamp, mode)
}}
onMouseDown={(e) => {
// Avoid selecting surrounding text when shift-clicking
e.preventDefault()
}}
/>
</TooltipTrigger>
<TooltipContent side='top' className='select-none px-3 py-2'>
{rangeLabel && (
<div className='text-[11px] text-muted-foreground'>{rangeLabel}</div>
)}
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className={`h-6 flex-1 rounded-[3px] ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${
isSelected ? 'relative z-10 ring-2 ring-primary ring-offset-1' : 'relative z-0'
}`}
aria-label={`Segment ${i + 1}`}
onMouseDown={(e) => {
// Avoid selecting surrounding text when shift-clicking
e.preventDefault()
}}
onClick={(e) => {
e.stopPropagation()
const mode = e.shiftKey ? 'range' : e.metaKey || e.ctrlKey ? 'toggle' : 'single'
onSegmentClick(workflowId, i, segment.timestamp, mode)
}}
/>
</TooltipTrigger>
<TooltipContent side='top' className='select-none px-3 py-2'>
{tooltipContent}
</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
)
}
export default StatusBar
@@ -0,0 +1,17 @@
export const getTriggerColor = (trigger: string | null | undefined): string => {
if (!trigger) return '#9ca3af'
switch (trigger.toLowerCase()) {
case 'manual':
return '#9ca3af'
case 'schedule':
return '#10b981'
case 'webhook':
return '#f97316'
case 'chat':
return '#8b5cf6'
case 'api':
return '#3b82f6'
default:
return '#9ca3af'
}
}
@@ -0,0 +1,378 @@
import { useMemo, useState } from 'react'
import { Info, Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import LineChart, {
type LineChartPoint,
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/line-chart'
import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface ExecutionLogItem {
id: string
executionId: string
startedAt: string
level: string
trigger: string
triggerUserId: string | null
triggerInputs: any
outputs: any
errorMessage: string | null
duration: number | null
cost: {
input: number
output: number
total: number
} | null
workflowName?: string
workflowColor?: string
}
export interface WorkflowDetailsData {
errorRates: LineChartPoint[]
durations?: LineChartPoint[]
executionCounts: LineChartPoint[]
logs: ExecutionLogItem[]
allLogs: ExecutionLogItem[]
}
export function WorkflowDetails({
workspaceId,
expandedWorkflowId,
workflowName,
overview,
details,
selectedSegmentIndex,
selectedSegment,
clearSegmentSelection,
formatCost,
}: {
workspaceId: string
expandedWorkflowId: string
workflowName: string
overview: { total: number; success: number; failures: number; rate: number }
details: WorkflowDetailsData | undefined
selectedSegmentIndex: number[] | null
selectedSegment: { timestamp: string; totalExecutions: number } | null
clearSegmentSelection: () => void
formatCost: (n: number) => string
}) {
const router = useRouter()
const { workflows } = useWorkflowRegistry()
const workflowColor = useMemo(
() => workflows[expandedWorkflowId]?.color || '#3972F6',
[workflows, expandedWorkflowId]
)
const [expandedRowId, setExpandedRowId] = useState<string | null>(null)
return (
<div className='mt-5 overflow-hidden rounded-[11px] border bg-card shadow-sm'>
<div className='border-b bg-muted/30 px-4 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<button
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
className='group inline-flex items-center gap-2 text-left'
>
<span
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflowColor }}
/>
<span className='font-semibold text-sm tracking-tight group-hover:text-primary'>
{workflowName}
</span>
</button>
</div>
<div className='flex items-center gap-2'>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Executions</span>
<span className='font-semibold text-sm leading-none'>{overview.total}</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Success</span>
<span className='font-semibold text-sm leading-none'>
{overview.rate.toFixed(1)}%
</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Failures</span>
<span className='font-semibold text-sm leading-none'>{overview.failures}</span>
</div>
</div>
</div>
</div>
<div className='p-4'>
{details ? (
<>
{Array.isArray(selectedSegmentIndex) &&
selectedSegmentIndex.length > 0 &&
selectedSegment &&
(() => {
const tsObj = selectedSegment?.timestamp
? new Date(selectedSegment.timestamp)
: null
const tsLabel =
tsObj && !Number.isNaN(tsObj.getTime())
? tsObj.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'Selected segment'
return (
<div className='mb-4 flex items-center justify-between rounded-lg border border-primary/30 bg-primary/10 px-4 py-2.5 text-foreground text-sm'>
<div className='flex items-center gap-2'>
<div className='h-2 w-2 animate-pulse rounded-full bg-primary ring-2 ring-primary/40' />
<span className='font-medium'>
Filtered to {tsLabel}
{selectedSegmentIndex.length > 1
? ` (+${selectedSegmentIndex.length - 1} more segment${selectedSegmentIndex.length - 1 > 1 ? 's' : ''})`
: ''}
— {selectedSegment.totalExecutions} execution
{selectedSegment.totalExecutions !== 1 ? 's' : ''}
</span>
</div>
<button
onClick={clearSegmentSelection}
className='rounded px-2 py-1 text-foreground text-xs hover:bg-primary/20 focus:outline-none focus:ring-2 focus:ring-primary/50'
>
Clear filter
</button>
</div>
)
})()}
{(() => {
const hasDuration = Array.isArray(details.durations) && details.durations.length > 0
const gridCols = hasDuration
? 'md:grid-cols-2 xl:grid-cols-4'
: 'md:grid-cols-2 xl:grid-cols-3'
return (
<div className={`mb-4 grid grid-cols-1 gap-4 ${gridCols}`}>
<LineChart
data={details.errorRates}
label='Error Rate'
color='#ef4444'
unit='%'
/>
{hasDuration && (
<LineChart
data={details.durations!}
label='Workflow Duration'
color='#3b82f6'
unit='ms'
/>
)}
<LineChart
data={details.executionCounts}
label='Usage'
color='#10b981'
unit=' execs'
/>
{(() => {
const failures = details.errorRates.map((e, i) => ({
timestamp: e.timestamp,
value: ((e.value || 0) / 100) * (details.executionCounts[i]?.value || 0),
}))
return <LineChart data={failures} label='Failures' color='#f59e0b' unit='' />
})()}
</div>
)
})()}
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='w-full overflow-x-auto'>
<div>
<div className='border-border border-b'>
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4'>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Status
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Trigger
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Cost
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Workflow
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Output
</div>
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Duration
</div>
</div>
</div>
</div>
</div>
<div className='flex-1 overflow-auto' style={{ maxHeight: '400px' }}>
<div className='pb-4'>
{(() => {
const logsToDisplay = details.logs
if (logsToDisplay.length === 0) {
return (
<div className='flex h-full items-center justify-center py-8'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Info className='h-5 w-5' />
<span className='text-sm'>
No executions found in this time segment
</span>
</div>
</div>
)
}
return logsToDisplay.map((log) => {
const logDate = log?.startedAt ? new Date(log.startedAt) : null
const formattedDate =
logDate && !Number.isNaN(logDate.getTime())
? formatDate(logDate.toISOString())
: ({ compactDate: '—', compactTime: '' } as any)
const outputsStr = log.outputs ? JSON.stringify(log.outputs) : '—'
const errorStr = log.errorMessage || ''
const isExpanded = expandedRowId === log.id
return (
<div
key={log.id}
className={cn(
'cursor-pointer border-border border-b transition-all duration-200',
isExpanded ? 'bg-accent/30' : 'hover:bg-accent/20'
)}
onClick={() =>
setExpandedRowId((prev) => (prev === log.id ? null : log.id))
}
>
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4'>
<div>
<div className='text-[13px]'>
<span className='font-sm text-muted-foreground'>
{formattedDate.compactDate}
</span>
<span
style={{ marginLeft: '8px' }}
className='hidden font-medium sm:inline'
>
{formattedDate.compactTime}
</span>
</div>
</div>
<div>
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
log.level === 'error'
? 'bg-red-500 text-white'
: 'bg-secondary text-card-foreground'
)}
>
{log.level}
</div>
</div>
<div>
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
)}
style={
log.trigger.toLowerCase() === 'manual'
? undefined
: { backgroundColor: getTriggerColor(log.trigger) }
}
>
{log.trigger}
</div>
) : (
<div className='text-muted-foreground text-xs'>—</div>
)}
</div>
<div>
<div className='font-medium text-muted-foreground text-xs'>
{log.cost && log.cost.total > 0 ? formatCost(log.cost.total) : '—'}
</div>
</div>
{/* Workflow cell */}
<div className='whitespace-nowrap'>
{log.workflowName ? (
<div className='inline-flex items-center gap-2'>
<span
className='h-3.5 w-3.5 rounded'
style={{ backgroundColor: log.workflowColor || '#64748b' }}
/>
<span
className='max-w-[150px] truncate text-muted-foreground text-xs'
title={log.workflowName}
>
{log.workflowName}
</span>
</div>
) : (
<span className='text-muted-foreground text-xs'>—</span>
)}
</div>
{/* Output cell */}
<div className='min-w-0 truncate whitespace-nowrap pr-2 text-[13px] text-muted-foreground'>
{log.level === 'error' && errorStr ? (
<span className='font-medium text-red-500 dark:text-red-400'>
{errorStr}
</span>
) : outputsStr.length > 220 ? (
`${outputsStr.slice(0, 217)}…`
) : (
outputsStr
)}
</div>
<div className='text-right'>
<div className='text-muted-foreground text-xs tabular-nums'>
{typeof log.duration === 'number' ? `${log.duration}ms` : '—'}
</div>
</div>
</div>
{isExpanded && (
<div className='px-2 pt-0 pb-4'>
<div className='rounded-md border bg-muted/30 p-2'>
<pre className='max-h-60 overflow-auto whitespace-pre-wrap break-words text-xs'>
{log.level === 'error' && errorStr ? errorStr : outputsStr}
</pre>
</div>
</div>
)}
</div>
)
})
})()}
</div>
</div>
</div>
</>
) : (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />
</div>
)}
</div>
</div>
)
}
export default WorkflowDetails
@@ -0,0 +1,166 @@
import { useMemo } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import StatusBar, {
type StatusBarSegment,
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/status-bar'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface WorkflowExecutionItem {
workflowId: string
workflowName: string
segments: StatusBarSegment[]
overallSuccessRate: number
}
export function WorkflowsList({
executions,
filteredExecutions,
expandedWorkflowId,
onToggleWorkflow,
selectedSegmentIndex,
onSegmentClick,
searchQuery,
segmentDurationMs,
}: {
executions: WorkflowExecutionItem[]
filteredExecutions: WorkflowExecutionItem[]
expandedWorkflowId: string | null
onToggleWorkflow: (workflowId: string) => void
selectedSegmentIndex: number[] | null
onSegmentClick: (
workflowId: string,
segmentIndex: number,
timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => void
searchQuery: string
segmentDurationMs: number
}) {
const { workflows } = useWorkflowRegistry()
const segmentsCount = filteredExecutions[0]?.segments?.length || 120
const durationLabel = useMemo(() => {
const segMs = Math.max(1, Math.floor(segmentDurationMs || 0))
const days = Math.round(segMs / (24 * 60 * 60 * 1000))
if (days >= 1) return `${days} day${days !== 1 ? 's' : ''}`
const hours = Math.round(segMs / (60 * 60 * 1000))
if (hours >= 1) return `${hours} hour${hours !== 1 ? 's' : ''}`
const mins = Math.max(1, Math.round(segMs / (60 * 1000)))
return `${mins} minute${mins !== 1 ? 's' : ''}`
}, [segmentDurationMs])
const Axis = () => {
if (!filteredExecutions.length || !segmentsCount || !segmentDurationMs) return null
const firstTs = filteredExecutions[0]?.segments?.[0]?.timestamp
if (!firstTs) return null
const start = new Date(firstTs)
if (Number.isNaN(start.getTime())) return null
const totalMs = segmentsCount * segmentDurationMs
const end = new Date(start.getTime() + totalMs)
const midMs = start.getTime() + totalMs / 2
// Avoid duplicate labels by shifting mid tick slightly if it rounds identical to start/end
const mid = new Date(midMs + 60 * 1000)
const useDates = totalMs >= 24 * 60 * 60 * 1000
const fmt = (d: Date) => {
if (useDates) return d.toLocaleString('en-US', { month: 'short', day: 'numeric' })
return d.toLocaleString('en-US', { hour: 'numeric' })
}
return (
<div className='relative px-3 pt-2 pb-1'>
<div className='mr-[80px] ml-[224px]'>
<div className='relative h-4'>
<div className='-z-10 -translate-y-1/2 absolute inset-x-0 top-1/2 h-px bg-border' />
<div className='flex justify-between text-[10px] text-muted-foreground'>
<span>{fmt(start)}</span>
<span>{fmt(mid)}</span>
<span className='text-right'>{fmt(end)}</span>
</div>
</div>
</div>
</div>
)
}
function DynamicLegend() {
return (
<p className='mt-0.5 text-[11px] text-muted-foreground'>
Each cell ≈ {durationLabel} of the selected range. Click a cell to filter details.
</p>
)
}
return (
<div
className='overflow-hidden rounded-lg border bg-card shadow-sm'
style={{ maxHeight: '380px', display: 'flex', flexDirection: 'column' }}
>
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2.5'>
<div className='flex items-center justify-between'>
<div>
<h3 className='font-medium text-sm'>Workflows</h3>
<DynamicLegend />
</div>
<span className='text-muted-foreground text-xs'>
{filteredExecutions.length} workflow
{filteredExecutions.length !== 1 ? 's' : ''}
{searchQuery && ` (filtered from ${executions.length})`}
</span>
</div>
</div>
<Axis />
<ScrollArea className='flex-1' style={{ height: 'calc(350px - 41px)' }}>
<div className='space-y-1 p-3'>
{filteredExecutions.length === 0 ? (
<div className='py-8 text-center text-muted-foreground text-sm'>
No workflows found matching "{searchQuery}"
</div>
) : (
filteredExecutions.map((workflow) => {
const isSelected = expandedWorkflowId === workflow.workflowId
return (
<div
key={workflow.workflowId}
className={`flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1.5 transition-colors ${
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
}`}
onClick={() => onToggleWorkflow(workflow.workflowId)}
>
<div className='w-52 min-w-0 flex-shrink-0'>
<div className='flex items-center gap-2'>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}}
/>
<h3 className='truncate font-medium text-sm'>{workflow.workflowName}</h3>
</div>
</div>
<div className='flex-1'>
<StatusBar
segments={workflow.segments}
selectedSegmentIndices={isSelected ? selectedSegmentIndex : null}
onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs}
/>
</div>
<div className='w-16 flex-shrink-0 text-right'>
<span className='font-medium text-muted-foreground text-sm'>
{workflow.overallSuccessRate.toFixed(1)}%
</span>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
)
}
export default WorkflowsList
@@ -15,8 +15,13 @@ export default function Timeline() {
const specificTimeRanges: TimeRange[] = [
'Past 30 minutes',
'Past hour',
'Past 6 hours',
'Past 12 hours',
'Past 24 hours',
'Past 3 days',
'Past 7 days',
'Past 14 days',
'Past 30 days',
]
return (
@@ -32,13 +37,15 @@ export default function Timeline() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
side='bottom'
align='end'
sideOffset={6}
collisionPadding={8}
className='w-[220px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
onSelect={() => {
setTimeRange('All time')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
@@ -52,8 +59,7 @@ export default function Timeline() {
{specificTimeRanges.map((range) => (
<DropdownMenuItem
key={range}
onSelect={(e) => {
e.preventDefault()
onSelect={() => {
setTimeRange(range)
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,12 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Info, Loader2, Play, RefreshCw, Square } from 'lucide-react'
import { AlertCircle, Info, Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { cn } from '@/lib/utils'
import Controls from '@/app/workspace/[workspaceId]/logs/components/dashboard/controls'
import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/components/search/search'
import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar'
import ExecutionsDashboard from '@/app/workspace/[workspaceId]/logs/executions-dashboard'
@@ -679,102 +677,28 @@ export default function Logs() {
<div className='flex min-w-0 flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto p-6'>
{/* Search and Controls */}
<div className='mb-8 flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start'>
<AutocompleteSearch
value={searchQuery}
onChange={setSearchQuery}
placeholder='Search logs...'
availableWorkflows={availableWorkflows}
availableFolders={availableFolders}
onOpenChange={(open) => {
isSearchOpenRef.current = open
}}
/>
<div className='ml-auto flex flex-shrink-0 items-center gap-3'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleRefresh}
className='h-9 rounded-[11px] hover:bg-secondary'
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className='h-5 w-5 animate-spin' />
) : (
<RefreshCw className='h-5 w-5' />
)}
<span className='sr-only'>Refresh</span>
</Button>
</TooltipTrigger>
<TooltipContent>{isRefreshing ? 'Refreshing...' : 'Refresh'}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleExport}
className='h-9 rounded-[11px] hover:bg-secondary'
aria-label='Export CSV'
>
{/* Download icon */}
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='h-5 w-5'
>
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<polyline points='7 10 12 15 17 10' />
<line x1='12' y1='15' x2='12' y2='3' />
</svg>
<span className='sr-only'>Export CSV</span>
</Button>
</TooltipTrigger>
<TooltipContent>Export CSV</TooltipContent>
</Tooltip>
<Button
className={`group h-9 gap-2 rounded-[11px] border bg-card text-card-foreground shadow-xs transition-all duration-200 hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white ${
isLive
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)] text-white'
: 'border-border'
}`}
onClick={toggleLive}
>
{isLive ? (
<Square className='!h-3.5 !w-3.5 fill-current' />
) : (
<Play className='!h-3.5 !w-3.5 group-hover:fill-current' />
)}
<span>Live</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<div className='flex items-center rounded-[11px] border bg-card p-2'>
<Switch
checked={(viewMode as string) === 'dashboard'}
onCheckedChange={(checked) => setViewMode(checked ? 'dashboard' : 'logs')}
className='data-[state=checked]:bg-primary'
/>
</div>
</TooltipTrigger>
<TooltipContent>
{(viewMode as string) === 'dashboard'
? 'Switch to logs view'
: 'Switch to executions dashboard'}
</TooltipContent>
</Tooltip>
</div>
</div>
<Controls
isRefetching={isRefreshing}
resetToNow={handleRefresh}
live={isLive}
setLive={(fn) => setIsLive(fn)}
viewMode={viewMode as string}
setViewMode={setViewMode as (mode: 'logs' | 'dashboard') => void}
searchComponent={
<AutocompleteSearch
value={searchQuery}
onChange={setSearchQuery}
placeholder='Search logs...'
availableWorkflows={availableWorkflows}
availableFolders={availableFolders}
onOpenChange={(open) => {
isSearchOpenRef.current = open
}}
/>
}
showExport={true}
onExport={handleExport}
/>
{/* Table container */}
<div className='flex flex-1 flex-col overflow-hidden'>
+40 -3
View File
@@ -15,18 +15,30 @@ const updateURL = (params: URLSearchParams) => {
window.history.replaceState({}, '', url)
}
const DEFAULT_TIME_RANGE: TimeRange = 'Past 12 hours'
const parseTimeRangeFromURL = (value: string | null): TimeRange => {
switch (value) {
case 'past-30-minutes':
return 'Past 30 minutes'
case 'past-hour':
return 'Past hour'
case 'past-6-hours':
return 'Past 6 hours'
case 'past-12-hours':
return 'Past 12 hours'
case 'past-24-hours':
return 'Past 24 hours'
case 'past-3-days':
return 'Past 3 days'
case 'past-7-days':
return 'Past 7 days'
case 'past-14-days':
return 'Past 14 days'
case 'past-30-days':
return 'Past 30 days'
default:
return 'All time'
return DEFAULT_TIME_RANGE
}
}
@@ -53,10 +65,20 @@ const timeRangeToURL = (timeRange: TimeRange): string => {
return 'past-30-minutes'
case 'Past hour':
return 'past-hour'
case 'Past 6 hours':
return 'past-6-hours'
case 'Past 12 hours':
return 'past-12-hours'
case 'Past 24 hours':
return 'past-24-hours'
case 'Past 3 days':
return 'past-3-days'
case 'Past 7 days':
return 'past-7-days'
case 'Past 14 days':
return 'past-14-days'
case 'Past 30 days':
return 'past-30-days'
default:
return 'all-time'
}
@@ -66,7 +88,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
logs: [],
workspaceId: '',
viewMode: 'logs',
timeRange: 'All time',
timeRange: DEFAULT_TIME_RANGE,
level: 'all',
workflowIds: [],
folderIds: [],
@@ -237,7 +259,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
const params = new URLSearchParams()
// Only add non-default values to keep URL clean
if (timeRange !== 'All time') {
if (timeRange !== DEFAULT_TIME_RANGE) {
params.set('timeRange', timeRangeToURL(timeRange))
}
@@ -305,12 +327,27 @@ export const useFilterStore = create<FilterState>((set, get) => ({
case 'Past hour':
startDate = new Date(now.getTime() - 60 * 60 * 1000)
break
case 'Past 6 hours':
startDate = new Date(now.getTime() - 6 * 60 * 60 * 1000)
break
case 'Past 12 hours':
startDate = new Date(now.getTime() - 12 * 60 * 60 * 1000)
break
case 'Past 24 hours':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case 'Past 3 days':
startDate = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
break
case 'Past 7 days':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case 'Past 14 days':
startDate = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
break
case 'Past 30 days':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
default:
startDate = new Date(0)
}
+5
View File
@@ -154,8 +154,13 @@ export interface LogsResponse {
export type TimeRange =
| 'Past 30 minutes'
| 'Past hour'
| 'Past 6 hours'
| 'Past 12 hours'
| 'Past 24 hours'
| 'Past 3 days'
| 'Past 7 days'
| 'Past 14 days'
| 'Past 30 days'
| 'All time'
export type LogLevel = 'error' | 'info' | 'all'
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all'