mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(dashboard): update UI for execution dashboard, fix next-runtime-env issues for SSO (#1649)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
+12
-6
@@ -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'>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user